// 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, private readonly tip = 800_000, ) {} async getTipHeight(): Promise { this.calls.push('tip'); return this.tip; } async getAddress(address: string): Promise { 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 { 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 { 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 { 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 { 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: (fn: (root: HDKey) => T): T => fn(HDKey.fromMasterSeed(mnemonicToSeedSync(PUBLIC_TEST_MNEMONIC))), }; function backendFor(world: Map, 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([ [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([ [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([[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([[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([[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([[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, }); }); });