// Coin selection and PSBT construction/signing for the native on-chain wallet backend. // // This is the raw-transaction half of the wallet: given a set of UTXOs discovered from the chain (see // chain.ts) and a BIP32 root supplied by the caller, it picks inputs, builds a bitcoinjs-lib Psbt, // signs it, and hands back a finalised transaction ready for `EsploraClient.broadcast`. // // It is modelled on Zeus's SweepStore (stores/SweepStore.ts) — the same p2pkh / p2sh-p2wpkh / p2wpkh / // p2tr input construction, the same `toXOnly` from bitcoinjs-lib/src/psbt/bip371 — with three // differences that matter: // // 1. Zeus sweeps a single WIF key and estimates the fee by signing a throwaway PSBT. Here the fee is // estimated analytically from per-script-type weights (see WEIGHTS below), because selection has // to know the fee before it knows the input set, and signing to find out costs a key. // 2. Zeus refuses p2tr sweeps (ZEUS-3276). Taproot key-path spends work here: the private key is // BIP341-tweaked before signing and the signer exposes `signSchnorr`. // 3. SECURITY: no function in this file reads a seed from disk, env or module state. `signAndFinalize` // takes the root HDKey as a parameter and the caller — keys.ts, via the backend's signer interface // — decides whether it is willing to hand one over. Broadcast deliberately lives in chain.ts so // the root is never held across an `await`. import type { HDKey } from '@scure/bip32'; import * as bitcoin from 'bitcoinjs-lib'; import { toXOnly } from 'bitcoinjs-lib/src/psbt/bip371'; import * as ecc from '@bitcoinerlab/secp256k1'; import { BackendError, type AddressType, type BitcoinNetwork } from './types'; // bitcoinjs-lib needs an ECC backend for anything taproot (x-only point tweaking). Module-level and // idempotent — `initEcc()` is exported so a consumer that only touches address derivation can force it // without depending on import order. let eccReady = false; export function initEcc(): void { if (eccReady) return; bitcoin.initEccLib(ecc); eccReady = true; } initEcc(); // ── networks ───────────────────────────────────────────────────────────────────────────────────── /** * bitcoinjs-lib ships bitcoin/testnet/regtest only. Signet shares testnet's address parameters exactly * (`tb` bech32 HRP, 0x6f p2pkh version, 0xc4 p2sh version) — only the genesis block and message magic * differ, and neither is used for address encoding or signing — so signet maps onto testnet. */ export function networkFor(network: BitcoinNetwork): bitcoin.Network { switch (network) { case 'bitcoin': return bitcoin.networks.bitcoin; case 'regtest': return bitcoin.networks.regtest; case 'testnet': case 'signet': return bitcoin.networks.testnet; } } /** BIP44 coin type: 0 for mainnet, 1 for every test chain (SLIP-44 "Testnet (all coins)"). */ export function coinTypeFor(network: BitcoinNetwork): 0 | 1 { return network === 'bitcoin' ? 0 : 1; } // ── script classification ──────────────────────────────────────────────────────────────────────── /** * Classify a scriptPubKey. Returns null for anything the wallet cannot own or size precisely (p2wsh, * bare multisig, op_return, future witness versions). * * Note that a bare p2sh script is reported as 'p2sh-p2wpkh'. On the *output* side that is exact — every * p2sh output is 23 bytes regardless of what redeems it — and on the input side the wallet only ever * owns the wrapped-segwit form, so the conflation is safe in both directions. */ export function scriptType(script: Uint8Array): AddressType | null { const b = script; if (b.length === 25 && b[0] === 0x76 && b[1] === 0xa9 && b[2] === 0x14 && b[23] === 0x88 && b[24] === 0xac) { return 'p2pkh'; } if (b.length === 23 && b[0] === 0xa9 && b[1] === 0x14 && b[22] === 0x87) return 'p2sh-p2wpkh'; if (b.length === 22 && b[0] === 0x00 && b[1] === 0x14) return 'p2wpkh'; if (b.length === 34 && b[0] === 0x51 && b[1] === 0x20) return 'p2tr'; return null; } /** Same, from hex. */ export function scriptTypeFromHex(hex: string): AddressType | null { return scriptType(Buffer.from(hex, 'hex')); } /** Classify a destination address by the script it encodes. Throws when the address is not valid here. */ export function addressScriptType(address: string, network: bitcoin.Network): AddressType | null { return scriptType(outputScriptFor(address, network)); } /** `bitcoin.address.toOutputScript` with the failure turned into a 400 instead of a bare Error. */ export function outputScriptFor(address: string, network: bitcoin.Network): Buffer { try { return bitcoin.address.toOutputScript(address, network); } catch { throw new BackendError(`invalid address for this network: ${address}`, 400, 'INVALID_ADDRESS'); } } // ── size and dust constants ────────────────────────────────────────────────────────────────────── // Everything here is in *weight units* (4 wu = 1 vbyte) so segwit's quarter-vbyte discount survives the // arithmetic instead of being rounded away per input. A flat 148-in/34-out estimate — the usual // shortcut — overpays a p2wpkh spend by ~2.2x and a p2tr spend by ~2.6x, which at any real fee rate is // money handed to a miner for nothing. // // Input weights, assuming a 72-byte low-R DER signature (71 is common, 72 is the worst case) and a // 33-byte compressed pubkey: // // p2pkh 32 txid + 4 vout + 1 len + 107 scriptSig + 4 seq = 148 vB → 592 wu // p2sh-p2wpkh 64 base vB (scriptSig = 23-byte redeemScript push) + 108 wu witness → 364 wu // p2wpkh 41 base vB + 108 wu witness (2 items: 72-byte sig, 33-byte key) → 272 wu // p2tr key-path 41 base vB + 66 wu witness (1 item: 64-byte schnorr sig) → 230 wu // // which come out at 148 / 91 / 68 / 57.5 vbytes — the same numbers Bitcoin Core assumes. const INPUT_WEIGHT: Record = { p2pkh: 592, 'p2sh-p2wpkh': 364, p2wpkh: 272, p2tr: 230, }; // Output weights: 8-byte value + 1-byte script length + the script itself, all ×4. // p2pkh 25B script → 34 vB, p2sh 23B → 32 vB, p2wpkh 22B → 31 vB, p2tr 34B → 43 vB. const OUTPUT_WEIGHT: Record = { p2pkh: 136, 'p2sh-p2wpkh': 128, p2wpkh: 124, p2tr: 172, }; /** Unclassifiable output (p2wsh, future witness versions): charge the 43-vbyte p2tr/p2wsh size. */ const OUTPUT_WEIGHT_UNKNOWN = 172; /** version(4) + locktime(4), ×4. The input/output count varints are added separately. */ const TX_OVERHEAD_WEIGHT = 32; /** Segwit marker + flag: 1 byte each, but they live in the witness so they weigh 1 wu each. */ const SEGWIT_MARKER_WEIGHT = 2; /** * Bitcoin Core's dust threshold: 3 sat/vB (the default dustRelayFee) times the size of the output plus * the size of the input that would eventually spend it — witness inputs are counted as a flat 67 vB. * p2pkh (34+148)*3 = 546 · p2sh (32+148)*3 = 540 · p2wpkh (31+67)*3 = 294 · p2tr (43+67)*3 = 330 * A change output below its threshold is not created; the remainder is donated to the fee instead. */ export const DUST_THRESHOLD: Record = { p2pkh: 546, 'p2sh-p2wpkh': 540, p2wpkh: 294, p2tr: 330, }; /** Dust floor for an output we could not classify — use the most demanding value we know. */ const DUST_UNKNOWN = 546; /** The network will not relay below this, so a caller asking for less gets bumped rather than stuck. */ const MIN_SAT_VB = 1; function varintWeight(n: number): number { if (n < 0xfd) return 4; if (n <= 0xffff) return 12; return 20; } function isSegwit(type: AddressType): boolean { return type !== 'p2pkh'; } export type VsizeParams = { inputs: AddressType[]; /** Output script types, in order. `null` means "unclassifiable" and is charged 43 vbytes. */ outputs: (AddressType | null)[]; }; /** * Virtual size of the transaction these inputs and outputs would produce, rounded up. Accurate to * within one vbyte per input (the DER signature is occasionally 71 bytes rather than 72), always in the * conservative direction, so the realised fee rate lands at or just above what was asked for. */ export function estimateVsize({ inputs, outputs }: VsizeParams): number { let weight = TX_OVERHEAD_WEIGHT + varintWeight(inputs.length) + varintWeight(outputs.length); if (inputs.some(isSegwit)) weight += SEGWIT_MARKER_WEIGHT; for (const type of inputs) weight += INPUT_WEIGHT[type]; for (const type of outputs) weight += type === null ? OUTPUT_WEIGHT_UNKNOWN : OUTPUT_WEIGHT[type]; return Math.ceil(weight / 4); } // ── coin selection ─────────────────────────────────────────────────────────────────────────────── /** * A UTXO the selector may spend. `derivationPath` is the FULL path from the wallet root * (`m/84'/0'/0'/0/7`), not the account-relative form the public Utxo type carries, because it is used * verbatim to derive the signing key. */ export type SpendableUtxo = { txid: string; vout: number; amountSats: number; address: string; addressType: AddressType; confirmations: number; derivationPath: string; frozen: boolean; /** scriptPubKey of the output being spent, hex. */ scriptPubKeyHex: string; /** Compressed 33-byte pubkey of the owning address, hex. */ pubkeyHex: string; }; export type CoinSelectionParams = { utxos: SpendableUtxo[]; /** Sats to pay the recipient. Ignored when `sendAll` is set. */ targetSats: number; sendAll?: boolean; satPerVbyte: number; /** Script type of the recipient output; null when it is a script we cannot size exactly. */ recipientType: AddressType | null; /** Script type the change output would use. */ changeType: AddressType; /** Coin control: restrict the input set to these `txid:vout` outpoints. */ outpoints?: string[]; spendUnconfirmed?: boolean; }; export type CoinSelection = { inputs: SpendableUtxo[]; /** Sats actually paid to the recipient. Equals `targetSats` unless `sendAll`. */ outputSats: number; /** Sats returned to the wallet, or null when no change output is created. */ changeSats: number | null; feeSats: number; /** The vsize the fee was computed from. */ vsize: number; }; const sumSats = (utxos: SpendableUtxo[]): number => utxos.reduce((acc, u) => acc + u.amountSats, 0); const outpointOf = (u: SpendableUtxo): string => `${u.txid}:${u.vout}`; function dustFor(type: AddressType | null): number { return type === null ? DUST_UNKNOWN : DUST_THRESHOLD[type]; } /** * Coin selection: a two-phase accumulative selector. Deliberately not branch-and-bound — BnB's payoff * is finding changeless solutions in a large UTXO set, and it needs a waste metric plus a fallback * anyway. Instead: * * Phase 1 — smallest sufficient single input. Walk the eligible UTXOs smallest-first and take the * first one that can cover the payment on its own. One input is the cheapest possible * transaction, and going smallest-first quietly consolidates the wallet's dust over time. * Phase 2 — largest-first accumulation. Nothing single-handedly covers it, so add UTXOs * largest-first (confirmed before unconfirmed) until the total covers payment + fee. Going * largest-first minimises the input count, and each input costs real money. * * Both phases decide change the same way: prefer a change output, but if what is left after the * with-change fee falls under the dust threshold, drop the change output and donate the remainder to * the fee. The donation is bounded by roughly (one output's fee + one dust threshold), so it can never * quietly become a large overpayment. */ export function selectCoins(params: CoinSelectionParams): CoinSelection { const rate = Math.max(params.satPerVbyte, MIN_SAT_VB); const allow = params.outpoints && params.outpoints.length > 0 ? new Set(params.outpoints) : null; const eligible = params.utxos.filter((u) => { // Frozen is never overridable, not even by naming the outpoint. Freezing is the owner saying "this // coin does not exist for spending purposes" — a reservation against an open channel, a coin held // for tax lots, dust being kept out of the way — and a rule that a sufficiently explicit request // can bypass is not a rule. The route refuses a named frozen outpoint before ever reaching here; // this is what makes that true for any future caller too. if (u.frozen) return false; // An explicit coin-control pick is otherwise authoritative: it overrides the confirmed-only // default, because the user named this exact outpoint and can see what it is. if (allow) return allow.has(outpointOf(u)); if (!params.spendUnconfirmed && u.confirmations < 1) return false; return true; }); if (eligible.length === 0) { const why = allow ? 'none of the selected outpoints are spendable' : 'no spendable UTXOs'; throw new BackendError(why, 400, 'INSUFFICIENT_FUNDS'); } if (params.sendAll) return sweepAll(eligible, rate, params.recipientType); if (!Number.isInteger(params.targetSats) || params.targetSats <= 0) { throw new BackendError('amountSats must be a positive integer', 400, 'INVALID_AMOUNT'); } const attempt = (inputs: SpendableUtxo[]): CoinSelection | null => { const total = sumSats(inputs); const types = inputs.map((u) => u.addressType); const withChange = estimateVsize({ inputs: types, outputs: [params.recipientType, params.changeType] }); const noChange = estimateVsize({ inputs: types, outputs: [params.recipientType] }); const feeWithChange = Math.ceil(withChange * rate); const feeNoChange = Math.ceil(noChange * rate); const change = total - params.targetSats - feeWithChange; if (change >= DUST_THRESHOLD[params.changeType]) { return { inputs: [...inputs], outputSats: params.targetSats, changeSats: change, feeSats: feeWithChange, vsize: withChange, }; } if (total >= params.targetSats + feeNoChange) { // Changeless: everything above the payment is fee. Smaller transaction, no dust output created. return { inputs: [...inputs], outputSats: params.targetSats, changeSats: null, feeSats: total - params.targetSats, vsize: noChange, }; } return null; }; // Phase 1 — smallest sufficient single input. const ascending = [...eligible].sort((a, b) => a.amountSats - b.amountSats); for (const utxo of ascending) { const single = attempt([utxo]); if (single) return single; } // Phase 2 — largest-first accumulation, confirmed coins ahead of unconfirmed ones. const ordered = [...eligible].sort((a, b) => { const aPending = a.confirmations > 0 ? 0 : 1; const bPending = b.confirmations > 0 ? 0 : 1; if (aPending !== bPending) return aPending - bPending; return b.amountSats - a.amountSats; }); const chosen: SpendableUtxo[] = []; for (const utxo of ordered) { chosen.push(utxo); const selection = attempt(chosen); if (selection) return selection; } const available = sumSats(eligible); throw new BackendError( `insufficient funds: ${available} sat available, ${params.targetSats} sat requested plus fees`, 400, 'INSUFFICIENT_FUNDS', ); } /** sendAll: every eligible coin in, one output out, the fee taken off that output. */ function sweepAll(inputs: SpendableUtxo[], rate: number, recipientType: AddressType | null): CoinSelection { const total = sumSats(inputs); const vsize = estimateVsize({ inputs: inputs.map((u) => u.addressType), outputs: [recipientType] }); const feeSats = Math.ceil(vsize * rate); const outputSats = total - feeSats; if (outputSats < dustFor(recipientType)) { throw new BackendError( `sweep leaves ${outputSats} sat after a ${feeSats} sat fee, below the dust threshold`, 400, 'INSUFFICIENT_FUNDS', ); } return { inputs: [...inputs], outputSats, changeSats: null, feeSats, vsize }; } // ── PSBT construction ──────────────────────────────────────────────────────────────────────────── /** RBF-signalling sequence (BIP125): any input below 0xfffffffe marks the whole transaction replaceable. */ const SEQUENCE_RBF = 0xfffffffd; const SEQUENCE_FINAL = 0xffffffff; export type PsbtInputSource = { txid: string; vout: number; amountSats: number; addressType: AddressType; /** scriptPubKey being spent, hex. */ scriptPubKeyHex: string; /** Compressed 33-byte pubkey of the owning address, hex. */ pubkeyHex: string; /** Full BIP32 path from the wallet root — returned in `inputPaths` for `signAndFinalize`. */ derivationPath: string; /** Whole previous transaction, hex. Required for p2pkh inputs, unused otherwise. */ prevTxHex?: string; }; export type PsbtOutputSpec = { address: string; amountSats: number }; export type BuildPsbtParams = { network: bitcoin.Network; inputs: PsbtInputSource[]; outputs: PsbtOutputSpec[]; /** Signal RBF. Default true. */ rbf?: boolean; locktime?: number; }; export type BuiltPsbt = { psbt: bitcoin.Psbt; /** Derivation paths in input order — pass straight to `signAndFinalize`. */ inputPaths: string[]; }; /** * Build an unsigned PSBT. Input construction is per script type, exactly as Zeus's SweepStore does it: * legacy p2pkh needs the whole previous transaction (`nonWitnessUtxo`), segwit needs only the output * being spent (`witnessUtxo`), wrapped segwit additionally needs the p2wpkh `redeemScript`, and taproot * needs the x-only internal key so the signer can be matched against the output key. */ export function buildPsbt(params: BuildPsbtParams): BuiltPsbt { initEcc(); const { network } = params; if (params.inputs.length === 0) throw new BackendError('cannot build a PSBT with no inputs', 400); if (params.outputs.length === 0) throw new BackendError('cannot build a PSBT with no outputs', 400); const psbt = new bitcoin.Psbt({ network }); psbt.setVersion(2); psbt.setLocktime(params.locktime ?? 0); const sequence = params.rbf === false ? SEQUENCE_FINAL : SEQUENCE_RBF; const inputPaths: string[] = []; for (const source of params.inputs) { const pubkey = Buffer.from(source.pubkeyHex, 'hex'); const script = Buffer.from(source.scriptPubKeyHex, 'hex'); const base = { hash: source.txid, index: source.vout, sequence }; switch (source.addressType) { case 'p2pkh': { if (!source.prevTxHex) { throw new BackendError(`p2pkh input ${source.txid}:${source.vout} needs the previous tx hex`, 500); } psbt.addInput({ ...base, nonWitnessUtxo: Buffer.from(source.prevTxHex, 'hex') }); break; } case 'p2wpkh': { psbt.addInput({ ...base, witnessUtxo: { script, value: source.amountSats } }); break; } case 'p2sh-p2wpkh': { const redeem = bitcoin.payments.p2wpkh({ pubkey, network }); if (!redeem.output) throw new BackendError('failed to build the p2wpkh redeemScript', 500); psbt.addInput({ ...base, witnessUtxo: { script, value: source.amountSats }, redeemScript: redeem.output, }); break; } case 'p2tr': { psbt.addInput({ ...base, witnessUtxo: { script, value: source.amountSats }, tapInternalKey: toXOnly(pubkey), }); break; } } inputPaths.push(source.derivationPath); } for (const output of params.outputs) { // Validate against the configured network before adding — bitcoinjs would accept a foreign-network // address encoded for a chain with the same prefixes and silently burn the funds. outputScriptFor(output.address, network); psbt.addOutput({ address: output.address, value: output.amountSats }); } return { psbt, inputPaths }; } // ── signing ────────────────────────────────────────────────────────────────────────────────────── export type SignedTx = { txid: string; rawHex: string; /** Realised vsize of the signed transaction — compare against the estimate to audit fee accuracy. */ vsize: number; feeSats: number; }; /** * BIP341 key-path tweak. The output key is `P + H_TapTweak(P) * G` where P is the x-only internal key, * so the private key has to be tweaked the same way before it can produce a matching schnorr signature. * If the internal point has odd Y the scalar is negated first, since x-only keys are always even-Y. */ function tweakPrivateKey(priv: Uint8Array, pubkey: Buffer): Uint8Array { const even = pubkey[0] === 0x02 ? priv : ecc.privateNegate(priv); const tweak = bitcoin.crypto.taggedHash('TapTweak', toXOnly(pubkey)); const tweaked = ecc.privateAdd(even, tweak); if (!tweaked) throw new BackendError('taproot tweak produced an invalid key', 500); return tweaked; } function keyMaterial(node: HDKey): { priv: Uint8Array; pubkey: Buffer } { const priv = node.privateKey; const pub = node.publicKey; if (!priv || !pub) throw new BackendError('derived node has no private key — cannot sign', 500); return { priv, pubkey: Buffer.from(pub) }; } /** ECDSA signer for p2pkh / p2sh-p2wpkh / p2wpkh inputs. */ function ecdsaSigner(node: HDKey): bitcoin.Signer { const { priv, pubkey } = keyMaterial(node); return { publicKey: pubkey, sign: (hash: Buffer) => Buffer.from(ecc.sign(hash, priv)), }; } /** * Schnorr signer for a taproot key-path spend. `publicKey` must be the TWEAKED key: bitcoinjs matches * `toXOnly(signer.publicKey)` against the output key in the prevout (psbt.js getTaprootHashesForSig), * and the untweaked internal key would simply not match, failing with "Can not sign for input". */ function taprootSigner(node: HDKey): bitcoin.Signer { const { priv, pubkey } = keyMaterial(node); const tweakedPriv = tweakPrivateKey(priv, pubkey); const tweakedPub = ecc.pointFromScalar(tweakedPriv, true); if (!tweakedPub) throw new BackendError('taproot tweak produced an invalid point', 500); return { publicKey: Buffer.from(tweakedPub), sign: (hash: Buffer) => Buffer.from(ecc.sign(hash, tweakedPriv)), signSchnorr: (hash: Buffer) => Buffer.from(ecc.signSchnorr(hash, tweakedPriv)), }; } /** @scure/bip32 only accepts the apostrophe form of a hardened index. */ function normalizePath(path: string): string { return path.replace(/[hH]/g, "'"); } /** * Sign every input from keys derived off `root`, finalise, and extract the transaction. * * `root` is a PARAMETER and is never cached, stored or closed over past this call: the caller holds the * key material and this function borrows it for the duration of a synchronous signing pass. Nothing * here awaits, so the root cannot be pinned alive by a pending network call. */ export function signAndFinalize(psbt: bitcoin.Psbt, root: HDKey, inputPaths: string[]): SignedTx { initEcc(); const count = psbt.data.inputs.length; if (inputPaths.length !== count) { throw new BackendError(`expected ${count} derivation paths, got ${inputPaths.length}`, 500); } for (let i = 0; i < count; i++) { const path = inputPaths[i]; const input = psbt.data.inputs[i]; if (path === undefined || input === undefined) { throw new BackendError(`missing derivation path for input #${i}`, 500); } const node = root.derive(normalizePath(path)); // `tapInternalKey` is set only by the p2tr branch of buildPsbt, so it is the authoritative marker // for "this input needs a schnorr signature over a tweaked key". psbt.signInput(i, input.tapInternalKey ? taprootSigner(node) : ecdsaSigner(node)); } psbt.finalizeAllInputs(); const feeSats = psbt.getFee(); const tx = psbt.extractTransaction(); return { txid: tx.getId(), rawHex: tx.toHex(), vsize: tx.virtualSize(), feeSats }; }