four wallet defects, none urgent, all cheap: selectCoins let an explicit coin-control pick override a freeze. naming an outpoint now overrides only the confirmed-only default; frozen is absolute. balances counted frozen coins in onchainConfirmed, so Send showed a figure a max-value spend could not reach. Balances gains onchainFrozen — a component of confirmed, not a deduction — filled at the route layer, because freezing is Officer policy in Postgres and no backend can see it. The route only reads utxos when something is actually frozen. Send subtracts it under "Spendable"; Overview lists it beside unconfirmed. a rescan in flight upstream was invisible after a sidecar restart, and a second POST would have queued behind it (scantxoutset is single-threaded node-wide). adoptRescan polls an existing NBXplorer scan instead of starting one, and the GET route falls back to it when local state is gone. the per-variant scan deadline counted queue time, so a variant that sat behind another wallet's scan timed out without ever having run. the deadline now refreshes while the status reads Queued. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
571 lines
24 KiB
TypeScript
571 lines
24 KiB
TypeScript
// 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<AddressType, number> = {
|
||
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<AddressType, number> = {
|
||
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<AddressType, number> = {
|
||
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 };
|
||
}
|