Files
officer a4cf939be7 bitcoin-wallet, extracted from the platform as a plugin
Tenth plugin out, and the one whose extraction turned on a permission decision
rather than a mechanical move.

The wallet permission was kind: 'admin' — owner only, not grantable, invisible
to every other account. Plugin permissions are ALWAYS kind: 'app': mount.ts
forces it and the manifest has no field for anything else, deliberately, so
that "can a plugin escalate itself" is a question which cannot be asked. So
extraction could not preserve admin, and that was raised as a blocker before
any code moved.

It was the right change anyway, and the objection inverts. Every wallet query
is scoped by user_id, and that id comes from X-Officer-User which the platform
injects from the authenticated session — a caller cannot name someone else's.
So granting this gives an account ITS OWN wallet. Not through the API and not
with root either does it hand over anyone else's, because their seed is sealed
under a passphrase the server never stores. kind: 'admin' was single-user
thinking; grantable is how a second person gets a wallet at all.

Named bitcoin-wallet rather than wallet: it is a bitcoin wallet specifically,
and the appName drives the route, the API prefix, the table prefix and the
install key together. Tables renamed to bitcoin_wallet_* accordingly — the
prefix guard normalises the hyphen. Free only because no wallet table existed
on any server, which was checked before deciding: db:push treats a rename as a
drop plus a create.

The three custody test files moved with it and still run — 72 tests over
scrypt sealing, envelope open and bolt11. bunfig.toml's test root was widened
on 2026-08-15 after music took lyrics.test.ts and the suite silently shrank by
nine, so that trap was already closed.

68 palette classes converted, five status vocabularies hoisted into shared.ts.
Two deliberate flattenings recorded in PLUGIN.md: testnet/signet/regtest all
read as warning, and the bitcoin and lightning rails no longer differ by hue.

Deferred on purpose: watch-only data (xpubs, balances, history) stays
unencrypted so a locked wallet still shows a balance. Anyone with the database
can therefore SEE any user's balances; they can never move a coin.
2026-08-19 05:18:29 +00:00

823 lines
35 KiB
TypeScript

