diff --git a/wallets/rn_cli_wallet/package.json b/wallets/rn_cli_wallet/package.json index 8b1cbefd..1b0ea469 100644 --- a/wallets/rn_cli_wallet/package.json +++ b/wallets/rn_cli_wallet/package.json @@ -7,7 +7,7 @@ "postinstall": "./scripts/copy-sample-files.sh", "prebuild": "expo prebuild", "postprebuild": "node scripts/setup-secrets.js", - "android": "expo run:android", + "android": "expo run:android --app-id com.walletconnect.web3wallet.rnsample.debug", "android:debug": "expo run:android --app-id com.walletconnect.web3wallet.rnsample.debug", "android:internal": "expo run:android --variant internal --app-id com.walletconnect.web3wallet.rnsample.internal", "android:prod": "expo run:android --variant release --app-id com.walletconnect.web3wallet.rnsample", @@ -31,6 +31,7 @@ "permit2:revoke": "node ./scripts/revoke-permit2-approval.js" }, "dependencies": { + "@bitcoinerlab/secp256k1": "1.2.0", "@bottom-tabs/react-navigation": "1.3.1", "@craftzdog/react-native-buffer": "6.1.1", "@expo/metro-runtime": "~56.0.15", @@ -39,6 +40,7 @@ "@mhpdev/react-native-haptics": "1.1.0", "@mysten/sui": "1.45.2", "@noble/hashes": "1.8.0", + "@noble/secp256k1": "3.0.0", "@react-native-async-storage/async-storage": "2.2.0", "@react-native-community/netinfo": "12.0.1", "@react-navigation/bottom-tabs": "7.18.3", @@ -53,10 +55,14 @@ "@ton/crypto": "3.3.0", "@ton/ton": "15.4.0", "@walletconnect/react-native-compat": "2.23.10", + "bip32": "4.0.0", "bip39": "3.1.0", + "bitcoinjs-lib": "6.1.7", + "bitcoinjs-message": "2.2.0", "bs58": "6.0.0", "crc-32": "1.2.2", "dayjs": "1.11.21", + "ecpair": "2.1.0", "ed25519-hd-key": "1.3.0", "ethers": "6.13.5", "expo": "56.0.12", diff --git a/wallets/rn_cli_wallet/src/assets/chains/bitcoin.png b/wallets/rn_cli_wallet/src/assets/chains/bitcoin.png new file mode 100644 index 00000000..b991e4b7 Binary files /dev/null and b/wallets/rn_cli_wallet/src/assets/chains/bitcoin.png differ diff --git a/wallets/rn_cli_wallet/src/components/Modal.tsx b/wallets/rn_cli_wallet/src/components/Modal.tsx index 01112829..ab126ba3 100644 --- a/wallets/rn_cli_wallet/src/components/Modal.tsx +++ b/wallets/rn_cli_wallet/src/components/Modal.tsx @@ -23,6 +23,9 @@ import SessionSignTronModal from '@/modals/SessionSignTronModal'; import SessionSignCantonModal from '@/modals/SessionSignCantonModal'; import SessionSolanaSignMessageModal from '@/modals/SessionSolanaSignMessageModal'; import SessionSolanaSignTransactionModal from '@/modals/SessionSolanaSignTransactionModal'; +import SessionBitcoinSignMessageModal from '@/modals/SessionBitcoinSignMessageModal'; +import SessionBitcoinSendTransactionModal from '@/modals/SessionBitcoinSendTransactionModal'; +import SessionBitcoinGetAddressesModal from '@/modals/SessionBitcoinGetAddressesModal'; import PaymentOptionsModal from '@/modals/PaymentOptionsModal'; import ImportWalletModal from '@/modals/ImportWalletModal'; import SessionDetailModal from '@/modals/SessionDetailModal'; @@ -79,6 +82,12 @@ export default function Modal() { return ; case 'SessionSolanaSignTransactionModal': return ; + case 'SessionBitcoinSignMessageModal': + return ; + case 'SessionBitcoinSendTransactionModal': + return ; + case 'SessionBitcoinGetAddressesModal': + return ; case 'PaymentOptionsModal': return ; case 'ImportWalletModal': diff --git a/wallets/rn_cli_wallet/src/components/NetworkDropdown.tsx b/wallets/rn_cli_wallet/src/components/NetworkDropdown.tsx new file mode 100644 index 00000000..e0b59210 --- /dev/null +++ b/wallets/rn_cli_wallet/src/components/NetworkDropdown.tsx @@ -0,0 +1,176 @@ +import { useState } from 'react'; +import { Image, ScrollView, StyleSheet, View } from 'react-native'; + +import { useTheme } from '@/hooks/useTheme'; +import { PresetsUtil } from '@/utils/PresetsUtil'; +import { Spacing, BorderRadius } from '@/utils/ThemeUtil'; +import { Text } from '@/components/Text'; +import { Button } from '@/components/Button'; +import CaretUpDown from '@/assets/CaretUpDown'; +import Checkmark from '@/assets/Checkmark'; + +export interface NetworkDropdownOption { + label: T; + chainId: string; +} + +interface NetworkDropdownProps { + options: readonly NetworkDropdownOption[]; + selected: T; + onSelect: (label: T) => void; + // Optional controlled open state. When provided, the parent owns whether the + // list is expanded (e.g. to close it when another field gains focus). + isOpen?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export function NetworkDropdown({ + options, + selected, + onSelect, + isOpen, + onOpenChange, +}: NetworkDropdownProps) { + const Theme = useTheme(); + const [internalOpen, setInternalOpen] = useState(false); + + const expanded = isOpen ?? internalOpen; + const setExpanded = (open: boolean) => { + if (onOpenChange) { + onOpenChange(open); + } else { + setInternalOpen(open); + } + }; + + const selectedOption = + options.find(option => option.label === selected) ?? options[0]; + + const handleSelect = (label: T) => { + onSelect(label); + setExpanded(false); + }; + + return ( + + + + {expanded && ( + + + {options.map(option => { + const isSelected = option.label === selected; + return ( + + ); + })} + + + )} + + ); +} + +const styles = StyleSheet.create({ + wrapper: { + width: '100%', + }, + control: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderWidth: 1, + borderRadius: BorderRadius[3], + paddingVertical: Spacing[3], + paddingHorizontal: Spacing[4], + }, + list: { + borderWidth: 1, + borderRadius: BorderRadius[3], + marginTop: Spacing[2], + paddingHorizontal: Spacing[4], + paddingVertical: Spacing[2], + }, + scroll: { + maxHeight: 240, + }, + listContent: { + gap: Spacing[1], + }, + row: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: Spacing[2], + }, + chainInfo: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing[2], + }, + chainLogo: { + width: 24, + height: 24, + borderRadius: BorderRadius.full, + }, +}); diff --git a/wallets/rn_cli_wallet/src/constants/Bitcoin.ts b/wallets/rn_cli_wallet/src/constants/Bitcoin.ts new file mode 100644 index 00000000..325c181a --- /dev/null +++ b/wallets/rn_cli_wallet/src/constants/Bitcoin.ts @@ -0,0 +1,50 @@ +// Bitcoin (bip122) +import Bitcoin from '@/assets/chains/bitcoin.png'; +import { Chain } from '@/utils/TypesUtil'; + +/** + * Methods + */ +export const BIP122_SIGNING_METHODS = { + BIP122_SIGN_MESSAGE: 'signMessage', + BIP122_GET_ACCOUNT_ADDRESSES: 'getAccountAddresses', + BIP122_SEND_TRANSACTION: 'sendTransfer', + BIP122_SIGN_PSBT: 'signPsbt', +}; + +/** + * Events + */ +export const BIP122_EVENTS = { + BIP122_ADDRESSES_CHANGED: 'bip122_addressesChanged', +}; + +export const BIP122_NAMESPACE = 'bip122'; + +// Mainnet genesis block hash (CAIP-2 reference). Testnet is intentionally not +// supported here — the RN wallet exposes only mainnet chains. +export const BIP122_MAINNET_ID = '000000000019d6689c085ae165831e93'; +export const BIP122_MAINNET_CAIP2 = `${BIP122_NAMESPACE}:${BIP122_MAINNET_ID}`; + +export type IBip122ChainId = typeof BIP122_MAINNET_CAIP2; + +// BIP44 coin type for Bitcoin mainnet (used for HD derivation). +export const BIP122_MAINNET_COIN_TYPE = '0'; +export const BIP122_MAINNET_RPC = 'https://mempool.space/api'; + +export const BIP122_MAINNET = { + [BIP122_MAINNET_CAIP2]: { + chainId: BIP122_MAINNET_ID, + namespace: BIP122_NAMESPACE, + name: 'Bitcoin', + rpcUrl: BIP122_MAINNET_RPC, + }, +}; + +export const BIP122_NETWORKS_IMAGES = { + [BIP122_MAINNET_CAIP2]: Bitcoin, +}; + +export const BIP122_CHAINS: Record = { + ...BIP122_MAINNET, +}; diff --git a/wallets/rn_cli_wallet/src/hooks/useInitializeWalletKit.ts b/wallets/rn_cli_wallet/src/hooks/useInitializeWalletKit.ts index b750ee51..0161b4c2 100644 --- a/wallets/rn_cli_wallet/src/hooks/useInitializeWalletKit.ts +++ b/wallets/rn_cli_wallet/src/hooks/useInitializeWalletKit.ts @@ -10,6 +10,7 @@ import { createOrRestoreTonWallet } from '@/utils/TonWalletUtil'; import { createOrRestoreTronWallet } from '@/utils/TronWalletUtil'; import { createOrRestoreCantonWallet } from '@/utils/CantonWalletUtil'; import { createOrRestoreSolanaWallet } from '@/utils/SolanaWalletUtil'; +import { createOrRestoreBitcoinWallet } from '@/utils/BitcoinWalletUtil'; export default function useInitializeWalletKit() { const [initialized, setInitialized] = useState(false); @@ -28,6 +29,8 @@ export default function useInitializeWalletKit() { await createOrRestoreCantonWallet(); const { solanaAddress, solanaWallet } = await createOrRestoreSolanaWallet(); + const { bitcoinAddress, bitcoinWallet } = + await createOrRestoreBitcoinWallet(); SettingsStore.setEIP155Address(eip155Addresses[0]); SettingsStore.setWallet(eip155Wallets[eip155Addresses[0]]); @@ -41,6 +44,8 @@ export default function useInitializeWalletKit() { SettingsStore.setCantonWallet(cantonWallet); SettingsStore.setSolanaAddress(solanaAddress); SettingsStore.setSolanaWallet(solanaWallet); + SettingsStore.setBitcoinAddress(bitcoinAddress); + SettingsStore.setBitcoinWallet(bitcoinWallet); await createWalletKit(relayerRegionURL); setInitialized(true); SettingsStore.state.initPromiseResolver?.resolve(undefined); diff --git a/wallets/rn_cli_wallet/src/hooks/useWalletKitEventsManager.ts b/wallets/rn_cli_wallet/src/hooks/useWalletKitEventsManager.ts index 3d542c87..663410b5 100644 --- a/wallets/rn_cli_wallet/src/hooks/useWalletKitEventsManager.ts +++ b/wallets/rn_cli_wallet/src/hooks/useWalletKitEventsManager.ts @@ -16,6 +16,7 @@ import { TRON_SIGNING_METHODS } from '@/constants/Tron'; import { CANTON_SIGNING_METHODS } from '@/constants/Canton'; import { approveCantonRequest } from '@/utils/CantonRequestHandlerUtil'; import { SOLANA_SIGNING_METHODS } from '@/constants/Solana'; +import { BIP122_SIGNING_METHODS } from '@/constants/Bitcoin'; export default function useWalletKitEventsManager(initialized: boolean) { /****************************************************************************** @@ -183,6 +184,22 @@ export default function useWalletKitEventsManager(initialized: boolean) { requestEvent, requestSession, }); + case BIP122_SIGNING_METHODS.BIP122_SIGN_MESSAGE: + return ModalStore.open('SessionBitcoinSignMessageModal', { + requestEvent, + requestSession, + }); + case BIP122_SIGNING_METHODS.BIP122_GET_ACCOUNT_ADDRESSES: + return ModalStore.open('SessionBitcoinGetAddressesModal', { + requestEvent, + requestSession, + }); + case BIP122_SIGNING_METHODS.BIP122_SEND_TRANSACTION: + case BIP122_SIGNING_METHODS.BIP122_SIGN_PSBT: + return ModalStore.open('SessionBitcoinSendTransactionModal', { + requestEvent, + requestSession, + }); default: return ModalStore.open('SessionUnsuportedMethodModal', { requestEvent, diff --git a/wallets/rn_cli_wallet/src/lib/BitcoinLib.ts b/wallets/rn_cli_wallet/src/lib/BitcoinLib.ts new file mode 100644 index 00000000..1333edd6 --- /dev/null +++ b/wallets/rn_cli_wallet/src/lib/BitcoinLib.ts @@ -0,0 +1,476 @@ +// Buffer is polyfilled globally by '@walletconnect/react-native-compat' (loaded +// in index.js). We intentionally use the global (Node-typed) Buffer here rather +// than @craftzdog/react-native-buffer so the types line up with bitcoinjs-lib. +import * as bip39 from 'bip39'; +import * as bitcoin from 'bitcoinjs-lib'; +// tiny-secp256k1 (used by the web sample) is a WASM module and does not run +// under Hermes/React Native. @bitcoinerlab/secp256k1 is a pure-JS drop-in +// implementing the same interface (built on @noble), so bitcoinjs-lib, bip32 +// and ecpair all accept it via their factories. +import * as ecc from '@bitcoinerlab/secp256k1'; +import ECPairFactory from 'ecpair'; +import BIP32Factory, { BIP32Interface } from 'bip32'; +import bitcoinMessage from 'bitcoinjs-message'; +import { schnorr, hashes as nobleHashes } from '@noble/secp256k1'; +import { sha256 as nobleSha256 } from '@noble/hashes/sha256'; +import { hmac as nobleHmac } from '@noble/hashes/hmac'; + +import LogStore from '@/store/LogStore'; +import { + BIP122_MAINNET_CAIP2, + BIP122_MAINNET_COIN_TYPE, + IBip122ChainId, +} from '@/constants/Bitcoin'; + +bitcoin.initEccLib(ecc); + +// @noble/secp256k1 v3 leaves the synchronous hash functions unset; its sync API +// (schnorr.sign, used for the ordinals/taproot path) throws "hashes.sha256 not +// set" until we provide them. Wire them from @noble/hashes (pure-JS, Hermes-safe) +// so we don't depend on WebCrypto being available. +nobleHashes.sha256 = (message: Uint8Array) => nobleSha256(message); +nobleHashes.hmacSha256 = (key: Uint8Array, message: Uint8Array) => + nobleHmac(nobleSha256, key, message); + +const ECPair = ECPairFactory(ecc); +const bip32 = BIP32Factory(ecc); + +// The RN wallet only exposes Bitcoin mainnet. +const NETWORK = bitcoin.networks.bitcoin; +// Number of receiving addresses to derive per chain. +const ADDRESS_COUNT = 20; + +interface IInitArguments { + mnemonic?: string; +} + +interface IUTXO { + txid: string; + vout: number; + value: number; + status: { + confirmed: boolean; + block_height: number; + block_hash: string; + block_time: number; + }; +} + +interface ICreateTransaction { + network: bitcoin.Network; + recipientAddress: string; + amount: number; + changeAddress: string; + memo?: string; + utxos: IUTXO[]; + privateKeyWIF: string; + feeRate: number; +} + +interface IAddressData { + address: string; + path: string; + publicKey: string; +} + +interface IPsbtInput { + address: string; + index: number; + sighashTypes: number[]; +} + +interface ISignPsbt { + account: string; + psbt: string; + signInputs: IPsbtInput[]; + broadcast: boolean; + chainId: IBip122ChainId; +} + +const validator = ( + pubkey: Buffer, + msghash: Buffer, + signature: Buffer, +): boolean => { + return ECPair.fromPublicKey(pubkey).verify(msghash, signature); +}; + +/** + * Library + */ +export default class BitcoinLib { + private account: BIP32Interface; + private mnemonic: string; + private addresses = {} as Record>; + private ordinals = {} as Record>; + private keys = {} as Record< + IBip122ChainId, + Map + >; + + constructor({ mnemonic }: IInitArguments) { + this.keys[BIP122_MAINNET_CAIP2] = new Map(); + this.addresses[BIP122_MAINNET_CAIP2] = new Map(); + this.ordinals[BIP122_MAINNET_CAIP2] = new Map(); + + this.mnemonic = mnemonic ? mnemonic : bip39.generateMnemonic(); + const seed = bip39.mnemonicToSeedSync(this.mnemonic); + const root = bip32.fromSeed(seed); + this.account = bip32.fromBase58(root.toBase58()); + this.loadAddresses(); + LogStore.info('Bitcoin wallet initialized', 'BitcoinLib', 'constructor', { + address: this.getAddress(BIP122_MAINNET_CAIP2), + }); + } + + static async init({ mnemonic }: IInitArguments) { + return new BitcoinLib({ mnemonic }); + } + + public getAddress(chainId: IBip122ChainId) { + return Array.from(this.addresses[chainId].values())[0].address; + } + + public getOrdinalsAddress(chainId: IBip122ChainId) { + return Array.from(this.ordinals[chainId].values())[0].address; + } + + public getMnemonic() { + return this.mnemonic; + } + + public getAddresses(chainId: IBip122ChainId, intentions?: string[]) { + if (intentions && intentions[0] === 'ordinal') { + return this.ordinals[chainId]; + } + return this.addresses[chainId]; + } + + public async signMessage({ + message, + address, + protocol, + chainId, + }: { + message: string; + address: string; + protocol?: string; + chainId: IBip122ChainId; + }) { + if (protocol && protocol !== 'ecdsa') { + throw new Error(`Supported signing protocols: ecdsa, received: ${protocol}`); + } + const addressData = this.getAddressData(address, chainId); + if (!addressData) { + throw new Error(`Unknown address: ${address}`); + } + const keyData = this.keys[chainId].get(address)!; + const keyPair = ECPair.fromWIF(keyData.wif); + const privateKey = keyPair.privateKey!; + + let signature: Buffer; + if (this.isOrdinal(address, chainId)) { + const messageHash = bitcoin.crypto.sha256(Buffer.from(message)); + const sig = await schnorr.sign(messageHash, privateKey); + signature = Buffer.from(sig); + } else { + signature = bitcoinMessage.sign(message, privateKey, keyPair.compressed, { + segwitType: 'p2wpkh', + }); + } + + LogStore.log('Bitcoin message signed', 'BitcoinLib', 'signMessage'); + return { + signature: signature.toString('hex').replace('0x', ''), + address, + }; + } + + public async sendTransfer(params: { + account: string; + recipientAddress: string; + amount: string; + changeAddress?: string; + memo?: string; + chainId: IBip122ChainId; + }) { + const { account, recipientAddress, amount, changeAddress, memo, chainId } = + params; + const satoshis = parseInt(amount, 10); + + const addressData = this.getAddressData(account, chainId); + if (!addressData) { + throw new Error(`Unknown address: ${account}`); + } + + if (satoshis < 0) { + throw new Error(`Invalid amount: ${amount}`); + } + + const utxos = (await this.getUTXOs(account)) as IUTXO[]; + if (!utxos || utxos.length === 0) { + throw new Error(`No UTXOs found for address: ${account}`); + } + + let utxosValue = 0; + const utxosToSpend: IUTXO[] = []; + // Greedily select UTXOs until the target amount is covered. A `return` + // inside forEach only skips to the next item (not break), which would spend + // every UTXO — use for...of + break so we stop once we have enough. + for (const utxo of utxos) { + utxosValue += utxo.value; + utxosToSpend.push(utxo); + if (utxosValue >= satoshis) { + break; + } + } + + const keyData = this.keys[chainId].get(account)!; + const transaction = await this.createTransaction({ + network: keyData.network, + recipientAddress, + amount: satoshis, + changeAddress: changeAddress || account, + utxos: utxosToSpend, + privateKeyWIF: keyData.wif, + memo, + feeRate: await this.getFeeRate(), + }); + const txid = await this.broadcastTransaction(transaction); + LogStore.log('Bitcoin transfer sent', 'BitcoinLib', 'sendTransfer', { + txid, + }); + return txid; + } + + public async signPsbt({ + account, + psbt, + signInputs, + broadcast = false, + chainId, + }: ISignPsbt) { + const keyData = this.keys[chainId].get(account)!; + const keyPair = ECPair.fromWIF(keyData.wif); + const transaction = bitcoin.Psbt.fromBase64(psbt, { + network: keyData.network, + }); + signInputs.forEach(({ address, index, sighashTypes }) => { + let keyPairToSignWith = keyPair; + if (address !== account) { + const inputKeyData = this.keys[chainId].get(address)!; + keyPairToSignWith = ECPair.fromWIF(inputKeyData.wif); + } + transaction.signInput(index, keyPairToSignWith, sighashTypes); + }); + // Validate the inputs we actually signed. Hardcoding index 0 would throw + // when the dApp asks to sign inputs that don't include index 0. + signInputs.forEach(({ index }) => { + transaction.validateSignaturesOfInput(index, validator); + }); + transaction.finalizeAllInputs(); + + if (!broadcast) { + LogStore.log('Bitcoin PSBT signed', 'BitcoinLib', 'signPsbt'); + return { + psbt: transaction.toBase64(), + }; + } + + const tx = transaction.extractTransaction(); + const txid = await this.broadcastTransaction(tx.toHex()); + LogStore.log('Bitcoin PSBT signed and broadcast', 'BitcoinLib', 'signPsbt', { + txid, + }); + return { + psbt: transaction.toBase64(), + txid, + }; + } + + async getUTXOs(address: string): Promise { + return await ( + await fetch(`https://mempool.space/api/address/${address}/utxo`) + ).json(); + } + + async broadcastTransaction(transaction: string) { + const result = await fetch('https://mempool.space/api/tx', { + method: 'POST', + body: transaction, + }); + + if (result.ok) { + return await result.text(); + } + throw new Error('Error broadcasting transaction: ' + (await result.text())); + } + + getAvailableBalance(utxos: IUTXO[]) { + return utxos.reduce((acc, { value }) => acc + value, 0); + } + + private async getFeeRate() { + const defaultFeeRate = 2; + try { + const response = await fetch( + 'https://mempool.space/api/v1/fees/recommended', + ); + if (response.ok) { + const data = await response.json(); + return parseInt(data?.economyFee ?? defaultFeeRate, 10); + } + } catch (e) { + LogStore.error( + (e as Error).message, + 'BitcoinLib', + 'getFeeRate', + ); + } + return defaultFeeRate; + } + + private generateAddress({ + index, + coinType, + chainId, + change = false, + taproot = false, + }: { + index: number; + coinType: string; + chainId: IBip122ChainId; + change?: boolean; + taproot?: boolean; + }) { + // BIP86 (m/86') is the standard derivation for single-key P2TR (taproot/ + // ordinals); BIP84 (m/84') is for P2WPKH. Using the right purpose per + // address type keeps addresses interoperable with other BIP86/BIP84 wallets. + const purpose = taproot ? 86 : 84; + const path = `m/${purpose}'/${coinType}'/0'/${change ? 1 : 0}/${index}`; + const child = this.account.derivePath(path); + let address: string; + if (taproot) { + address = bitcoin.payments.p2tr({ + pubkey: child.publicKey.slice(1), + network: NETWORK, + }).address!; + } else { + address = bitcoin.payments.p2wpkh({ + pubkey: child.publicKey, + network: NETWORK, + }).address!; + } + const wif = child.toWIF(); + this.keys[chainId].set(address, { wif, network: NETWORK }); + return { address, path, publicKey: child.publicKey.toString('hex') }; + } + + private loadAddresses() { + const chainId: IBip122ChainId = BIP122_MAINNET_CAIP2; + const coinType = BIP122_MAINNET_COIN_TYPE; + + for (let i = 0; i < ADDRESS_COUNT; i++) { + const addressParams = { index: i, chainId, coinType }; + // payment addresses (P2WPKH) + const addressData = this.generateAddress(addressParams); + this.addresses[chainId].set(addressData.address, addressData); + // ordinals / taproot addresses (P2TR) + const taprootAddress = this.generateAddress({ + ...addressParams, + taproot: true, + }); + this.ordinals[chainId].set(taprootAddress.address, taprootAddress); + } + } + + private async createTransaction({ + network, + recipientAddress, + amount, + changeAddress, + memo, + utxos, + privateKeyWIF, + feeRate, + }: ICreateTransaction) { + const psbt = new bitcoin.Psbt({ network }); + const keyPair = ECPair.fromWIF(privateKeyWIF); + const payment = bitcoin.payments.p2wpkh({ + pubkey: keyPair.publicKey, + network, + }); + + utxos.forEach(utxo => { + psbt.addInput({ + hash: utxo.txid, + index: utxo.vout, + witnessUtxo: { + script: Buffer.from(payment.output?.toString('hex')!, 'hex'), + value: utxo.value, + }, + }); + }); + + psbt.addOutput({ + address: recipientAddress, + value: amount, + }); + const change = this.calculateChange(utxos, amount, feeRate); + + if (change > 0) { + psbt.addOutput({ + address: changeAddress, + value: change, + }); + } + + if (memo) { + const data = Buffer.from(memo, 'utf8'); + const embed = bitcoin.payments.embed({ data: [data] }); + psbt.addOutput({ + script: embed.output!, + value: 0, + }); + } + + psbt.signAllInputs(keyPair); + // All inputs are signed with our key, so validate every one (not just + // index 0) before finalizing. + psbt.validateSignaturesOfAllInputs(validator); + psbt.finalizeAllInputs(); + + const tx = psbt.extractTransaction(); + return tx.toHex(); + } + + // Helper function to calculate change + private calculateChange( + utxos: IUTXO[], + amount: number, + feeRate: number, + ): number { + const inputSum = utxos.reduce((sum, utxo) => sum + utxo.value, 0); + /** + * 10 bytes: estimated fixed transaction overhead. + * 148 bytes: average size of each input (UTXO). + * 34 bytes: size of each output (x2 for recipient + change). + */ + const estimatedSize = 10 + 148 * utxos.length + 34 * 2; + const fee = estimatedSize * feeRate; + const change = inputSum - amount - fee; + return change; + } + + private getAddressData(address: string, chainId: IBip122ChainId) { + const addressData = this.addresses[chainId].get(address); + if (addressData) { + return addressData; + } + return this.ordinals[chainId].get(address); + } + + private isOrdinal(address: string, chainId: IBip122ChainId) { + return this.ordinals[chainId].has(address); + } +} diff --git a/wallets/rn_cli_wallet/src/modals/ImportWalletModal.tsx b/wallets/rn_cli_wallet/src/modals/ImportWalletModal.tsx index bb8622b4..a1c8b175 100644 --- a/wallets/rn_cli_wallet/src/modals/ImportWalletModal.tsx +++ b/wallets/rn_cli_wallet/src/modals/ImportWalletModal.tsx @@ -19,21 +19,43 @@ import { loadTronWallet } from '@/utils/TronWalletUtil'; import { loadSuiWallet } from '@/utils/SuiWalletUtil'; import { loadCantonWallet } from '@/utils/CantonWalletUtil'; import { loadSolanaWallet } from '@/utils/SolanaWalletUtil'; +import { loadBitcoinWallet } from '@/utils/BitcoinWalletUtil'; import { Text } from '@/components/Text'; import { ModalCloseButton } from '@/components/ModalCloseButton'; -import { SegmentedControl } from '@/components/SegmentedControl'; +import { + NetworkDropdown, + NetworkDropdownOption, +} from '@/components/NetworkDropdown'; import { Spacing, BorderRadius, FontFamily } from '@/utils/ThemeUtil'; import { ActionButton } from '@/components/ActionButton'; +import { TON_CHAINS } from '@/constants/Ton'; +import { TRON_CHAINS } from '@/constants/Tron'; +import { SUI_MAINNET_CAIP2 } from '@/constants/Sui'; +import { CANTON_CHAINS } from '@/constants/Canton'; +import { SOLANA_MAINNET_CAIP2 } from '@/constants/Solana'; +import { BIP122_MAINNET_CAIP2 } from '@/constants/Bitcoin'; + +type ChainOption = + | 'Ethereum' + | 'Ton' + | 'Tron' + | 'Sui' + | 'Canton' + | 'Solana' + | 'Bitcoin'; -const CHAIN_OPTIONS = [ - 'Ethereum', - 'Ton', - 'Tron', - 'Sui', - 'Canton', - 'Solana', +// Representative chainId per option, used to render the network icon in the +// dropdown. Derived from each namespace's chain map so it stays correct if the +// underlying CAIP-2 ids change. +const CHAIN_OPTIONS: readonly NetworkDropdownOption[] = [ + { label: 'Ethereum', chainId: 'eip155:1' }, + { label: 'Ton', chainId: Object.keys(TON_CHAINS)[0] }, + { label: 'Tron', chainId: Object.keys(TRON_CHAINS)[0] }, + { label: 'Sui', chainId: SUI_MAINNET_CAIP2 }, + { label: 'Canton', chainId: Object.keys(CANTON_CHAINS)[0] }, + { label: 'Solana', chainId: SOLANA_MAINNET_CAIP2 }, + { label: 'Bitcoin', chainId: BIP122_MAINNET_CAIP2 }, ] as const; -type ChainOption = (typeof CHAIN_OPTIONS)[number]; const PLACEHOLDER_TEXT: Record = { Ethereum: 'Mnemonic or private key (0x…)', @@ -42,6 +64,7 @@ const PLACEHOLDER_TEXT: Record = { Sui: 'Mnemonic phrase (12–24 words)', Canton: 'Secret key (128 hex chars)', Solana: 'Mnemonic (12–24 words) or base58 secret key', + Bitcoin: 'Mnemonic phrase (12–24 words)', }; const EMPTY_INPUT_ERROR: Record = { @@ -51,6 +74,7 @@ const EMPTY_INPUT_ERROR: Record = { Sui: 'Enter a mnemonic phrase.', Canton: 'Enter an Ed25519 secret key.', Solana: 'Enter a mnemonic or base58 secret key.', + Bitcoin: 'Enter a mnemonic phrase.', }; export default function ImportWalletModal() { @@ -58,6 +82,7 @@ export default function ImportWalletModal() { const [selectedChain, setSelectedChain] = useState('Ethereum'); const [input, setInput] = useState(''); const [isLoading, setIsLoading] = useState(false); + const [isNetworkDropdownOpen, setIsNetworkDropdownOpen] = useState(false); const handleChainChange = (chain: ChainOption) => { setSelectedChain(chain); @@ -150,6 +175,23 @@ export default function ImportWalletModal() { address = result.address; break; } + case 'Bitcoin': { + const result = await loadBitcoinWallet(sanitizedInput); + address = result.address; + // Refetch balances with the new Bitcoin address + WalletStore.fetchBalances( + { + eip155Address: SettingsStore.state.eip155Address, + tonAddress: SettingsStore.state.tonAddress, + tronAddress: SettingsStore.state.tronAddress, + suiAddress: SettingsStore.state.suiAddress, + solanaAddress: SettingsStore.state.solanaAddress, + bitcoinAddress: address, + }, + { force: true }, + ); + break; + } case 'Solana': { const result = await loadSolanaWallet(sanitizedInput); address = result.address; @@ -225,10 +267,12 @@ export default function ImportWalletModal() { - @@ -245,6 +289,7 @@ export default function ImportWalletModal() { placeholderTextColor={Theme['text-secondary']} value={input} onChangeText={setInput} + onFocus={() => setIsNetworkDropdownOpen(false)} multiline numberOfLines={4} autoCapitalize="none" diff --git a/wallets/rn_cli_wallet/src/modals/SessionBitcoinGetAddressesModal.tsx b/wallets/rn_cli_wallet/src/modals/SessionBitcoinGetAddressesModal.tsx new file mode 100644 index 00000000..32e495fe --- /dev/null +++ b/wallets/rn_cli_wallet/src/modals/SessionBitcoinGetAddressesModal.tsx @@ -0,0 +1,163 @@ +import { useSnapshot } from 'valtio'; +import { useCallback, useMemo, useState } from 'react'; +import { View, StyleSheet } from 'react-native'; +import { SignClientTypes } from '@walletconnect/types'; +import { showToast } from '@/utils/ToastUtil'; + +import { Message } from '@/components/Modal/Message'; +import { AppInfoCard } from '@/components/AppInfoCard'; +import { NetworkInfoCard } from '@/components/NetworkInfoCard'; + +import { walletKit } from '@/utils/WalletKitUtil'; +import { handleRedirect } from '@/utils/LinkingUtils'; +import LogStore from '@/store/LogStore'; +import ModalStore from '@/store/ModalStore'; +import SettingsStore from '@/store/SettingsStore'; +import { RequestModal } from './RequestModal'; +import { + approveBitcoinRequest, + rejectBitcoinRequest, +} from '@/utils/BitcoinRequestHandlerUtil'; +import { bitcoinAddresses } from '@/utils/BitcoinWalletUtil'; +import { Text } from '@/components/Text'; +import { Spacing } from '@/utils/ThemeUtil'; +import { haptics } from '@/utils/haptics'; + +export default function SessionBitcoinGetAddressesModal() { + const { data } = useSnapshot(ModalStore.state); + const { currentRequestVerifyContext } = useSnapshot(SettingsStore.state); + const requestEvent = data?.requestEvent; + const session = data?.requestSession; + const isLinkMode = session?.transportType === 'link_mode'; + + const [isLoadingApprove, setIsLoadingApprove] = useState(false); + const [isLoadingReject, setIsLoadingReject] = useState(false); + + const { validation, isScam } = currentRequestVerifyContext?.verified || {}; + + const { topic, params } = requestEvent!; + const { request } = params; + const peerMetadata = session?.peer?.metadata as SignClientTypes.Metadata; + + const displayAddresses = useMemo(() => { + const intentions = request.params?.intentions as string[] | undefined; + const isOrdinal = intentions && intentions[0] === 'ordinal'; + const list = bitcoinAddresses + ? isOrdinal + ? [bitcoinAddresses[1]] + : [bitcoinAddresses[0]] + : []; + return list.join('\n'); + }, [request.params]); + + const onApprove = useCallback(async () => { + if (requestEvent) { + setIsLoadingApprove(true); + try { + const response = await approveBitcoinRequest(requestEvent); + await walletKit.respondSessionRequest({ topic, response }); + haptics.requestResponse(); + // The handler returns a JSON-RPC error response (rather than throwing) + // on failure. Surface it so the wallet doesn't look successful. + const errorMessage = + 'error' in response ? response.error.message : undefined; + if (errorMessage) { + showToast({ + type: 'error', + text1: 'Couldn’t share addresses', + text2: errorMessage, + }); + } + handleRedirect({ + peerRedirect: peerMetadata?.redirect, + isLinkMode: isLinkMode, + error: errorMessage, + }); + } catch (e) { + LogStore.error( + (e as Error).message, + 'SessionBitcoinGetAddressesModal', + 'onApprove', + ); + showToast({ + type: 'error', + text1: 'Couldn’t share addresses', + text2: (e as Error).message, + }); + } finally { + setIsLoadingApprove(false); + ModalStore.close(); + } + } + }, [requestEvent, peerMetadata, topic, isLinkMode]); + + const onReject = useCallback(async () => { + if (requestEvent) { + setIsLoadingReject(true); + try { + const response = rejectBitcoinRequest(requestEvent); + await walletKit.respondSessionRequest({ topic, response }); + haptics.requestResponse(); + handleRedirect({ + peerRedirect: peerMetadata?.redirect, + isLinkMode: isLinkMode, + error: 'User rejected Bitcoin addresses request', + }); + } catch (e) { + LogStore.error( + (e as Error).message, + 'SessionBitcoinGetAddressesModal', + 'onReject', + ); + showToast({ + type: 'error', + text1: 'Couldn’t reject request', + text2: (e as Error).message, + }); + } finally { + setIsLoadingReject(false); + ModalStore.close(); + } + } + }, [requestEvent, topic, peerMetadata, isLinkMode]); + + if (!requestEvent || !session) { + return ( + + Missing request data + + ); + } + + return ( + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + width: '100%', + marginVertical: Spacing[2], + paddingHorizontal: Spacing[4], + rowGap: Spacing[2], + }, +}); diff --git a/wallets/rn_cli_wallet/src/modals/SessionBitcoinSendTransactionModal.tsx b/wallets/rn_cli_wallet/src/modals/SessionBitcoinSendTransactionModal.tsx new file mode 100644 index 00000000..b13937b6 --- /dev/null +++ b/wallets/rn_cli_wallet/src/modals/SessionBitcoinSendTransactionModal.tsx @@ -0,0 +1,166 @@ +import { useSnapshot } from 'valtio'; +import { useCallback, useMemo, useState } from 'react'; +import { View, StyleSheet } from 'react-native'; +import { SignClientTypes } from '@walletconnect/types'; +import { showToast } from '@/utils/ToastUtil'; + +import { Message } from '@/components/Modal/Message'; +import { AppInfoCard } from '@/components/AppInfoCard'; +import { NetworkInfoCard } from '@/components/NetworkInfoCard'; + +import { walletKit } from '@/utils/WalletKitUtil'; +import { handleRedirect } from '@/utils/LinkingUtils'; +import LogStore from '@/store/LogStore'; +import ModalStore from '@/store/ModalStore'; +import SettingsStore from '@/store/SettingsStore'; +import { RequestModal } from './RequestModal'; +import { + approveBitcoinRequest, + rejectBitcoinRequest, +} from '@/utils/BitcoinRequestHandlerUtil'; +import { BIP122_SIGNING_METHODS } from '@/constants/Bitcoin'; +import { Text } from '@/components/Text'; +import { Spacing } from '@/utils/ThemeUtil'; +import { haptics } from '@/utils/haptics'; + +export default function SessionBitcoinSendTransactionModal() { + const { data } = useSnapshot(ModalStore.state); + const { currentRequestVerifyContext } = useSnapshot(SettingsStore.state); + const requestEvent = data?.requestEvent; + const session = data?.requestSession; + const isLinkMode = session?.transportType === 'link_mode'; + + const [isLoadingApprove, setIsLoadingApprove] = useState(false); + const [isLoadingReject, setIsLoadingReject] = useState(false); + + const { validation, isScam } = currentRequestVerifyContext?.verified || {}; + + const { topic, params } = requestEvent!; + const { request } = params; + const peerMetadata = session?.peer?.metadata as SignClientTypes.Metadata; + + const displayPayload = useMemo(() => { + try { + return JSON.stringify(request.params, null, 2); + } catch { + return String(request.params); + } + }, [request.params]); + + const intention = + request.method === BIP122_SIGNING_METHODS.BIP122_SIGN_PSBT + ? 'Sign a transaction for' + : 'Sign & send a transaction for'; + + const onApprove = useCallback(async () => { + if (requestEvent) { + setIsLoadingApprove(true); + try { + const response = await approveBitcoinRequest(requestEvent); + await walletKit.respondSessionRequest({ topic, response }); + haptics.requestResponse(); + // The handler returns a JSON-RPC error response (rather than throwing) + // when the transfer/PSBT fails, e.g. no UTXOs. Surface it so the wallet + // doesn't look like it succeeded. + const errorMessage = + 'error' in response ? response.error.message : undefined; + if (errorMessage) { + showToast({ + type: 'error', + text1: 'Couldn’t sign transaction', + text2: errorMessage, + }); + } + handleRedirect({ + peerRedirect: peerMetadata?.redirect, + isLinkMode: isLinkMode, + error: errorMessage, + }); + } catch (e) { + LogStore.error( + (e as Error).message, + 'SessionBitcoinSendTransactionModal', + 'onApprove', + ); + showToast({ + type: 'error', + text1: 'Couldn’t sign transaction', + text2: (e as Error).message, + }); + } finally { + setIsLoadingApprove(false); + ModalStore.close(); + } + } + }, [requestEvent, peerMetadata, topic, isLinkMode]); + + const onReject = useCallback(async () => { + if (requestEvent) { + setIsLoadingReject(true); + try { + const response = rejectBitcoinRequest(requestEvent); + await walletKit.respondSessionRequest({ topic, response }); + haptics.requestResponse(); + handleRedirect({ + peerRedirect: peerMetadata?.redirect, + isLinkMode: isLinkMode, + error: 'User rejected Bitcoin transaction request', + }); + } catch (e) { + LogStore.error( + (e as Error).message, + 'SessionBitcoinSendTransactionModal', + 'onReject', + ); + showToast({ + type: 'error', + text1: 'Couldn’t reject request', + text2: (e as Error).message, + }); + } finally { + setIsLoadingReject(false); + ModalStore.close(); + } + } + }, [requestEvent, topic, peerMetadata, isLinkMode]); + + if (!requestEvent || !session) { + return ( + + Missing request data + + ); + } + + return ( + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + width: '100%', + marginVertical: Spacing[2], + paddingHorizontal: Spacing[4], + rowGap: Spacing[2], + }, +}); diff --git a/wallets/rn_cli_wallet/src/modals/SessionBitcoinSignMessageModal.tsx b/wallets/rn_cli_wallet/src/modals/SessionBitcoinSignMessageModal.tsx new file mode 100644 index 00000000..fd6c8e4d --- /dev/null +++ b/wallets/rn_cli_wallet/src/modals/SessionBitcoinSignMessageModal.tsx @@ -0,0 +1,153 @@ +import { useSnapshot } from 'valtio'; +import { useCallback, useState } from 'react'; +import { View, StyleSheet } from 'react-native'; +import { SignClientTypes } from '@walletconnect/types'; +import { showToast } from '@/utils/ToastUtil'; + +import { Message } from '@/components/Modal/Message'; +import { AppInfoCard } from '@/components/AppInfoCard'; +import { NetworkInfoCard } from '@/components/NetworkInfoCard'; + +import { walletKit } from '@/utils/WalletKitUtil'; +import { handleRedirect } from '@/utils/LinkingUtils'; +import LogStore from '@/store/LogStore'; +import ModalStore from '@/store/ModalStore'; +import SettingsStore from '@/store/SettingsStore'; +import { RequestModal } from './RequestModal'; +import { + approveBitcoinRequest, + rejectBitcoinRequest, +} from '@/utils/BitcoinRequestHandlerUtil'; +import { Text } from '@/components/Text'; +import { Spacing } from '@/utils/ThemeUtil'; +import { haptics } from '@/utils/haptics'; + +export default function SessionBitcoinSignMessageModal() { + const { data } = useSnapshot(ModalStore.state); + const { currentRequestVerifyContext } = useSnapshot(SettingsStore.state); + const requestEvent = data?.requestEvent; + const session = data?.requestSession; + const isLinkMode = session?.transportType === 'link_mode'; + + const [isLoadingApprove, setIsLoadingApprove] = useState(false); + const [isLoadingReject, setIsLoadingReject] = useState(false); + + const { validation, isScam } = currentRequestVerifyContext?.verified || {}; + + const { topic, params } = requestEvent!; + const { request } = params; + const peerMetadata = session?.peer?.metadata as SignClientTypes.Metadata; + + const displayMessage = request.params?.message || ''; + + const onApprove = useCallback(async () => { + if (requestEvent) { + setIsLoadingApprove(true); + try { + const response = await approveBitcoinRequest(requestEvent); + await walletKit.respondSessionRequest({ topic, response }); + haptics.requestResponse(); + // The handler returns a JSON-RPC error response (rather than throwing) + // when signing fails. Surface it so the wallet doesn't look successful. + const errorMessage = + 'error' in response ? response.error.message : undefined; + if (errorMessage) { + showToast({ + type: 'error', + text1: 'Couldn’t sign message', + text2: errorMessage, + }); + } + handleRedirect({ + peerRedirect: peerMetadata?.redirect, + isLinkMode: isLinkMode, + error: errorMessage, + }); + } catch (e) { + LogStore.error( + (e as Error).message, + 'SessionBitcoinSignMessageModal', + 'onApprove', + ); + showToast({ + type: 'error', + text1: 'Couldn’t sign message', + text2: (e as Error).message, + }); + } finally { + setIsLoadingApprove(false); + ModalStore.close(); + } + } + }, [requestEvent, peerMetadata, topic, isLinkMode]); + + const onReject = useCallback(async () => { + if (requestEvent) { + setIsLoadingReject(true); + try { + const response = rejectBitcoinRequest(requestEvent); + await walletKit.respondSessionRequest({ topic, response }); + haptics.requestResponse(); + handleRedirect({ + peerRedirect: peerMetadata?.redirect, + isLinkMode: isLinkMode, + error: 'User rejected Bitcoin message request', + }); + } catch (e) { + LogStore.error( + (e as Error).message, + 'SessionBitcoinSignMessageModal', + 'onReject', + ); + showToast({ + type: 'error', + text1: 'Couldn’t reject request', + text2: (e as Error).message, + }); + } finally { + setIsLoadingReject(false); + ModalStore.close(); + } + } + }, [requestEvent, topic, peerMetadata, isLinkMode]); + + if (!requestEvent || !session) { + return ( + + Missing request data + + ); + } + + return ( + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + width: '100%', + marginVertical: Spacing[2], + paddingHorizontal: Spacing[4], + rowGap: Spacing[2], + }, +}); diff --git a/wallets/rn_cli_wallet/src/modals/SessionProposalModal.tsx b/wallets/rn_cli_wallet/src/modals/SessionProposalModal.tsx index bf12cdd7..63dfb4c0 100644 --- a/wallets/rn_cli_wallet/src/modals/SessionProposalModal.tsx +++ b/wallets/rn_cli_wallet/src/modals/SessionProposalModal.tsx @@ -32,6 +32,12 @@ import { SOLANA_EVENTS, SOLANA_SIGNING_METHODS, } from '@/constants/Solana'; +import { bitcoinAddresses } from '@/utils/BitcoinWalletUtil'; +import { + BIP122_CHAINS, + BIP122_EVENTS, + BIP122_SIGNING_METHODS, +} from '@/constants/Bitcoin'; import { AccordionCard } from '@/components/AccordionCard'; import { AppInfoCard } from '@/components/AppInfoCard'; import { NetworkSelector } from '@/components/NetworkSelector'; @@ -90,6 +96,10 @@ export default function SessionProposalModal() { const solanaMethods = Object.values(SOLANA_SIGNING_METHODS); const solanaEvents = Object.values(SOLANA_EVENTS); + const bip122Chains = Object.keys(BIP122_CHAINS); + const bip122Methods = Object.values(BIP122_SIGNING_METHODS); + const bip122Events = Object.values(BIP122_EVENTS); + return { eip155: { chains: eip155Chains, @@ -135,6 +145,17 @@ export default function SessionProposalModal() { ? solanaChains.map(chain => `${chain}:${solanaAddresses[0]}`) : [], }, + bip122: { + chains: bip122Chains, + methods: bip122Methods, + events: bip122Events, + // Expose both the payment (P2WPKH) and ordinals (P2TR) addresses. + accounts: bitcoinAddresses?.[0] + ? bip122Chains.flatMap(chain => + bitcoinAddresses.map(address => `${chain}:${address}`), + ) + : [], + }, }; }, []); diff --git a/wallets/rn_cli_wallet/src/screens/SecretPhrase/index.tsx b/wallets/rn_cli_wallet/src/screens/SecretPhrase/index.tsx index 52866c97..9b9753de 100644 --- a/wallets/rn_cli_wallet/src/screens/SecretPhrase/index.tsx +++ b/wallets/rn_cli_wallet/src/screens/SecretPhrase/index.tsx @@ -121,6 +121,7 @@ export default function SecretPhrase() { tronWallet, cantonWallet, solanaWallet, + bitcoinWallet, } = useSnapshot(SettingsStore.state); const Theme = useTheme(); @@ -145,6 +146,9 @@ export default function SecretPhrase() { const solanaMnemonic = solanaWallet?.getMnemonic?.() || null; const solanaBase58SecretKey = solanaWallet?.getSecretKey?.() ?? null; + // Get Bitcoin mnemonic + const bitcoinMnemonic = bitcoinWallet?.getMnemonic?.() ?? null; + return ( )} + + ); } diff --git a/wallets/rn_cli_wallet/src/screens/Wallets/components/TokenBalanceCard.tsx b/wallets/rn_cli_wallet/src/screens/Wallets/components/TokenBalanceCard.tsx index 818609dc..ce5d139a 100644 --- a/wallets/rn_cli_wallet/src/screens/Wallets/components/TokenBalanceCard.tsx +++ b/wallets/rn_cli_wallet/src/screens/Wallets/components/TokenBalanceCard.tsx @@ -101,7 +101,9 @@ export const TokenBalanceCard = React.memo(function TokenBalanceCard({ - {formatBalance(balance.quantity.numeric, balance.symbol)} + {balance.balanceUnavailable + ? `~ ${balance.symbol}` + : formatBalance(balance.quantity.numeric, balance.symbol)} {truncateAddress(walletAddress)} diff --git a/wallets/rn_cli_wallet/src/screens/Wallets/index.tsx b/wallets/rn_cli_wallet/src/screens/Wallets/index.tsx index 1753450d..3e410799 100644 --- a/wallets/rn_cli_wallet/src/screens/Wallets/index.tsx +++ b/wallets/rn_cli_wallet/src/screens/Wallets/index.tsx @@ -28,13 +28,22 @@ function getAddressForChain( if (chainId.startsWith('solana:')) { return addresses.solanaAddress || ''; } + if (chainId.startsWith('bip122:')) { + return addresses.bitcoinAddress || ''; + } // Default to EIP155 address for all EVM chains return addresses.eip155Address || ''; } export default function Wallets() { - const { eip155Address, tonAddress, tronAddress, suiAddress, solanaAddress } = - useSnapshot(SettingsStore.state); + const { + eip155Address, + tonAddress, + tronAddress, + suiAddress, + solanaAddress, + bitcoinAddress, + } = useSnapshot(SettingsStore.state); const { balances, isLoading } = useSnapshot(WalletStore.state); const Theme = useTheme(); @@ -45,8 +54,16 @@ export default function Wallets() { tronAddress, suiAddress, solanaAddress, + bitcoinAddress, }), - [eip155Address, tonAddress, tronAddress, suiAddress, solanaAddress], + [ + eip155Address, + tonAddress, + tronAddress, + suiAddress, + solanaAddress, + bitcoinAddress, + ], ); const fetchBalances = useCallback(() => { @@ -55,7 +72,8 @@ export default function Wallets() { addresses.tonAddress || addresses.tronAddress || addresses.suiAddress || - addresses.solanaAddress + addresses.solanaAddress || + addresses.bitcoinAddress ) { WalletStore.fetchBalances(addresses); } diff --git a/wallets/rn_cli_wallet/src/services/BalanceService.ts b/wallets/rn_cli_wallet/src/services/BalanceService.ts index 86c642ee..e72b31d5 100644 --- a/wallets/rn_cli_wallet/src/services/BalanceService.ts +++ b/wallets/rn_cli_wallet/src/services/BalanceService.ts @@ -5,6 +5,19 @@ import LogStore, { serializeError } from '@/store/LogStore'; const BALANCE_API_PATH = '/v1/account'; +// Thrown when the balance API returns a non-OK HTTP status. Carries the status +// so callers can branch on it (e.g. 400 = chain/address unsupported) without +// parsing the error message. Failures already logged here at the appropriate +// level, so the aggregator doesn't re-log them. +class BalanceFetchError extends Error { + status: number; + constructor(chainId: string, status: number) { + super(`Failed to fetch balance for ${chainId}: ${status}`); + this.name = 'BalanceFetchError'; + this.status = status; + } +} + /** * Fetches token balances for a single chain * @param address - Wallet address @@ -64,13 +77,25 @@ async function fetchBalanceForChain( if (!response.ok) { const errorText = await response.text(); - LogStore.error('Error response', 'BalanceService', 'fetchBalanceForChain', { - status: response.status, - body: errorText, - }); - throw new Error( - `Failed to fetch balance for ${chainId}: ${response.status}`, - ); + // A 400 means the balance API doesn't support this chain/address format + // (e.g. bip122/Bitcoin, sui) — this is expected and handled by the caller + // (fails silently per-chain), so log it as a warning rather than an error. + if (response.status === 400) { + LogStore.warn( + 'Chain not supported by balance API', + 'BalanceService', + 'fetchBalanceForChain', + { chainId, status: response.status, body: errorText }, + ); + } else { + LogStore.error( + 'Error response', + 'BalanceService', + 'fetchBalanceForChain', + { chainId, status: response.status, body: errorText }, + ); + } + throw new BalanceFetchError(chainId, response.status); } const data: BalanceResponse = await response.json(); @@ -130,14 +155,15 @@ export async function fetchBalancesForChains( }, ); allBalances.push(...result.value); - } else { + } else if (!(result.reason instanceof BalanceFetchError)) { + // HTTP failures are already logged (at the right level) by + // fetchBalanceForChain; only surface unexpected errors here, e.g. a + // network failure that threw before a response was received. LogStore.error( `Chain ${chainId} failed`, 'BalanceService', 'fetchBalancesForChains', - { - error: serializeError(result.reason), - }, + { error: serializeError(result.reason) }, ); } }); diff --git a/wallets/rn_cli_wallet/src/store/ModalStore.ts b/wallets/rn_cli_wallet/src/store/ModalStore.ts index 2d9a2f23..58f0cc23 100644 --- a/wallets/rn_cli_wallet/src/store/ModalStore.ts +++ b/wallets/rn_cli_wallet/src/store/ModalStore.ts @@ -36,6 +36,9 @@ interface State { | 'SessionSignCantonModal' | 'SessionSolanaSignMessageModal' | 'SessionSolanaSignTransactionModal' + | 'SessionBitcoinSignMessageModal' + | 'SessionBitcoinSendTransactionModal' + | 'SessionBitcoinGetAddressesModal' | 'PaymentOptionsModal' | 'ImportWalletModal' | 'SessionDetailModal' diff --git a/wallets/rn_cli_wallet/src/store/SettingsStore.ts b/wallets/rn_cli_wallet/src/store/SettingsStore.ts index b90b8869..12e28253 100644 --- a/wallets/rn_cli_wallet/src/store/SettingsStore.ts +++ b/wallets/rn_cli_wallet/src/store/SettingsStore.ts @@ -9,6 +9,7 @@ import TonLib from '../lib/TonLib'; import TronLib from '../lib/TronLib'; import CantonLib from '../lib/CantonLib'; import SolanaLib from '../lib/SolanaLib'; +import BitcoinLib from '../lib/BitcoinLib'; import { MMKV } from 'react-native-mmkv'; function getInitialThemeMode(): 'light' | 'dark' { @@ -41,6 +42,8 @@ interface State { cantonWallet: CantonLib | null; solanaAddress: string; solanaWallet: SolanaLib | null; + bitcoinAddress: string; + bitcoinWallet: BitcoinLib | null; relayerRegionURL: string; activeChainId: string; currentRequestVerifyContext?: Verify.Context; @@ -75,6 +78,8 @@ const state = proxy({ cantonWallet: null, solanaAddress: '', solanaWallet: null, + bitcoinAddress: '', + bitcoinWallet: null, relayerRegionURL: '', sessions: [], wallet: null, @@ -184,6 +189,14 @@ const SettingsStore = { state.solanaWallet = ref(solanaWallet); }, + setBitcoinAddress(bitcoinAddress: string) { + state.bitcoinAddress = bitcoinAddress; + }, + + setBitcoinWallet(bitcoinWallet: BitcoinLib) { + state.bitcoinWallet = ref(bitcoinWallet); + }, + setThemeMode(value: 'light' | 'dark') { state.themeMode = value; Appearance.setColorScheme?.(value); diff --git a/wallets/rn_cli_wallet/src/store/WalletStore.ts b/wallets/rn_cli_wallet/src/store/WalletStore.ts index 8db04bc5..604a8f62 100644 --- a/wallets/rn_cli_wallet/src/store/WalletStore.ts +++ b/wallets/rn_cli_wallet/src/store/WalletStore.ts @@ -6,6 +6,7 @@ import { fetchERC20Balances } from '@/services/ERC20BalanceService'; import { EIP155_CHAINS } from '@/constants/Eip155'; import { isSpamToken } from '@/utils/SpamFilter'; import { SOLANA_MAINNET_CAIP2 } from '@/constants/Solana'; +import { BIP122_MAINNET_CAIP2 } from '@/constants/Bitcoin'; import LogStore, { serializeError } from '@/store/LogStore'; const mmkv = new MMKV(); @@ -16,6 +17,7 @@ const TON_SUPPORTED_CHAINS = ['ton:-239']; const TRON_SUPPORTED_CHAINS = ['tron:0x2b6653dc']; const SUI_SUPPORTED_CHAINS = ['sui:mainnet']; const SOLANA_SUPPORTED_CHAINS = [SOLANA_MAINNET_CAIP2]; +const BITCOIN_SUPPORTED_CHAINS = [BIP122_MAINNET_CAIP2]; export interface WalletAddresses { eip155Address?: string; @@ -23,6 +25,7 @@ export interface WalletAddresses { tronAddress?: string; suiAddress?: string; solanaAddress?: string; + bitcoinAddress?: string; } interface WalletState { @@ -78,6 +81,7 @@ const MAINNET_NATIVE_TOKENS = { 'tron:0x2b6653dc': { name: 'TRON', symbol: 'TRX', decimals: '6' }, 'sui:mainnet': { name: 'Sui', symbol: 'SUI', decimals: '9' }, [SOLANA_MAINNET_CAIP2]: { name: 'Solana', symbol: 'SOL', decimals: '9' }, + [BIP122_MAINNET_CAIP2]: { name: 'Bitcoin', symbol: 'BTC', decimals: '8' }, }; /** @@ -85,9 +89,21 @@ const MAINNET_NATIVE_TOKENS = { * 1. Filters out tokens with 0 value (except mainnet native tokens) * 2. Ensures mainnet native tokens are always present for address visibility */ +// Per-namespace flag: true when that chain's balance request failed, so the +// synthesized native row should read "~" (unknown) instead of a confirmed 0. +interface BalanceUnavailableFlags { + eip155?: boolean; + ton?: boolean; + tron?: boolean; + sui?: boolean; + solana?: boolean; + bitcoin?: boolean; +} + function processBalances( apiBalances: TokenBalance[], addresses: WalletAddresses, + unavailable: BalanceUnavailableFlags = {}, ): TokenBalance[] { const mainnetChainIds = Object.keys(MAINNET_NATIVE_TOKENS); @@ -118,6 +134,7 @@ function processBalances( price: 0, quantity: { decimals: '18', numeric: '0' }, iconUrl: undefined, + balanceUnavailable: unavailable.eip155, }); } } @@ -136,6 +153,7 @@ function processBalances( price: 0, quantity: { decimals: '9', numeric: '0' }, iconUrl: undefined, + balanceUnavailable: unavailable.ton, }); } } @@ -154,6 +172,7 @@ function processBalances( price: 0, quantity: { decimals: '6', numeric: '0' }, iconUrl: undefined, + balanceUnavailable: unavailable.tron, }); } } @@ -172,6 +191,7 @@ function processBalances( price: 0, quantity: { decimals: '9', numeric: '0' }, iconUrl: undefined, + balanceUnavailable: unavailable.sui, }); } } @@ -190,6 +210,26 @@ function processBalances( price: 0, quantity: { decimals: '9', numeric: '0' }, iconUrl: undefined, + balanceUnavailable: unavailable.solana, + }); + } + } + + // BTC on mainnet + if (addresses.bitcoinAddress) { + const hasBtcMainnet = result.some( + b => b.chainId === BIP122_MAINNET_CAIP2 && !b.address, + ); + if (!hasBtcMainnet) { + result.push({ + name: 'Bitcoin', + symbol: 'BTC', + chainId: BIP122_MAINNET_CAIP2, + value: 0, + price: 0, + quantity: { decimals: '8', numeric: '0' }, + iconUrl: undefined, + balanceUnavailable: unavailable.bitcoin, }); } } @@ -220,7 +260,8 @@ const WalletStore = { !addresses.tonAddress && !addresses.tronAddress && !addresses.suiAddress && - !addresses.solanaAddress + !addresses.solanaAddress && + !addresses.bitcoinAddress ) { return; } @@ -237,6 +278,7 @@ const WalletStore = { tronResult, suiResult, solanaResult, + bitcoinResult, erc20Balances, ] = await Promise.all([ // EIP155 balances (or empty result if no address) @@ -277,6 +319,16 @@ const WalletStore = { balances: [] as TokenBalance[], anySuccess: false, }), + // Bitcoin balances (or empty result if no address) + addresses.bitcoinAddress + ? fetchBalancesForChains( + addresses.bitcoinAddress, + BITCOIN_SUPPORTED_CHAINS, + ) + : Promise.resolve({ + balances: [] as TokenBalance[], + anySuccess: false, + }), // On-chain ERC-20 balances (EURC etc.) addresses.eip155Address ? fetchERC20Balances(addresses.eip155Address) @@ -289,7 +341,8 @@ const WalletStore = { tonResult.anySuccess || tronResult.anySuccess || suiResult.anySuccess || - solanaResult.anySuccess; + solanaResult.anySuccess || + bitcoinResult.anySuccess; if (!anySuccess) { return; @@ -302,6 +355,7 @@ const WalletStore = { ...tronResult.balances, ...suiResult.balances, ...solanaResult.balances, + ...bitcoinResult.balances, ]; // Merge on-chain ERC-20 balances (only non-zero) unless the API already returned them @@ -334,8 +388,20 @@ const WalletStore = { } } + // A group that has an address but didn't return any successful response + // means the balance is unknown (e.g. the API rejects bip122/sui) — mark + // it so the synthesized native row shows "~" instead of a confirmed 0. + const unavailable = { + eip155: !!addresses.eip155Address && !eip155Result.anySuccess, + ton: !!addresses.tonAddress && !tonResult.anySuccess, + tron: !!addresses.tronAddress && !tronResult.anySuccess, + sui: !!addresses.suiAddress && !suiResult.anySuccess, + solana: !!addresses.solanaAddress && !solanaResult.anySuccess, + bitcoin: !!addresses.bitcoinAddress && !bitcoinResult.anySuccess, + }; + // Filter 0-balance tokens and ensure mainnet natives are present - const allBalances = processBalances(apiBalances, addresses); + const allBalances = processBalances(apiBalances, addresses, unavailable); // Sort: tokens with value first, then by chain allBalances.sort((a, b) => { diff --git a/wallets/rn_cli_wallet/src/utils/BalanceTypes.ts b/wallets/rn_cli_wallet/src/utils/BalanceTypes.ts index 8b3d2c97..4943442e 100644 --- a/wallets/rn_cli_wallet/src/utils/BalanceTypes.ts +++ b/wallets/rn_cli_wallet/src/utils/BalanceTypes.ts @@ -10,6 +10,9 @@ export interface TokenBalance { numeric: string; }; iconUrl?: string; + // True when the balance API request for this chain failed, so the amount is + // unknown (shown as "~") rather than a confirmed 0. + balanceUnavailable?: boolean; } export interface BalanceResponse { diff --git a/wallets/rn_cli_wallet/src/utils/BitcoinRequestHandlerUtil.ts b/wallets/rn_cli_wallet/src/utils/BitcoinRequestHandlerUtil.ts new file mode 100644 index 00000000..93472871 --- /dev/null +++ b/wallets/rn_cli_wallet/src/utils/BitcoinRequestHandlerUtil.ts @@ -0,0 +1,104 @@ +import { formatJsonRpcError, formatJsonRpcResult } from '@json-rpc-tools/utils'; +import { SignClientTypes } from '@walletconnect/types'; +import { getSdkError } from '@walletconnect/utils'; + +import LogStore, { serializeError } from '@/store/LogStore'; +import { getWallet } from './BitcoinWalletUtil'; +import { BIP122_SIGNING_METHODS, IBip122ChainId } from '@/constants/Bitcoin'; + +type RequestEventArgs = Omit< + SignClientTypes.EventArguments['session_request'], + 'verifyContext' +>; + +export async function approveBitcoinRequest(requestEvent: RequestEventArgs) { + const { params, id } = requestEvent; + const { chainId, request } = params; + const bip122ChainId = chainId as IBip122ChainId; + + const wallet = await getWallet(); + if (!wallet) { + LogStore.error( + 'Bitcoin wallet not initialized', + 'BitcoinRequestHandler', + 'approveBitcoinRequest', + ); + return formatJsonRpcError(id, 'Bitcoin wallet not initialized'); + } + + const account = wallet.getAddress(bip122ChainId); + + switch (request.method) { + case BIP122_SIGNING_METHODS.BIP122_SIGN_MESSAGE: + try { + const result = await wallet.signMessage({ + message: request.params.message, + address: request.params.address || account, + protocol: request.params.protocol, + chainId: bip122ChainId, + }); + return formatJsonRpcResult(id, result); + } catch (error: any) { + LogStore.error(error.message, 'BitcoinRequestHandler', 'signMessage', { + error: serializeError(error), + }); + return formatJsonRpcError(id, error.message); + } + case BIP122_SIGNING_METHODS.BIP122_GET_ACCOUNT_ADDRESSES: + try { + const addresses = wallet.getAddresses( + bip122ChainId, + request.params?.intentions, + ); + return formatJsonRpcResult(id, Array.from(addresses.values())); + } catch (error: any) { + LogStore.error( + error.message, + 'BitcoinRequestHandler', + 'getAccountAddresses', + { error: serializeError(error) }, + ); + return formatJsonRpcError(id, error.message); + } + case BIP122_SIGNING_METHODS.BIP122_SEND_TRANSACTION: + try { + const txid = await wallet.sendTransfer({ + account: request.params.account || account, + recipientAddress: request.params.recipientAddress, + amount: request.params.amount, + changeAddress: request.params.changeAddress, + memo: request.params.memo, + chainId: bip122ChainId, + }); + return formatJsonRpcResult(id, { txid }); + } catch (error: any) { + LogStore.error(error.message, 'BitcoinRequestHandler', 'sendTransfer', { + error: serializeError(error), + }); + return formatJsonRpcError(id, error.message); + } + case BIP122_SIGNING_METHODS.BIP122_SIGN_PSBT: + try { + const result = await wallet.signPsbt({ + account: request.params.account || account, + psbt: request.params.psbt, + signInputs: request.params.signInputs, + broadcast: request.params.broadcast ?? false, + chainId: bip122ChainId, + }); + return formatJsonRpcResult(id, result); + } catch (error: any) { + LogStore.error(error.message, 'BitcoinRequestHandler', 'signPsbt', { + error: serializeError(error), + }); + return formatJsonRpcError(id, error.message); + } + default: + throw new Error(getSdkError('INVALID_METHOD').message); + } +} + +export function rejectBitcoinRequest(request: RequestEventArgs) { + const { id } = request; + return formatJsonRpcError(id, getSdkError('USER_REJECTED').message); +} diff --git a/wallets/rn_cli_wallet/src/utils/BitcoinWalletUtil.ts b/wallets/rn_cli_wallet/src/utils/BitcoinWalletUtil.ts new file mode 100644 index 00000000..66000a1c --- /dev/null +++ b/wallets/rn_cli_wallet/src/utils/BitcoinWalletUtil.ts @@ -0,0 +1,85 @@ +import * as bip39 from 'bip39'; + +import BitcoinLib from '../lib/BitcoinLib'; +import { storage } from './storage'; +import SettingsStore from '@/store/SettingsStore'; +import { BIP122_MAINNET_CAIP2 } from '@/constants/Bitcoin'; + +const BITCOIN_MNEMONIC_KEY = 'BITCOIN_MNEMONIC_1'; + +export let wallet1: BitcoinLib; +// [0] = mainnet payment (P2WPKH) address, [1] = mainnet ordinals (P2TR) address +export let bitcoinAddresses: string[]; + +function isMnemonic(input: string): boolean { + const words = input.split(/\s+/).filter(Boolean); + if (![12, 15, 18, 21, 24].includes(words.length)) { + return false; + } + return bip39.validateMnemonic(input); +} + +/** + * Utilities + */ +export async function createOrRestoreBitcoinWallet() { + const mnemonic1 = await storage.getItem(BITCOIN_MNEMONIC_KEY); + + if (mnemonic1) { + wallet1 = await BitcoinLib.init({ mnemonic: mnemonic1 }); + } else { + wallet1 = await BitcoinLib.init({}); + // Don't store private keys in local storage in a production project! + await storage.setItem(BITCOIN_MNEMONIC_KEY, wallet1.getMnemonic()); + } + + bitcoinAddresses = [ + wallet1.getAddress(BIP122_MAINNET_CAIP2), + wallet1.getOrdinalsAddress(BIP122_MAINNET_CAIP2), + ]; + + return { + bitcoinWallet: wallet1, + bitcoinAddress: bitcoinAddresses[0], + bitcoinAddresses, + }; +} + +export const getWallet = async () => { + return wallet1; +}; + +export async function loadBitcoinWallet(input: string): Promise<{ + address: string; + wallet: BitcoinLib; +}> { + const trimmedInput = input.trim(); + + if (!isMnemonic(trimmedInput)) { + throw new Error('Invalid input. Provide a 12-24 word BIP39 mnemonic.'); + } + + const newWallet = await BitcoinLib.init({ mnemonic: trimmedInput }); + await storage.setItem(BITCOIN_MNEMONIC_KEY, trimmedInput); + + const newAddress = newWallet.getAddress(BIP122_MAINNET_CAIP2); + + // Update module-level exports + wallet1 = newWallet; + bitcoinAddresses = [ + newAddress, + newWallet.getOrdinalsAddress(BIP122_MAINNET_CAIP2), + ]; + + if (__DEV__) { + console.warn( + '[SECURITY] Bitcoin key material stored unencrypted. Use secure enclave in production.', + ); + } + + // Update store + SettingsStore.setBitcoinAddress(newAddress); + SettingsStore.setBitcoinWallet(newWallet); + + return { address: newAddress, wallet: newWallet }; +} diff --git a/wallets/rn_cli_wallet/src/utils/PresetsUtil.ts b/wallets/rn_cli_wallet/src/utils/PresetsUtil.ts index a7dc9dd3..6036dc6e 100644 --- a/wallets/rn_cli_wallet/src/utils/PresetsUtil.ts +++ b/wallets/rn_cli_wallet/src/utils/PresetsUtil.ts @@ -5,6 +5,7 @@ import { EIP155_CHAINS, EIP155_NETWORK_IMAGES } from '@/constants/Eip155'; import { TRON_CHAINS, TRON_NETWORKS_IMAGES } from '@/constants/Tron'; import { CANTON_CHAINS, CANTON_NETWORKS_IMAGES } from '@/constants/Canton'; import { SOLANA_CHAINS, SOLANA_NETWORKS_IMAGES } from '@/constants/Solana'; +import { BIP122_CHAINS, BIP122_NETWORKS_IMAGES } from '@/constants/Bitcoin'; const NetworkImages: Record = { ...EIP155_NETWORK_IMAGES, @@ -13,6 +14,7 @@ const NetworkImages: Record = { ...TRON_NETWORKS_IMAGES, ...CANTON_NETWORKS_IMAGES, ...SOLANA_NETWORKS_IMAGES, + ...BIP122_NETWORKS_IMAGES, }; export const ALL_CHAINS = { @@ -22,6 +24,7 @@ export const ALL_CHAINS = { ...TRON_CHAINS, ...CANTON_CHAINS, ...SOLANA_CHAINS, + ...BIP122_CHAINS, }; export const PresetsUtil = { diff --git a/wallets/rn_cli_wallet/src/utils/misc.ts b/wallets/rn_cli_wallet/src/utils/misc.ts index 1accbbeb..645fc5c0 100644 --- a/wallets/rn_cli_wallet/src/utils/misc.ts +++ b/wallets/rn_cli_wallet/src/utils/misc.ts @@ -1,3 +1,5 @@ +import { Platform } from 'react-native'; + import { getBundleId } from '@/utils/AppInfo'; type Environment = 'debug' | 'internal' | 'production'; @@ -6,7 +8,12 @@ export function getEnvironment(): Environment { try { const bundleId = getBundleId(); if (!bundleId || typeof bundleId !== 'string') { - console.warn('Invalid bundle ID detected:', bundleId); + // On web there's no native bundle ID (expo-application returns null), so + // an empty value is expected — only warn on native, where it's a real + // signal that something is misconfigured. + if (Platform.OS !== 'web') { + console.warn('Invalid bundle ID detected:', bundleId); + } return 'production'; } if (bundleId.endsWith('.debug')) return 'debug'; diff --git a/wallets/rn_cli_wallet/yarn.lock b/wallets/rn_cli_wallet/yarn.lock index 0d31062d..8fcece50 100644 --- a/wallets/rn_cli_wallet/yarn.lock +++ b/wallets/rn_cli_wallet/yarn.lock @@ -2066,6 +2066,15 @@ __metadata: languageName: node linkType: hard +"@bitcoinerlab/secp256k1@npm:1.2.0": + version: 1.2.0 + resolution: "@bitcoinerlab/secp256k1@npm:1.2.0" + dependencies: + "@noble/curves": ^1.7.0 + checksum: c95eda77bc271f178d19a9c48cce53e5a303f6710dbd650aa24405416d87b52ab03038b7e28cc55150a0cdf0bd463d2b299791777c332c1556c86d03159e965a + languageName: node + linkType: hard + "@bottom-tabs/react-navigation@npm:1.3.1": version: 1.3.1 resolution: "@bottom-tabs/react-navigation@npm:1.3.1" @@ -3276,7 +3285,7 @@ __metadata: languageName: node linkType: hard -"@noble/curves@npm:1.9.7, @noble/curves@npm:^1.4.2": +"@noble/curves@npm:1.9.7, @noble/curves@npm:^1.4.2, @noble/curves@npm:^1.7.0": version: 1.9.7 resolution: "@noble/curves@npm:1.9.7" dependencies: @@ -3322,6 +3331,13 @@ __metadata: languageName: node linkType: hard +"@noble/secp256k1@npm:3.0.0": + version: 3.0.0 + resolution: "@noble/secp256k1@npm:3.0.0" + checksum: f5c3be6fa1f27bd00606051cba9de62031ee67c4c519b3df9232c7343a740b805ca092f7c7a8d2ac9d9db99e4cc7ae8a8929842db64911a80aa2a071317a4749 + languageName: node + linkType: hard + "@nodable/entities@npm:^2.1.0": version: 2.1.0 resolution: "@nodable/entities@npm:2.1.0" @@ -4054,7 +4070,7 @@ __metadata: languageName: node linkType: hard -"@scure/base@npm:1.2.6, @scure/base@npm:^1.2.6, @scure/base@npm:~1.2.5": +"@scure/base@npm:1.2.6, @scure/base@npm:^1.1.1, @scure/base@npm:^1.2.6, @scure/base@npm:~1.2.5": version: 1.2.6 resolution: "@scure/base@npm:1.2.6" checksum: 1058cb26d5e4c1c46c9cc0ae0b67cc66d306733baf35d6ebdd8ddaba242b80c3807b726e3b48cb0411bb95ec10d37764969063ea62188f86ae9315df8ea6b325 @@ -5186,6 +5202,7 @@ __metadata: "@babel/core": ^7.25.2 "@babel/preset-env": ^7.25.3 "@babel/runtime": ^7.25.0 + "@bitcoinerlab/secp256k1": 1.2.0 "@bottom-tabs/react-navigation": 1.3.1 "@craftzdog/react-native-buffer": 6.1.1 "@expo/metro-runtime": ~56.0.15 @@ -5194,6 +5211,7 @@ __metadata: "@mhpdev/react-native-haptics": 1.1.0 "@mysten/sui": 1.45.2 "@noble/hashes": 1.8.0 + "@noble/secp256k1": 3.0.0 "@react-native-async-storage/async-storage": 2.2.0 "@react-native-community/cli": ^20.0.0 "@react-native-community/cli-platform-android": ^20.0.0 @@ -5222,10 +5240,14 @@ __metadata: "@walletconnect/react-native-compat": 2.23.10 babel-plugin-module-resolver: ^5.0.0 babel-preset-expo: 56.0.15 + bip32: 4.0.0 bip39: 3.1.0 + bitcoinjs-lib: 6.1.7 + bitcoinjs-message: 2.2.0 bs58: 6.0.0 crc-32: 1.2.2 dayjs: 1.11.21 + ecpair: 2.1.0 ed25519-hd-key: 1.3.0 eslint: ^8.19.0 eslint-plugin-ft-flow: ^2.0.1 @@ -5967,6 +5989,13 @@ __metadata: languageName: node linkType: hard +"base-x@npm:^4.0.0": + version: 4.0.1 + resolution: "base-x@npm:4.0.1" + checksum: c9061e576f7376b2bc6b69eca131254bb16ebe1445b535a3f0d68f27524e724965b6c191dffd255bf80f9bdf5eb9d1c8d0320903e83116f2c3e09f81b5ecb6a2 + languageName: node + linkType: hard + "base-x@npm:^5.0.0": version: 5.0.1 resolution: "base-x@npm:5.0.1" @@ -5999,6 +6028,20 @@ __metadata: languageName: node linkType: hard +"bech32@npm:^1.1.3": + version: 1.1.4 + resolution: "bech32@npm:1.1.4" + checksum: 0e98db619191548390d6f09ff68b0253ba7ae6a55db93dfdbb070ba234c1fd3308c0606fbcc95fad50437227b10011e2698b89f0181f6e7f845c499bd14d0f4b + languageName: node + linkType: hard + +"bech32@npm:^2.0.0": + version: 2.0.0 + resolution: "bech32@npm:2.0.0" + checksum: fa15acb270b59aa496734a01f9155677b478987b773bf701f465858bf1606c6a970085babd43d71ce61895f1baa594cb41a2cd1394bd2c6698f03cc2d811300e + languageName: node + linkType: hard + "big-integer@npm:1.6.x": version: 1.6.52 resolution: "big-integer@npm:1.6.52" @@ -6013,6 +6056,34 @@ __metadata: languageName: node linkType: hard +"bindings@npm:^1.5.0": + version: 1.5.0 + resolution: "bindings@npm:1.5.0" + dependencies: + file-uri-to-path: 1.0.0 + checksum: 65b6b48095717c2e6105a021a7da4ea435aa8d3d3cd085cb9e85bcb6e5773cf318c4745c3f7c504412855940b585bdf9b918236612a1c7a7942491de176f1ae7 + languageName: node + linkType: hard + +"bip174@npm:^2.1.1": + version: 2.1.1 + resolution: "bip174@npm:2.1.1" + checksum: bc5b99e7d1acd9484aec117dc4d931a8b6d3a77ffb84e9672a9e8be2e41c22a3d41b4dad307cbe84091c6a30ee2ceaf8e1b3036b91201d4767d0772485ecb225 + languageName: node + linkType: hard + +"bip32@npm:4.0.0": + version: 4.0.0 + resolution: "bip32@npm:4.0.0" + dependencies: + "@noble/hashes": ^1.2.0 + "@scure/base": ^1.1.1 + typeforce: ^1.11.5 + wif: ^2.0.6 + checksum: a36253ac164db50a25e88effce84aec1eaba2ec2fbd7a19bc1f836f9b1c934fa148c3a01ddece7f136596330074d98c6b2a441e51914c302d130f56f0f893d0d + languageName: node + linkType: hard + "bip39@npm:3.1.0": version: 3.1.0 resolution: "bip39@npm:3.1.0" @@ -6031,6 +6102,43 @@ __metadata: languageName: node linkType: hard +"bip66@npm:^1.1.5": + version: 1.1.5 + resolution: "bip66@npm:1.1.5" + dependencies: + safe-buffer: ^5.0.1 + checksum: 956cff6e51d7206571ef8ce875bc5fa61b5c181589790b9155799b7edcae4b20dbb3eed43b188ff3eec27cdbe98e0b7e0ec9f1cb2e4f5370c119028b248ad859 + languageName: node + linkType: hard + +"bitcoinjs-lib@npm:6.1.7": + version: 6.1.7 + resolution: "bitcoinjs-lib@npm:6.1.7" + dependencies: + "@noble/hashes": ^1.2.0 + bech32: ^2.0.0 + bip174: ^2.1.1 + bs58check: ^3.0.1 + typeforce: ^1.11.3 + varuint-bitcoin: ^1.1.2 + checksum: 2fbac2bffc2fe0e1d5441fc09f092fa8a83a4572b10fd2601c960e19a60cdcab81b6bff8cefcefda80b952fe8795387c97219fc4676995bf554f26f3d9aadc58 + languageName: node + linkType: hard + +"bitcoinjs-message@npm:2.2.0": + version: 2.2.0 + resolution: "bitcoinjs-message@npm:2.2.0" + dependencies: + bech32: ^1.1.3 + bs58check: ^2.1.2 + buffer-equals: ^1.0.3 + create-hash: ^1.1.2 + secp256k1: ^3.0.1 + varuint-bitcoin: ^1.0.1 + checksum: 2b851d7b976487118f34507739aabf5bca4b3c14ba997f46b932c71a83104a66a9476a808688a319e3a772c026d65381939852f091affc9dbae0f174eb6b03a1 + languageName: node + linkType: hard + "bl@npm:^4.0.3, bl@npm:^4.1.0": version: 4.1.0 resolution: "bl@npm:4.1.0" @@ -6136,6 +6244,13 @@ __metadata: languageName: node linkType: hard +"brorand@npm:^1.1.0": + version: 1.1.0 + resolution: "brorand@npm:1.1.0" + checksum: 8a05c9f3c4b46572dec6ef71012b1946db6cae8c7bb60ccd4b7dd5a84655db49fe043ecc6272e7ef1f69dc53d6730b9e2a3a03a8310509a3d797a618cbee52be + languageName: node + linkType: hard + "brotli@npm:1.3.3": version: 1.3.3 resolution: "brotli@npm:1.3.3" @@ -6145,6 +6260,20 @@ __metadata: languageName: node linkType: hard +"browserify-aes@npm:^1.0.6": + version: 1.2.0 + resolution: "browserify-aes@npm:1.2.0" + dependencies: + buffer-xor: ^1.0.3 + cipher-base: ^1.0.0 + create-hash: ^1.1.0 + evp_bytestokey: ^1.0.3 + inherits: ^2.0.1 + safe-buffer: ^5.0.1 + checksum: 4a17c3eb55a2aa61c934c286f34921933086bf6d67f02d4adb09fcc6f2fc93977b47d9d884c25619144fccd47b3b3a399e1ad8b3ff5a346be47270114bcf7104 + languageName: node + linkType: hard + "browserslist@npm:^4.24.0, browserslist@npm:^4.28.0, browserslist@npm:^4.28.1": version: 4.28.1 resolution: "browserslist@npm:4.28.1" @@ -6193,6 +6322,36 @@ __metadata: languageName: node linkType: hard +"bs58@npm:^5.0.0": + version: 5.0.0 + resolution: "bs58@npm:5.0.0" + dependencies: + base-x: ^4.0.0 + checksum: 2475cb0684e07077521aac718e604a13e0f891d58cff923d437a2f7e9e28703ab39fce9f84c7c703ab369815a675f11e3bd394d38643bfe8969fbe42e6833d45 + languageName: node + linkType: hard + +"bs58check@npm:<3.0.0, bs58check@npm:^2.1.2": + version: 2.1.2 + resolution: "bs58check@npm:2.1.2" + dependencies: + bs58: ^4.0.0 + create-hash: ^1.1.0 + safe-buffer: ^5.1.2 + checksum: 43bdf08a5dd04581b78f040bc4169480e17008da482ffe2a6507327bbc4fc5c28de0501f7faf22901cfe57fbca79cbb202ca529003fedb4cb8dccd265b38e54d + languageName: node + linkType: hard + +"bs58check@npm:^3.0.1": + version: 3.0.1 + resolution: "bs58check@npm:3.0.1" + dependencies: + "@noble/hashes": ^1.2.0 + bs58: ^5.0.0 + checksum: dbbecc7a09f3836e821149266c864c4bbd545539cea43c35f23f4c3c46b54c86c52b65d224b9ea2e916fa6d93bd2ce9fac5b6c6bfcf19621a9c209a5602f71c8 + languageName: node + linkType: hard + "bser@npm:2.1.1": version: 2.1.1 resolution: "bser@npm:2.1.1" @@ -6202,6 +6361,13 @@ __metadata: languageName: node linkType: hard +"buffer-equals@npm:^1.0.3": + version: 1.0.4 + resolution: "buffer-equals@npm:1.0.4" + checksum: 392a2f82acdaad46392aec7ce54a8ff0b2a650b5802ccb0c77072050bbc7fd4e101f38460c7e88cdc7e130421882977f595d5c1a3d3343611562ecf7c684a70f + languageName: node + linkType: hard + "buffer-from@npm:^1.0.0": version: 1.1.2 resolution: "buffer-from@npm:1.1.2" @@ -6209,6 +6375,13 @@ __metadata: languageName: node linkType: hard +"buffer-xor@npm:^1.0.3": + version: 1.0.3 + resolution: "buffer-xor@npm:1.0.3" + checksum: 10c520df29d62fa6e785e2800e586a20fc4f6dfad84bcdbd12e1e8a83856de1cb75c7ebd7abe6d036bbfab738a6cf18a3ae9c8e5a2e2eb3167ca7399ce65373a + languageName: node + linkType: hard + "buffer@npm:6.0.3, buffer@npm:^6.0.3, buffer@npm:~6.0.3": version: 6.0.3 resolution: "buffer@npm:6.0.3" @@ -6448,7 +6621,7 @@ __metadata: languageName: node linkType: hard -"cipher-base@npm:^1.0.1, cipher-base@npm:^1.0.3": +"cipher-base@npm:^1.0.0, cipher-base@npm:^1.0.1, cipher-base@npm:^1.0.3": version: 1.0.7 resolution: "cipher-base@npm:1.0.7" dependencies: @@ -6794,7 +6967,7 @@ __metadata: languageName: node linkType: hard -"create-hash@npm:^1.1.0": +"create-hash@npm:^1.1.0, create-hash@npm:^1.1.2, create-hash@npm:^1.2.0": version: 1.2.0 resolution: "create-hash@npm:1.2.0" dependencies: @@ -6807,7 +6980,7 @@ __metadata: languageName: node linkType: hard -"create-hmac@npm:1.1.7": +"create-hmac@npm:1.1.7, create-hmac@npm:^1.1.4": version: 1.1.7 resolution: "create-hmac@npm:1.1.7" dependencies: @@ -7274,6 +7447,17 @@ __metadata: languageName: node linkType: hard +"drbg.js@npm:^1.0.1": + version: 1.0.1 + resolution: "drbg.js@npm:1.0.1" + dependencies: + browserify-aes: ^1.0.6 + create-hash: ^1.1.2 + create-hmac: ^1.1.4 + checksum: f8df5cdd4fb792e548d6187cbc446fbd0afd8f1ef7fa486e1c286c2adee55a687183ce48ab178e9f24965c2deabb6e2ba7a7ee2d675264b951356480eb042476 + languageName: node + linkType: hard + "dunder-proto@npm:^1.0.0, dunder-proto@npm:^1.0.1": version: 1.0.1 resolution: "dunder-proto@npm:1.0.1" @@ -7292,6 +7476,17 @@ __metadata: languageName: node linkType: hard +"ecpair@npm:2.1.0": + version: 2.1.0 + resolution: "ecpair@npm:2.1.0" + dependencies: + randombytes: ^2.1.0 + typeforce: ^1.18.0 + wif: ^2.0.6 + checksum: 924a776808f91d2fdd33a7033f84e3bbfe668ae98c0b9764c7b923e018accd8de57012bf20e419e0a5ef73ec3ec3738a654e71abe12f537b2fd7bcf02b93659f + languageName: node + linkType: hard + "ed25519-hd-key@npm:1.3.0": version: 1.3.0 resolution: "ed25519-hd-key@npm:1.3.0" @@ -7323,6 +7518,21 @@ __metadata: languageName: node linkType: hard +"elliptic@npm:6.6.1": + version: 6.6.1 + resolution: "elliptic@npm:6.6.1" + dependencies: + bn.js: ^4.11.9 + brorand: ^1.1.0 + hash.js: ^1.0.0 + hmac-drbg: ^1.0.1 + inherits: ^2.0.4 + minimalistic-assert: ^1.0.1 + minimalistic-crypto-utils: ^1.0.1 + checksum: 27b14a52f68bbbc0720da259f712cb73e953f6d2047958cd02fb0d0ade2e83849dc39fb4af630889c67df8817e24237428cf59c4f4c07700f755b401149a7375 + languageName: node + linkType: hard + "emittery@npm:^0.13.1": version: 0.13.1 resolution: "emittery@npm:0.13.1" @@ -7989,6 +8199,17 @@ __metadata: languageName: node linkType: hard +"evp_bytestokey@npm:^1.0.3": + version: 1.0.3 + resolution: "evp_bytestokey@npm:1.0.3" + dependencies: + md5.js: ^1.3.4 + node-gyp: latest + safe-buffer: ^5.1.1 + checksum: ad4e1577f1a6b721c7800dcc7c733fe01f6c310732bb5bf2240245c2a5b45a38518b91d8be2c610611623160b9d1c0e91f1ce96d639f8b53e8894625cf20fa45 + languageName: node + linkType: hard + "execa@npm:^5.0.0": version: 5.1.1 resolution: "execa@npm:5.1.1" @@ -8414,6 +8635,13 @@ __metadata: languageName: node linkType: hard +"file-uri-to-path@npm:1.0.0": + version: 1.0.0 + resolution: "file-uri-to-path@npm:1.0.0" + checksum: b648580bdd893a008c92c7ecc96c3ee57a5e7b6c4c18a9a09b44fb5d36d79146f8e442578bc0e173dc027adf3987e254ba1dfd6e3ec998b7c282873010502144 + languageName: node + linkType: hard + "fill-range@npm:^7.1.1": version: 7.1.1 resolution: "fill-range@npm:7.1.1" @@ -8926,6 +9154,16 @@ __metadata: languageName: node linkType: hard +"hash.js@npm:^1.0.0, hash.js@npm:^1.0.3": + version: 1.1.7 + resolution: "hash.js@npm:1.1.7" + dependencies: + inherits: ^2.0.3 + minimalistic-assert: ^1.0.1 + checksum: e350096e659c62422b85fa508e4b3669017311aa4c49b74f19f8e1bc7f3a54a584fdfd45326d4964d6011f2b2d882e38bea775a96046f2a61b7779a979629d8f + languageName: node + linkType: hard + "hasown@npm:^2.0.2": version: 2.0.2 resolution: "hasown@npm:2.0.2" @@ -9008,6 +9246,17 @@ __metadata: languageName: node linkType: hard +"hmac-drbg@npm:^1.0.1": + version: 1.0.1 + resolution: "hmac-drbg@npm:1.0.1" + dependencies: + hash.js: ^1.0.3 + minimalistic-assert: ^1.0.0 + minimalistic-crypto-utils: ^1.0.1 + checksum: bd30b6a68d7f22d63f10e1888aee497d7c2c5c0bb469e66bbdac99f143904d1dfe95f8131f95b3e86c86dd239963c9d972fcbe147e7cffa00e55d18585c43fe0 + languageName: node + linkType: hard + "hoist-non-react-statics@npm:^3.3.0": version: 3.3.2 resolution: "hoist-non-react-statics@npm:3.3.2" @@ -11219,6 +11468,20 @@ __metadata: languageName: node linkType: hard +"minimalistic-assert@npm:^1.0.0, minimalistic-assert@npm:^1.0.1": + version: 1.0.1 + resolution: "minimalistic-assert@npm:1.0.1" + checksum: cc7974a9268fbf130fb055aff76700d7e2d8be5f761fb5c60318d0ed010d839ab3661a533ad29a5d37653133385204c503bfac995aaa4236f4e847461ea32ba7 + languageName: node + linkType: hard + +"minimalistic-crypto-utils@npm:^1.0.1": + version: 1.0.1 + resolution: "minimalistic-crypto-utils@npm:1.0.1" + checksum: 6e8a0422b30039406efd4c440829ea8f988845db02a3299f372fceba56ffa94994a9c0f2fd70c17f9969eedfbd72f34b5070ead9656a34d3f71c0bd72583a0ed + languageName: node + linkType: hard + "minimatch@npm:5.1.8": version: 5.1.8 resolution: "minimatch@npm:5.1.8" @@ -11364,6 +11627,15 @@ __metadata: languageName: node linkType: hard +"nan@npm:^2.14.0": + version: 2.28.0 + resolution: "nan@npm:2.28.0" + dependencies: + node-gyp: latest + checksum: a6b29036ae9825ca0c7e2b4956b0ce7e54a66457af6300bf2a5550ab95c878c75c63373c07f9dc0e5cd2edbfb049961ff817485f59136eabe48bdd3fd6cc92e4 + languageName: node + linkType: hard + "nanoid@npm:3.3.8": version: 3.3.8 resolution: "nanoid@npm:3.3.8" @@ -12402,6 +12674,15 @@ __metadata: languageName: node linkType: hard +"randombytes@npm:^2.1.0": + version: 2.1.0 + resolution: "randombytes@npm:2.1.0" + dependencies: + safe-buffer: ^5.1.0 + checksum: d779499376bd4cbb435ef3ab9a957006c8682f343f14089ed5f27764e4645114196e75b7f6abf1cbd84fd247c0cb0651698444df8c9bf30e62120fbbc52269d6 + languageName: node + linkType: hard + "range-parser@npm:~1.2.1": version: 1.2.1 resolution: "range-parser@npm:1.2.1" @@ -13296,7 +13577,7 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:5.2.1, safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.2, safe-buffer@npm:^5.2.1, safe-buffer@npm:~5.2.0": +"safe-buffer@npm:5.2.1, safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:^5.1.1, safe-buffer@npm:^5.1.2, safe-buffer@npm:^5.2.1, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" checksum: b99c4b41fdd67a6aaf280fcd05e9ffb0813654894223afb78a31f14a19ad220bba8aba1cb14eddce1fcfb037155fe6de4e861784eb434f7d11ed58d1e70dd491 @@ -13368,6 +13649,23 @@ __metadata: languageName: node linkType: hard +"secp256k1@npm:^3.0.1": + version: 3.8.1 + resolution: "secp256k1@npm:3.8.1" + dependencies: + bindings: ^1.5.0 + bip66: ^1.1.5 + bn.js: ^4.11.8 + create-hash: ^1.2.0 + drbg.js: ^1.0.1 + elliptic: ^6.5.7 + nan: ^2.14.0 + node-gyp: latest + safe-buffer: ^5.1.2 + checksum: 6e06fd1a362cd42add8780f352c666b393c4b8e6d316f3527d50d354ac43bb9bfe089a434a61a9e96b03729d3f2f026be8f6dd6c6094c914305ac7da34d65c09 + languageName: node + linkType: hard + "semver@npm:7.7.1": version: 7.7.1 resolution: "semver@npm:7.7.1" @@ -14600,6 +14898,13 @@ __metadata: languageName: node linkType: hard +"typeforce@npm:^1.11.3, typeforce@npm:^1.11.5, typeforce@npm:^1.18.0": + version: 1.18.0 + resolution: "typeforce@npm:1.18.0" + checksum: e3b21e27e76cb05f32285bef7c30a29760e79c622cfe4aa3c179ce49d1c7895b7154c8deedb9fe4599b1fd0428d35860d43e0776da1c04861168f3ad7ed99c70 + languageName: node + linkType: hard + "typescript@npm:~6.0.3": version: 6.0.3 resolution: "typescript@npm:6.0.3" @@ -14981,6 +15286,15 @@ __metadata: languageName: node linkType: hard +"varuint-bitcoin@npm:^1.0.1, varuint-bitcoin@npm:^1.1.2": + version: 1.1.2 + resolution: "varuint-bitcoin@npm:1.1.2" + dependencies: + safe-buffer: ^5.1.1 + checksum: 1c900bf08f2408ae33a6094dc5d809bdb6673eaf6039062d88c230155873e51e29c760053611f93ccd024854d04ebd92ed95c744720e94a79ca4e1150fcce071 + languageName: node + linkType: hard + "vary@npm:~1.1.2": version: 1.1.2 resolution: "vary@npm:1.1.2" @@ -15201,6 +15515,15 @@ __metadata: languageName: node linkType: hard +"wif@npm:^2.0.6": + version: 2.0.6 + resolution: "wif@npm:2.0.6" + dependencies: + bs58check: <3.0.0 + checksum: 8c3147ef98d56f394d66f0477f699fba7fc18dd0d1c2c5d0f8408be41acffed589fa82447d80eae5afc9a3cbd943bc3eebb337b9f114955adeaad02a244f4f9a + languageName: node + linkType: hard + "word-wrap@npm:^1.2.5": version: 1.2.5 resolution: "word-wrap@npm:1.2.5"