From 4adf58dded3ef23c641bf8e8c11ef12a0c52bbe0 Mon Sep 17 00:00:00 2001 From: Jingles Date: Sat, 16 May 2026 09:38:46 +0800 Subject: [PATCH 1/2] feat(bitcoin): add headless + browser wallet SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement IBitcoinWallet across two surfaces: - BitcoinHeadlessWallet: in-process signer (BIP-39 → BIP-32 → BIP-84/86). - getNetwork, getAddresses, getAccounts, getBalance - signMessage (ECDSA with magic-prefix + 65-byte recoverable sig) - signTransfer (RBF opt-in, dust-aware change, P2WPKH vbyte estimates) - signPsbt (P2WPKH ECDSA + P2TR BIP-86 key-path Schnorr w/ tweaked key) - BitcoinBrowserWallet: registry + Xverse adapter speaking Sats Connect with dual-envelope support (sats-connect-core normalised + raw JSON-RPC 2.0). Test coverage: 93 bitcoin tests across 5 suites — BIP-84/86 vectors, recoverable-sig verification, dust-handling, Taproot tweaked-key signing, Xverse wire-format compatibility, and address-type validation. Co-Authored-By: Claude Opus 4.7 --- package-lock.json | 135 +++- package.json | 3 + .../address/bitcoin-address-manager.ts | 161 +++++ src/bitcoin/address/bitcoin-address.ts | 122 ++++ src/bitcoin/interfaces/bitcoin-provider.ts | 23 + src/bitcoin/types/address-info.ts | 8 + src/bitcoin/types/chain-stats.ts | 7 + src/bitcoin/types/index.ts | 7 + src/bitcoin/types/mempool-stats.ts | 7 + src/bitcoin/types/script-info.ts | 8 + src/bitcoin/types/transactions-info.ts | 34 + src/bitcoin/types/transactions-status.ts | 6 + src/bitcoin/types/utxo.ts | 11 + .../wallet/browser/adapters/xverse-adapter.ts | 301 +++++++++ .../wallet/browser/bitcoin-browser-wallet.ts | 127 +++- src/bitcoin/wallet/core/bitcoin-core.ts | 12 + .../wallet/mesh/bitcoin-headless-wallet.ts | 619 +++++++++++++++++- src/index.ts | 20 + test/bitcoin/bitcoin-address-manager.test.ts | 164 +++++ test/bitcoin/bitcoin-address.test.ts | 92 +++ test/bitcoin/bitcoin-browser-wallet.test.ts | 133 ++++ test/bitcoin/bitcoin-headless-wallet.test.ts | 442 +++++++++++++ test/bitcoin/xverse-adapter.test.ts | 382 +++++++++++ 23 files changed, 2800 insertions(+), 24 deletions(-) create mode 100644 src/bitcoin/address/bitcoin-address-manager.ts create mode 100644 src/bitcoin/address/bitcoin-address.ts create mode 100644 src/bitcoin/interfaces/bitcoin-provider.ts create mode 100644 src/bitcoin/types/address-info.ts create mode 100644 src/bitcoin/types/chain-stats.ts create mode 100644 src/bitcoin/types/index.ts create mode 100644 src/bitcoin/types/mempool-stats.ts create mode 100644 src/bitcoin/types/script-info.ts create mode 100644 src/bitcoin/types/transactions-info.ts create mode 100644 src/bitcoin/types/transactions-status.ts create mode 100644 src/bitcoin/types/utxo.ts create mode 100644 src/bitcoin/wallet/browser/adapters/xverse-adapter.ts create mode 100644 src/bitcoin/wallet/core/bitcoin-core.ts create mode 100644 test/bitcoin/bitcoin-address-manager.test.ts create mode 100644 test/bitcoin/bitcoin-address.test.ts create mode 100644 test/bitcoin/bitcoin-browser-wallet.test.ts create mode 100644 test/bitcoin/bitcoin-headless-wallet.test.ts create mode 100644 test/bitcoin/xverse-adapter.test.ts diff --git a/package-lock.json b/package-lock.json index a901a33..3a80011 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@meshsdk/wallet", - "version": "2.0.0-beta.8", + "version": "2.0.0-beta.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@meshsdk/wallet", - "version": "2.0.0-beta.8", + "version": "2.0.0-beta.9", "license": "Apache-2.0", "dependencies": { "@cardano-sdk/core": "0.46.11", @@ -18,6 +18,9 @@ "@types/bn.js": "^5.1.5", "base32-encoding": "^1.0.0", "bip32": "^5.0.0", + "bip39": "^3.1.0", + "bitcoinjs-lib": "^6.1.7", + "ecpair": "^2.1.0", "json-bigint": "^1.0.0", "tiny-secp256k1": "^2.2.4" }, @@ -3627,6 +3630,15 @@ "file-uri-to-path": "1.0.0" } }, + "node_modules/bip174": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bip174/-/bip174-2.1.1.tgz", + "integrity": "sha512-mdFV5+/v0XyNYXjBS6CQPLo9ekCx4gtKZFnJm5PMto7Fs9hTTDpkkzOB7/FtluRI6JbUUAu+snTYfJRgHLZbZQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/bip32": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/bip32/-/bip32-5.0.1.tgz", @@ -3647,12 +3659,53 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/bip39/-/bip39-3.1.0.tgz", "integrity": "sha512-c9kiwdk45Do5GL0vJMe7tS95VjCii65mYAH7DfWl3uW8AVzXKQVUm64i3hzVybBDMp9r7j9iNxR85+ul8MdN/A==", - "dev": true, "license": "ISC", "dependencies": { "@noble/hashes": "^1.2.0" } }, + "node_modules/bitcoinjs-lib": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-6.1.7.tgz", + "integrity": "sha512-tlf/r2DGMbF7ky1MgUqXHzypYHakkEnm0SZP23CJKIqNY/5uNAnMbFhMJdhjrL/7anfb/U8+AlpdjPWjPnAalg==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/bitcoinjs-lib/node_modules/base-x": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.1.tgz", + "integrity": "sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==", + "license": "MIT" + }, + "node_modules/bitcoinjs-lib/node_modules/bs58": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-5.0.0.tgz", + "integrity": "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==", + "license": "MIT", + "dependencies": { + "base-x": "^4.0.0" + } + }, + "node_modules/bitcoinjs-lib/node_modules/bs58check": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-3.0.1.tgz", + "integrity": "sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.2.0", + "bs58": "^5.0.0" + } + }, "node_modules/blake2b": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/blake2b/-/blake2b-2.1.4.tgz", @@ -4468,6 +4521,58 @@ "node": ">= 0.4" } }, + "node_modules/ecpair": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ecpair/-/ecpair-2.1.0.tgz", + "integrity": "sha512-cL/mh3MtJutFOvFc27GPZE2pWL3a3k4YvzUWEOvilnfZVlH3Jwgx/7d6tlD7/75tNk8TG2m+7Kgtz0SI1tWcqw==", + "license": "MIT", + "dependencies": { + "randombytes": "^2.1.0", + "typeforce": "^1.18.0", + "wif": "^2.0.6" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/ecpair/node_modules/base-x": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.11.tgz", + "integrity": "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ecpair/node_modules/bs58": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz", + "integrity": "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==", + "license": "MIT", + "dependencies": { + "base-x": "^3.0.2" + } + }, + "node_modules/ecpair/node_modules/bs58check": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-2.1.2.tgz", + "integrity": "sha512-0TS1jicxdU09dwJMNZtVAfzPi6Q6QeN0pM1Fkzrjn+XYHvzMKPU3pHVpva+769iNVSfIYWf7LJ6WR+BuuMf8cA==", + "license": "MIT", + "dependencies": { + "bs58": "^4.0.0", + "create-hash": "^1.1.0", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/ecpair/node_modules/wif": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/wif/-/wif-2.0.6.tgz", + "integrity": "sha512-HIanZn1zmduSF+BQhkE+YXIbEiH0xPr1012QbFEGB0xsKqJii0/SqJjyn8dFv6y36kOznMgMB+LGcbZTJ1xACQ==", + "license": "MIT", + "dependencies": { + "bs58check": "<3.0.0" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.302", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", @@ -7959,6 +8064,15 @@ ], "license": "MIT" }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -9126,6 +9240,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/typeforce": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/typeforce/-/typeforce-1.18.0.tgz", + "integrity": "sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", @@ -9409,6 +9529,15 @@ } } }, + "node_modules/varuint-bitcoin": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/varuint-bitcoin/-/varuint-bitcoin-1.1.2.tgz", + "integrity": "sha512-4EVb+w4rx+YfVM32HQX42AbbT7/1f5zwAYhIujKXKk8NQK+JfRVl3pqT3hjNn/L+RstigmGGKVwHA/P0wgITZw==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.1" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", diff --git a/package.json b/package.json index dcc5153..ba8a09d 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,9 @@ "@types/bn.js": "^5.1.5", "base32-encoding": "^1.0.0", "bip32": "^5.0.0", + "bip39": "^3.1.0", + "bitcoinjs-lib": "^6.1.7", + "ecpair": "^2.1.0", "json-bigint": "^1.0.0", "tiny-secp256k1": "^2.2.4" }, diff --git a/src/bitcoin/address/bitcoin-address-manager.ts b/src/bitcoin/address/bitcoin-address-manager.ts new file mode 100644 index 0000000..f092727 --- /dev/null +++ b/src/bitcoin/address/bitcoin-address-manager.ts @@ -0,0 +1,161 @@ +import type { BIP32Interface } from "bip32"; +import type { Network } from "bitcoinjs-lib"; + +import { + AddressPurpose, + AddressType, +} from "../interfaces/bitcoin-wallet"; +import { bip32 } from "../wallet/core/bitcoin-core"; +import { + DerivedBitcoinAddress, + deriveP2trAddress, + deriveP2wpkhAddress, +} from "./bitcoin-address"; + +/** + * Standard BIP derivation paths used by the Mesh Bitcoin wallet: + * - BIP-84 native SegWit (P2WPKH) for the `payment` purpose + * - BIP-86 single-key Taproot (P2TR) for the `ordinals` purpose + * + * `coinType` is 0 for mainnet, 1 for any testnet (including Testnet4). + */ +export function getCoinType(network: Network): 0 | 1 { + // bitcoinjs-lib doesn't expose a clean network-id check; use the bech32 hrp. + return network.bech32 === "bc" ? 0 : 1; +} + +export function paymentPath( + network: Network, + account = 0, + change = 0, + index = 0, +): string { + return `m/84'/${getCoinType(network)}'/${account}'/${change}/${index}`; +} + +export function ordinalsPath( + network: Network, + account = 0, + change = 0, + index = 0, +): string { + return `m/86'/${getCoinType(network)}'/${account}'/${change}/${index}`; +} + +export interface BitcoinAddressManagerConfig { + network: Network; + /** + * BIP-32 root node (seed-derived). The manager owns derivation from this root. + * Optional when the manager is used in read-only mode (no key material available). + */ + root?: BIP32Interface; + /** + * Account index for hardened account derivation (defaults to 0). + */ + account?: number; +} + +/** + * Centralises address derivation for the Bitcoin wallet across purposes. + * Mirrors the role of Cardano's `AddressManager` — single source of truth for + * which addresses correspond to which purposes. + */ +export class BitcoinAddressManager { + private readonly network: Network; + private readonly root?: BIP32Interface; + private readonly account: number; + + constructor(config: BitcoinAddressManagerConfig) { + this.network = config.network; + this.root = config.root; + this.account = config.account ?? 0; + } + + static fromSeed(seed: Buffer, network: Network, account = 0): BitcoinAddressManager { + const root = bip32.fromSeed(seed, network); + return new BitcoinAddressManager({ network, root, account }); + } + + getNetwork(): Network { + return this.network; + } + + private requireRoot(): BIP32Interface { + if (!this.root) { + throw new Error("[BitcoinAddressManager] No BIP-32 root configured"); + } + return this.root; + } + + /** + * Get the address for a single purpose, deriving fresh from the root. + */ + getAddress(purpose: AddressPurpose, change = 0, index = 0): DerivedBitcoinAddress { + if (purpose === AddressPurpose.Payment) { + const path = paymentPath(this.network, this.account, change, index); + const child = this.requireRoot().derivePath(path); + const { address, publicKey } = deriveP2wpkhAddress(child.publicKey, this.network); + return new DerivedBitcoinAddress({ + address, + publicKey, + purpose, + addressType: AddressType.p2wpkh, + derivationPath: path, + }); + } + + if (purpose === AddressPurpose.Ordinals) { + const path = ordinalsPath(this.network, this.account, change, index); + const child = this.requireRoot().derivePath(path); + const { address, publicKey } = deriveP2trAddress(child.publicKey, this.network); + return new DerivedBitcoinAddress({ + address, + publicKey, + purpose, + addressType: AddressType.p2tr, + derivationPath: path, + }); + } + + throw new Error( + `[BitcoinAddressManager] Unsupported address purpose: ${purpose}`, + ); + } + + /** + * Get addresses for an array of purposes (default: payment + ordinals). + * Skips unsupported purposes silently to remain forward-compatible with + * non-Bitcoin Sats Connect purposes (`stacks`, `starknet`, `spark`). + */ + getAddresses(purposes?: AddressPurpose[]): DerivedBitcoinAddress[] { + const list = purposes && purposes.length > 0 + ? purposes + : [AddressPurpose.Payment, AddressPurpose.Ordinals]; + + const out: DerivedBitcoinAddress[] = []; + for (const purpose of list) { + if (purpose !== AddressPurpose.Payment && purpose !== AddressPurpose.Ordinals) { + continue; + } + out.push(this.getAddress(purpose)); + } + return out; + } + + /** + * Get the BIP-32 child node for a purpose — needed for signing. + */ + getChild(purpose: AddressPurpose, change = 0, index = 0): BIP32Interface { + const path = purpose === AddressPurpose.Payment + ? paymentPath(this.network, this.account, change, index) + : purpose === AddressPurpose.Ordinals + ? ordinalsPath(this.network, this.account, change, index) + : null; + if (!path) { + throw new Error( + `[BitcoinAddressManager] Unsupported address purpose: ${purpose}`, + ); + } + return this.requireRoot().derivePath(path); + } +} diff --git a/src/bitcoin/address/bitcoin-address.ts b/src/bitcoin/address/bitcoin-address.ts new file mode 100644 index 0000000..05e9771 --- /dev/null +++ b/src/bitcoin/address/bitcoin-address.ts @@ -0,0 +1,122 @@ +import type { Network } from "bitcoinjs-lib"; + +import { + AddressPurpose, + AddressType, + BitcoinAddress as BitcoinAddressInfo, + BitcoinAccount, +} from "../interfaces/bitcoin-wallet"; +import { bitcoin } from "../wallet/core/bitcoin-core"; + +export type BitcoinNetworkName = "Mainnet" | "Testnet4"; + +/** + * Map a friendly network name to a bitcoinjs `Network` object. + * + * NOTE: bitcoinjs-lib does not currently ship a dedicated Testnet4 network object. + * Testnet4 and Testnet3 use identical address encoding (`tb` bech32/bech32m, same + * version/HRP/script-hash formats), so reusing `bitcoin.networks.testnet` is safe + * for address derivation and PSBT construction. Chain state differs, but that is + * the provider's responsibility, not the encoder's. + */ +export function networkFromName(name: BitcoinNetworkName): Network { + return name === "Mainnet" ? bitcoin.networks.bitcoin : bitcoin.networks.testnet; +} + +/** + * Drop the parity prefix byte of a compressed secp256k1 pubkey (33 bytes -> 32 bytes). + * Required for Taproot (BIP-340) which uses x-only public keys. Returns a copy so + * callers cannot accidentally mutate the source buffer. + */ +export function toXOnly(pubkey: Buffer): Buffer { + return pubkey.length === 32 ? Buffer.from(pubkey) : Buffer.from(pubkey.subarray(1, 33)); +} + +/** + * Derive the P2WPKH (native SegWit) payment address from a compressed public key. + */ +export function deriveP2wpkhAddress( + publicKey: Buffer | Uint8Array, + network: Network, +): { address: string; publicKey: string } { + const pubkey = Buffer.isBuffer(publicKey) ? publicKey : Buffer.from(publicKey); + const payment = bitcoin.payments.p2wpkh({ pubkey, network }); + if (!payment.address) { + throw new Error("[BitcoinAddress] Failed to derive P2WPKH address"); + } + return { + address: payment.address, + publicKey: pubkey.toString("hex"), + }; +} + +/** + * Derive the P2TR (Taproot, ordinals) address from a compressed public key. + * Uses the BIP-86 single-key spend path (no script tree). + */ +export function deriveP2trAddress( + publicKey: Buffer | Uint8Array, + network: Network, +): { address: string; publicKey: string } { + const pubkey = Buffer.isBuffer(publicKey) ? publicKey : Buffer.from(publicKey); + const internalPubkey = toXOnly(pubkey); + const payment = bitcoin.payments.p2tr({ internalPubkey, network }); + if (!payment.address) { + throw new Error("[BitcoinAddress] Failed to derive P2TR address"); + } + return { + address: payment.address, + publicKey: internalPubkey.toString("hex"), + }; +} + +/** + * Internal derived-address record. Holds the public-facing fields plus the + * derivation path so the manager can request signing from the right child node. + * Public wallet methods convert this to plain `BitcoinAddress` / `BitcoinAccount` + * shapes defined on `IBitcoinWallet`. + */ +export class DerivedBitcoinAddress { + readonly address: string; + readonly publicKey: string; + readonly purpose: AddressPurpose; + readonly addressType: AddressType; + readonly walletType: "software" | "ledger" | "keystone"; + readonly derivationPath: string; + + constructor(args: { + address: string; + publicKey: string; + purpose: AddressPurpose; + addressType: AddressType; + walletType?: "software" | "ledger" | "keystone"; + derivationPath: string; + }) { + this.address = args.address; + this.publicKey = args.publicKey; + this.purpose = args.purpose; + this.addressType = args.addressType; + this.walletType = args.walletType ?? "software"; + this.derivationPath = args.derivationPath; + } + + toBitcoinAddress(): BitcoinAddressInfo { + return { + address: this.address, + publicKey: this.publicKey, + purpose: this.purpose, + addressType: this.addressType, + walletType: this.walletType, + }; + } + + toBitcoinAccount(): BitcoinAccount { + return { + walletType: this.walletType, + address: this.address, + publicKey: this.publicKey, + purpose: this.purpose, + addressType: this.addressType, + }; + } +} diff --git a/src/bitcoin/interfaces/bitcoin-provider.ts b/src/bitcoin/interfaces/bitcoin-provider.ts new file mode 100644 index 0000000..4637221 --- /dev/null +++ b/src/bitcoin/interfaces/bitcoin-provider.ts @@ -0,0 +1,23 @@ +import { AddressInfo } from "../types/address-info"; +import { ScriptInfo } from "../types/script-info"; +import { TransactionsInfo } from "../types/transactions-info"; +import { TransactionsStatus } from "../types/transactions-status"; +import { UTxO } from "../types/utxo"; + +export interface IBitcoinProvider { + fetchAddress(address: string): Promise; + fetchAddressTransactions( + address: string, + last_seen_txid?: string, + ): Promise; + fetchAddressUTxOs(address: string): Promise; + fetchScript(hash: string): Promise; + fetchScriptTransactions( + hash: string, + last_seen_txid?: string, + ): Promise; + fetchScriptUTxOs(hash: string): Promise; + fetchTransactionStatus(txid: string): Promise; + fetchFeeEstimates(blocks: number): Promise; + submitTx(tx: string): Promise; +} diff --git a/src/bitcoin/types/address-info.ts b/src/bitcoin/types/address-info.ts new file mode 100644 index 0000000..9b07d21 --- /dev/null +++ b/src/bitcoin/types/address-info.ts @@ -0,0 +1,8 @@ +import { ChainStats } from "./chain-stats"; +import { MempoolStats } from "./mempool-stats"; + +export type AddressInfo = { + address: string; + chain_stats: ChainStats; + mempool_stats: MempoolStats; +}; diff --git a/src/bitcoin/types/chain-stats.ts b/src/bitcoin/types/chain-stats.ts new file mode 100644 index 0000000..b7e8075 --- /dev/null +++ b/src/bitcoin/types/chain-stats.ts @@ -0,0 +1,7 @@ +export type ChainStats = { + funded_txo_count: number; + funded_txo_sum: number; + spent_txo_count: number; + spent_txo_sum: number; + tx_count: number; +}; diff --git a/src/bitcoin/types/index.ts b/src/bitcoin/types/index.ts new file mode 100644 index 0000000..2a42344 --- /dev/null +++ b/src/bitcoin/types/index.ts @@ -0,0 +1,7 @@ +export * from "./address-info"; +export * from "./chain-stats"; +export * from "./mempool-stats"; +export * from "./script-info"; +export * from "./transactions-info"; +export * from "./transactions-status"; +export * from "./utxo"; diff --git a/src/bitcoin/types/mempool-stats.ts b/src/bitcoin/types/mempool-stats.ts new file mode 100644 index 0000000..d3a8bf2 --- /dev/null +++ b/src/bitcoin/types/mempool-stats.ts @@ -0,0 +1,7 @@ +export type MempoolStats = { + funded_txo_count: number; + funded_txo_sum: number; + spent_txo_count: number; + spent_txo_sum: number; + tx_count: number; +}; diff --git a/src/bitcoin/types/script-info.ts b/src/bitcoin/types/script-info.ts new file mode 100644 index 0000000..6258524 --- /dev/null +++ b/src/bitcoin/types/script-info.ts @@ -0,0 +1,8 @@ +import { ChainStats } from "./chain-stats"; +import { MempoolStats } from "./mempool-stats"; + +export type ScriptInfo = { + scripthash: string; + chain_stats: ChainStats; + mempool_stats: MempoolStats; +}; diff --git a/src/bitcoin/types/transactions-info.ts b/src/bitcoin/types/transactions-info.ts new file mode 100644 index 0000000..d84507d --- /dev/null +++ b/src/bitcoin/types/transactions-info.ts @@ -0,0 +1,34 @@ +import { TransactionsStatus } from "./transactions-status"; + +export type TransactionsInfo = { + txid: string; + version: number; + locktime: number; + vin: { + txid: string; + vout: number; + prevout: { + scriptpubkey: string; + scriptpubkey_asm: string; + scriptpubkey_type: string; + scriptpubkey_address: string; + value: number; + }; + scriptsig: string; + scriptsig_asm: string; + witness: string[]; + is_coinbase: boolean; + sequence: number; + }[]; + vout: { + scriptpubkey: string; + scriptpubkey_asm: string; + scriptpubkey_type: string; + scriptpubkey_address: string; + value: number; + }[]; + size: number; + weight: number; + fee: number; + status: TransactionsStatus; +}; diff --git a/src/bitcoin/types/transactions-status.ts b/src/bitcoin/types/transactions-status.ts new file mode 100644 index 0000000..804bd9f --- /dev/null +++ b/src/bitcoin/types/transactions-status.ts @@ -0,0 +1,6 @@ +export type TransactionsStatus = { + confirmed: boolean; + block_height: number; + block_hash: string; + block_time: number; +}; diff --git a/src/bitcoin/types/utxo.ts b/src/bitcoin/types/utxo.ts new file mode 100644 index 0000000..16bab79 --- /dev/null +++ b/src/bitcoin/types/utxo.ts @@ -0,0 +1,11 @@ +export type UTxO = { + status: { + block_hash: string; + block_height: number; + block_time: number; + confirmed: boolean; + }; + txid: string; + value: number; + vout: number; +}; diff --git a/src/bitcoin/wallet/browser/adapters/xverse-adapter.ts b/src/bitcoin/wallet/browser/adapters/xverse-adapter.ts new file mode 100644 index 0000000..13ce2b4 --- /dev/null +++ b/src/bitcoin/wallet/browser/adapters/xverse-adapter.ts @@ -0,0 +1,301 @@ +import { + AddressPurpose, + AddressType, + BitcoinAccount, + BitcoinAddress, + BitcoinBalance, + BitcoinSignature, + IBitcoinWallet, + MessageSigningProtocols, +} from "../../../interfaces/bitcoin-wallet"; + +/** + * Shape of the Xverse `BitcoinProvider` reachable via `window.XverseProviders.BitcoinProvider`. + * We only depend on the `request(method, params)` JSON-RPC-style surface, which is the public + * Sats Connect protocol — no SDK dependency required. + * + * See: https://docs.xverse.app/sats-connect (`getAddresses`, `getAccounts`, `getBalance`, + * `signMessage`, `sendTransfer`, `signPsbt`, `getNetwork`). + */ +export interface XverseBitcoinProvider { + request( + method: string, + params?: Record | null, + ): Promise>; +} + +/** + * The Xverse provider's `request()` returns one of two envelopes, depending on + * whether the dApp went through the sats-connect-core library (which normalises + * the wire format) or hit `window.XverseProviders.BitcoinProvider` directly + * (raw JSON-RPC 2.0). We accept both so the adapter works either way. + */ +export type XverseResponse = + | { status: "success"; result: T } + | { status: "error"; error: { code: number; message: string } } + | { jsonrpc: "2.0"; result: T; id?: string | number | null } + | { jsonrpc: "2.0"; error: { code: number; message: string }; id?: string | number | null }; + +/** + * Typed error preserving the RPC error code so callers can distinguish + * `USER_REJECTION` (-32000) from `ACCESS_DENIED` (-32002), etc. + */ +export class XverseRpcError extends Error { + readonly code: number; + readonly method: string; + constructor(method: string, code: number, message: string) { + super(`[XverseAdapter] ${method} failed (${code}): ${message}`); + this.name = "XverseRpcError"; + this.code = code; + this.method = method; + } +} + +declare global { + interface Window { + XverseProviders?: { + BitcoinProvider?: XverseBitcoinProvider; + }; + BitcoinProvider?: XverseBitcoinProvider; + } +} + +export function isXverseInstalled(): boolean { + if (typeof globalThis === "undefined") return false; + const w = globalThis as unknown as Window; + return Boolean( + w?.XverseProviders?.BitcoinProvider ?? w?.BitcoinProvider, + ); +} + +function getProvider(): XverseBitcoinProvider { + const w = globalThis as unknown as Window; + const provider = + w?.XverseProviders?.BitcoinProvider ?? w?.BitcoinProvider; + if (!provider) { + throw new Error( + "[XverseAdapter] Xverse provider not found on window. Install Xverse and reload.", + ); + } + return provider; +} + +async function call( + method: string, + params?: Record | null, +): Promise { + const response = (await getProvider().request(method, params ?? null)) as + | { status?: string; result?: T; error?: { code: number; message: string }; jsonrpc?: string }; + + // sats-connect-normalised envelope: { status, result | error } + if (response.status === "error" && response.error) { + throw new XverseRpcError(method, response.error.code, response.error.message); + } + if (response.status === "success" && "result" in response) { + return response.result as T; + } + // Raw JSON-RPC 2.0 envelope: { jsonrpc, result | error } + if (response.jsonrpc === "2.0") { + if (response.error) { + throw new XverseRpcError(method, response.error.code, response.error.message); + } + if ("result" in response) { + return response.result as T; + } + } + // Some Xverse builds return the unwrapped result directly. Trust the type. + if (response && !("status" in response) && !("jsonrpc" in response)) { + return response as unknown as T; + } + throw new XverseRpcError(method, -1, "Unrecognised response envelope from Xverse"); +} + +type XverseAddressItem = { + address: string; + publicKey: string; + addressType: string; + purpose: AddressPurpose | string; + walletType?: "software" | "ledger" | "keystone"; +}; + +function normalizeAddressType(t: string): AddressType { + const lower = t.toLowerCase(); + const known = Object.values(AddressType) as string[]; + if (known.includes(lower)) return lower as AddressType; + // Xverse historically returns "p2sh" for nested-SegWit; map common aliases. + if (lower === "p2sh-p2wpkh") return "p2sh" as AddressType; + throw new Error(`[XverseAdapter] Unknown addressType from provider: ${t}`); +} + +/** + * Adapter that implements `IBitcoinWallet` against the Xverse / Sats Connect surface. + * Created on `enable()` after the user authorizes the dApp. + */ +export class XverseAdapter implements IBitcoinWallet { + private connected = false; + private cachedAddresses: BitcoinAddress[] | undefined; + + private constructor() {} + + static async enable(): Promise { + if (!isXverseInstalled()) { + throw new Error( + "[XverseAdapter] Xverse is not installed. Visit https://www.xverse.app to install.", + ); + } + const adapter = new XverseAdapter(); + await adapter.requestAddresses([ + AddressPurpose.Payment, + AddressPurpose.Ordinals, + ]); + adapter.connected = true; + return adapter; + } + + private async requestAddresses( + purposes: AddressPurpose[], + ): Promise { + const result = await call<{ addresses: XverseAddressItem[] }>( + "getAddresses", + { purposes, message: "Connect to Mesh SDK" }, + ); + const list = result.addresses.map((a) => ({ + address: a.address, + publicKey: a.publicKey, + purpose: a.purpose as AddressPurpose, + addressType: normalizeAddressType(a.addressType), + walletType: (a.walletType ?? "software") as + | "software" + | "ledger" + | "keystone", + })); + this.cachedAddresses = list; + return list; + } + + async getNetwork(): Promise<"Mainnet" | "Testnet4"> { + // Sats Connect canonical method is `wallet_getNetwork`. Some older Xverse + // builds also accept bare `getNetwork`; we try the canonical form first and + // fall back so we don't break on either surface. + type R = { bitcoin: { name: string } } | { name: string }; + let result: R; + try { + result = await call("wallet_getNetwork"); + } catch (err) { + if (err instanceof XverseRpcError && err.code === -32601 /* METHOD_NOT_FOUND */) { + result = await call("getNetwork"); + } else { + throw err; + } + } + const raw = + (result as { name?: string }).name + ?? ((result as { bitcoin?: { name?: string } }).bitcoin?.name); + const lower = (raw ?? "").toLowerCase(); + if (lower === "mainnet") return "Mainnet"; + if (lower === "testnet4") return "Testnet4"; + // The IBitcoinWallet contract only models Mainnet/Testnet4. Signet, + // Testnet3, Regtest etc. should surface loudly rather than silently + // misreporting as Testnet4. + throw new Error(`[XverseAdapter] Unsupported network from provider: ${raw}`); + } + + async getAddresses( + addressPurposes: AddressPurpose[], + ): Promise { + if (!this.connected || !this.cachedAddresses) { + return this.requestAddresses(addressPurposes); + } + const wanted = new Set(addressPurposes); + return this.cachedAddresses.filter((a) => wanted.has(a.purpose)); + } + + async getAccounts( + addressPurposes: AddressPurpose[], + ): Promise { + type AccountItem = XverseAddressItem; + // Per the Sats Connect spec `getAccounts` returns a bare array, but older + // Xverse builds wrapped the array under `accounts` / `addresses`. Handle + // both shapes so the adapter remains compatible across provider versions. + const result = await call< + AccountItem[] | { addresses?: AccountItem[]; accounts?: AccountItem[] } + >("getAccounts", { purposes: addressPurposes, message: "Connect to Mesh SDK" }); + const items: AccountItem[] = Array.isArray(result) + ? result + : (result.accounts ?? result.addresses ?? []); + return items.map((a) => ({ + walletType: (a.walletType ?? "software") as + | "software" + | "ledger" + | "keystone", + address: a.address, + publicKey: a.publicKey, + purpose: a.purpose as AddressPurpose, + addressType: normalizeAddressType(a.addressType), + })); + } + + async getBalance(): Promise { + const result = await call("getBalance"); + return { + confirmed: String(result.confirmed), + unconfirmed: String(result.unconfirmed), + total: String(result.total), + }; + } + + async signMessage( + address: string, + message: string, + protocol?: MessageSigningProtocols, + ): Promise { + // Per Sats Connect, when `protocol` is omitted Xverse picks the right one + // for the address type (ECDSA for P2WPKH/P2SH, BIP-322 for Taproot). + // Forcing a default here would break P2WPKH signing under BIP-322 enforcement. + const params: Record = { address, message }; + if (protocol) params.protocol = protocol; + const result = await call<{ + signature: string; + messageHash: string; + address: string; + protocol?: MessageSigningProtocols; + }>("signMessage", params); + return { + signature: result.signature, + messageHash: result.messageHash, + address: result.address, + protocol: result.protocol ?? protocol ?? MessageSigningProtocols.ECDSA, + }; + } + + /** + * Xverse's Sats Connect exposes `sendTransfer` which prompts the user to sign + * AND broadcast in one step — there is no "sign-only" variant. We surface the + * resulting txid, matching the contract that `signTransfer` returns a tx hex + * or txid string. + */ + async signTransfer( + recipients: { address: string; amount: number }[], + ): Promise { + const result = await call<{ txid: string }>("sendTransfer", { + recipients: recipients.map((r) => ({ + address: r.address, + amount: r.amount, + })), + }); + return result.txid; + } + + async signPsbt(signConfig: { + psbt: string; + signInputs?: { [x: string]: number[] } | undefined; + broadcast?: boolean | undefined; + }): Promise { + const result = await call<{ psbt: string; txid?: string }>("signPsbt", { + psbt: signConfig.psbt, + signInputs: signConfig.signInputs, + broadcast: signConfig.broadcast, + }); + return signConfig.broadcast && result.txid ? result.txid : result.psbt; + } +} diff --git a/src/bitcoin/wallet/browser/bitcoin-browser-wallet.ts b/src/bitcoin/wallet/browser/bitcoin-browser-wallet.ts index dded7e6..4482ff9 100644 --- a/src/bitcoin/wallet/browser/bitcoin-browser-wallet.ts +++ b/src/bitcoin/wallet/browser/bitcoin-browser-wallet.ts @@ -1,5 +1,126 @@ -export class BitcoinBrowserWallet { - constructor() { - // Implementation for browser Bitcoin wallet +import { + AddressPurpose, + BitcoinAccount, + BitcoinAddress, + BitcoinBalance, + BitcoinSignature, + IBitcoinWallet, + MessageSigningProtocols, +} from "../../interfaces/bitcoin-wallet"; +import { isXverseInstalled, XverseAdapter } from "./adapters/xverse-adapter"; + +export type InstalledBitcoinWallet = { + id: string; + name: string; + icon?: string; +}; + +type BitcoinWalletEntry = { + factory: () => Promise; + meta: InstalledBitcoinWallet; + isInstalled: () => boolean; +}; + +const REGISTRY: Record = { + xverse: { + factory: () => XverseAdapter.enable(), + isInstalled: isXverseInstalled, + meta: { + id: "xverse", + name: "Xverse", + icon: "https://www.xverse.app/favicon.ico", + }, + }, +}; + +/** + * BitcoinBrowserWallet wraps an `IBitcoinWallet`-compatible browser provider + * (e.g., Xverse). Mirrors `CardanoBrowserWallet` for the Bitcoin chain. + * + * Typical usage: + * const wallets = BitcoinBrowserWallet.getInstalledWallets(); + * const wallet = await BitcoinBrowserWallet.enable("xverse"); + * const balance = await wallet.getBalance(); + */ +export class BitcoinBrowserWallet implements IBitcoinWallet { + walletInstance: IBitcoinWallet; + + constructor(walletInstance: IBitcoinWallet) { + this.walletInstance = walletInstance; + } + + getNetwork(): Promise<"Mainnet" | "Testnet4"> { + return this.walletInstance.getNetwork(); + } + + getAddresses(addressPurposes: AddressPurpose[]): Promise { + return this.walletInstance.getAddresses(addressPurposes); + } + + getAccounts(addressPurposes: AddressPurpose[]): Promise { + return this.walletInstance.getAccounts(addressPurposes); + } + + getBalance(): Promise { + return this.walletInstance.getBalance(); + } + + signMessage( + address: string, + message: string, + protocol?: MessageSigningProtocols, + ): Promise { + return this.walletInstance.signMessage(address, message, protocol); + } + + signTransfer( + recipients: { address: string; amount: number }[], + ): Promise { + return this.walletInstance.signTransfer(recipients); + } + + signPsbt(signConfig: { + psbt: string; + signInputs?: { [x: string]: number[] } | undefined; + broadcast?: boolean | undefined; + }): Promise { + return this.walletInstance.signPsbt(signConfig); + } + + /** + * Returns a list of Bitcoin wallets the user has installed in the browser. + */ + static getInstalledWallets(): InstalledBitcoinWallet[] { + const out: InstalledBitcoinWallet[] = []; + if (typeof globalThis === "undefined") return out; + for (const entry of Object.values(REGISTRY)) { + try { + if (entry.isInstalled()) out.push(entry.meta); + } catch { + // ignore — provider probing should never throw + } + } + return out; + } + + /** + * Request the user's permission to connect, returning a wrapped wallet on success. + */ + static async enable(walletName: string): Promise { + const entry = REGISTRY[walletName.toLowerCase()]; + if (!entry) { + throw new Error( + `[BitcoinBrowserWallet] Unknown wallet: ${walletName}. Supported: ${Object.keys(REGISTRY).join(", ")}`, + ); + } + try { + const instance = await entry.factory(); + return new BitcoinBrowserWallet(instance); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + throw new Error( + `[BitcoinBrowserWallet] An error occurred during enable: ${msg}`, + ); + } } } diff --git a/src/bitcoin/wallet/core/bitcoin-core.ts b/src/bitcoin/wallet/core/bitcoin-core.ts new file mode 100644 index 0000000..deb6a20 --- /dev/null +++ b/src/bitcoin/wallet/core/bitcoin-core.ts @@ -0,0 +1,12 @@ +import * as bitcoin from "bitcoinjs-lib"; +import * as ecc from "tiny-secp256k1"; +import * as bip39 from "bip39"; +import { BIP32Factory } from "bip32"; +import { ECPairFactory } from "ecpair"; + +const bip32 = BIP32Factory(ecc); +const ECPair = ECPairFactory(ecc); + +bitcoin.initEccLib(ecc); + +export { bitcoin, ECPair, bip32, bip39, ecc }; diff --git a/src/bitcoin/wallet/mesh/bitcoin-headless-wallet.ts b/src/bitcoin/wallet/mesh/bitcoin-headless-wallet.ts index 67e6a7e..3151b12 100644 --- a/src/bitcoin/wallet/mesh/bitcoin-headless-wallet.ts +++ b/src/bitcoin/wallet/mesh/bitcoin-headless-wallet.ts @@ -1,32 +1,615 @@ -import { AddressPurpose, BitcoinAccount, BitcoinAddress, BitcoinBalance, BitcoinSignature, IBitcoinWallet, MessageSigningProtocols } from "../../interfaces/bitcoin-wallet"; +import type { BIP32Interface } from "bip32"; +import type { Network, Psbt, Signer } from "bitcoinjs-lib"; + +import { BitcoinAddressManager } from "../../address/bitcoin-address-manager"; +import { + BitcoinNetworkName, + DerivedBitcoinAddress, + networkFromName, + toXOnly, +} from "../../address/bitcoin-address"; +import { IBitcoinProvider } from "../../interfaces/bitcoin-provider"; +import { + AddressPurpose, + BitcoinAccount, + BitcoinAddress, + BitcoinBalance, + BitcoinSignature, + IBitcoinWallet, + MessageSigningProtocols, +} from "../../interfaces/bitcoin-wallet"; +import { + ECPair, + bip32, + bip39, + bitcoin, + ecc, +} from "../core/bitcoin-core"; export interface BitcoinHeadlessWalletConfig { - // Configuration options for the headless wallet can be added here + /** Network this wallet operates on. */ + network: BitcoinNetworkName; + /** Optional provider for UTXO/fee fetching and broadcast. */ + provider?: IBitcoinProvider; + /** Optional BIP-39 passphrase. */ + password?: string; + /** Optional account index (default 0). */ + account?: number; +} + +interface InternalConfig { + network: BitcoinNetworkName; + bitcoinNetwork: Network; + root: BIP32Interface; + manager: BitcoinAddressManager; + provider?: IBitcoinProvider; + account: number; } +/** + * Internal signer extension. Carries the optional BIP-341 internal x-only + * pubkey alongside the standard `Signer` surface so `signSingleInput` can + * populate `input.tapInternalKey` when signing Taproot inputs. ECDSA signers + * (P2WPKH) simply omit `internalPubkey`. + */ +type TaprootCapableSigner = Signer & { internalPubkey?: Buffer }; + export class BitcoinHeadlessWallet implements IBitcoinWallet { - constructor() { - // Implementation for headless Bitcoin wallet + protected readonly networkName: BitcoinNetworkName; + protected readonly bitcoinNetwork: Network; + protected readonly root: BIP32Interface; + protected readonly manager: BitcoinAddressManager; + protected readonly provider?: IBitcoinProvider; + protected readonly account: number; + + protected constructor(cfg: InternalConfig) { + this.networkName = cfg.network; + this.bitcoinNetwork = cfg.bitcoinNetwork; + this.root = cfg.root; + this.manager = cfg.manager; + this.provider = cfg.provider; + this.account = cfg.account; + } + + /** + * Create a headless wallet from an existing BIP-32 root and configuration. + */ + static async create( + config: BitcoinHeadlessWalletConfig & { root: BIP32Interface }, + ): Promise { + const bitcoinNetwork = networkFromName(config.network); + const manager = new BitcoinAddressManager({ + network: bitcoinNetwork, + root: config.root, + account: config.account ?? 0, + }); + return new BitcoinHeadlessWallet({ + network: config.network, + bitcoinNetwork, + root: config.root, + manager, + provider: config.provider, + account: config.account ?? 0, + }); + } + + /** + * Create a headless wallet from a BIP-39 mnemonic phrase. + */ + static async fromMnemonic( + config: BitcoinHeadlessWalletConfig & { mnemonic: string[] }, + ): Promise { + const phrase = config.mnemonic.join(" "); + if (!bip39.validateMnemonic(phrase)) { + throw new Error("[BitcoinHeadlessWallet] Invalid mnemonic provided"); + } + const seed = await bip39.mnemonicToSeed(phrase, config.password ?? ""); + const bitcoinNetwork = networkFromName(config.network); + const root = bip32.fromSeed(seed, bitcoinNetwork); + return BitcoinHeadlessWallet.create({ ...config, root }); + } + + /** + * Create a headless wallet from BIP-39 entropy (hex string). + */ + static async fromEntropy( + config: BitcoinHeadlessWalletConfig & { entropy: string }, + ): Promise { + const mnemonic = bip39.entropyToMnemonic(config.entropy); + const seed = await bip39.mnemonicToSeed(mnemonic, config.password ?? ""); + const bitcoinNetwork = networkFromName(config.network); + const root = bip32.fromSeed(seed, bitcoinNetwork); + return BitcoinHeadlessWallet.create({ ...config, root }); + } + + // --------------------------------------------------------------------------- + // IBitcoinWallet + // --------------------------------------------------------------------------- + + async getNetwork(): Promise { + return this.networkName; + } + + async getAddresses( + addressPurposes: AddressPurpose[], + ): Promise { + return this.manager + .getAddresses(addressPurposes) + .map((a) => a.toBitcoinAddress()); + } + + async getAccounts( + addressPurposes: AddressPurpose[], + ): Promise { + return this.manager + .getAddresses(addressPurposes) + .map((a) => a.toBitcoinAccount()); + } + + async getBalance(): Promise { + if (!this.provider) { + throw new Error( + "[BitcoinHeadlessWallet] No provider provided. Pass an IBitcoinProvider to fetch balance.", + ); + } + const [paymentAddress] = this.manager.getAddresses([AddressPurpose.Payment]); + const info = await this.provider.fetchAddress(paymentAddress.address); + const confirmed = + info.chain_stats.funded_txo_sum - info.chain_stats.spent_txo_sum; + const unconfirmed = + info.mempool_stats.funded_txo_sum - info.mempool_stats.spent_txo_sum; + const total = confirmed + unconfirmed; + return { + confirmed: confirmed.toString(), + unconfirmed: unconfirmed.toString(), + total: total.toString(), + }; + } + + async signMessage( + address: string, + message: string, + protocol: MessageSigningProtocols = MessageSigningProtocols.ECDSA, + ): Promise { + if (protocol === MessageSigningProtocols.BIP322) { + throw new Error( + "[BitcoinHeadlessWallet] BIP-322 message signing is not yet supported. Use ECDSA.", + ); + } + + const derived = this.findDerivedByAddress(address); + const child = this.manager.getChild(derived.purpose); + if (!child.privateKey) { + throw new Error( + "[BitcoinHeadlessWallet] Private key unavailable for signing", + ); + } + + const privateKey = Buffer.from(child.privateKey); + const keyPair = ECPair.fromPrivateKey(privateKey, { + compressed: true, + network: this.bitcoinNetwork, + }); + + // Bitcoin signed-message standard: hash256( varInt(magicLen) || magic || varInt(msgLen) || msg ) + // The magic prefix is required for any external verifier (Electrum, Sparrow, block + // explorers, bitcoinjs-message) to accept the signature. + const messageBuffer = Buffer.from(message, "utf8"); + const magic = Buffer.from("Bitcoin Signed Message:\n", "utf8"); + const bufferToHash = Buffer.concat([ + varIntBuffer(magic.length), + magic, + varIntBuffer(messageBuffer.length), + messageBuffer, + ]); + const hash = bitcoin.crypto.hash256(bufferToHash); + + // Produce a 65-byte compact recoverable signature: [header || r || s]. + // Header = 27 + 4 (compressed) + recoveryId → 0x1f or 0x20 for compressed P2PKH-style. + // External verifiers use the header to recover the pubkey and confirm the address. + const rawSig = Buffer.from(ecc.sign(hash, privateKey)); + const compressedPubkey = Buffer.from(keyPair.publicKey); + const recoveryId = findRecoveryId(hash, rawSig, compressedPubkey); + const header = 27 + 4 + recoveryId; + const sig65 = Buffer.concat([Buffer.from([header]), rawSig]); + + return { + signature: sig65.toString("base64"), + messageHash: hash.toString("hex"), + address, + protocol: MessageSigningProtocols.ECDSA, + }; + } + + async signTransfer( + recipients: { address: string; amount: number }[], + ): Promise { + if (!this.provider) { + throw new Error( + "[BitcoinHeadlessWallet] No provider provided. Pass an IBitcoinProvider to send.", + ); + } + if (!recipients.length) { + throw new Error("[BitcoinHeadlessWallet] No recipients provided"); + } + + const [paymentAddress] = this.manager.getAddresses([AddressPurpose.Payment]); + const utxos = await this.provider.fetchAddressUTxOs(paymentAddress.address); + + let feeRate = 2; + try { + feeRate = await this.provider.fetchFeeEstimates(6); + } catch { + // fall back to default + } + + const targetAmount = recipients.reduce((sum, r) => sum + r.amount, 0); + const { selectedUtxos, change } = selectUtxosLargestFirst( + utxos, + targetAmount, + feeRate, + recipients.length, + ); + + const psbt = new bitcoin.Psbt({ network: this.bitcoinNetwork }); + const paymentChild = this.manager.getChild(AddressPurpose.Payment); + const p2wpkh = bitcoin.payments.p2wpkh({ + pubkey: Buffer.from(paymentChild.publicKey), + network: this.bitcoinNetwork, + }); + + selectedUtxos.forEach((utxo) => { + psbt.addInput({ + hash: utxo.txid, + index: utxo.vout, + // BIP-125 RBF opt-in: 0xfffffffd allows the user to fee-bump if the tx stalls. + sequence: 0xfffffffd, + witnessUtxo: { + script: p2wpkh.output!, + value: utxo.value, + }, + }); + }); + + recipients.forEach((r) => { + psbt.addOutput({ address: r.address, value: r.amount }); + }); + + // The selector returns `change: 0` when change would be below dust — in that + // case skip adding a change output and let the dust be absorbed as miner fee. + if (change > 0) { + psbt.addOutput({ address: paymentAddress.address, value: change }); + } + + const signInputs: Record = { + [paymentAddress.address]: Array.from( + { length: psbt.inputCount }, + (_, i) => i, + ), + }; + + return this.signPsbt({ + psbt: psbt.toBase64(), + signInputs, + broadcast: true, + }); } - getNetwork(): Promise<"Mainnet" | "Testnet4"> { - throw new Error("Method not implemented."); + + async signPsbt(signConfig: { + psbt: string; + signInputs?: { [x: string]: number[] } | undefined; + broadcast?: boolean | undefined; + }): Promise { + const { psbt: psbtBase64, signInputs, broadcast = false } = signConfig; + + const psbt = bitcoin.Psbt.fromBase64(psbtBase64, { + network: this.bitcoinNetwork, + }); + + const addressToSigner = new Map< + string, + { signer: TaprootCapableSigner; purpose: AddressPurpose } + >(); + for (const purpose of [AddressPurpose.Payment, AddressPurpose.Ordinals]) { + const derived = this.manager.getAddress(purpose); + addressToSigner.set(derived.address, { + signer: this.signerForPurpose(purpose), + purpose, + }); + } + + const targets = signInputs ?? buildDefaultSignTargets(psbt, addressToSigner); + const indexesUsed = new Set(); + + for (const [address, indexes] of Object.entries(targets)) { + const entry = addressToSigner.get(address); + if (!entry) { + throw new Error( + `[BitcoinHeadlessWallet] Address ${address} is not managed by this wallet`, + ); + } + for (const index of indexes) { + this.signSingleInput(psbt, index, entry); + indexesUsed.add(index); + } + } + + if (broadcast) { + if (!this.provider) { + throw new Error( + "[BitcoinHeadlessWallet] No provider configured for broadcasting", + ); + } + for (const index of indexesUsed) { + psbt.finalizeInput(index); + } + const tx = psbt.extractTransaction(); + const txid = await this.provider.submitTx(tx.toHex()); + return txid; + } + + return psbt.toBase64(); } - getAddresses(addressPurposes: AddressPurpose[]): Promise { - throw new Error("Method not implemented."); + + // --------------------------------------------------------------------------- + // Internals + // --------------------------------------------------------------------------- + + protected findDerivedByAddress(address: string): DerivedBitcoinAddress { + for (const purpose of [AddressPurpose.Payment, AddressPurpose.Ordinals]) { + const derived = this.manager.getAddress(purpose); + if (derived.address === address) return derived; + } + throw new Error( + `[BitcoinHeadlessWallet] Address ${address} is not managed by this wallet`, + ); } - getAccounts(addressPurposes: AddressPurpose[]): Promise { - throw new Error("Method not implemented."); + + protected signerForPurpose(purpose: AddressPurpose): TaprootCapableSigner { + const child = this.manager.getChild(purpose); + if (!child.privateKey) { + throw new Error("[BitcoinHeadlessWallet] Private key unavailable"); + } + + if (purpose === AddressPurpose.Payment) { + const pair = ECPair.fromPrivateKey(Buffer.from(child.privateKey), { + network: this.bitcoinNetwork, + }); + return pair as unknown as TaprootCapableSigner; + } + + // Taproot: BIP-86 single-key spend. + // + // bitcoinjs-lib matches `signer.publicKey` (x-only) against the OUTPUT key + // extracted from the witnessUtxo script (`OP_1 <32-byte-output-key>`). The + // output key = internalKey + H_TapTweak(internalKey)·G, so the signer must + // expose the TWEAKED x-only pubkey and sign with the tweaked private key. + // We also stash the INTERNAL x-only key so `signSingleInput` can populate + // `input.tapInternalKey` (required by bitcoinjs-lib to trigger key-path + // matching — see getTaprootHashesForSig). + const internalPubkey = toXOnly(Buffer.from(child.publicKey)); + const tweakedPrivateKey = tweakPrivateKey( + Buffer.from(child.privateKey), + internalPubkey, + ); + const tweakedFullPub = ecc.pointFromScalar(tweakedPrivateKey, true); + if (!tweakedFullPub) { + throw new Error("[BitcoinHeadlessWallet] Failed to derive tweaked Taproot pubkey"); + } + const tweakedXOnly = toXOnly(Buffer.from(tweakedFullPub)); + const signer: TaprootCapableSigner = { + publicKey: tweakedXOnly, + internalPubkey, + network: this.bitcoinNetwork, + // Calling `sign` on a Taproot input is a usage error — bitcoinjs only calls + // `signSchnorr`. Throwing makes a misuse loud instead of silently emitting + // a Schnorr signature where ECDSA was expected. + sign: (): Buffer => { + throw new Error( + "[BitcoinHeadlessWallet] Taproot signer does not support ECDSA `sign`; use signSchnorr", + ); + }, + signSchnorr: (hash: Buffer): Buffer => { + const sig = ecc.signSchnorr(hash, tweakedPrivateKey); + return Buffer.from(sig); + }, + }; + return signer; } - getBalance(): Promise { - throw new Error("Method not implemented."); + + protected signSingleInput( + psbt: Psbt, + index: number, + entry: { signer: TaprootCapableSigner; purpose: AddressPurpose }, + ) { + if (entry.purpose === AddressPurpose.Ordinals) { + // BIP-371: set `tapInternalKey` on the input so bitcoinjs-lib's + // getTaprootHashesForSig recognises this as a key-path spend. The + // matching check is `toXOnly(signer.publicKey).equals(outputKey)` where + // outputKey is extracted from the witnessUtxo script — our signer's + // publicKey is the tweaked output key, so the match succeeds. + // SIGHASH_DEFAULT (0x00) yields the BIP-341 64-byte Schnorr signature. + if (!entry.signer.internalPubkey) { + throw new Error( + "[BitcoinHeadlessWallet] Taproot signer missing internalPubkey", + ); + } + psbt.updateInput(index, { + tapInternalKey: entry.signer.internalPubkey, + }); + psbt.signInput( + index, + entry.signer as unknown as Signer, + [bitcoin.Transaction.SIGHASH_DEFAULT], + ); + } else { + psbt.signInput(index, entry.signer as unknown as Signer); + } } - signMessage(address: string, message: string, protocol?: MessageSigningProtocols): Promise { - throw new Error("Method not implemented."); +} + +// ----------------------------------------------------------------------------- +// Helpers (module-scope, not exported as public API) +// ----------------------------------------------------------------------------- + +/** + * P2WPKH dust threshold under default relay policy (Bitcoin Core ~0.21+): + * outputs below this value are non-standard and the tx will fail to relay. + * 546 sats matches `GetDustThreshold` for a P2WPKH output at the default + * 3000 sat/kvB dust feerate. Absorb anything below this into the miner fee. + */ +const DUST_THRESHOLD_P2WPKH = 546; + +/** + * Build per-address sign-target index lists by inspecting each PSBT input's + * witnessUtxo script type. Each input is assigned to the single signer that + * matches its script (P2WPKH-payment vs P2TR-ordinals). Without this, a + * blanket `[0..n]` assignment causes bitcoinjs-lib to attempt signing each + * input with BOTH signers, producing a "Can not sign for input" throw or + * a corrupted PSBT. + * + * Inputs whose script doesn't match any managed address are skipped — the + * caller can detect that case by passing explicit `signInputs`. + */ +function buildDefaultSignTargets( + psbt: Psbt, + addressToSigner: Map, +): Record { + const purposeToAddress = new Map(); + for (const [address, entry] of addressToSigner.entries()) { + purposeToAddress.set(entry.purpose, address); + } + const out: Record = {}; + for (let i = 0; i < psbt.inputCount; i++) { + const input = psbt.data.inputs[i]; + const script = input?.witnessUtxo?.script; + if (!script) continue; + let purpose: AddressPurpose | undefined; + if (script.length === 22 && script[0] === 0x00 && script[1] === 0x14) { + // OP_0 <20-byte-hash> → P2WPKH + purpose = AddressPurpose.Payment; + } else if (script.length === 34 && script[0] === 0x51 && script[1] === 0x20) { + // OP_1 <32-byte-x-only-key> → P2TR + purpose = AddressPurpose.Ordinals; + } + if (!purpose) continue; + const address = purposeToAddress.get(purpose); + if (!address) continue; + (out[address] ??= []).push(i); + } + return out; +} + +/** + * Largest-first UTXO selection with P2WPKH-realistic vbyte estimates. + * + * vbyte math (BIP-141 / BIP-144): + * - overhead: 10.5 vB (version 4 + locktime 4 + segwit marker/flag 0.5 + 2× varint <253) + * - per P2WPKH input: 68 vB (outpoint 41 + script_len 1 + sequence 0 + witness ~27) + * - per output: 31 vB (value 8 + script_len 1 + scriptPubKey 22) + * + * Tries with-change first; if change would be sub-dust, retries without a + * change output (one fewer 31 vB output) so the dust is consumed as fee. + */ +function selectUtxosLargestFirst( + utxos: { txid: string; vout: number; value: number }[], + targetAmount: number, + feeRate: number, + numRecipients: number, +): { selectedUtxos: typeof utxos; change: number } { + const VB_OVERHEAD = 11; + const VB_INPUT_P2WPKH = 68; + const VB_OUTPUT = 31; + const recipientsVb = numRecipients * VB_OUTPUT; + + const sorted = [...utxos].sort((a, b) => b.value - a.value); + let selectedValue = 0; + const selected: typeof utxos = []; + + for (const utxo of sorted) { + selected.push(utxo); + selectedValue += utxo.value; + const inputsVb = selected.length * VB_INPUT_P2WPKH; + const vbytesWithChange = VB_OVERHEAD + inputsVb + recipientsVb + VB_OUTPUT; + const vbytesNoChange = VB_OVERHEAD + inputsVb + recipientsVb; + const feeWithChange = Math.ceil(vbytesWithChange * feeRate); + const feeNoChange = Math.ceil(vbytesNoChange * feeRate); + + if (selectedValue >= targetAmount + feeWithChange) { + const change = selectedValue - targetAmount - feeWithChange; + if (change >= DUST_THRESHOLD_P2WPKH) { + return { selectedUtxos: selected, change }; + } + // Change would be dust — drop the change output, absorb dust as fee. + if (selectedValue >= targetAmount + feeNoChange) { + return { selectedUtxos: selected, change: 0 }; + } + } else if (selectedValue >= targetAmount + feeNoChange) { + // Just enough for a no-change tx. + return { selectedUtxos: selected, change: 0 }; + } + } + throw new Error("[BitcoinHeadlessWallet] Insufficient funds for transaction"); +} + +/** + * Recover the recoveryId (0 or 1) for an ECDSA signature given the message hash and + * the expected compressed public key. Returns the id whose point recovery matches + * the signer's pubkey. Required to emit a 65-byte compact recoverable signature + * compatible with the Bitcoin signed-message standard. + */ +function findRecoveryId(hash: Buffer, sig: Buffer, expectedPubkey: Buffer): 0 | 1 { + for (const rid of [0, 1] as const) { + const recovered = ecc.recover(hash, sig, rid, true); + if (recovered && Buffer.from(recovered).equals(expectedPubkey)) { + return rid; + } + } + throw new Error( + "[BitcoinHeadlessWallet] Failed to determine recovery id for signature", + ); +} + +function varIntBuffer(n: number): Buffer { + if (n < 0xfd) return Buffer.from([n]); + if (n <= 0xffff) return Buffer.from([0xfd, n & 0xff, n >> 8]); + if (n <= 0xffffffff) + return Buffer.from([ + 0xfe, + n & 0xff, + (n >> 8) & 0xff, + (n >> 16) & 0xff, + (n >> 24) & 0xff, + ]); + throw new Error("Message too long"); +} + +/** + * Tweak a BIP-340 private key with the Taproot output tweak (BIP-86 single-key path). + * Required before signing P2TR inputs with no script tree. + */ +function tweakPrivateKey(privateKey: Buffer, internalPubkey: Buffer): Buffer { + // BIP-341: if the internal pubkey has odd Y parity, negate the private key + // before tweaking so that the resulting key produces the expected x-only output key. + const fullPub = ecc.pointFromScalar(privateKey, true); + if (!fullPub) { + throw new Error("[BitcoinHeadlessWallet] Invalid private key"); } - signTransfer(recipients: { address: string; amount: number; }[]): Promise { - throw new Error("Method not implemented."); + let priv: Buffer | Uint8Array = privateKey; + if (fullPub[0] === 0x03) { + const negated = ecc.privateNegate(privateKey); + if (!negated) { + throw new Error("[BitcoinHeadlessWallet] Failed to negate private key"); + } + priv = negated; } - signPsbt(signConfig: { psbt: string; signInputs?: { [x: string]: number[]; } | undefined; broadcast?: boolean | undefined; }): Promise { - throw new Error("Method not implemented."); + const taggedHash = bitcoin.crypto.taggedHash( + "TapTweak", + Buffer.from(internalPubkey), + ); + const tweaked = ecc.privateAdd(priv, taggedHash); + if (!tweaked) { + throw new Error("[BitcoinHeadlessWallet] Failed to tweak Taproot private key"); } + return Buffer.from(tweaked); } diff --git a/src/index.ts b/src/index.ts index b25520e..4b55959 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,3 +7,23 @@ export * from "./cardano/wallet/browser/mesh-browser-wallet"; export * from "./cardano/wallet/mesh/cardano-headless-wallet"; export * from "./cardano/wallet/mesh/mesh-wallet"; export * from "./cardano/interfaces/cardano-wallet"; +export { + AddressPurpose, + AddressType as BitcoinAddressType, + MessageSigningProtocols, +} from "./bitcoin/interfaces/bitcoin-wallet"; +export type { + BitcoinAccount, + BitcoinAddress, + BitcoinBalance, + BitcoinSignature, + IBitcoinWallet, +} from "./bitcoin/interfaces/bitcoin-wallet"; +export * from "./bitcoin/interfaces/bitcoin-provider"; +export * from "./bitcoin/types"; +export * from "./bitcoin/address/bitcoin-address"; +export * from "./bitcoin/address/bitcoin-address-manager"; +export * from "./bitcoin/wallet/mesh/bitcoin-headless-wallet"; +export * from "./bitcoin/wallet/browser/bitcoin-browser-wallet"; +export * from "./bitcoin/wallet/browser/adapters/xverse-adapter"; +export * from "./multi-chain/interfaces/multi-chain-wallet"; diff --git a/test/bitcoin/bitcoin-address-manager.test.ts b/test/bitcoin/bitcoin-address-manager.test.ts new file mode 100644 index 0000000..d8198f1 --- /dev/null +++ b/test/bitcoin/bitcoin-address-manager.test.ts @@ -0,0 +1,164 @@ +import { + BitcoinAddressManager, + paymentPath, + ordinalsPath, + getCoinType, +} from "../../src/bitcoin/address/bitcoin-address-manager"; +import { networkFromName } from "../../src/bitcoin/address/bitcoin-address"; +import { AddressPurpose, AddressType } from "../../src/bitcoin/interfaces/bitcoin-wallet"; +import { bip39 } from "../../src/bitcoin/wallet/core/bitcoin-core"; + +const TEST_MNEMONIC = [ + "muscle", "urban", "donkey", "public", "summer", "recycle", + "kitten", "silver", "pluck", "myth", "install", "useful", +]; + +const expectedTestnetP2WPKH = "tb1qq6km6823v806scer3feqx2xcyrdhgcgw7y80us"; +const expectedTestnetP2TR = "tb1ptc3m295wnrt8e2sw3q3mcshqc4v9w9hfuq7eenhm5dsymj6w2xysn7vgx7"; + +async function makeManager(network: "Mainnet" | "Testnet4") { + const seed = await bip39.mnemonicToSeed(TEST_MNEMONIC.join(" "), ""); + const btcNetwork = networkFromName(network); + return BitcoinAddressManager.fromSeed(seed, btcNetwork, 0); +} + +describe("BitcoinAddressManager", () => { + describe("getCoinType()", () => { + it("returns 0 for Mainnet (bech32 hrp = 'bc')", () => { + const network = networkFromName("Mainnet"); + expect(getCoinType(network)).toBe(0); + }); + + it("returns 1 for Testnet4 (bech32 hrp = 'tb')", () => { + const network = networkFromName("Testnet4"); + expect(getCoinType(network)).toBe(1); + }); + }); + + describe("derivation paths", () => { + it("paymentPath uses BIP-84 with correct coinType for Mainnet", () => { + const network = networkFromName("Mainnet"); + expect(paymentPath(network, 0, 0, 0)).toBe("m/84'/0'/0'/0/0"); + expect(paymentPath(network, 1, 0, 5)).toBe("m/84'/0'/1'/0/5"); + }); + + it("paymentPath uses BIP-84 with correct coinType for Testnet4", () => { + const network = networkFromName("Testnet4"); + expect(paymentPath(network, 0, 0, 0)).toBe("m/84'/1'/0'/0/0"); + }); + + it("ordinalsPath uses BIP-86 with correct coinType for Mainnet", () => { + const network = networkFromName("Mainnet"); + expect(ordinalsPath(network, 0, 0, 0)).toBe("m/86'/0'/0'/0/0"); + }); + + it("ordinalsPath uses BIP-86 with correct coinType for Testnet4", () => { + const network = networkFromName("Testnet4"); + expect(ordinalsPath(network, 0, 0, 0)).toBe("m/86'/1'/0'/0/0"); + }); + }); + + describe("getAddress()", () => { + it("derives the canonical BIP-84 P2WPKH address on testnet", async () => { + const manager = await makeManager("Testnet4"); + const addr = manager.getAddress(AddressPurpose.Payment); + expect(addr.address).toBe(expectedTestnetP2WPKH); + expect(addr.addressType).toBe(AddressType.p2wpkh); + expect(addr.purpose).toBe(AddressPurpose.Payment); + expect(addr.derivationPath).toBe("m/84'/1'/0'/0/0"); + }); + + it("derives the canonical BIP-86 P2TR address on testnet", async () => { + const manager = await makeManager("Testnet4"); + const addr = manager.getAddress(AddressPurpose.Ordinals); + expect(addr.address).toBe(expectedTestnetP2TR); + expect(addr.addressType).toBe(AddressType.p2tr); + expect(addr.purpose).toBe(AddressPurpose.Ordinals); + expect(addr.derivationPath).toBe("m/86'/1'/0'/0/0"); + }); + + it("derives mainnet bech32 addresses starting with 'bc1'", async () => { + const manager = await makeManager("Mainnet"); + const payment = manager.getAddress(AddressPurpose.Payment); + const ordinals = manager.getAddress(AddressPurpose.Ordinals); + expect(payment.address.startsWith("bc1q")).toBe(true); + expect(ordinals.address.startsWith("bc1p")).toBe(true); + }); + + it("derived publicKey for payment is 33-byte compressed (66 hex chars)", async () => { + const manager = await makeManager("Testnet4"); + const addr = manager.getAddress(AddressPurpose.Payment); + expect(addr.publicKey.length).toBe(66); + }); + + it("derived publicKey for ordinals is 32-byte x-only (64 hex chars)", async () => { + const manager = await makeManager("Testnet4"); + const addr = manager.getAddress(AddressPurpose.Ordinals); + expect(addr.publicKey.length).toBe(64); + }); + + it("throws for unsupported purpose (stacks/starknet/spark)", async () => { + const manager = await makeManager("Testnet4"); + expect(() => manager.getAddress(AddressPurpose.Stacks)).toThrow(/Unsupported/); + }); + + it("derives different addresses at different indexes", async () => { + const manager = await makeManager("Testnet4"); + const a = manager.getAddress(AddressPurpose.Payment, 0, 0); + const b = manager.getAddress(AddressPurpose.Payment, 0, 1); + expect(a.address).not.toBe(b.address); + }); + }); + + describe("getAddresses()", () => { + it("defaults to [Payment, Ordinals] when called with empty array", async () => { + const manager = await makeManager("Testnet4"); + const addrs = manager.getAddresses([]); + expect(addrs).toHaveLength(2); + expect(addrs[0]!.purpose).toBe(AddressPurpose.Payment); + expect(addrs[1]!.purpose).toBe(AddressPurpose.Ordinals); + }); + + it("returns only the requested purposes", async () => { + const manager = await makeManager("Testnet4"); + const addrs = manager.getAddresses([AddressPurpose.Ordinals]); + expect(addrs).toHaveLength(1); + expect(addrs[0]!.purpose).toBe(AddressPurpose.Ordinals); + }); + + it("skips unsupported purposes silently", async () => { + const manager = await makeManager("Testnet4"); + const addrs = manager.getAddresses([ + AddressPurpose.Payment, + AddressPurpose.Stacks, + AddressPurpose.Starknet, + ]); + expect(addrs).toHaveLength(1); + expect(addrs[0]!.purpose).toBe(AddressPurpose.Payment); + }); + }); + + describe("getChild()", () => { + it("returns a BIP-32 child node for payment purpose", async () => { + const manager = await makeManager("Testnet4"); + const child = manager.getChild(AddressPurpose.Payment); + expect(child.privateKey).toBeDefined(); + expect(child.publicKey).toBeDefined(); + }); + + it("returns a BIP-32 child node for ordinals purpose", async () => { + const manager = await makeManager("Testnet4"); + const child = manager.getChild(AddressPurpose.Ordinals); + expect(child.privateKey).toBeDefined(); + expect(child.publicKey).toBeDefined(); + }); + }); + + describe("read-only mode (no root)", () => { + it("throws when calling getAddress without a root", () => { + const network = networkFromName("Testnet4"); + const manager = new BitcoinAddressManager({ network }); + expect(() => manager.getAddress(AddressPurpose.Payment)).toThrow(/root/); + }); + }); +}); diff --git a/test/bitcoin/bitcoin-address.test.ts b/test/bitcoin/bitcoin-address.test.ts new file mode 100644 index 0000000..fa5d755 --- /dev/null +++ b/test/bitcoin/bitcoin-address.test.ts @@ -0,0 +1,92 @@ +import { + deriveP2trAddress, + deriveP2wpkhAddress, + networkFromName, + toXOnly, +} from "../../src/bitcoin/address/bitcoin-address"; + +// BIP-86 test vector (mainnet, m/86'/0'/0'/0/0): +// https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki#test-vectors +const BIP86_PUBKEY_HEX = + "03cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115"; +const BIP86_EXPECTED_P2TR_MAINNET = + "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr"; + +// BIP-84 test vector (mainnet, m/84'/0'/0'/0/0): +// https://github.com/bitcoin/bips/blob/master/bip-0084.mediawiki#test-vectors +const BIP84_PUBKEY_HEX = + "0330d54fd0dd420a6e5f8d3624f5f3482cae350f79d5f0753bf5beef9c2d91af3c"; +const BIP84_EXPECTED_P2WPKH_MAINNET = + "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu"; + +describe("BitcoinAddress derivation primitives", () => { + describe("toXOnly()", () => { + it("strips the parity byte from a 33-byte compressed pubkey", () => { + const compressed = Buffer.from(BIP86_PUBKEY_HEX, "hex"); + expect(compressed.length).toBe(33); + const xonly = toXOnly(compressed); + expect(xonly.length).toBe(32); + expect(xonly.toString("hex")).toBe(BIP86_PUBKEY_HEX.slice(2)); + }); + + it("is a no-op for an already 32-byte key", () => { + const xonly = Buffer.from(BIP86_PUBKEY_HEX.slice(2), "hex"); + expect(toXOnly(xonly).toString("hex")).toBe(xonly.toString("hex")); + }); + }); + + describe("deriveP2wpkhAddress()", () => { + it("matches the BIP-84 mainnet test vector", () => { + const network = networkFromName("Mainnet"); + const result = deriveP2wpkhAddress(Buffer.from(BIP84_PUBKEY_HEX, "hex"), network); + expect(result.address).toBe(BIP84_EXPECTED_P2WPKH_MAINNET); + expect(result.publicKey).toBe(BIP84_PUBKEY_HEX); + }); + + it("accepts Uint8Array pubkey input", () => { + const network = networkFromName("Mainnet"); + const pubkey = new Uint8Array(Buffer.from(BIP84_PUBKEY_HEX, "hex")); + const result = deriveP2wpkhAddress(pubkey, network); + expect(result.address).toBe(BIP84_EXPECTED_P2WPKH_MAINNET); + }); + + it("produces testnet bech32 'tb1q' addresses on Testnet4", () => { + const network = networkFromName("Testnet4"); + const result = deriveP2wpkhAddress(Buffer.from(BIP84_PUBKEY_HEX, "hex"), network); + expect(result.address.startsWith("tb1q")).toBe(true); + }); + }); + + describe("deriveP2trAddress()", () => { + it("matches the BIP-86 mainnet test vector", () => { + const network = networkFromName("Mainnet"); + const result = deriveP2trAddress(Buffer.from(BIP86_PUBKEY_HEX, "hex"), network); + expect(result.address).toBe(BIP86_EXPECTED_P2TR_MAINNET); + }); + + it("stores publicKey as the 32-byte x-only key (hex)", () => { + const network = networkFromName("Mainnet"); + const result = deriveP2trAddress(Buffer.from(BIP86_PUBKEY_HEX, "hex"), network); + expect(result.publicKey).toBe(BIP86_PUBKEY_HEX.slice(2)); + expect(result.publicKey.length).toBe(64); + }); + + it("produces testnet bech32m 'tb1p' addresses on Testnet4", () => { + const network = networkFromName("Testnet4"); + const result = deriveP2trAddress(Buffer.from(BIP86_PUBKEY_HEX, "hex"), network); + expect(result.address.startsWith("tb1p")).toBe(true); + }); + }); + + describe("networkFromName()", () => { + it("returns the bitcoinjs mainnet network for 'Mainnet'", () => { + const network = networkFromName("Mainnet"); + expect(network.bech32).toBe("bc"); + }); + + it("returns the bitcoinjs testnet network for 'Testnet4'", () => { + const network = networkFromName("Testnet4"); + expect(network.bech32).toBe("tb"); + }); + }); +}); diff --git a/test/bitcoin/bitcoin-browser-wallet.test.ts b/test/bitcoin/bitcoin-browser-wallet.test.ts new file mode 100644 index 0000000..5c71d50 --- /dev/null +++ b/test/bitcoin/bitcoin-browser-wallet.test.ts @@ -0,0 +1,133 @@ +import { BitcoinBrowserWallet } from "../../src/bitcoin/wallet/browser/bitcoin-browser-wallet"; +import { + AddressPurpose, + IBitcoinWallet, + MessageSigningProtocols, +} from "../../src/bitcoin/interfaces/bitcoin-wallet"; + +/** + * BitcoinBrowserWallet is a thin pass-through wrapper around an injected + * IBitcoinWallet (typically an XverseAdapter). Test the public surface by + * passing a hand-rolled fake provider. + */ +function makeFake(): jest.Mocked { + return { + getNetwork: jest.fn().mockResolvedValue("Testnet4"), + getAddresses: jest.fn().mockResolvedValue([]), + getAccounts: jest.fn().mockResolvedValue([]), + getBalance: jest.fn().mockResolvedValue({ confirmed: "0", unconfirmed: "0", total: "0" }), + signMessage: jest.fn().mockResolvedValue({ + signature: "sig", + messageHash: "hash", + address: "addr", + protocol: MessageSigningProtocols.ECDSA, + }), + signTransfer: jest.fn().mockResolvedValue("txid-fake"), + signPsbt: jest.fn().mockResolvedValue("psbt-fake"), + } as unknown as jest.Mocked; +} + +describe("BitcoinBrowserWallet", () => { + describe("getInstalledWallets", () => { + it("returns empty list when no providers exist on globalThis", () => { + // Ensure Xverse isn't installed + delete (globalThis as any).XverseProviders; + delete (globalThis as any).BitcoinProvider; + const wallets = BitcoinBrowserWallet.getInstalledWallets(); + expect(wallets).toEqual([]); + }); + + it("includes Xverse when window.XverseProviders.BitcoinProvider is present", () => { + (globalThis as any).XverseProviders = { + BitcoinProvider: { request: jest.fn() }, + }; + const wallets = BitcoinBrowserWallet.getInstalledWallets(); + expect(wallets.find((w) => w.id === "xverse")).toBeDefined(); + delete (globalThis as any).XverseProviders; + }); + + it("includes Xverse when only legacy window.BitcoinProvider is present", () => { + (globalThis as any).BitcoinProvider = { request: jest.fn() }; + const wallets = BitcoinBrowserWallet.getInstalledWallets(); + expect(wallets.find((w) => w.id === "xverse")).toBeDefined(); + delete (globalThis as any).BitcoinProvider; + }); + }); + + describe("enable", () => { + it("throws for an unknown wallet name", async () => { + await expect(BitcoinBrowserWallet.enable("does-not-exist")).rejects.toThrow( + /Unknown wallet/, + ); + }); + + it("throws when xverse is not installed", async () => { + delete (globalThis as any).XverseProviders; + delete (globalThis as any).BitcoinProvider; + await expect(BitcoinBrowserWallet.enable("xverse")).rejects.toThrow(); + }); + }); + + describe("constructor + pass-through methods", () => { + it("delegates getNetwork to wrapped wallet", async () => { + const fake = makeFake(); + const wallet = new BitcoinBrowserWallet(fake); + await wallet.getNetwork(); + expect(fake.getNetwork).toHaveBeenCalledTimes(1); + }); + + it("delegates getAddresses with given purposes", async () => { + const fake = makeFake(); + const wallet = new BitcoinBrowserWallet(fake); + await wallet.getAddresses([AddressPurpose.Payment, AddressPurpose.Ordinals]); + expect(fake.getAddresses).toHaveBeenCalledWith([ + AddressPurpose.Payment, + AddressPurpose.Ordinals, + ]); + }); + + it("delegates getAccounts", async () => { + const fake = makeFake(); + const wallet = new BitcoinBrowserWallet(fake); + await wallet.getAccounts([AddressPurpose.Payment]); + expect(fake.getAccounts).toHaveBeenCalledWith([AddressPurpose.Payment]); + }); + + it("delegates getBalance", async () => { + const fake = makeFake(); + const wallet = new BitcoinBrowserWallet(fake); + const balance = await wallet.getBalance(); + expect(balance).toEqual({ confirmed: "0", unconfirmed: "0", total: "0" }); + expect(fake.getBalance).toHaveBeenCalledTimes(1); + }); + + it("delegates signMessage with explicit protocol", async () => { + const fake = makeFake(); + const wallet = new BitcoinBrowserWallet(fake); + await wallet.signMessage("tb1q...", "hi", MessageSigningProtocols.BIP322); + expect(fake.signMessage).toHaveBeenCalledWith( + "tb1q...", + "hi", + MessageSigningProtocols.BIP322, + ); + }); + + it("delegates signTransfer", async () => { + const fake = makeFake(); + const wallet = new BitcoinBrowserWallet(fake); + const txid = await wallet.signTransfer([ + { address: "tb1q...", amount: 1000 }, + ]); + expect(txid).toBe("txid-fake"); + expect(fake.signTransfer).toHaveBeenCalled(); + }); + + it("delegates signPsbt", async () => { + const fake = makeFake(); + const wallet = new BitcoinBrowserWallet(fake); + const out = await wallet.signPsbt({ psbt: "cHNidP...", broadcast: false }); + expect(out).toBe("psbt-fake"); + expect(fake.signPsbt).toHaveBeenCalled(); + }); + }); +}); diff --git a/test/bitcoin/bitcoin-headless-wallet.test.ts b/test/bitcoin/bitcoin-headless-wallet.test.ts new file mode 100644 index 0000000..e061a21 --- /dev/null +++ b/test/bitcoin/bitcoin-headless-wallet.test.ts @@ -0,0 +1,442 @@ +import { BitcoinHeadlessWallet } from "../../src/bitcoin/wallet/mesh/bitcoin-headless-wallet"; +import { + AddressPurpose, + MessageSigningProtocols, +} from "../../src/bitcoin/interfaces/bitcoin-wallet"; +import { IBitcoinProvider } from "../../src/bitcoin/interfaces/bitcoin-provider"; +import { bitcoin, ecc } from "../../src/bitcoin/wallet/core/bitcoin-core"; + +const TEST_MNEMONIC = [ + "muscle", "urban", "donkey", "public", "summer", "recycle", + "kitten", "silver", "pluck", "myth", "install", "useful", +]; + +const TESTNET_P2WPKH = "tb1qq6km6823v806scer3feqx2xcyrdhgcgw7y80us"; +const TESTNET_P2TR = "tb1ptc3m295wnrt8e2sw3q3mcshqc4v9w9hfuq7eenhm5dsymj6w2xysn7vgx7"; + +/** + * Minimal IBitcoinProvider mock — just enough for getBalance / signTransfer flows. + * Each test overrides only the methods it exercises. + */ +function makeProvider(overrides: Partial = {}): IBitcoinProvider { + const base: IBitcoinProvider = { + fetchAddress: jest.fn(), + fetchAddressTransactions: jest.fn(), + fetchAddressUTxOs: jest.fn(), + fetchScript: jest.fn(), + fetchScriptTransactions: jest.fn(), + fetchScriptUTxOs: jest.fn(), + fetchTransactionStatus: jest.fn(), + fetchFeeEstimates: jest.fn(), + submitTx: jest.fn(), + } as unknown as IBitcoinProvider; + return Object.assign(base, overrides); +} + +async function makeWallet(network: "Mainnet" | "Testnet4" = "Testnet4", provider?: IBitcoinProvider) { + return BitcoinHeadlessWallet.fromMnemonic({ + network, + mnemonic: TEST_MNEMONIC, + provider, + }); +} + +describe("BitcoinHeadlessWallet", () => { + // --------------------------------------------------------------------------- + // factories + getNetwork + // --------------------------------------------------------------------------- + describe("factories", () => { + it("fromMnemonic builds a wallet on Testnet4", async () => { + const wallet = await makeWallet("Testnet4"); + expect(await wallet.getNetwork()).toBe("Testnet4"); + }); + + it("fromMnemonic builds a wallet on Mainnet", async () => { + const wallet = await makeWallet("Mainnet"); + expect(await wallet.getNetwork()).toBe("Mainnet"); + }); + + it("fromMnemonic rejects an invalid mnemonic", async () => { + await expect( + BitcoinHeadlessWallet.fromMnemonic({ + network: "Testnet4", + mnemonic: ["not", "a", "valid", "mnemonic"], + }), + ).rejects.toThrow(/Invalid mnemonic/); + }); + + it("fromEntropy builds a wallet from a 128-bit hex entropy", async () => { + const wallet = await BitcoinHeadlessWallet.fromEntropy({ + network: "Testnet4", + entropy: "00000000000000000000000000000000", + }); + expect(await wallet.getNetwork()).toBe("Testnet4"); + }); + }); + + // --------------------------------------------------------------------------- + // getAddresses / getAccounts + // --------------------------------------------------------------------------- + describe("getAddresses / getAccounts", () => { + it("returns canonical BIP-84 + BIP-86 testnet addresses", async () => { + const wallet = await makeWallet("Testnet4"); + const addrs = await wallet.getAddresses([ + AddressPurpose.Payment, + AddressPurpose.Ordinals, + ]); + expect(addrs.find((a) => a.purpose === AddressPurpose.Payment)?.address).toBe(TESTNET_P2WPKH); + expect(addrs.find((a) => a.purpose === AddressPurpose.Ordinals)?.address).toBe(TESTNET_P2TR); + }); + + it("getAccounts returns the same data plus walletType=software", async () => { + const wallet = await makeWallet("Testnet4"); + const accts = await wallet.getAccounts([AddressPurpose.Payment]); + expect(accts).toHaveLength(1); + expect(accts[0]!.walletType).toBe("software"); + expect(accts[0]!.address).toBe(TESTNET_P2WPKH); + }); + }); + + // --------------------------------------------------------------------------- + // getBalance + // --------------------------------------------------------------------------- + describe("getBalance", () => { + it("throws when no provider is configured", async () => { + const wallet = await makeWallet("Testnet4"); + await expect(wallet.getBalance()).rejects.toThrow(/No provider/); + }); + + it("sums confirmed + unconfirmed correctly", async () => { + const provider = makeProvider({ + fetchAddress: jest.fn().mockResolvedValue({ + chain_stats: { funded_txo_sum: 100_000, spent_txo_sum: 40_000 }, + mempool_stats: { funded_txo_sum: 5_000, spent_txo_sum: 1_000 }, + }), + }); + const wallet = await makeWallet("Testnet4", provider); + const bal = await wallet.getBalance(); + expect(bal.confirmed).toBe("60000"); + expect(bal.unconfirmed).toBe("4000"); + expect(bal.total).toBe("64000"); + expect(provider.fetchAddress).toHaveBeenCalledWith(TESTNET_P2WPKH); + }); + + it("handles a zero-activity address", async () => { + const provider = makeProvider({ + fetchAddress: jest.fn().mockResolvedValue({ + chain_stats: { funded_txo_sum: 0, spent_txo_sum: 0 }, + mempool_stats: { funded_txo_sum: 0, spent_txo_sum: 0 }, + }), + }); + const wallet = await makeWallet("Testnet4", provider); + const bal = await wallet.getBalance(); + expect(bal.confirmed).toBe("0"); + expect(bal.total).toBe("0"); + }); + }); + + // --------------------------------------------------------------------------- + // signMessage + // --------------------------------------------------------------------------- + describe("signMessage", () => { + it("rejects BIP-322 with a clear error", async () => { + const wallet = await makeWallet("Testnet4"); + await expect( + wallet.signMessage(TESTNET_P2WPKH, "hello", MessageSigningProtocols.BIP322), + ).rejects.toThrow(/BIP-322/); + }); + + it("rejects unmanaged addresses", async () => { + const wallet = await makeWallet("Testnet4"); + await expect( + wallet.signMessage("tb1qfakeaddressdoesnotexistxyz", "hello"), + ).rejects.toThrow(/not managed/); + }); + + it("produces a 65-byte base64 ECDSA recoverable signature with magic prefix", async () => { + const wallet = await makeWallet("Testnet4"); + const sig = await wallet.signMessage(TESTNET_P2WPKH, "hello world"); + expect(sig.address).toBe(TESTNET_P2WPKH); + expect(sig.protocol).toBe(MessageSigningProtocols.ECDSA); + const decoded = Buffer.from(sig.signature, "base64"); + expect(decoded.length).toBe(65); + // Header byte for compressed pubkey is 31 or 32 (27 + 4 + recoveryId). + expect([31, 32]).toContain(decoded[0]); + }); + + it("messageHash equals hash256(magicPrefix || message) and is recoverable", async () => { + const wallet = await makeWallet("Testnet4"); + const message = "Bitcoin headless test"; + const sig = await wallet.signMessage(TESTNET_P2WPKH, message); + const decoded = Buffer.from(sig.signature, "base64"); + const header = decoded[0]!; + const rawSig = decoded.subarray(1); + const recoveryId = (header - 27 - 4) as 0 | 1; + const hash = Buffer.from(sig.messageHash, "hex"); + const recovered = ecc.recover(hash, rawSig, recoveryId, true); + expect(recovered).toBeDefined(); + // Recovered pubkey must correspond to the BIP-84 payment address. + const p2wpkh = bitcoin.payments.p2wpkh({ + pubkey: Buffer.from(recovered!), + network: bitcoin.networks.testnet, + }); + expect(p2wpkh.address).toBe(TESTNET_P2WPKH); + }); + + it("deterministic — same input produces same signature (RFC 6979)", async () => { + const wallet = await makeWallet("Testnet4"); + const a = await wallet.signMessage(TESTNET_P2WPKH, "stable"); + const b = await wallet.signMessage(TESTNET_P2WPKH, "stable"); + expect(a.signature).toBe(b.signature); + }); + }); + + // --------------------------------------------------------------------------- + // signTransfer + // --------------------------------------------------------------------------- + describe("signTransfer", () => { + const utxo = { + txid: "a".repeat(64), + vout: 0, + value: 100_000, + status: { confirmed: true }, + }; + + it("throws when no provider is configured", async () => { + const wallet = await makeWallet("Testnet4"); + await expect( + wallet.signTransfer([{ address: TESTNET_P2WPKH, amount: 10_000 }]), + ).rejects.toThrow(/No provider/); + }); + + it("throws when recipients is empty", async () => { + const provider = makeProvider(); + const wallet = await makeWallet("Testnet4", provider); + await expect(wallet.signTransfer([])).rejects.toThrow(/No recipients/); + }); + + it("throws insufficient funds when UTXO set cannot cover target+fee", async () => { + const provider = makeProvider({ + fetchAddressUTxOs: jest.fn().mockResolvedValue([ + { ...utxo, value: 500 }, + ]), + fetchFeeEstimates: jest.fn().mockResolvedValue(1), + }); + const wallet = await makeWallet("Testnet4", provider); + await expect( + wallet.signTransfer([{ address: TESTNET_P2WPKH, amount: 10_000 }]), + ).rejects.toThrow(/Insufficient funds/); + }); + + it("broadcasts a signed tx and returns the txid", async () => { + const submitTx = jest.fn().mockResolvedValue("deadbeefcafe"); + const provider = makeProvider({ + fetchAddressUTxOs: jest.fn().mockResolvedValue([utxo]), + fetchFeeEstimates: jest.fn().mockResolvedValue(2), + submitTx, + }); + const wallet = await makeWallet("Testnet4", provider); + const txid = await wallet.signTransfer([ + { address: TESTNET_P2WPKH, amount: 10_000 }, + ]); + expect(txid).toBe("deadbeefcafe"); + expect(submitTx).toHaveBeenCalledTimes(1); + // Tx hex passed to submitTx must be a non-empty hex string. + const submittedHex = (submitTx.mock.calls[0] as string[])[0]!; + expect(typeof submittedHex).toBe("string"); + expect(submittedHex.length).toBeGreaterThan(20); + }); + + it("falls back to default feeRate if provider.fetchFeeEstimates throws", async () => { + const submitTx = jest.fn().mockResolvedValue("txid-fallback"); + const provider = makeProvider({ + fetchAddressUTxOs: jest.fn().mockResolvedValue([utxo]), + fetchFeeEstimates: jest.fn().mockRejectedValue(new Error("fee API down")), + submitTx, + }); + const wallet = await makeWallet("Testnet4", provider); + const txid = await wallet.signTransfer([ + { address: TESTNET_P2WPKH, amount: 10_000 }, + ]); + expect(txid).toBe("txid-fallback"); + }); + + it("omits the change output when change would be sub-dust (< 546 sats)", async () => { + // Build a UTXO sized so that after fee + amount, the leftover is < 546 sats. + // With 1 sat/vB and ~110 vB tx with-change vs ~79 vB tx without-change, + // a UTXO of 10_700 with amount 10_500 leaves ~110 sats → below dust. + const tinyUtxo = { ...utxo, value: 10_700 }; + let submittedHex = ""; + const provider = makeProvider({ + fetchAddressUTxOs: jest.fn().mockResolvedValue([tinyUtxo]), + fetchFeeEstimates: jest.fn().mockResolvedValue(1), + submitTx: jest.fn().mockImplementation((hex: string) => { + submittedHex = hex; + return Promise.resolve("txid-dust"); + }), + }); + const wallet = await makeWallet("Testnet4", provider); + await wallet.signTransfer([{ address: TESTNET_P2WPKH, amount: 10_500 }]); + // The serialised tx must contain exactly one output (the recipient). + const tx = bitcoin.Transaction.fromHex(submittedHex); + expect(tx.outs).toHaveLength(1); + expect(tx.outs[0]!.value).toBe(10_500); + }); + + it("adds a change output when change is above dust", async () => { + let submittedHex = ""; + const provider = makeProvider({ + fetchAddressUTxOs: jest.fn().mockResolvedValue([{ ...utxo, value: 100_000 }]), + fetchFeeEstimates: jest.fn().mockResolvedValue(1), + submitTx: jest.fn().mockImplementation((hex: string) => { + submittedHex = hex; + return Promise.resolve("txid-change"); + }), + }); + const wallet = await makeWallet("Testnet4", provider); + await wallet.signTransfer([{ address: TESTNET_P2WPKH, amount: 10_000 }]); + const tx = bitcoin.Transaction.fromHex(submittedHex); + expect(tx.outs).toHaveLength(2); + expect(tx.outs[0]!.value).toBe(10_000); + expect(tx.outs[1]!.value).toBeGreaterThanOrEqual(546); + }); + + it("uses RBF-opt-in sequence (0xfffffffd) on inputs", async () => { + let submittedHex = ""; + const provider = makeProvider({ + fetchAddressUTxOs: jest.fn().mockResolvedValue([utxo]), + fetchFeeEstimates: jest.fn().mockResolvedValue(2), + submitTx: jest.fn().mockImplementation((hex: string) => { + submittedHex = hex; + return Promise.resolve("rbf-txid"); + }), + }); + const wallet = await makeWallet("Testnet4", provider); + await wallet.signTransfer([{ address: TESTNET_P2WPKH, amount: 10_000 }]); + const tx = bitcoin.Transaction.fromHex(submittedHex); + expect(tx.ins[0]!.sequence).toBe(0xfffffffd); + }); + }); + + // --------------------------------------------------------------------------- + // signPsbt + // --------------------------------------------------------------------------- + describe("signPsbt", () => { + function buildUnsignedP2wpkhPsbt(value: number, recipientAmount: number) { + // Build a PSBT that the wallet's P2WPKH key can sign. + // We need a witnessUtxo whose script matches the wallet's payment pubkey. + const wallet = BitcoinHeadlessWallet.fromMnemonic({ + network: "Testnet4", + mnemonic: TEST_MNEMONIC, + }); + return wallet.then(async (w) => { + const [paymentAddr] = await w.getAddresses([AddressPurpose.Payment]); + const p2wpkh = bitcoin.payments.p2wpkh({ + address: paymentAddr.address, + network: bitcoin.networks.testnet, + }); + const psbt = new bitcoin.Psbt({ network: bitcoin.networks.testnet }); + psbt.addInput({ + hash: "b".repeat(64), + index: 0, + witnessUtxo: { script: p2wpkh.output!, value }, + }); + psbt.addOutput({ address: paymentAddr.address, value: recipientAmount }); + return psbt; + }); + } + + it("signs a PSBT input matching the wallet's P2WPKH script and returns base64", async () => { + const wallet = await makeWallet("Testnet4"); + const psbt = await buildUnsignedP2wpkhPsbt(100_000, 90_000); + const signedB64 = await wallet.signPsbt({ + psbt: psbt.toBase64(), + signInputs: { [TESTNET_P2WPKH]: [0] }, + broadcast: false, + }); + const parsed = bitcoin.Psbt.fromBase64(signedB64, { + network: bitcoin.networks.testnet, + }); + // After signing there should be a partial sig present on input 0. + expect(parsed.data.inputs[0]!.partialSig?.length).toBeGreaterThan(0); + }); + + it("auto-detects which managed address signs which input when signInputs is omitted", async () => { + const wallet = await makeWallet("Testnet4"); + const psbt = await buildUnsignedP2wpkhPsbt(100_000, 90_000); + const signedB64 = await wallet.signPsbt({ + psbt: psbt.toBase64(), + broadcast: false, + }); + const parsed = bitcoin.Psbt.fromBase64(signedB64, { + network: bitcoin.networks.testnet, + }); + expect(parsed.data.inputs[0]!.partialSig?.length).toBeGreaterThan(0); + }); + + it("rejects signing when targeted address is not managed by the wallet", async () => { + const wallet = await makeWallet("Testnet4"); + const psbt = await buildUnsignedP2wpkhPsbt(100_000, 90_000); + await expect( + wallet.signPsbt({ + psbt: psbt.toBase64(), + signInputs: { "tb1qstranger000000000000000000000000": [0] }, + broadcast: false, + }), + ).rejects.toThrow(/not managed/); + }); + + it("throws when broadcast=true and no provider configured", async () => { + const wallet = await makeWallet("Testnet4"); + const psbt = await buildUnsignedP2wpkhPsbt(100_000, 90_000); + await expect( + wallet.signPsbt({ + psbt: psbt.toBase64(), + signInputs: { [TESTNET_P2WPKH]: [0] }, + broadcast: true, + }), + ).rejects.toThrow(/broadcasting/); + }); + + it("finalises, extracts, and submits when broadcast=true", async () => { + const submitTx = jest.fn().mockResolvedValue("psbt-broadcast-txid"); + const provider = makeProvider({ submitTx }); + const wallet = await makeWallet("Testnet4", provider); + const psbt = await buildUnsignedP2wpkhPsbt(100_000, 90_000); + const txid = await wallet.signPsbt({ + psbt: psbt.toBase64(), + signInputs: { [TESTNET_P2WPKH]: [0] }, + broadcast: true, + }); + expect(txid).toBe("psbt-broadcast-txid"); + expect(submitTx).toHaveBeenCalledTimes(1); + }); + + it("sets tapInternalKey before signing Taproot inputs (BIP-86 path)", async () => { + const wallet = await makeWallet("Testnet4"); + const [ord] = await wallet.getAddresses([AddressPurpose.Ordinals]); + const p2tr = bitcoin.payments.p2tr({ + address: ord.address, + network: bitcoin.networks.testnet, + }); + const psbt = new bitcoin.Psbt({ network: bitcoin.networks.testnet }); + psbt.addInput({ + hash: "c".repeat(64), + index: 0, + witnessUtxo: { script: p2tr.output!, value: 50_000 }, + }); + psbt.addOutput({ address: ord.address, value: 45_000 }); + + const signedB64 = await wallet.signPsbt({ + psbt: psbt.toBase64(), + signInputs: { [ord.address]: [0] }, + broadcast: false, + }); + const parsed = bitcoin.Psbt.fromBase64(signedB64, { + network: bitcoin.networks.testnet, + }); + expect(parsed.data.inputs[0]!.tapKeySig).toBeDefined(); + expect(parsed.data.inputs[0]!.tapKeySig!.length).toBe(64); + }); + }); +}); diff --git a/test/bitcoin/xverse-adapter.test.ts b/test/bitcoin/xverse-adapter.test.ts new file mode 100644 index 0000000..e3bffc2 --- /dev/null +++ b/test/bitcoin/xverse-adapter.test.ts @@ -0,0 +1,382 @@ +import { + XverseAdapter, + XverseRpcError, + isXverseInstalled, +} from "../../src/bitcoin/wallet/browser/adapters/xverse-adapter"; +import { + AddressPurpose, + MessageSigningProtocols, +} from "../../src/bitcoin/interfaces/bitcoin-wallet"; + +/** + * Install a synthetic Xverse `BitcoinProvider` on globalThis so the adapter + * can be tested without a real browser. Each test installs a `request` mock + * that returns the wire response shape under test. + */ +function installProvider(request: jest.Mock) { + (globalThis as any).XverseProviders = { + BitcoinProvider: { request }, + }; +} + +afterEach(() => { + delete (globalThis as any).XverseProviders; + delete (globalThis as any).BitcoinProvider; +}); + +describe("isXverseInstalled", () => { + it("returns false when neither provider key is on globalThis", () => { + expect(isXverseInstalled()).toBe(false); + }); + + it("returns true when XverseProviders.BitcoinProvider is present", () => { + installProvider(jest.fn()); + expect(isXverseInstalled()).toBe(true); + }); + + it("returns true when legacy window.BitcoinProvider is present", () => { + (globalThis as any).BitcoinProvider = { request: jest.fn() }; + expect(isXverseInstalled()).toBe(true); + }); +}); + +describe("XverseAdapter.enable", () => { + it("throws when Xverse is not installed", async () => { + await expect(XverseAdapter.enable()).rejects.toThrow(/not installed/); + }); + + it("calls getAddresses on enable and seeds the cache", async () => { + const request = jest.fn().mockResolvedValue({ + status: "success", + result: { + addresses: [ + { + address: "tb1qpayment", + publicKey: "0203abcd", + addressType: "p2wpkh", + purpose: AddressPurpose.Payment, + walletType: "software", + }, + ], + }, + }); + installProvider(request); + const adapter = await XverseAdapter.enable(); + expect(request).toHaveBeenCalledWith( + "getAddresses", + expect.objectContaining({ + purposes: [AddressPurpose.Payment, AddressPurpose.Ordinals], + }), + ); + const addrs = await adapter.getAddresses([AddressPurpose.Payment]); + expect(addrs).toHaveLength(1); + expect(addrs[0]!.address).toBe("tb1qpayment"); + }); +}); + +describe("XverseAdapter — wire-format compatibility", () => { + it("handles the sats-connect-normalised {status, result} envelope", async () => { + const request = jest.fn().mockResolvedValue({ + status: "success", + result: { addresses: [] }, + }); + installProvider(request); + const adapter = await XverseAdapter.enable(); + expect(adapter).toBeDefined(); + }); + + it("handles the raw JSON-RPC 2.0 envelope", async () => { + const request = jest.fn().mockResolvedValue({ + jsonrpc: "2.0", + result: { addresses: [] }, + id: 1, + }); + installProvider(request); + const adapter = await XverseAdapter.enable(); + expect(adapter).toBeDefined(); + }); + + it("throws XverseRpcError on a normalised error envelope", async () => { + const request = jest.fn().mockResolvedValue({ + status: "error", + error: { code: -32000, message: "User rejected" }, + }); + installProvider(request); + await expect(XverseAdapter.enable()).rejects.toThrow(/User rejected/); + }); + + it("throws XverseRpcError on a raw JSON-RPC error envelope", async () => { + const request = jest.fn().mockResolvedValue({ + jsonrpc: "2.0", + error: { code: -32002, message: "Access denied" }, + }); + installProvider(request); + await expect(XverseAdapter.enable()).rejects.toThrow(/Access denied/); + }); + + it("preserves the RPC error code on XverseRpcError", async () => { + const request = jest.fn().mockResolvedValue({ + status: "error", + error: { code: -32000, message: "User rejected" }, + }); + installProvider(request); + try { + await XverseAdapter.enable(); + fail("expected throw"); + } catch (err) { + expect(err).toBeInstanceOf(XverseRpcError); + expect((err as XverseRpcError).code).toBe(-32000); + } + }); +}); + +describe("XverseAdapter.getNetwork", () => { + async function setupConnectedAdapter(networkResult: unknown) { + const request = jest.fn(); + // First call: getAddresses (from enable) + request.mockResolvedValueOnce({ status: "success", result: { addresses: [] } }); + // Second call: wallet_getNetwork + request.mockResolvedValueOnce({ status: "success", result: networkResult }); + installProvider(request); + const adapter = await XverseAdapter.enable(); + return { adapter, request }; + } + + it("calls wallet_getNetwork (canonical Sats Connect method)", async () => { + const { adapter, request } = await setupConnectedAdapter({ + bitcoin: { name: "Mainnet" }, + }); + const network = await adapter.getNetwork(); + expect(network).toBe("Mainnet"); + expect(request.mock.calls[1]![0]).toBe("wallet_getNetwork"); + }); + + it("falls back to bare getNetwork if wallet_getNetwork returns METHOD_NOT_FOUND", async () => { + const request = jest.fn(); + request.mockResolvedValueOnce({ status: "success", result: { addresses: [] } }); + request.mockResolvedValueOnce({ + status: "error", + error: { code: -32601, message: "Method not found" }, + }); + request.mockResolvedValueOnce({ + status: "success", + result: { name: "Testnet4" }, + }); + installProvider(request); + const adapter = await XverseAdapter.enable(); + const network = await adapter.getNetwork(); + expect(network).toBe("Testnet4"); + expect(request.mock.calls.map((c: any[]) => c[0])).toEqual([ + "getAddresses", + "wallet_getNetwork", + "getNetwork", + ]); + }); + + it("throws on unsupported networks (e.g. Signet) instead of silently misreporting", async () => { + const { adapter } = await setupConnectedAdapter({ bitcoin: { name: "Signet" } }); + await expect(adapter.getNetwork()).rejects.toThrow(/Unsupported network/); + }); + + it("accepts the flat {name} response shape", async () => { + const { adapter } = await setupConnectedAdapter({ name: "Mainnet" }); + expect(await adapter.getNetwork()).toBe("Mainnet"); + }); +}); + +describe("XverseAdapter.getAccounts", () => { + async function connected(request: jest.Mock) { + installProvider(request); + request.mockResolvedValueOnce({ status: "success", result: { addresses: [] } }); + return XverseAdapter.enable(); + } + + it("handles the bare-array result shape (modern Sats Connect)", async () => { + const request = jest.fn(); + const adapter = await connected(request); + request.mockResolvedValueOnce({ + status: "success", + result: [ + { + address: "tb1qabc", + publicKey: "0203aa", + addressType: "p2wpkh", + purpose: AddressPurpose.Payment, + walletType: "software", + }, + ], + }); + const accts = await adapter.getAccounts([AddressPurpose.Payment]); + expect(accts).toHaveLength(1); + expect(accts[0]!.address).toBe("tb1qabc"); + }); + + it("handles the wrapped {accounts: [...]} result shape (legacy)", async () => { + const request = jest.fn(); + const adapter = await connected(request); + request.mockResolvedValueOnce({ + status: "success", + result: { + accounts: [ + { + address: "tb1qold", + publicKey: "0203bb", + addressType: "p2wpkh", + purpose: AddressPurpose.Payment, + walletType: "software", + }, + ], + }, + }); + const accts = await adapter.getAccounts([AddressPurpose.Payment]); + expect(accts).toHaveLength(1); + expect(accts[0]!.address).toBe("tb1qold"); + }); +}); + +describe("XverseAdapter address-type validation", () => { + it("throws on unknown addressType values", async () => { + const request = jest.fn().mockResolvedValue({ + status: "success", + result: { + addresses: [ + { + address: "tb1qmystery", + publicKey: "0203", + addressType: "not-a-real-type", + purpose: AddressPurpose.Payment, + walletType: "software", + }, + ], + }, + }); + installProvider(request); + await expect(XverseAdapter.enable()).rejects.toThrow(/Unknown addressType/); + }); + + it("maps the legacy p2sh-p2wpkh alias to p2sh", async () => { + const request = jest.fn().mockResolvedValue({ + status: "success", + result: { + addresses: [ + { + address: "2N...", + publicKey: "0203", + addressType: "p2sh-p2wpkh", + purpose: AddressPurpose.Payment, + walletType: "software", + }, + ], + }, + }); + installProvider(request); + const adapter = await XverseAdapter.enable(); + const addrs = await adapter.getAddresses([AddressPurpose.Payment]); + expect(addrs[0]!.addressType).toBe("p2sh"); + }); +}); + +describe("XverseAdapter.signMessage", () => { + async function connected(request: jest.Mock) { + installProvider(request); + request.mockResolvedValueOnce({ status: "success", result: { addresses: [] } }); + return XverseAdapter.enable(); + } + + it("omits the protocol field when caller does not specify one", async () => { + const request = jest.fn(); + const adapter = await connected(request); + request.mockResolvedValueOnce({ + status: "success", + result: { + signature: "sig", + messageHash: "hash", + address: "tb1q...", + protocol: MessageSigningProtocols.ECDSA, + }, + }); + await adapter.signMessage("tb1q...", "hello"); + const params = request.mock.calls[1]![1] as Record; + expect("protocol" in params).toBe(false); + expect(params.address).toBe("tb1q..."); + expect(params.message).toBe("hello"); + }); + + it("forwards the protocol when caller specifies one", async () => { + const request = jest.fn(); + const adapter = await connected(request); + request.mockResolvedValueOnce({ + status: "success", + result: { + signature: "sig", + messageHash: "hash", + address: "tb1q...", + protocol: MessageSigningProtocols.BIP322, + }, + }); + await adapter.signMessage("tb1q...", "hi", MessageSigningProtocols.BIP322); + const params = request.mock.calls[1]![1] as Record; + expect(params.protocol).toBe(MessageSigningProtocols.BIP322); + }); +}); + +describe("XverseAdapter.signTransfer", () => { + it("calls sendTransfer and returns the txid string", async () => { + const request = jest.fn(); + request.mockResolvedValueOnce({ status: "success", result: { addresses: [] } }); + request.mockResolvedValueOnce({ + status: "success", + result: { txid: "abcd1234" }, + }); + installProvider(request); + const adapter = await XverseAdapter.enable(); + const txid = await adapter.signTransfer([ + { address: "tb1qrecip", amount: 1000 }, + ]); + expect(txid).toBe("abcd1234"); + expect(request.mock.calls[1]![0]).toBe("sendTransfer"); + }); +}); + +describe("XverseAdapter.signPsbt", () => { + it("returns psbt base64 when broadcast=false", async () => { + const request = jest.fn(); + request.mockResolvedValueOnce({ status: "success", result: { addresses: [] } }); + request.mockResolvedValueOnce({ + status: "success", + result: { psbt: "cHNidP-signed" }, + }); + installProvider(request); + const adapter = await XverseAdapter.enable(); + const out = await adapter.signPsbt({ psbt: "cHNidP-unsigned", broadcast: false }); + expect(out).toBe("cHNidP-signed"); + }); + + it("returns txid when broadcast=true and provider returns one", async () => { + const request = jest.fn(); + request.mockResolvedValueOnce({ status: "success", result: { addresses: [] } }); + request.mockResolvedValueOnce({ + status: "success", + result: { psbt: "cHNidP-signed", txid: "broadcast-txid" }, + }); + installProvider(request); + const adapter = await XverseAdapter.enable(); + const out = await adapter.signPsbt({ psbt: "cHNidP-unsigned", broadcast: true }); + expect(out).toBe("broadcast-txid"); + }); +}); + +describe("XverseAdapter.getBalance", () => { + it("coerces numeric values to strings", async () => { + const request = jest.fn(); + request.mockResolvedValueOnce({ status: "success", result: { addresses: [] } }); + request.mockResolvedValueOnce({ + status: "success", + result: { confirmed: 1234, unconfirmed: 0, total: 1234 }, + }); + installProvider(request); + const adapter = await XverseAdapter.enable(); + const bal = await adapter.getBalance(); + expect(bal).toEqual({ confirmed: "1234", unconfirmed: "0", total: "1234" }); + }); +}); From f1e59503a62ec128eac84ae76e3ab74673d3916f Mon Sep 17 00:00:00 2001 From: Jingles Date: Sat, 16 May 2026 14:38:40 +0800 Subject: [PATCH 2/2] feat(bitcoin): add verifyMessage to close sign/verify gap Mirrors existing signMessage (BIP-137, 65-byte compact recoverable ECDSA, base64). Cross-type acceptance: recovered pubkey matched against P2PKH / P2SH-P2WPKH / P2WPKH / P2TR (BIP-86). Adds IBitcoinWallet.verifyMessage, instance methods on Headless + Browser wallets, and local trustless verify in the Xverse adapter (throws clear "not yet supported" for non-65-byte BIP-322 sigs). Co-Authored-By: Claude Opus 4.7 --- src/bitcoin/interfaces/bitcoin-wallet.ts | 11 ++ .../wallet/browser/adapters/xverse-adapter.ts | 28 ++++ .../wallet/browser/bitcoin-browser-wallet.ts | 8 + .../wallet/mesh/bitcoin-headless-wallet.ts | 139 +++++++++++++++-- src/index.ts | 1 + test/bitcoin/verify-message.test.ts | 147 ++++++++++++++++++ 6 files changed, 322 insertions(+), 12 deletions(-) create mode 100644 test/bitcoin/verify-message.test.ts diff --git a/src/bitcoin/interfaces/bitcoin-wallet.ts b/src/bitcoin/interfaces/bitcoin-wallet.ts index eb1392a..f340138 100644 --- a/src/bitcoin/interfaces/bitcoin-wallet.ts +++ b/src/bitcoin/interfaces/bitcoin-wallet.ts @@ -51,6 +51,12 @@ export type BitcoinSignature = { protocol: MessageSigningProtocols; }; +export type VerifyMessageResult = { + valid: boolean; + recoveredPublicKey?: string; + reason?: string; +}; + export interface IBitcoinWallet { getNetwork(): Promise<"Mainnet" | "Testnet4">; getAddresses(addressPurposes: AddressPurpose[]): Promise; @@ -61,6 +67,11 @@ export interface IBitcoinWallet { message: string, protocol?: MessageSigningProtocols, ): Promise; + verifyMessage( + address: string, + message: string, + signature: string, + ): Promise; signTransfer( recipients: { address: string; diff --git a/src/bitcoin/wallet/browser/adapters/xverse-adapter.ts b/src/bitcoin/wallet/browser/adapters/xverse-adapter.ts index 13ce2b4..3fd55f3 100644 --- a/src/bitcoin/wallet/browser/adapters/xverse-adapter.ts +++ b/src/bitcoin/wallet/browser/adapters/xverse-adapter.ts @@ -8,6 +8,8 @@ import { IBitcoinWallet, MessageSigningProtocols, } from "../../../interfaces/bitcoin-wallet"; +import { networkFromName } from "../../../address/bitcoin-address"; +import { verifyBitcoinMessage } from "../../mesh/bitcoin-headless-wallet"; /** * Shape of the Xverse `BitcoinProvider` reachable via `window.XverseProviders.BitcoinProvider`. @@ -268,6 +270,32 @@ export class XverseAdapter implements IBitcoinWallet { }; } + /** + * Verify a Bitcoin signed-message locally. Trustless — does not call the extension. + * Supports the ECDSA 65-byte recoverable format. BIP-322 signatures (which Xverse + * may produce for Taproot addresses) throw a clear "not yet supported" error rather + * than silently returning false, so callers can distinguish "invalid" from "unsupported". + */ + async verifyMessage( + address: string, + message: string, + signature: string, + ): Promise { + const decoded = Buffer.from(signature, "base64"); + if (decoded.length !== 65) { + throw new Error( + "[XverseAdapter] verifyMessage only supports ECDSA (65-byte) signatures. BIP-322 verification is not yet implemented.", + ); + } + const networkName = await this.getNetwork(); + return verifyBitcoinMessage( + address, + message, + signature, + networkFromName(networkName), + ).valid; + } + /** * Xverse's Sats Connect exposes `sendTransfer` which prompts the user to sign * AND broadcast in one step — there is no "sign-only" variant. We surface the diff --git a/src/bitcoin/wallet/browser/bitcoin-browser-wallet.ts b/src/bitcoin/wallet/browser/bitcoin-browser-wallet.ts index 4482ff9..329aa66 100644 --- a/src/bitcoin/wallet/browser/bitcoin-browser-wallet.ts +++ b/src/bitcoin/wallet/browser/bitcoin-browser-wallet.ts @@ -73,6 +73,14 @@ export class BitcoinBrowserWallet implements IBitcoinWallet { return this.walletInstance.signMessage(address, message, protocol); } + verifyMessage( + address: string, + message: string, + signature: string, + ): Promise { + return this.walletInstance.verifyMessage(address, message, signature); + } + signTransfer( recipients: { address: string; amount: number }[], ): Promise { diff --git a/src/bitcoin/wallet/mesh/bitcoin-headless-wallet.ts b/src/bitcoin/wallet/mesh/bitcoin-headless-wallet.ts index 3151b12..7d8c5d5 100644 --- a/src/bitcoin/wallet/mesh/bitcoin-headless-wallet.ts +++ b/src/bitcoin/wallet/mesh/bitcoin-headless-wallet.ts @@ -17,6 +17,7 @@ import { BitcoinSignature, IBitcoinWallet, MessageSigningProtocols, + VerifyMessageResult, } from "../../interfaces/bitcoin-wallet"; import { ECPair, @@ -191,18 +192,8 @@ export class BitcoinHeadlessWallet implements IBitcoinWallet { network: this.bitcoinNetwork, }); - // Bitcoin signed-message standard: hash256( varInt(magicLen) || magic || varInt(msgLen) || msg ) - // The magic prefix is required for any external verifier (Electrum, Sparrow, block - // explorers, bitcoinjs-message) to accept the signature. - const messageBuffer = Buffer.from(message, "utf8"); - const magic = Buffer.from("Bitcoin Signed Message:\n", "utf8"); - const bufferToHash = Buffer.concat([ - varIntBuffer(magic.length), - magic, - varIntBuffer(messageBuffer.length), - messageBuffer, - ]); - const hash = bitcoin.crypto.hash256(bufferToHash); + // Bitcoin signed-message standard preimage (see bitcoinMessageHash helper). + const hash = bitcoinMessageHash(message); // Produce a 65-byte compact recoverable signature: [header || r || s]. // Header = 27 + 4 (compressed) + recoveryId → 0x1f or 0x20 for compressed P2PKH-style. @@ -221,6 +212,15 @@ export class BitcoinHeadlessWallet implements IBitcoinWallet { }; } + async verifyMessage( + address: string, + message: string, + signature: string, + ): Promise { + return verifyBitcoinMessage(address, message, signature, this.bitcoinNetwork) + .valid; + } + async signTransfer( recipients: { address: string; amount: number }[], ): Promise { @@ -570,6 +570,121 @@ function findRecoveryId(hash: Buffer, sig: Buffer, expectedPubkey: Buffer): 0 | ); } +/** + * Compute the Bitcoin signed-message hash: hash256(varInt(magicLen) || magic || varInt(msgLen) || msg). + * Shared by signMessage and verifyBitcoinMessage so both sides agree on the preimage. + */ +function bitcoinMessageHash(message: string): Buffer { + const messageBuffer = Buffer.from(message, "utf8"); + const magic = Buffer.from("Bitcoin Signed Message:\n", "utf8"); + const preimage = Buffer.concat([ + varIntBuffer(magic.length), + magic, + varIntBuffer(messageBuffer.length), + messageBuffer, + ]); + return bitcoin.crypto.hash256(preimage); +} + +/** + * Verify a Bitcoin signed-message (BIP-137 style, 65-byte compact recoverable ECDSA, base64). + * + * Cross-type acceptance: recovers the pubkey from the signature and matches against + * P2PKH / P2SH-P2WPKH / P2WPKH / P2TR (BIP-86) addresses derived from that pubkey. + * The header byte selects compression + recoveryId per BIP-137 but does not constrain + * which address type the caller may verify against — matches how Sparrow/Leather behave. + */ +export function verifyBitcoinMessage( + address: string, + message: string, + signature: string, + network: Network, +): VerifyMessageResult { + let decoded: Buffer; + try { + decoded = Buffer.from(signature, "base64"); + // Buffer.from with base64 silently drops invalid chars; re-encode and compare + // to catch garbage like "not-a-real-signature". + if (decoded.toString("base64").replace(/=+$/, "") !== + signature.replace(/=+$/, "")) { + return { valid: false, reason: "signature is not valid base64" }; + } + } catch { + return { valid: false, reason: "signature is not valid base64" }; + } + + if (decoded.length !== 65) { + return { + valid: false, + reason: `signature must be 65 bytes, got ${decoded.length}`, + }; + } + + const header = decoded[0]!; + if (header < 27 || header > 42) { + return { + valid: false, + reason: `signature header byte ${header} outside BIP-137 range 27..42`, + }; + } + + const compressed = header >= 31; + const recoveryId = ((header - 27) & 3) as 0 | 1 | 2 | 3; + const rawSig = decoded.subarray(1); + const hash = bitcoinMessageHash(message); + + const recovered = ecc.recover(hash, rawSig, recoveryId, compressed); + if (!recovered) { + return { valid: false, reason: "failed to recover public key from signature" }; + } + const recoveredPubkey = Buffer.from(recovered); + + if (!ecc.verify(hash, recoveredPubkey, rawSig)) { + return { valid: false, reason: "signature does not verify against recovered pubkey" }; + } + + const candidates: string[] = []; + try { + candidates.push( + bitcoin.payments.p2pkh({ pubkey: recoveredPubkey, network }).address!, + ); + } catch { /* skip */ } + if (compressed) { + try { + candidates.push( + bitcoin.payments.p2wpkh({ pubkey: recoveredPubkey, network }).address!, + ); + } catch { /* skip */ } + try { + const p2wpkh = bitcoin.payments.p2wpkh({ pubkey: recoveredPubkey, network }); + candidates.push( + bitcoin.payments.p2sh({ redeem: p2wpkh, network }).address!, + ); + } catch { /* skip */ } + try { + candidates.push( + bitcoin.payments.p2tr({ + internalPubkey: toXOnly(recoveredPubkey), + network, + }).address!, + ); + } catch { /* skip */ } + } + + if (candidates.includes(address)) { + return { + valid: true, + recoveredPublicKey: recoveredPubkey.toString("hex"), + }; + } + + return { + valid: false, + reason: "address does not match any standard form of the recovered pubkey", + recoveredPublicKey: recoveredPubkey.toString("hex"), + }; +} + function varIntBuffer(n: number): Buffer { if (n < 0xfd) return Buffer.from([n]); if (n <= 0xffff) return Buffer.from([0xfd, n & 0xff, n >> 8]); diff --git a/src/index.ts b/src/index.ts index 4b55959..76c651a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ export type { BitcoinBalance, BitcoinSignature, IBitcoinWallet, + VerifyMessageResult, } from "./bitcoin/interfaces/bitcoin-wallet"; export * from "./bitcoin/interfaces/bitcoin-provider"; export * from "./bitcoin/types"; diff --git a/test/bitcoin/verify-message.test.ts b/test/bitcoin/verify-message.test.ts new file mode 100644 index 0000000..b6db14b --- /dev/null +++ b/test/bitcoin/verify-message.test.ts @@ -0,0 +1,147 @@ +import { + BitcoinHeadlessWallet, + verifyBitcoinMessage, +} from "../../src/bitcoin/wallet/mesh/bitcoin-headless-wallet"; +import { bitcoin } from "../../src/bitcoin/wallet/core/bitcoin-core"; + +const TEST_MNEMONIC = [ + "muscle", "urban", "donkey", "public", "summer", "recycle", + "kitten", "silver", "pluck", "myth", "install", "useful", +]; + +const TESTNET_P2WPKH = "tb1qq6km6823v806scer3feqx2xcyrdhgcgw7y80us"; +const TESTNET_P2TR = "tb1ptc3m295wnrt8e2sw3q3mcshqc4v9w9hfuq7eenhm5dsymj6w2xysn7vgx7"; + +async function makeWallet() { + return BitcoinHeadlessWallet.fromMnemonic({ + network: "Testnet4", + mnemonic: TEST_MNEMONIC, + }); +} + +describe("verifyBitcoinMessage / BitcoinHeadlessWallet.verifyMessage", () => { + describe("roundtrip", () => { + it("instance verifyMessage returns true for a sig produced by signMessage", async () => { + const wallet = await makeWallet(); + const { signature } = await wallet.signMessage(TESTNET_P2WPKH, "hello world"); + const ok = await wallet.verifyMessage(TESTNET_P2WPKH, "hello world", signature); + expect(ok).toBe(true); + }); + + it("free function returns valid:true with recoveredPublicKey hex", async () => { + const wallet = await makeWallet(); + const { signature } = await wallet.signMessage(TESTNET_P2WPKH, "hello world"); + const result = verifyBitcoinMessage( + TESTNET_P2WPKH, + "hello world", + signature, + bitcoin.networks.testnet, + ); + expect(result.valid).toBe(true); + expect(result.recoveredPublicKey).toMatch(/^[0-9a-f]{66}$/); + expect(result.reason).toBeUndefined(); + }); + }); + + describe("rejection paths", () => { + it("returns false for a tampered message", async () => { + const wallet = await makeWallet(); + const { signature } = await wallet.signMessage(TESTNET_P2WPKH, "hello world"); + const ok = await wallet.verifyMessage(TESTNET_P2WPKH, "hello WORLD", signature); + expect(ok).toBe(false); + }); + + it("returns false when verifying against an unrelated address", async () => { + const wallet = await makeWallet(); + const { signature } = await wallet.signMessage(TESTNET_P2WPKH, "hello"); + // Valid testnet P2WPKH address from a different key — fixed string from bitcoinjs-lib test vectors. + const unrelated = "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx"; + const ok = await wallet.verifyMessage(unrelated, "hello", signature); + expect(ok).toBe(false); + }); + + it("free function flags malformed signatures with a reason", () => { + const result = verifyBitcoinMessage( + TESTNET_P2WPKH, + "hello", + Buffer.from("too-short").toString("base64"), + bitcoin.networks.testnet, + ); + expect(result.valid).toBe(false); + expect(result.reason).toMatch(/65 bytes/); + }); + + it("free function flags non-base64 signature input", () => { + const result = verifyBitcoinMessage( + TESTNET_P2WPKH, + "hello", + "not-a-real-signature!!", + bitcoin.networks.testnet, + ); + expect(result.valid).toBe(false); + expect(result.reason).toBeDefined(); + }); + + it("free function flags out-of-range header byte", () => { + // Build a 65-byte buffer with header=0 (outside 27..42). + const bogus = Buffer.alloc(65, 0); + const result = verifyBitcoinMessage( + TESTNET_P2WPKH, + "hello", + bogus.toString("base64"), + bitcoin.networks.testnet, + ); + expect(result.valid).toBe(false); + expect(result.reason).toMatch(/header byte/); + }); + }); + + describe("cross-type acceptance", () => { + it("accepts the same signature against the P2TR address derived from the same payment pubkey path", async () => { + // signMessage only manages addresses the wallet knows about — Payment (P2WPKH) and Ordinals (P2TR) + // share the wallet's seed but live on different BIP paths and therefore different pubkeys. + // The cross-type check that's meaningful here: a single recovered pubkey must verify against + // any of *its own* standard address forms. We sign with the P2WPKH key and verify against the + // legacy P2PKH address derived from the same recovered pubkey. + const wallet = await makeWallet(); + const { signature } = await wallet.signMessage(TESTNET_P2WPKH, "cross-type"); + + // Re-derive the same pubkey from the signature, build its P2PKH form, verify. + const decoded = Buffer.from(signature, "base64"); + const header = decoded[0]!; + const recoveryId = ((header - 27) & 3) as 0 | 1; + const rawSig = decoded.subarray(1); + const { ecc } = await import("../../src/bitcoin/wallet/core/bitcoin-core"); + const hash = bitcoin.crypto.hash256( + Buffer.concat([ + Buffer.from([0x18]), + Buffer.from("Bitcoin Signed Message:\n", "utf8"), + Buffer.from([10]), + Buffer.from("cross-type", "utf8"), + ]), + ); + const recovered = ecc.recover(hash, rawSig, recoveryId, true)!; + const p2pkh = bitcoin.payments.p2pkh({ + pubkey: Buffer.from(recovered), + network: bitcoin.networks.testnet, + }).address!; + + const result = verifyBitcoinMessage( + p2pkh, + "cross-type", + signature, + bitcoin.networks.testnet, + ); + expect(result.valid).toBe(true); + }); + + it("rejects a P2TR address derived from a different pubkey (different BIP path)", async () => { + // The wallet's P2TR address uses a different derivation path (BIP-86) than the P2WPKH (BIP-84). + // signing as P2WPKH should NOT verify against the wallet's P2TR address because the pubkey differs. + const wallet = await makeWallet(); + const { signature } = await wallet.signMessage(TESTNET_P2WPKH, "different paths"); + const ok = await wallet.verifyMessage(TESTNET_P2TR, "different paths", signature); + expect(ok).toBe(false); + }); + }); +});