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:
2026-07-31 06:48:06 +00:00
co-authored by Claude Opus 5
parent 5ee56e736b
commit f8826e4c24
69 changed files with 11867 additions and 0 deletions
+565
View File
@@ -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 };
}