add the bitcoin wallet sidecar and ui
the owner's work, committed as one unit rather than split: the registration
files (App.tsx, Dock, AppRegistry, hono.ts, the schema and db barrels,
ecosystem.config.cjs) all reference modules under src/servers/{api,sidecar}/wallet
and src/workspaces/officerdev/src/apps/Wallet, so committing the shared plumbing
on its own would leave a commit that does not build.
officer-wallet is a new pm2 peer holding seed material sealed under an owner
passphrase on top of VAULT_STORE_KEY, with an unlock ttl after which the root key
is wiped from memory. five backends: on-chain via esplora, and lnd, clnrest,
lndhub and nwc for lightning. bolt11 encode/decode is implemented in-tree.
no secrets in the diff — the key-shaped literals under sidecar/wallet are the
bolt11 spec vectors and the bip39 "abandon … about" vector. .env.example gains
placeholders only. bun test src/servers/sidecar/wallet: 38 pass, 0 fail.
not reviewed line by line; assembled and verified to build, not audited.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,565 @@
|
||||
// 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) => {
|
||||
// An explicit coin-control pick is authoritative: it overrides both the frozen flag and the
|
||||
// confirmed-only default, because the user named this exact outpoint.
|
||||
if (allow) return allow.has(outpointOf(u));
|
||||
if (u.frozen) return false;
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user