// The native on-chain wallet backend.
//
// This is the server-side replacement for Zeus's two embedded backends. EmbeddedLND
// (backends/EmbeddedLND.ts) and LdkNode (backends/LdkNode.ts) are thin JS shims over React Native
// native modules — `lndmobile`, `ldk-node-rn` — that bundle an actual node into the app process.
// Neither exists off a phone, so neither can be ported into a Bun sidecar. Instead this backend is a
// real wallet in its own right: it derives addresses from a BIP32 account xpub, reads the chain through
// an injected WalletChainSource (chain-source.ts), and builds/signs its own transactions with
// bitcoinjs-lib (psbt.ts).
//
// It owns the KEYS and the MONEY: derivation, the snapshot cache, coin selection, PSBT construction,
// signing. It owns no HTTP at all. Which indexer answers — a public Esplora, the owner's own node behind
// NBXplorer — is a constructor argument, and nothing below this line knows the difference.
//
// WATCH-ONLY WHILE LOCKED is the load-bearing design property. Everything a UI polls —
// getInfo / getBalances / getTransactions / getNewAddress / getUtxos / estimateFees — is derived from
// the account XPUB and public chain data, so it works with the wallet locked and no key material in
// memory. `signer.withRoot` is reached from exactly two methods, sendCoins and signMessage, and both
// fail fast with WalletLockedError before doing any work. Nothing else in this file touches the signer.
//
// There is no lightning here at all: the capability set omits every lightning flag, so invoices,
// payments, channels and peers all fall through to BaseBackend's 501.
import type { WalletChainSnapshot } from '../../db';
import { 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 {
mapLimit,
MAX_SCAN_INDEX,
REQUEST_CONCURRENCY,
type AddressEntry,
type ChainIndex,
type ScanContext,
type ScannedAddress,
type WalletChainSource,
} from '../chain-source';
import {
buildPsbt,
coinTypeFor,
initEcc,
networkFor,
outputScriptFor,
scriptType,
selectCoins,
signAndFinalize,
type PsbtInputSource,
type PsbtOutputSpec,
type SpendableUtxo,
} from '../psbt';
import {
BackendError,
WalletLockedError,
type AddressType,
type Balances,
type BackendKind,
type BitcoinNetwork,
type Capability,
type FeeEstimates,
type NewAddressRequest,
type NodeInfo,
type OnchainTx,
type RescanOptions,
type RescanState,
type SendCoinsRequest,
type SendCoinsResult,
type SignMessageResult,
type SyncState,
type Utxo,
type VerifyMessageResult,
} from '../types';
import { BaseBackend } from './base';
// ── the signer boundary ──────────────────────────────────────────────────────────────────────────
/**
* The wallet's key custodian, implemented by keys.ts and injected here.
*
* This backend never sees a seed, a mnemonic, a passphrase or a file path. It receives a root HDKey for
* the duration of one synchronous callback and nothing more, which is what keeps the watch-only path
* genuinely key-free rather than key-free by convention.
*
* `withRoot` MUST throw `WalletLockedError` when the wallet is locked, and MUST NOT be async — an async
* borrow would pin the root in memory across arbitrary awaits.
*/
export interface WalletSigner {
isUnlocked(): boolean;
withRoot<T>(fn: (root: HDKey) => T): T;
}
// ── the persistence boundary ─────────────────────────────────────────────────────────────────────
/**
* Durable storage for the last snapshot this backend read off the chain. Injected the same way the
* signer is, and for the same reason: this file stays a pure wallet with no idea which database it is
* attached to, or that it has a wallet id at all. resolve.ts binds the real implementation.
*
* Every method is best-effort by contract. A cache that is unreachable, corrupt or empty must degrade
* to today's behaviour — a slow first load — and must never fail a wallet request.
*/
export interface ChainCacheStore {
load(): Promise<{ snapshot: WalletChainSnapshot | null; syncedAt: Date | null; lastError: string | null } | null>;
save(snapshot: WalletChainSnapshot): Promise<void>;
recordError(message: string): Promise<void>;
}
// ── extended key parsing ─────────────────────────────────────────────────────────────────────────
/**
* SLIP-132 version bytes. @scure/bip32 refuses an extended key whose version does not match the
* `versions` it was handed, so the prefix has to be recognised before the key can be imported. Wallets
* export the purpose-tagged forms (zpub for BIP84, ypub for BIP49, vpub/upub on testnet) as often as
* they export a plain xpub, and all of them are the same key with different four leading bytes.
*/
const XPUB_VERSIONS: Record<string, { public: number; private: number }> = {
xpub: { public: 0x0488b21e, private: 0x0488ade4 },
ypub: { public: 0x049d7cb2, private: 0x049d7878 },
zpub: { public: 0x04b24746, private: 0x04b2430c },
tpub: { public: 0x043587cf, private: 0x04358394 },
upub: { public: 0x044a5262, private: 0x044a4e28 },
vpub: { public: 0x045f1cf6, private: 0x045f18bc },
};
function parseAccountXpub(key: string): HDKey {
const prefix = key.slice(0, 4).toLowerCase();
const versions = XPUB_VERSIONS[prefix];
if (!versions) throw new BackendError(`unrecognised extended key prefix '${prefix}'`, 400, 'BAD_XPUB');
try {
return HDKey.fromExtendedKey(key, versions);
} catch (err) {
throw new BackendError(`could not parse the account extended key: ${String(err)}`, 400, 'BAD_XPUB');
}
}
// ── derivation ───────────────────────────────────────────────────────────────────────────────────
/** BIP44 purpose per script type: 44' legacy, 49' wrapped segwit, 84' native segwit, 86' taproot. */
const PURPOSE: Record<AddressType, number> = {
p2pkh: 44,
'p2sh-p2wpkh': 49,
p2wpkh: 84,
p2tr: 86,
};
/** Preference order when the caller does not name a script type. Native segwit first. */
const TYPE_PREFERENCE: readonly AddressType[] = ['p2wpkh', 'p2tr', 'p2sh-p2wpkh', 'p2pkh'];
type Account = {
type: AddressType;
node: HDKey;
/** The extended key exactly as it was given. Wallet-level chain sources index by this string. */
accountXpub: string;
/** Account-level path from the wallet root, e.g. `m/84'/0'/0'`. */
basePath: string;
};
/**
* One coherent read of the chain: the address scan and everything derived from it, taken together.
*
* Balances, UTXOs and history used to be fetched by three separate endpoints on top of a shared scan,
* which meant the three queries a wallet screen fires on mount could each observe a different moment.
* Building them as one snapshot costs the same requests — collectUtxos only asks about funded addresses
* and collectTxs only about touched ones — and makes the whole thing a single unit to cache, serve stale
* and persist.
*/
type Snapshot = {
/** Unix ms the chain was read. Survives a restart: a restored snapshot carries its original time. */
at: number;
tipHeight: number;
addresses: ScannedAddress[];
utxos: SpendableUtxo[];
txs: OnchainTx[];
};
// ── tuning ───────────────────────────────────────────────────────────────────────────────────────
/** How long a snapshot is served without kicking off a refresh behind it. */
const SCAN_TTL_MS = 30_000;
const DEFAULT_TX_LIMIT = 100;
// ── the backend ──────────────────────────────────────────────────────────────────────────────────
export type OnchainBackendOptions = {
chain: WalletChainSource;
network: BitcoinNetwork;
/**
* The account-level extended public key. A bare string is taken as the BIP84 (p2wpkh) account; pass a
* map to enable more than one script type, e.g. `{ p2wpkh: 'zpub…', p2tr: 'xpub…' }`.
*/
accountXpub: string | Partial<Record<AddressType, string>>;
signer: WalletSigner;
/** Omit for a backend that should hold its chain view in memory only — tests do. */
cache?: ChainCacheStore;
};
export class OnchainBackend extends BaseBackend {
readonly kind: BackendKind = 'onchain';
protected readonly capabilities: ReadonlySet<Capability> = new Set<Capability>([
'onchainReceive',
'onchainSend',
'coinControl',
'psbt',
'sweep',
'signMessage',
]);
private readonly chain: WalletChainSource;
private readonly network: BitcoinNetwork;
private readonly btcNetwork: bitcoin.Network;
private readonly signer: WalletSigner;
private readonly accounts: Map<AddressType, Account>;
private readonly defaultType: AddressType;
/** Derivation is pure EC math over a fixed xpub, so every address is derived at most once. */
private readonly derived = new Map<string, AddressEntry>();
/** In-memory issuance high-water mark per `type:chain`, so consecutive getNewAddress calls advance.
* Deliberately not persisted: after a restart the first unused address is recomputed from the chain,
* which is correct as soon as an issued address has actually been paid. */
private readonly issued = new Map<string, number>();
private readonly cache: ChainCacheStore | null;
private current: Snapshot | null = null;
private inflight: Promise<Snapshot> | null = null;
/** Memoised so concurrent first reads do one cache load between them, not one each. */
private hydration: Promise<void> | null = null;
private lastError: string | null = null;
constructor(opts: OnchainBackendOptions) {
super();
initEcc();
this.chain = opts.chain;
this.network = opts.network;
this.btcNetwork = networkFor(opts.network);
this.signer = opts.signer;
this.cache = opts.cache ?? null;
const coin = coinTypeFor(opts.network);
const raw = typeof opts.accountXpub === 'string' ? { p2wpkh: opts.accountXpub } : opts.accountXpub;
this.accounts = new Map();
for (const type of TYPE_PREFERENCE) {
const xpub = raw[type];
if (!xpub) continue;
this.accounts.set(type, {
type,
node: parseAccountXpub(xpub),
accountXpub: xpub,
basePath: `m/${PURPOSE[type]}'/${coin}'/0'`,
});
}
const first = TYPE_PREFERENCE.find((t) => this.accounts.has(t));
if (!first) throw new BackendError('the on-chain backend needs at least one account xpub', 400, 'BAD_XPUB');
this.defaultType = first;
}
// ── derivation helpers ─────────────────────────────────────────────────────────────────────────
private accountFor(type: AddressType): Account {
const account = this.accounts.get(type);
if (!account) {
const have = [...this.accounts.keys()].join(', ') || 'none';
throw new BackendError(`no ${type} account is configured (have: ${have})`, 400, 'NO_ACCOUNT');
}
return account;
}
private derive(type: AddressType, chain: ChainIndex, index: number): AddressEntry {
const cacheKey = `${type}:${chain}:${index}`;
const hit = this.derived.get(cacheKey);
if (hit) return hit;
const account = this.accountFor(type);
const node = account.node.derive(`m/${chain}/${index}`);
const pub = node.publicKey;
if (!pub) throw new BackendError(`failed to derive ${type} ${chain}/${index}`, 500);
const pubkey = Buffer.from(pub);
const network = this.btcNetwork;
const payment =
type === 'p2wpkh'
? bitcoin.payments.p2wpkh({ pubkey, network })
: type === 'p2tr'
? bitcoin.payments.p2tr({ internalPubkey: toXOnly(pubkey), network })
: type === 'p2sh-p2wpkh'
? bitcoin.payments.p2sh({ redeem: bitcoin.payments.p2wpkh({ pubkey, network }), network })
: bitcoin.payments.p2pkh({ pubkey, network });
if (!payment.address || !payment.output) throw new BackendError(`failed to build a ${type} address`, 500);
const entry: AddressEntry = {
type,
chain,
index,
address: payment.address,
pubkeyHex: pubkey.toString('hex'),
scriptPubKeyHex: payment.output.toString('hex'),
path: `${account.basePath}/${chain}/${index}`,
relPath: `${chain}/${index}`,
};
this.derived.set(cacheKey, entry);
return entry;
}
// ── the snapshot: cache, refresh, persist ──────────────────────────────────────────────────────
getSyncState(): SyncState {
return {
syncedAt: this.current?.at ?? null,
stale: this.current !== null && Date.now() - this.current.at >= SCAN_TTL_MS,
lastError: this.lastError,
canRescan: this.chain.startRescan !== undefined,
rescan: this.chain.rescanState?.() ?? null,
};
}
/**
* Ask the chain source to go looking for this wallet's coins from scratch.
*
* Returns as soon as the search is queued — it takes minutes, and the caller is an HTTP route. The
* refresh chained onto its completion is the part that matters: a rescan that found coins has changed
* nothing visible until the snapshot in front of it is rebuilt, and without this the owner would be
* staring at the same zero until the TTL happened to expire.
*/
async startRescan(opts: RescanOptions = {}): Promise<RescanState> {
const start = this.chain.startRescan?.bind(this.chain);
if (!start) this.notSupported('rescanning the chain');
const { state, done } = start(this.scanContext().accounts, opts);
void done
.then(() => this.refresh())
// The rescan's own failure is already on its state and reported through getSyncState; a failed
// refresh behind it lands on lastError the same way. Neither should surface as an unhandled
// rejection in the sidecar's log.
.catch(() => {});
return state;
}
/**
* Re-attach to a rescan that outlived this process, and put the refresh back on the end of it.
*
* Read-only with respect to the upstream: it never starts a scan, so calling it when nothing is running
* costs one request per account and answers null.
*/
async adoptRescan(): Promise<RescanState | null> {
const adopt = this.chain.adoptRescan?.bind(this.chain);
if (!adopt) return null;
const handle = await adopt(this.scanContext().accounts);
if (!handle) return null;
// The same chaining startRescan does, and for the same reason: coins recovered by a scan are invisible
// until the snapshot in front of them is rebuilt.
void handle.done.then(() => this.refresh()).catch(() => {});
return handle.state;
}
/**
* The wallet's view of the chain, stale-while-revalidate.
*
* A snapshot within SCAN_TTL_MS is served as-is. An older one is served IMMEDIATELY with a refresh
* started behind it — that is the property the persistence exists for: after a restart, or while the
* Esplora upstream is slow or unreachable, the screen shows the last real numbers with their age
* rather than a blank wallet. Only a wallet that has genuinely never been read blocks on the network,
* and only that case can surface an error to the caller.
*
* `fresh` opts out for the two paths where stale data would be wrong rather than merely old.
*/
private async snapshot(opts?: { fresh?: boolean }): Promise<Snapshot> {
if (opts?.fresh) return this.refresh();
await this.hydrate();
const have = this.current;
if (have && Date.now() - have.at < SCAN_TTL_MS) return have;
const running = this.refresh();
if (!have) return running;
// The refresh outlives this response. Its failure is recorded on `lastError` and reported through
// getSyncState, so it is handled here only to keep it from surfacing as an unhandled rejection.
void running.catch(() => {});
return have;
}
/** Load the persisted snapshot into memory. Runs at most once, and never throws. */
private hydrate(): Promise<void> {
this.hydration ??= (async () => {
const store = this.cache;
if (!store || this.current) return;
try {
const row = await store.load();
if (!row) return;
this.lastError = row.lastError;
// A refresh may have completed while this load was in flight; live data always wins. A snapshot
// written by an older shape is ignored, not migrated — it is a cache, and the next refresh
// replaces it for free.
if (row.snapshot?.v === 1 && row.syncedAt && !this.current) {
this.current = this.fromPersisted(row.snapshot, row.syncedAt.getTime());
}
} catch (err) {
// A cache failure costs a slow load, nothing more. It must not become a wallet failure.
console.error('[wallet] chain cache load failed:', err instanceof Error ? err.message : err);
}
})();
return this.hydration;
}
/** Read the chain. Concurrent callers share one walk rather than each starting their own. */
private refresh(): Promise<Snapshot> {
this.inflight ??= this.buildSnapshot().then(
(snap) => {
this.inflight = null;
this.current = snap;
this.lastError = null;
void this.persist(snap);
return snap;
},
(err: unknown) => {
this.inflight = null;
const message = err instanceof Error ? err.message : String(err);
this.lastError = message;
void this.cache?.recordError(message).catch((e: unknown) => {
console.error('[wallet] chain cache error write failed:', e instanceof Error ? e.message : e);
});
throw err;
},
);
return this.inflight;
}
private async persist(snap: Snapshot): Promise<void> {
if (!this.cache) return;
try {
await this.cache.save(toPersisted(snap));
} catch (err) {
console.error('[wallet] chain cache write failed:', err instanceof Error ? err.message : err);
}
}
/**
* Rebuild a snapshot from what was stored. Only the chain's own facts were persisted; every address,
* script and pubkey is re-derived here from the account xpub, so a restored snapshot cannot disagree
* with the wallet's actual keys. An entry naming a script type this wallet no longer has an xpub for
* is dropped rather than faked.
*/
private fromPersisted(stored: WalletChainSnapshot, at: number): Snapshot {
const addresses: ScannedAddress[] = [];
for (const a of stored.addresses) {
const entry = this.tryDerive(a.type, a.chain, a.index);
if (!entry) continue;
addresses.push({
...entry,
confirmedSats: a.confirmed,
unconfirmedSats: a.unconfirmed,
txCount: a.txCount,
used: a.txCount > 0,
});
}
const utxos: SpendableUtxo[] = [];
for (const u of stored.utxos) {
const entry = this.tryDerive(u.type, u.chain, u.index);
if (!entry) continue;
utxos.push({
txid: u.txid,
vout: u.vout,
amountSats: u.value,
address: entry.address,
addressType: entry.type,
confirmations: u.blockHeight === null ? 0 : Math.max(0, stored.tipHeight - u.blockHeight + 1),
derivationPath: entry.path,
frozen: false,
scriptPubKeyHex: entry.scriptPubKeyHex,
pubkeyHex: entry.pubkeyHex,
});
}
return { at, tipHeight: stored.tipHeight, addresses, utxos, txs: stored.transactions };
}
/** Derive from untrusted stored coordinates — returns null rather than throwing on anything unusable. */
private tryDerive(type: string, chain: number, index: number): AddressEntry | null {
if (!isAddressType(type) || (chain !== 0 && chain !== 1)) return null;
if (!Number.isInteger(index) || index < 0 || index >= MAX_SCAN_INDEX) return null;
if (!this.accounts.has(type)) return null;
try {
return this.derive(type, chain, index);
} catch {
return null;
}
}
/**
* What the source is allowed to know: the account keys, which type is ours, and how to derive.
*
* `derive` is bound to this backend on purpose — a source that derived addresses itself could disagree
* with the wallet about which addresses are the wallet's, and the resulting bug would lose coins rather
* than fail loudly.
*/
private scanContext(): ScanContext {
return {
accounts: [...this.accounts.values()].map((a) => ({ type: a.type, accountXpub: a.accountXpub })),
defaultType: this.defaultType,
derive: (type, chain, index) => this.derive(type, chain, index),
};
}
/** One full read of the chain, stamped with the moment it was taken. */
private async buildSnapshot(): Promise<Snapshot> {
const scan = await this.chain.scan(this.scanContext());
return { at: Date.now(), ...scan };
}
/** Drop everything after a send, so the spent coins cannot reappear from cache on the next read. */
private invalidateScan(): void {
this.current = null;
}
// ── watch-only reads (no key material required) ────────────────────────────────────────────────
async getInfo(): Promise<NodeInfo> {
// Deliberately does not force a scan: this is the cheapest liveness probe the wallet has.
const blockHeight = await this.chain.getTipHeight();
return {
kind: 'onchain',
// An Esplora-backed wallet has no node identity, no alias and no upstream version to report.
pubkey: null,
alias: null,
version: null,
network: this.network,
blockHeight,
synced: true,
};
}
override async getBalances(): Promise<Balances> {
const snap = await this.snapshot();
let confirmed = 0;
let unconfirmed = 0;
for (const entry of snap.addresses) {
confirmed += entry.confirmedSats;
unconfirmed += entry.unconfirmedSats;
}
return {
onchainConfirmed: confirmed,
// Filled in by the route, which is the only layer that knows what the owner froze.
onchainFrozen: 0,
onchainUnconfirmed: unconfirmed,
// No channels exist, and null is the contract's "this backend has no lightning" value.
lightningBalance: null,
lightningInbound: null,
};
}
override async estimateFees(): Promise<FeeEstimates> {
return this.chain.getFeeEstimates();
}
override async getUtxos(): Promise<Utxo[]> {
const snap = await this.snapshot();
return snap.utxos.map((u) => ({
txid: u.txid,
vout: u.vout,
amountSats: u.amountSats,
address: u.address,
addressType: u.addressType,
confirmations: u.confirmations,
// The public contract wants the path relative to the account xpub, not the absolute one the
// signer uses, so it is recomputed from the tail of the full path.
derivationPath: u.derivationPath.split('/').slice(-2).join('/'),
// Freezing is a policy layer above this backend; nothing on-chain marks a coin frozen.
frozen: false,
}));
}
override async getNewAddress(req?: NewAddressRequest): Promise<{ address: string; type: AddressType }> {
const type = req?.type ?? this.defaultType;
this.accountFor(type);
const entry = await this.nextUnused(type, 0, req?.peek === true);
return { address: entry.address, type };
}
override async getTransactions(opts?: { limit?: number }): Promise<OnchainTx[]> {
const snap = await this.snapshot();
return snap.txs.slice(0, opts?.limit ?? DEFAULT_TX_LIMIT);
}
/** History for every address the scan found had been touched. Only those can appear in it. */
// ── spending (requires the root key) ───────────────────────────────────────────────────────────
override async sendCoins(req: SendCoinsRequest): Promise<SendCoinsResult> {
// Fail before any network work rather than after building a PSBT we cannot sign.
if (!this.signer.isUnlocked()) throw new WalletLockedError();
if (!Number.isFinite(req.satPerVbyte) || req.satPerVbyte <= 0) {
throw new BackendError('satPerVbyte must be a positive number', 400, 'INVALID_FEE_RATE');
}
const recipientScript = outputScriptFor(req.address, this.btcNetwork);
const recipientType = scriptType(recipientScript);
// Never stale. Selecting coins from a cached UTXO set would build a transaction spending outputs
// that may already be gone, and the failure would arrive as a broadcast rejection after signing.
const snap = await this.snapshot({ fresh: true });
// The snapshot comes off the chain, which knows nothing about freezing, so every UTXO in it claims
// `frozen: false`. selectCoins has always honoured the flag — nothing ever set it, which is what made
// a plain send happily spend a coin the owner had frozen, and made "send everything" sweep it.
const frozen = new Set(req.frozenOutpoints ?? []);
const utxos = frozen.size
? snap.utxos.map((u) => (frozen.has(`${u.txid}:${u.vout}`) ? { ...u, frozen: true } : u))
: snap.utxos;
const selection = selectCoins({
utxos,
targetSats: req.amountSats ?? 0,
sendAll: req.sendAll === true,
satPerVbyte: req.satPerVbyte,
recipientType,
changeType: this.defaultType,
outpoints: req.outpoints,
spendUnconfirmed: req.spendUnconfirmed,
});
// Legacy inputs commit to the whole previous transaction, so it has to be fetched; segwit inputs
// carry their own value and script in the PSBT and need nothing extra.
const inputs = await mapLimit<SpendableUtxo, PsbtInputSource>(selection.inputs, REQUEST_CONCURRENCY, async (u) => ({
txid: u.txid,
vout: u.vout,
amountSats: u.amountSats,
addressType: u.addressType,
scriptPubKeyHex: u.scriptPubKeyHex,
pubkeyHex: u.pubkeyHex,
derivationPath: u.derivationPath,
prevTxHex: u.addressType === 'p2pkh' ? await this.chain.getTxHex(u.txid) : undefined,
}));
const outputs: PsbtOutputSpec[] = [{ address: req.address, amountSats: selection.outputSats }];
if (selection.changeSats !== null) {
const change = await this.nextUnused(this.defaultType, 1, false);
outputs.push({ address: change.address, amountSats: selection.changeSats });
// Change-is-always-last is a well-known chain-analysis heuristic. One shuffle removes it.
if (Math.random() < 0.5) outputs.reverse();
}
const { psbt, inputPaths } = buildPsbt({
network: this.btcNetwork,
inputs,
outputs,
rbf: req.rbf,
});
// The only place a root key enters this file. Synchronous, so the key is not held across an await.
const signed = this.signer.withRoot((root) => signAndFinalize(psbt, root, inputPaths));
const txid = await this.chain.broadcast(signed.rawHex);
this.invalidateScan();
return { txid, feeSats: signed.feeSats, rawHex: signed.rawHex };
}
override async signMessage(message: string): Promise<SignMessageResult> {
if (!this.signer.isUnlocked()) throw new WalletLockedError();
// BIP137-style: the identity key is the first receive key of the default account, which is what
// every other wallet that signs with a derived key uses.
const entry = this.derive(this.defaultType, 0, 0);
const hash = bitcoinMessageHash(message, this.btcNetwork);
const signature = this.signer.withRoot((root) => {
const node = root.derive(entry.path.replace(/[hH]/g, "'"));
const priv = node.privateKey;
if (!priv) throw new BackendError('derived node has no private key', 500);
const { signature: sig, recoveryId } = ecc.signRecoverable(hash, priv);
// Header byte: 27 + recovery id, +4 because the key is compressed.
const header = Buffer.from([27 + recoveryId + 4]);
return Buffer.concat([header, Buffer.from(sig)]).toString('base64');
});
return { signature };
}
/**
* Verification needs no key material — only the xpub — so it stays available while locked.
*
* The contract takes no claimed address, and a recoverable signature *always* recovers some pubkey,
* so "did this recover" is not a meaningful answer: a tampered message would still report valid.
* `valid` therefore means "this wallet signed this message" — the recovered key is compared against
* the identity key `signMessage` uses. The recovered pubkey is returned either way, so a caller
* verifying a third party's signature can do its own comparison.
*/
override async verifyMessage(message: string, signature: string): Promise<VerifyMessageResult> {
const sig = Buffer.from(signature, 'base64');
if (sig.length !== 65) return { valid: false, pubkey: null };
const header = sig[0];
if (header === undefined || header < 27 || header > 42) return { valid: false, pubkey: null };
const recoveryId = ((header - 27) & 3) as 0 | 1 | 2 | 3;
const compressed = ((header - 27) & 4) !== 0;
const compact = sig.subarray(1);
const hash = bitcoinMessageHash(message, this.btcNetwork);
try {
const recovered = ecc.recover(hash, compact, recoveryId, compressed);
if (!recovered || !ecc.verify(hash, recovered, compact)) return { valid: false, pubkey: null };
const pubkey = Buffer.from(recovered).toString('hex');
return { valid: pubkey === this.derive(this.defaultType, 0, 0).pubkeyHex, pubkey };
} catch {
return { valid: false, pubkey: null };
}
}
// ── shared internals ───────────────────────────────────────────────────────────────────────────
/** Fetch UTXOs for every scanned address that still holds a balance. */
/**
* First address on a chain that the blockchain has never seen, at or beyond the in-memory issuance
* mark. `peek` reads without consuming; otherwise the mark advances so the next call hands out a
* different address even before the current one is paid.
*/
private async nextUnused(type: AddressType, chain: ChainIndex, peek: boolean): Promise<AddressEntry> {
// Deliberately never served from cache. Handing out an address whose stale record says "unused" but
// which has been paid since is silent address reuse — a privacy leak the owner cannot see and cannot
// undo. Failing loudly while the upstream is down is the better of the two, so this is the one read
// that stops working offline, and on purpose.
const snap = await this.snapshot({ fresh: true });
const key = `${type}:${chain}`;
const hint = this.issued.get(key) ?? 0;
const used = new Set<number>();
let highest = -1;
for (const entry of snap.addresses) {
if (entry.type !== type || entry.chain !== chain) continue;
highest = Math.max(highest, entry.index);
if (entry.used) used.add(entry.index);
}
let index = hint;
// Anything past the scanned window is unused by definition — the gap limit is what ended the scan.
while (index <= highest && used.has(index)) index++;
// ...but "unused" is not the same as "findable". The mark advances on every issuance, paid or not,
// so twenty unpaid addresses walk it past the end of the window the next scan covers, and a payment
// to the twenty-first would never be seen again. Rather than hand out an address the wallet could
// lose money on, stop and re-offer the last safe one: every index in that run is still virgin, so
// repeating one is not address reuse in the sense that matters — nothing was ever received on it.
// The floor of 0 covers a wallet the chain has never seen: Esplora short-circuits an account with no
// history and returns no addresses at all, leaving `highest` at -1, and index 0 is then the only one
// it will look at. Handing that out until it is paid is exactly right.
const ceiling = Math.max(0, highest + this.chain.issueAhead);
if (index > ceiling) index = ceiling;
if (!peek) this.issued.set(key, index + 1);
return this.derive(type, chain, index);
}
}
// ── helpers ──────────────────────────────────────────────────────────────────────────────────────
function isAddressType(value: string): value is AddressType {
return value === 'p2wpkh' || value === 'p2tr' || value === 'p2sh-p2wpkh' || value === 'p2pkh';
}
/**
* Reduce a snapshot to the chain facts worth storing. Addresses, scripts and pubkeys are dropped: they
* are pure derivation from the account xpub, so re-deriving them on load is both cheaper than storing
* them and the only way a restored snapshot is guaranteed to match the wallet's real keys.
*/
function toPersisted(snap: Snapshot): WalletChainSnapshot {
return {
v: 1,
tipHeight: snap.tipHeight,
addresses: snap.addresses.map((a) => ({
type: a.type,
chain: a.chain,
index: a.index,
confirmed: a.confirmedSats,
unconfirmed: a.unconfirmedSats,
txCount: a.txCount,
})),
utxos: snap.utxos.map((u) => {
// The last two path elements are the BIP44 chain and address index — the same tail getUtxos
// reports as the relative path.
const tail = u.derivationPath.split('/');
return {
txid: u.txid,
vout: u.vout,
value: u.amountSats,
type: u.addressType,
chain: Number(tail.at(-2)),
index: Number(tail.at(-1)),
// Stored as a height, not a count, so confirmations stay correct as the tip moves on.
blockHeight: u.confirmations > 0 ? snap.tipHeight - u.confirmations + 1 : null,
};
}),
transactions: snap.txs,
};
}
/** Varint, for the length prefix in the Bitcoin signed-message preimage. */
function varint(n: number): Buffer {
if (n < 0xfd) return Buffer.from([n]);
if (n <= 0xffff) {
const b = Buffer.alloc(3);
b[0] = 0xfd;
b.writeUInt16LE(n, 1);
return b;
}
const b = Buffer.alloc(5);
b[0] = 0xfe;
b.writeUInt32LE(n, 1);
return b;
}
/** sha256d(messagePrefix || varint(len) || message) — the standard signed-message preimage. */
function bitcoinMessageHash(message: string, network: bitcoin.Network): Buffer {
const prefix = Buffer.isBuffer(network.messagePrefix)
? network.messagePrefix
: Buffer.from(network.messagePrefix, 'utf8');
const body = Buffer.from(message, 'utf8');
return bitcoin.crypto.hash256(Buffer.concat([prefix, varint(body.length), body]));
}