Files
bitcoin-wallet/sidecar/chain-source-esplora.test.ts
T
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

337 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Behavioural pins for the Esplora chain source, driven through a real OnchainBackend.
//
// These exist because the scan used to live inside the backend and was moved out wholesale; nothing in
// the suite covered it before, so a silent behaviour change during the move would have shipped. The stub
// below is a full stand-in for the HTTP client, which means the whole path — gap-limit walk, balance
// summation, UTXO mapping, transaction scoring, address issuance — runs with no network at all.
import { describe, expect, test } from 'bun:test';
import { HDKey } from '@scure/bip32';
import { mnemonicToSeedSync } from '@scure/bip39';
import * as bitcoin from 'bitcoinjs-lib';
import type { EsploraAddress, EsploraChain, EsploraTx, EsploraUtxo } from './chain';
import { EsploraChainSource } from './chain-source-esplora';
import { OnchainBackend, type WalletSigner } from './backends/onchain';
import { initEcc } from './psbt';
initEcc();
// NEVER PUT A REAL ACCOUNT KEY IN A TEST. An xpub cannot spend, but it reveals every address a wallet
// will ever use and its entire history, permanently and irrevocably — and a test file is the easiest
// place in a repo for one to hide, because it looks like a fixture. This one is derived here, in the
// open, from the BIP84 specification's own published mnemonic, so its provenance is checkable rather
// than asserted. If you need a different fixture, derive it the same way; do not paste one in.
const PUBLIC_TEST_MNEMONIC =
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const ACCOUNT_XPUB = HDKey.fromMasterSeed(mnemonicToSeedSync(PUBLIC_TEST_MNEMONIC)).derive(
"m/84'/0'/0'",
).publicExtendedKey;
/** Derive the same p2wpkh address the backend will, independently of the backend. */
function addressAt(chain: 0 | 1, index: number): string {
const node = HDKey.fromExtendedKey(ACCOUNT_XPUB).deriveChild(chain).deriveChild(index);
const pubkey = Buffer.from(node.publicKey!);
return bitcoin.payments.p2wpkh({ pubkey, network: bitcoin.networks.bitcoin }).address!;
}
function scriptAt(chain: 0 | 1, index: number): string {
const node = HDKey.fromExtendedKey(ACCOUNT_XPUB).deriveChild(chain).deriveChild(index);
const pubkey = Buffer.from(node.publicKey!);
return bitcoin.payments.p2wpkh({ pubkey, network: bitcoin.networks.bitcoin }).output!.toString('hex');
}
const EMPTY_STATS = { funded_txo_count: 0, funded_txo_sum: 0, spent_txo_count: 0, spent_txo_sum: 0, tx_count: 0 };
type Funding = { confirmed?: number; spent?: number; mempool?: number; txCount?: number };
/**
* A stand-in for EsploraChain that serves a fixed world: a map of address → what the chain says about it,
* and everything else derived from that. Also counts requests, because the request count IS the behaviour
* under test for the gap-limit walk.
*/
class StubEsplora {
readonly calls: string[] = [];
constructor(
private readonly world: Map<string, Funding>,
private readonly tip = 800_000,
) {}
async getTipHeight(): Promise<number> {
this.calls.push('tip');
return this.tip;
}
async getAddress(address: string): Promise<EsploraAddress> {
this.calls.push(`addr:${address}`);
const f = this.world.get(address);
if (!f) return { address, chain_stats: { ...EMPTY_STATS }, mempool_stats: { ...EMPTY_STATS } };
return {
address,
chain_stats: {
...EMPTY_STATS,
funded_txo_sum: f.confirmed ?? 0,
spent_txo_sum: f.spent ?? 0,
tx_count: f.txCount ?? 1,
},
mempool_stats: { ...EMPTY_STATS, funded_txo_sum: f.mempool ?? 0, tx_count: f.mempool ? 1 : 0 },
};
}
async getAddressUtxos(address: string): Promise<EsploraUtxo[]> {
this.calls.push(`utxo:${address}`);
const f = this.world.get(address);
const value = (f?.confirmed ?? 0) - (f?.spent ?? 0);
if (!f || value <= 0) return [];
return [{ txid: 'a'.repeat(64), vout: 0, value, status: { confirmed: true, block_height: this.tip - 5 } }];
}
async getAddressTxs(address: string): Promise<EsploraTx[]> {
this.calls.push(`txs:${address}`);
const f = this.world.get(address);
if (!f) return [];
const idx = [...this.world.keys()].indexOf(address);
return [
{
txid: String(idx).padStart(64, 'b'),
version: 2,
locktime: 0,
fee: 200,
vin: [{ txid: 'c'.repeat(64), vout: 0, prevout: null, scriptsig: '', sequence: 0xffffffff, witness: [] }],
vout: [
{ scriptpubkey: scriptFor(address), scriptpubkey_asm: '', scriptpubkey_type: 'v0_p2wpkh', value: 50_000 },
],
status: { confirmed: true, block_height: this.tip - 5, block_time: 1_700_000_000 },
} as unknown as EsploraTx,
];
}
async getFeeEstimates() {
return { fastestFee: 10, halfHourFee: 8, hourFee: 5, economyFee: 2, minimumFee: 1 };
}
async getTxHex(): Promise<string> {
throw new Error('not needed');
}
/** Accepts anything and reports a fixed txid — the spend tests care what got selected, not what got sent. */
async broadcast(): Promise<string> {
this.calls.push('broadcast');
return 'd'.repeat(64);
}
}
/** Reverse-lookup the script for an address the stub was configured with. */
function scriptFor(address: string): string {
for (const chain of [0, 1] as const) {
for (let i = 0; i < 40; i++) if (addressAt(chain, i) === address) return scriptAt(chain, i);
}
return 'ff';
}
const LOCKED_SIGNER: WalletSigner = {
isUnlocked: () => false,
withRoot: () => {
throw new Error('locked');
},
};
/**
* A signer holding the fixture's own root key. Only the spend tests need one — everything else runs
* locked, which is the state the wallet is in almost all of the time.
*/
const UNLOCKED_SIGNER: WalletSigner = {
isUnlocked: () => true,
withRoot: <T>(fn: (root: HDKey) => T): T => fn(HDKey.fromMasterSeed(mnemonicToSeedSync(PUBLIC_TEST_MNEMONIC))),
};
function backendFor(world: Map<string, Funding>, signer: WalletSigner = LOCKED_SIGNER) {
const stub = new StubEsplora(world);
const source = new EsploraChainSource({
chain: stub as unknown as EsploraChain,
label: 'esplora(stub)',
});
const backend = new OnchainBackend({
chain: source,
network: 'bitcoin',
accountXpub: { p2wpkh: ACCOUNT_XPUB },
signer,
});
return { backend, stub };
}
describe('EsploraChainSource through OnchainBackend', () => {
test('the fixture key really is the published BIP84 vector, not somebody’s wallet', () => {
// The spec's own first receive address. If this fails, the mnemonic above is not what it claims and
// whatever replaced it must be treated as live key material until proven otherwise.
expect(addressAt(0, 0)).toBe('bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu');
});
test('an empty wallet costs one gap-limit window per chain and reports zero', async () => {
const { backend, stub } = backendFor(new Map());
expect(await backend.getBalances()).toEqual({
onchainConfirmed: 0,
onchainUnconfirmed: 0,
onchainFrozen: 0,
lightningBalance: null,
lightningInbound: null,
});
expect(await backend.getUtxos()).toEqual([]);
expect(await backend.getTransactions()).toEqual([]);
// One script type, two chains, one gap-limit window each: exactly 40 address lookups and no more.
expect(stub.calls.filter((c) => c.startsWith('addr:'))).toHaveLength(40);
// Nothing held a balance, so not a single UTXO call was worth making.
expect(stub.calls.filter((c) => c.startsWith('utxo:'))).toHaveLength(0);
});
test('unused script types cost one probe each, not a full walk', async () => {
// The same xpub under four script types: every one derives valid addresses, and only the default
// (p2wpkh) should be walked. This is the optimisation that keeps a scan at 43 requests instead of 160.
const stub = new StubEsplora(new Map());
const backend = new OnchainBackend({
chain: new EsploraChainSource({ chain: stub as unknown as EsploraChain, label: 'esplora(stub)' }),
network: 'bitcoin',
accountXpub: {
p2wpkh: ACCOUNT_XPUB,
p2tr: ACCOUNT_XPUB,
'p2sh-p2wpkh': ACCOUNT_XPUB,
p2pkh: ACCOUNT_XPUB,
},
signer: LOCKED_SIGNER,
});
await backend.getBalances();
// 40 for the walked default + 1 probe each for the three that have never been used.
expect(stub.calls.filter((c) => c.startsWith('addr:'))).toHaveLength(43);
});
test('balances sum the confirmed and mempool deltas across the scan', async () => {
const world = new Map<string, Funding>([
[addressAt(0, 0), { confirmed: 100_000, spent: 40_000 }],
[addressAt(0, 3), { confirmed: 25_000 }],
[addressAt(1, 0), { confirmed: 0, mempool: 7_000 }],
]);
const { backend } = backendFor(world);
const balances = await backend.getBalances();
expect(balances.onchainConfirmed).toBe(85_000);
expect(balances.onchainUnconfirmed).toBe(7_000);
const utxos = await backend.getUtxos();
expect(utxos.map((u) => u.amountSats).sort((a, b) => a - b)).toEqual([25_000, 60_000]);
// The public contract reports the path relative to the account xpub, not the absolute one.
expect(utxos.every((u) => /^[01]\/\d+$/.test(u.derivationPath ?? ''))).toBe(true);
expect(utxos.every((u) => u.confirmations === 6)).toBe(true);
});
test('a used address resets the gap, so funds past the first window are still found', async () => {
// Index 25 is beyond the initial 20-address window: it is only reachable if a hit at 19 reset the gap.
const world = new Map<string, Funding>([
[addressAt(0, 19), { confirmed: 1_000 }],
[addressAt(0, 25), { confirmed: 2_000 }],
]);
const { backend } = backendFor(world);
expect((await backend.getBalances()).onchainConfirmed).toBe(3_000);
});
test('getNewAddress peeks without consuming and advances only when asked', async () => {
const world = new Map<string, Funding>([[addressAt(0, 0), { confirmed: 5_000 }]]);
const { backend } = backendFor(world);
// 0/0 is used, so the first unused is 0/1 — and peeking twice must not move past it.
expect((await backend.getNewAddress({ peek: true })).address).toBe(addressAt(0, 1));
expect((await backend.getNewAddress({ peek: true })).address).toBe(addressAt(0, 1));
expect((await backend.getNewAddress()).address).toBe(addressAt(0, 1));
expect((await backend.getNewAddress()).address).toBe(addressAt(0, 2));
});
// Issuance used to be unbounded: the mark moved on every call, paid or not, so a run of unpaid
// addresses walked it past the end of the window the next scan covers. A payment there would have been
// invisible forever — Esplora has no rescan to go and look.
test('issuance stops at the far edge of the scanned window instead of walking past it', async () => {
const world = new Map<string, Funding>([[addressAt(0, 0), { confirmed: 5_000 }]]);
const { backend } = backendFor(world);
const issued: string[] = [];
for (let i = 0; i < 30; i++) issued.push((await backend.getNewAddress()).address);
// 0/0 is used, so the walk covers 0..20 — one full gap limit of misses past it — and stops.
expect(issued[0]).toBe(addressAt(0, 1));
expect(issued[19]).toBe(addressAt(0, 20));
// Everything after that re-offers the last address the scan can still see. All of them are virgin,
// so repeating one is not address reuse in the sense that loses privacy.
expect(issued.at(-1)).toBe(addressAt(0, 20));
const scanned = new Set(Array.from({ length: 21 }, (_, i) => addressAt(0, i)));
expect(issued.filter((a) => !scanned.has(a))).toEqual([]);
});
test('transactions are scored against the wallet and credit is positive', async () => {
const world = new Map<string, Funding>([[addressAt(0, 0), { confirmed: 50_000 }]]);
const { backend } = backendFor(world);
const txs = await backend.getTransactions();
expect(txs).toHaveLength(1);
const tx = txs[0]!;
expect(tx.amount).toBe(50_000);
expect(tx.confirmations).toBe(6);
expect(tx.destAddresses).toEqual([addressAt(0, 0)]);
// We funded no input, so the fee was the sender's and must not be attributed to this wallet.
expect(tx.feeSats).toBeNull();
});
// Freezing a coin is a promise that it will not be spent. It was not being kept: the route checked the
// frozen list only against outpoints the caller named explicitly, so coin control was safe while a
// plain send — the ordinary case — picked inputs from a snapshot on which every UTXO claimed
// `frozen: false`, because the chain has no idea what Officer froze. Sweeping was the worst of it: a
// "send everything" emptied the frozen coin too.
//
// These run unlocked and reach the signer, which is why the fixture key had to be the published test
// vector and nothing else.
describe('frozen coins', () => {
const fundedWorld = () => new Map<string, Funding>([[addressAt(0, 0), { confirmed: 50_000 }]]);
/** The single outpoint the stub serves for a funded address. */
const OUTPOINT = `${'a'.repeat(64)}:0`;
const TO = 'bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu';
test('automatic selection will not spend one', async () => {
const { backend, stub } = backendFor(fundedWorld(), UNLOCKED_SIGNER);
await expect(
backend.sendCoins({ address: TO, amountSats: 10_000, satPerVbyte: 5, frozenOutpoints: [OUTPOINT] }),
).rejects.toThrow(/no spendable UTXOs/);
// Nothing was signed and nothing left the machine.
expect(stub.calls).not.toContain('broadcast');
});
test('sendAll will not sweep one', async () => {
const { backend, stub } = backendFor(fundedWorld(), UNLOCKED_SIGNER);
await expect(
backend.sendCoins({ address: TO, sendAll: true, satPerVbyte: 5, frozenOutpoints: [OUTPOINT] }),
).rejects.toThrow(/no spendable UTXOs/);
expect(stub.calls).not.toContain('broadcast');
});
test('the same send succeeds once the coin is not frozen', async () => {
// The control. Without it the two tests above would still pass if sending were broken outright.
const { backend, stub } = backendFor(fundedWorld(), UNLOCKED_SIGNER);
const result = await backend.sendCoins({ address: TO, amountSats: 10_000, satPerVbyte: 5 });
expect(result.txid).toBe('d'.repeat(64));
expect(stub.calls).toContain('broadcast');
});
});
test('fee estimates pass through untouched', async () => {
const { backend } = backendFor(new Map());
expect(await backend.estimateFees()).toEqual({
fastestFee: 10,
halfHourFee: 8,
hourFee: 5,
economyFee: 2,
minimumFee: 1,
});
});
});