diff --git a/src/servers/sidecar/wallet/backends/onchain.ts b/src/servers/sidecar/wallet/backends/onchain.ts index ab481925..55a954bb 100644 --- a/src/servers/sidecar/wallet/backends/onchain.ts +++ b/src/servers/sidecar/wallet/backends/onchain.ts @@ -4,8 +4,13 @@ // (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 from an -// Esplora HTTP API (chain.ts), and builds/signs its own transactions with bitcoinjs-lib (psbt.ts). +// 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 @@ -21,7 +26,16 @@ 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 type { EsploraAddress, EsploraChain, EsploraTx } from '../chain'; +import { + mapLimit, + MAX_SCAN_INDEX, + REQUEST_CONCURRENCY, + type AddressEntry, + type ChainIndex, + type ScanContext, + type ScannedAddress, + type WalletChainSource, +} from '../chain-source'; import { buildPsbt, coinTypeFor, @@ -130,37 +144,15 @@ const PURPOSE: Record = { /** Preference order when the caller does not name a script type. Native segwit first. */ const TYPE_PREFERENCE: readonly AddressType[] = ['p2wpkh', 'p2tr', 'p2sh-p2wpkh', 'p2pkh']; -/** BIP44 chain index: 0 is the receive chain, 1 the internal (change) chain. */ -type ChainIndex = 0 | 1; - 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; }; -type AddressEntry = { - type: AddressType; - chain: ChainIndex; - index: number; - address: string; - /** Compressed 33-byte pubkey, hex. */ - pubkeyHex: string; - scriptPubKeyHex: string; - /** Full path from the wallet root — what the signer derives with. */ - path: string; - /** Path relative to the account xpub, which is what the public Utxo type carries. */ - relPath: string; -}; - -type ScannedAddress = AddressEntry & { - confirmedSats: number; - unconfirmedSats: number; - txCount: number; - used: boolean; -}; - /** * One coherent read of the chain: the address scan and everything derived from it, taken together. * @@ -175,35 +167,21 @@ type Snapshot = { at: number; tipHeight: number; addresses: ScannedAddress[]; - /** scriptPubKey hex → the entry that owns it. Used to score transactions as ours. Never persisted. */ - byScript: Map; utxos: SpendableUtxo[]; txs: OnchainTx[]; }; // ── tuning ─────────────────────────────────────────────────────────────────────────────────────── -/** BIP44's standard gap limit: 20 consecutive unused addresses ends the scan for a chain. */ -const GAP_LIMIT = 20; - /** How long a snapshot is served without kicking off a refresh behind it. */ const SCAN_TTL_MS = 30_000; -/** Cap on the persisted history. A wallet with years of activity should not grow an unbounded jsonb blob. */ -const MAX_CACHED_TXS = 500; - -/** Parallel Esplora requests. Public instances rate-limit, so this stays modest. */ -const REQUEST_CONCURRENCY = 6; - -/** Hard stop on a runaway scan — a misconfigured xpub against a busy chain must not loop forever. */ -const MAX_SCAN_INDEX = 1_000; - const DEFAULT_TX_LIMIT = 100; // ── the backend ────────────────────────────────────────────────────────────────────────────────── export type OnchainBackendOptions = { - chain: EsploraChain; + chain: WalletChainSource; network: BitcoinNetwork; /** * The account-level extended public key. A bare string is taken as the BIP84 (p2wpkh) account; pass a @@ -227,7 +205,7 @@ export class OnchainBackend extends BaseBackend { 'signMessage', ]); - private readonly chain: EsploraChain; + private readonly chain: WalletChainSource; private readonly network: BitcoinNetwork; private readonly btcNetwork: bitcoin.Network; private readonly signer: WalletSigner; @@ -269,6 +247,7 @@ export class OnchainBackend extends BaseBackend { this.accounts.set(type, { type, node: parseAccountXpub(xpub), + accountXpub: xpub, basePath: `m/${PURPOSE[type]}'/${coin}'/0'`, }); } @@ -457,10 +436,7 @@ export class OnchainBackend extends BaseBackend { }); } - const byScript = new Map(); - for (const entry of addresses) byScript.set(entry.scriptPubKeyHex, entry); - - return { at, tipHeight: stored.tipHeight, addresses, byScript, utxos, txs: stored.transactions }; + return { at, tipHeight: stored.tipHeight, addresses, utxos, txs: stored.transactions }; } /** Derive from untrusted stored coordinates — returns null rather than throwing on anything unusable. */ @@ -475,12 +451,25 @@ export class OnchainBackend extends BaseBackend { } } - /** One full read of the chain: scan, then everything derived from it, all at the same moment. */ + /** + * 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 { - const scan = await this.runScan(); - // Both fan out over the scanned address set and neither depends on the other, so they overlap. - const [utxos, txs] = await Promise.all([this.collectUtxos(scan), this.collectTxs(scan)]); - return { ...scan, utxos, txs }; + 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. */ @@ -488,64 +477,6 @@ export class OnchainBackend extends BaseBackend { this.current = null; } - private async runScan(): Promise> { - const tipHeight = await this.chain.getTipHeight(); - const addresses: ScannedAddress[] = []; - - // A wallet holds an account xpub for all four BIP purposes, but a full gap-limit walk of every one - // costs 4 types x 2 chains x 20 addresses = 160 requests, which trips the rate limit on every public - // Esplora instance. Only the wallet's own script type is walked unconditionally; the others are - // probed at receive index 0 first (one request each) and walked only if that address has ever been - // used. A freshly generated seed therefore costs 43 requests instead of 160, while a seed recovered - // from a wallet that used a different script type is still found rather than silently reported empty. - for (const type of this.accounts.keys()) { - if (type !== this.defaultType && !(await this.hasHistory(type))) continue; - for (const chain of [0, 1] as ChainIndex[]) { - addresses.push(...(await this.scanChain(type, chain))); - } - } - - const byScript = new Map(); - for (const entry of addresses) byScript.set(entry.scriptPubKeyHex, entry); - - return { at: Date.now(), tipHeight, addresses, byScript }; - } - - /** - * Has this script type ever been used at all? One request against receive index 0, which is the - * address any wallet hands out first — so a used account is essentially never missed, and an unused - * one costs a single call instead of a forty-address walk. - */ - private async hasHistory(type: AddressType): Promise { - const stat = await this.chain.getAddress(this.derive(type, 0, 0).address); - return toScannedAddress(this.derive(type, 0, 0), stat).used; - } - - /** Walk one (type, chain) pair a gap-limit window at a time until GAP_LIMIT consecutive misses. */ - private async scanChain(type: AddressType, chain: ChainIndex): Promise { - const found: ScannedAddress[] = []; - let index = 0; - let gap = 0; - - while (gap < GAP_LIMIT && index < MAX_SCAN_INDEX) { - const window = Array.from({ length: GAP_LIMIT }, (_, i) => this.derive(type, chain, index + i)); - const stats = await mapLimit(window, REQUEST_CONCURRENCY, (entry) => this.chain.getAddress(entry.address)); - - for (let i = 0; i < window.length; i++) { - const entry = window[i]; - const stat = stats[i]; - if (!entry || !stat) continue; - const scanned = toScannedAddress(entry, stat); - found.push(scanned); - gap = scanned.used ? 0 : gap + 1; - if (gap >= GAP_LIMIT) break; - } - index += window.length; - } - - return found; - } - // ── watch-only reads (no key material required) ──────────────────────────────────────────────── async getInfo(): Promise { @@ -614,69 +545,6 @@ export class OnchainBackend extends BaseBackend { } /** History for every address the scan found had been touched. Only those can appear in it. */ - private async collectTxs(scan: Omit): Promise { - const touched = scan.addresses.filter((a) => a.used); - const pages = await mapLimit(touched, REQUEST_CONCURRENCY, (a) => this.chain.getAddressTxs(a.address)); - - const seen = new Map(); - for (const page of pages) { - for (const tx of page) if (!seen.has(tx.txid)) seen.set(tx.txid, tx); - } - - const txs = [...seen.values()].map((tx) => this.toOnchainTx(tx, scan)); - txs.sort((a, b) => { - // Unconfirmed first (they have no height), then newest block, then newest timestamp. - const ah = a.blockHeight ?? Number.MAX_SAFE_INTEGER; - const bh = b.blockHeight ?? Number.MAX_SAFE_INTEGER; - if (ah !== bh) return bh - ah; - return (b.timestamp ?? 0) - (a.timestamp ?? 0); - }); - return txs.slice(0, MAX_CACHED_TXS); - } - - /** Score one Esplora transaction against the wallet's own scripts. */ - private toOnchainTx(tx: EsploraTx, scan: Omit): OnchainTx { - let credit = 0; - let debit = 0; - const ours: string[] = []; - const theirs: string[] = []; - - for (const out of tx.vout) { - const mine = scan.byScript.get(out.scriptpubkey); - if (mine) { - credit += out.value; - ours.push(mine.address); - } else if (out.scriptpubkey_address) { - theirs.push(out.scriptpubkey_address); - } - } - for (const input of tx.vin) { - const prevout = input.prevout; - if (prevout && scan.byScript.has(prevout.scriptpubkey)) debit += prevout.value; - } - - const height = tx.status.confirmed ? (tx.status.block_height ?? null) : null; - const confirmations = height === null ? 0 : Math.max(0, scan.tipHeight - height + 1); - const amount = credit - debit; - - return { - txid: tx.txid, - amount, - // The fee is only ours to report when we funded an input; for an incoming payment the sender - // paid it and attributing it to this wallet would be a lie. - feeSats: debit > 0 ? tx.fee : null, - blockHeight: height, - timestamp: tx.status.block_time ?? null, - confirmations, - // No label store in this backend; SendCoinsRequest.label is accepted and dropped. - label: null, - destAddresses: amount < 0 ? (theirs.length > 0 ? theirs : ours) : ours, - // Fetching /tx/{txid}/hex per transaction would double the request count for a list view. The - // contract permits null, and the raw hex is fetched on demand where it is actually needed. - rawHex: null, - }; - } - // ── spending (requires the root key) ─────────────────────────────────────────────────────────── override async sendCoins(req: SendCoinsRequest): Promise { @@ -793,31 +661,6 @@ export class OnchainBackend extends BaseBackend { // ── shared internals ─────────────────────────────────────────────────────────────────────────── /** Fetch UTXOs for every scanned address that still holds a balance. */ - private async collectUtxos(scan: Omit): Promise { - // An address whose funded and spent counts match holds nothing; asking Esplora about it is a - // wasted round trip, and on a wallet with long history that is most of the address set. - const funded = scan.addresses.filter((a) => a.confirmedSats + a.unconfirmedSats > 0); - const sets = await mapLimit(funded, REQUEST_CONCURRENCY, async (entry) => { - const utxos = await this.chain.getAddressUtxos(entry.address); - return utxos.map((u) => ({ - txid: u.txid, - vout: u.vout, - amountSats: u.value, - address: entry.address, - addressType: entry.type, - confirmations: - u.status.confirmed && u.status.block_height !== undefined - ? Math.max(0, scan.tipHeight - u.status.block_height + 1) - : 0, - derivationPath: entry.path, - frozen: false, - scriptPubKeyHex: entry.scriptPubKeyHex, - pubkeyHex: entry.pubkeyHex, - })); - }); - return sets.flat(); - } - /** * 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 @@ -891,21 +734,6 @@ function toPersisted(snap: Snapshot): WalletChainSnapshot { }; } -function toScannedAddress(entry: AddressEntry, stat: EsploraAddress): ScannedAddress { - const chainBalance = stat.chain_stats.funded_txo_sum - stat.chain_stats.spent_txo_sum; - // The mempool delta is signed: an unconfirmed spend of a confirmed coin reads negative here, which is - // exactly what the Balances contract wants in onchainUnconfirmed. - const mempoolBalance = stat.mempool_stats.funded_txo_sum - stat.mempool_stats.spent_txo_sum; - const txCount = stat.chain_stats.tx_count + stat.mempool_stats.tx_count; - return { - ...entry, - confirmedSats: chainBalance, - unconfirmedSats: mempoolBalance, - txCount, - used: txCount > 0, - }; -} - /** Varint, for the length prefix in the Bitcoin signed-message preimage. */ function varint(n: number): Buffer { if (n < 0xfd) return Buffer.from([n]); @@ -929,20 +757,3 @@ function bitcoinMessageHash(message: string, network: bitcoin.Network): Buffer { const body = Buffer.from(message, 'utf8'); return bitcoin.crypto.hash256(Buffer.concat([prefix, varint(body.length), body])); } - -/** Bounded-concurrency map that preserves input order. Esplora is per-address, so scans fan out wide. */ -async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { - const out = new Array(items.length); - let cursor = 0; - const worker = async (): Promise => { - for (;;) { - const i = cursor++; - if (i >= items.length) return; - const item = items[i]; - if (item === undefined) continue; - out[i] = await fn(item); - } - }; - await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); - return out; -} diff --git a/src/servers/sidecar/wallet/chain-source-esplora.test.ts b/src/servers/sidecar/wallet/chain-source-esplora.test.ts new file mode 100644 index 00000000..e67a18f3 --- /dev/null +++ b/src/servers/sidecar/wallet/chain-source-esplora.test.ts @@ -0,0 +1,249 @@ +// 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 * 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(); + +// A published BIP32 test vector, so the addresses below are reproducible by anything that speaks BIP84. +const ACCOUNT_XPUB = + 'xpub6DQv646WnF2m1tkyhhwu74tes1MunPX8psKwjmXSmWcPExp7FR9XP2q6m8VcK9uZMQE3rDmPnTnEDTHSxcmad7wzBvLk8PXC7Gnxe5GUpUq'; + +/** 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'); + } + + async broadcast(): Promise { + throw new Error('not needed'); + } +} + +/** 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'); + }, +}; + +function backendFor(world: Map) { + 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: LOCKED_SIGNER, + }); + return { backend, stub }; +} + +describe('EsploraChainSource through OnchainBackend', () => { + 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, + 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)); + }); + + 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(); + }); + + 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, + }); + }); +}); diff --git a/src/servers/sidecar/wallet/chain-source-esplora.ts b/src/servers/sidecar/wallet/chain-source-esplora.ts new file mode 100644 index 00000000..bf4c7d0c --- /dev/null +++ b/src/servers/sidecar/wallet/chain-source-esplora.ts @@ -0,0 +1,234 @@ +// The Esplora chain source: everything that used to be the on-chain backend's scanning half. +// +// Esplora answers about ONE address at a time and has no concept of a wallet, so finding the wallet is +// this file's job: derive addresses, ask about each, and stop after enough consecutive misses. That walk +// is why the file exists, and why it is the expensive source of the two. + +import type { EsploraAddress, EsploraChain, EsploraTx } from './chain'; +import type { SpendableUtxo } from './psbt'; +import { + mapLimit, + MAX_SCAN_INDEX, + REQUEST_CONCURRENCY, + type AddressEntry, + type ChainIndex, + type ScanContext, + type ScannedAddress, + type ScanResult, + type WalletChainSource, +} from './chain-source'; +import type { AddressType, FeeEstimates, OnchainTx } from './types'; + +/** BIP44's standard gap limit: 20 consecutive unused addresses ends the scan for a chain. */ +const GAP_LIMIT = 20; + +/** Cap on the history returned. A wallet with years of activity should not grow an unbounded blob. */ +const MAX_CACHED_TXS = 500; + +/** The scan proper, before the coins and history that hang off it. */ +type AddressScan = { + tipHeight: number; + addresses: ScannedAddress[]; + /** scriptPubKey hex → the entry that owns it. Used to score transactions as ours. */ + byScript: Map; +}; + +export type EsploraChainSourceOptions = { chain: EsploraChain; label: string }; + +export class EsploraChainSource implements WalletChainSource { + readonly label: string; + private readonly chain: EsploraChain; + + constructor(opts: EsploraChainSourceOptions) { + this.chain = opts.chain; + this.label = opts.label; + } + + getTipHeight(): Promise { + return this.chain.getTipHeight(); + } + + getFeeEstimates(): Promise { + return this.chain.getFeeEstimates(); + } + + getTxHex(txid: string): Promise { + return this.chain.getTxHex(txid); + } + + broadcast(rawHex: string): Promise { + return this.chain.broadcast(rawHex); + } + + async scan(ctx: ScanContext): Promise { + const scan = await this.runScan(ctx); + // Both fan out over the scanned address set and neither depends on the other, so they overlap. + const [utxos, txs] = await Promise.all([this.collectUtxos(scan), this.collectTxs(scan)]); + return { tipHeight: scan.tipHeight, addresses: scan.addresses, utxos, txs }; + } + + private async runScan(ctx: ScanContext): Promise { + const tipHeight = await this.chain.getTipHeight(); + const addresses: ScannedAddress[] = []; + + // A wallet holds an account xpub for all four BIP purposes, but a full gap-limit walk of every one + // costs 4 types x 2 chains x 20 addresses = 160 requests, which trips the rate limit on every public + // Esplora instance. Only the wallet's own script type is walked unconditionally; the others are + // probed at receive index 0 first (one request each) and walked only if that address has ever been + // used. A freshly generated seed therefore costs 43 requests instead of 160, while a seed recovered + // from a wallet that used a different script type is still found rather than silently reported empty. + for (const account of ctx.accounts) { + if (account.type !== ctx.defaultType && !(await this.hasHistory(ctx, account.type))) continue; + for (const chain of [0, 1] as ChainIndex[]) { + addresses.push(...(await this.scanChain(ctx, account.type, chain))); + } + } + + const byScript = new Map(); + for (const entry of addresses) byScript.set(entry.scriptPubKeyHex, entry); + + return { tipHeight, addresses, byScript }; + } + + /** + * Has this script type ever been used at all? One request against receive index 0, which is the + * address any wallet hands out first — so a used account is essentially never missed, and an unused + * one costs a single call instead of a forty-address walk. + */ + private async hasHistory(ctx: ScanContext, type: AddressType): Promise { + const entry = ctx.derive(type, 0, 0); + return toScannedAddress(entry, await this.chain.getAddress(entry.address)).used; + } + + /** Walk one (type, chain) pair a gap-limit window at a time until GAP_LIMIT consecutive misses. */ + private async scanChain(ctx: ScanContext, type: AddressType, chain: ChainIndex): Promise { + const found: ScannedAddress[] = []; + let index = 0; + let gap = 0; + + while (gap < GAP_LIMIT && index < MAX_SCAN_INDEX) { + const window = Array.from({ length: GAP_LIMIT }, (_, i) => ctx.derive(type, chain, index + i)); + const stats = await mapLimit(window, REQUEST_CONCURRENCY, (entry) => this.chain.getAddress(entry.address)); + + for (let i = 0; i < window.length; i++) { + const entry = window[i]; + const stat = stats[i]; + if (!entry || !stat) continue; + const scanned = toScannedAddress(entry, stat); + found.push(scanned); + gap = scanned.used ? 0 : gap + 1; + if (gap >= GAP_LIMIT) break; + } + index += window.length; + } + + return found; + } + + /** Fetch UTXOs for every scanned address that still holds a balance. */ + private async collectUtxos(scan: AddressScan): Promise { + // An address whose funded and spent counts match holds nothing; asking Esplora about it is a + // wasted round trip, and on a wallet with long history that is most of the address set. + const funded = scan.addresses.filter((a) => a.confirmedSats + a.unconfirmedSats > 0); + const sets = await mapLimit(funded, REQUEST_CONCURRENCY, async (entry) => { + const utxos = await this.chain.getAddressUtxos(entry.address); + return utxos.map((u) => ({ + txid: u.txid, + vout: u.vout, + amountSats: u.value, + address: entry.address, + addressType: entry.type, + confirmations: + u.status.confirmed && u.status.block_height !== undefined + ? Math.max(0, scan.tipHeight - u.status.block_height + 1) + : 0, + derivationPath: entry.path, + frozen: false, + scriptPubKeyHex: entry.scriptPubKeyHex, + pubkeyHex: entry.pubkeyHex, + })); + }); + return sets.flat(); + } + + /** History for every address the scan found had been touched. Only those can appear in it. */ + private async collectTxs(scan: AddressScan): Promise { + const touched = scan.addresses.filter((a) => a.used); + const pages = await mapLimit(touched, REQUEST_CONCURRENCY, (a) => this.chain.getAddressTxs(a.address)); + + const seen = new Map(); + for (const page of pages) { + for (const tx of page) if (!seen.has(tx.txid)) seen.set(tx.txid, tx); + } + + const txs = [...seen.values()].map((tx) => toOnchainTx(tx, scan)); + txs.sort((a, b) => { + // Unconfirmed first (they have no height), then newest block, then newest timestamp. + const ah = a.blockHeight ?? Number.MAX_SAFE_INTEGER; + const bh = b.blockHeight ?? Number.MAX_SAFE_INTEGER; + if (ah !== bh) return bh - ah; + return (b.timestamp ?? 0) - (a.timestamp ?? 0); + }); + return txs.slice(0, MAX_CACHED_TXS); + } +} + +// ── helpers ────────────────────────────────────────────────────────────────────────────────────── + +function toScannedAddress(entry: AddressEntry, stat: EsploraAddress): ScannedAddress { + const chainBalance = stat.chain_stats.funded_txo_sum - stat.chain_stats.spent_txo_sum; + // The mempool delta is signed: an unconfirmed spend of a confirmed coin reads negative here, which is + // exactly what the Balances contract wants in onchainUnconfirmed. + const mempoolBalance = stat.mempool_stats.funded_txo_sum - stat.mempool_stats.spent_txo_sum; + const txCount = stat.chain_stats.tx_count + stat.mempool_stats.tx_count; + return { + ...entry, + confirmedSats: chainBalance, + unconfirmedSats: mempoolBalance, + txCount, + used: txCount > 0, + }; +} + +/** Score one Esplora transaction against the wallet's own scripts. */ +function toOnchainTx(tx: EsploraTx, scan: AddressScan): OnchainTx { + let credit = 0; + let debit = 0; + const ours: string[] = []; + const theirs: string[] = []; + + for (const out of tx.vout) { + const mine = scan.byScript.get(out.scriptpubkey); + if (mine) { + credit += out.value; + ours.push(mine.address); + } else if (out.scriptpubkey_address) { + theirs.push(out.scriptpubkey_address); + } + } + for (const input of tx.vin) { + const prevout = input.prevout; + if (prevout && scan.byScript.has(prevout.scriptpubkey)) debit += prevout.value; + } + + const height = tx.status.confirmed ? (tx.status.block_height ?? null) : null; + const confirmations = height === null ? 0 : Math.max(0, scan.tipHeight - height + 1); + const amount = credit - debit; + + return { + txid: tx.txid, + amount, + // The fee is only ours to report when we funded an input; for an incoming payment the sender + // paid it and attributing it to this wallet would be a lie. + feeSats: debit > 0 ? tx.fee : null, + blockHeight: height, + timestamp: tx.status.block_time ?? null, + confirmations, + // No label store in this backend; SendCoinsRequest.label is accepted and dropped. + label: null, + destAddresses: amount < 0 ? (theirs.length > 0 ? theirs : ours) : ours, + // Fetching /tx/{txid}/hex per transaction would double the request count for a list view. The + // contract permits null, and the raw hex is fetched on demand where it is actually needed. + rawHex: null, + }; +} diff --git a/src/servers/sidecar/wallet/chain-source-nbxplorer.ts b/src/servers/sidecar/wallet/chain-source-nbxplorer.ts new file mode 100644 index 00000000..2584d93d --- /dev/null +++ b/src/servers/sidecar/wallet/chain-source-nbxplorer.ts @@ -0,0 +1,283 @@ +// The NBXplorer chain source: the owner's own bitcoind, read through NBXplorer's wallet-level index. +// +// The contrast with chain-source-esplora.ts is the whole reason the WalletChainSource seam exists. There +// is no gap-limit walk here and no address fan-out, because NBXplorer already knows what the wallet owns: +// the account xpub is registered once, and after that a scan is three calls per account regardless of how +// many addresses have been used. What that costs instead is reassembly — NBXplorer answers wallet-wide, +// so the per-address view the rest of the wallet expects has to be rebuilt from UTXO key paths. +// +// PRUNED-NODE CAVEAT, and it is a real one: this node is pruned to 25 GB, so a newly tracked xpub only +// picks up activity from the moment it is registered. History older than the prune horizon will not +// backfill and this source will report it as absent, not as an error. That is fine for a wallet created +// here and wrong for one being recovered from an old seed — which is exactly why Esplora stays available +// rather than being replaced. + +import * as bitcoin from 'bitcoinjs-lib'; +import { + type AddressEntry, + type ChainIndex, + type ScanAccount, + type ScanContext, + type ScannedAddress, + type ScanResult, + type WalletChainSource, +} from './chain-source'; +import { derivationScheme, type NbxplorerChain, type NbxTransaction, type NbxUtxo } from './nbxplorer'; +import type { SpendableUtxo } from './psbt'; +import { networkFor } from './psbt'; +import { BackendError, type AddressType, type BitcoinNetwork, type FeeEstimates, type OnchainTx } from './types'; + +/** Confirmation targets, in blocks, behind the five fee tiers the wallet reports. */ +const FEE_TARGETS = { fastestFee: 1, halfHourFee: 3, hourFee: 6, economyFee: 25, minimumFee: 144 } as const; + +export type NbxplorerChainSourceOptions = { chain: NbxplorerChain; network: BitcoinNetwork; label: string }; + +export class NbxplorerChainSource implements WalletChainSource { + readonly label: string; + private readonly chain: NbxplorerChain; + private readonly btcNetwork: bitcoin.Network; + + /** + * Accounts seen by the most recent scan. + * + * `getTxHex` is the reason this exists: NBXplorer has no global transaction lookup, only a per-scheme + * one, but the WalletChainSource signature carries a txid and nothing else. Every caller of getTxHex + * runs inside sendCoins, which forces a fresh scan before it selects a single coin, so the map is + * always populated by the time it is read. It is a memo of the last scan, never a source of truth. + */ + private accounts: readonly ScanAccount[] = []; + + constructor(opts: NbxplorerChainSourceOptions) { + this.chain = opts.chain; + this.btcNetwork = networkFor(opts.network); + this.label = opts.label; + } + + async getTipHeight(): Promise { + const status = await this.chain.getStatus(); + return status.chainHeight; + } + + /** + * NBXplorer answers one confirmation target per call, so the five tiers are five requests. + * + * In a quiet mempool every target returns the same number and the tiers collapse to one value. That is + * the node telling the truth, not a bug — there is genuinely no fee premium worth paying when the next + * block will clear the queue. The clamp only enforces that a slower target is never quoted higher than + * a faster one, which estimatesmartfee can otherwise produce at the boundaries. + */ + async getFeeEstimates(): Promise { + const entries = Object.entries(FEE_TARGETS) as [keyof FeeEstimates, number][]; + const rates = await Promise.all(entries.map(([, blocks]) => this.chain.getFeeRate(blocks))); + + const out = {} as FeeEstimates; + let ceiling = Number.POSITIVE_INFINITY; + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + const rate = rates[i]; + if (!entry || rate === undefined) continue; + ceiling = Math.min(ceiling, rate); + out[entry[0]] = ceiling; + } + return out; + } + + async getTxHex(txid: string): Promise { + if (this.accounts.length === 0) { + throw new BackendError('nbxplorer source was asked for a transaction before any scan', 500, 'NO_SCAN'); + } + // Which account funded the input is not known here, and asking the wrong one 404s rather than + // returning something wrong, so trying each in turn is safe. In practice the first one answers. + let last: unknown = null; + for (const account of this.accounts) { + try { + return await this.chain.getTxHex(account.accountXpub, account.type, txid); + } catch (err) { + last = err; + } + } + throw last instanceof Error ? last : new BackendError(`no account knows transaction ${txid}`, 502); + } + + async broadcast(rawHex: string): Promise { + await this.chain.broadcast(rawHex); + // NBXplorer's broadcast returns only success/failure, so the txid is computed locally. It is a hash + // of the bytes we just sent, so it cannot disagree with what was accepted. + return bitcoin.Transaction.fromHex(rawHex).getId(); + } + + async scan(ctx: ScanContext): Promise { + this.accounts = ctx.accounts; + + const perAccount = await Promise.all(ctx.accounts.map((account) => this.scanAccount(ctx, account))); + + const tipHeight = Math.max(0, ...perAccount.map((r) => r.tipHeight)); + const addresses = perAccount.flatMap((r) => r.addresses); + const utxos = perAccount.flatMap((r) => r.utxos); + + const byScript = new Map(); + for (const entry of addresses) byScript.set(entry.scriptPubKeyHex, entry); + + const txs = perAccount + .flatMap((r) => r.txs) + .map((tx) => this.toOnchainTx(tx, byScript, tipHeight)) + .sort((a, b) => { + // Unconfirmed first (they have no height), then newest block, then newest timestamp. + const ah = a.blockHeight ?? Number.MAX_SAFE_INTEGER; + const bh = b.blockHeight ?? Number.MAX_SAFE_INTEGER; + if (ah !== bh) return bh - ah; + return (b.timestamp ?? 0) - (a.timestamp ?? 0); + }); + + return { tipHeight, addresses, utxos, txs }; + } + + private async scanAccount(ctx: ScanContext, account: ScanAccount) { + const { accountXpub, type } = account; + // track() is idempotent and every one of these calls makes it first, so registration cannot be + // skipped — which matters because an unregistered scheme answers 200-with-zeros, not 404. + const [utxoChanges, history, unusedDeposit, unusedChange] = await Promise.all([ + this.chain.getUtxos(accountXpub, type), + this.chain.getTransactions(accountXpub, type), + this.chain.getUnusedAddress(accountXpub, type, 'Deposit'), + this.chain.getUnusedAddress(accountXpub, type, 'Change'), + ]); + + const tipHeight = utxoChanges.currentHeight; + + // A coin already being spent by something in the mempool is not spendable again. NBXplorer still + // lists it as a confirmed UTXO, so it has to be excluded explicitly or coin selection would build a + // transaction that double-spends the owner's own unconfirmed one. + const spentUnconfirmed = new Set(utxoChanges.spentUnconfirmed.map((u) => u.outpoint)); + + const confirmed = utxoChanges.confirmed.utxOs.filter((u) => !spentUnconfirmed.has(u.outpoint)); + const unconfirmed = utxoChanges.unconfirmed.utxOs.filter((u) => !spentUnconfirmed.has(u.outpoint)); + + // The per-address view the rest of the wallet works in. NBXplorer's own answer to "what is unused" is + // taken as authoritative for where each chain ends — everything below that index has been seen, which + // is what makes it unsafe to hand out again. + const highest: Record = { + 0: indexOf(unusedDeposit.keyPath), + 1: indexOf(unusedChange.keyPath), + }; + for (const u of [...confirmed, ...unconfirmed, ...utxoChanges.spentUnconfirmed]) { + const at = parseKeyPath(u.keyPath); + if (at) highest[at.chain] = Math.max(highest[at.chain], at.index); + } + + const sats = new Map(); + const bump = (u: NbxUtxo, field: 'confirmed' | 'unconfirmed', sign: 1 | -1) => { + const cur = sats.get(u.keyPath) ?? { confirmed: 0, unconfirmed: 0 }; + cur[field] += sign * u.value; + sats.set(u.keyPath, cur); + }; + for (const u of confirmed) bump(u, 'confirmed', 1); + for (const u of unconfirmed) bump(u, 'unconfirmed', 1); + // Matches Esplora's signed mempool delta: an unconfirmed spend of a confirmed coin reads negative, + // which is exactly what the Balances contract wants in onchainUnconfirmed. + for (const u of utxoChanges.spentUnconfirmed) bump(u, 'unconfirmed', -1); + + const addresses: ScannedAddress[] = []; + for (const chain of [0, 1] as ChainIndex[]) { + for (let index = 0; index <= highest[chain]; index++) { + const entry = ctx.derive(type, chain, index); + const held = sats.get(`${chain}/${index}`); + addresses.push({ + ...entry, + confirmedSats: held?.confirmed ?? 0, + unconfirmedSats: held?.unconfirmed ?? 0, + // Below the unused mark means NBXplorer has seen this address; the mark itself has not. + txCount: index < highest[chain] ? 1 : 0, + used: index < highest[chain], + }); + } + } + + const utxos = [...confirmed, ...unconfirmed].flatMap((u) => { + const at = parseKeyPath(u.keyPath); + if (!at) return []; + const entry = ctx.derive(type, at.chain, at.index); + return [ + { + txid: u.transactionHash, + vout: u.index, + amountSats: u.value, + address: entry.address, + addressType: entry.type, + confirmations: Math.max(0, u.confirmations), + derivationPath: entry.path, + frozen: false, + scriptPubKeyHex: entry.scriptPubKeyHex, + pubkeyHex: entry.pubkeyHex, + }, + ]; + }); + + const txs = [...history.confirmedTransactions.transactions, ...history.unconfirmedTransactions.transactions]; + return { tipHeight, addresses, utxos, txs }; + } + + /** + * NBXplorer already scores the transaction for us — `balanceChange` is signed and wallet-wide, which is + * precisely what the Esplora source computes by hand from credits and debits. + * + * The fee is NOT recoverable here, and reporting a wrong one would be worse than reporting none: the + * response carries no input values, and fetching every prevout to derive it would reintroduce the + * per-transaction fan-out this source exists to avoid. The contract permits null. + */ + private toOnchainTx(tx: NbxTransaction, byScript: Map, tipHeight: number): OnchainTx { + const amount = tx.balanceChange; + const ours: string[] = []; + const theirs: string[] = []; + + const hex = tx.transaction?.trim(); + if (hex) { + try { + for (const out of bitcoin.Transaction.fromHex(hex).outs) { + const mine = byScript.get(out.script.toString('hex')); + if (mine) { + ours.push(mine.address); + continue; + } + try { + theirs.push(bitcoin.address.fromOutputScript(out.script, this.btcNetwork)); + } catch { + // OP_RETURN and other unspendable scripts have no address. Not an error, just not a payee. + } + } + } catch { + // A transaction we cannot parse still has a correct amount and height; only the payee list is + // lost, and an empty one renders as "unknown" rather than as something false. + } + } + + const height = tx.confirmations > 0 ? (tx.height ?? null) : null; + return { + txid: tx.transactionId, + amount, + feeSats: null, + blockHeight: height, + timestamp: tx.timestamp, + confirmations: Math.max(0, tx.confirmations), + label: null, + destAddresses: amount < 0 ? (theirs.length > 0 ? theirs : ours) : ours, + rawHex: null, + }; + } +} + +// ── helpers ────────────────────────────────────────────────────────────────────────────────────── + +/** `"0/7"` → `{ chain: 0, index: 7 }`. Anything else is not an address this wallet derives. */ +function parseKeyPath(keyPath: string): { chain: ChainIndex; index: number } | null { + const parts = keyPath.split('/'); + const chain = Number(parts.at(-2)); + const index = Number(parts.at(-1)); + if ((chain !== 0 && chain !== 1) || !Number.isInteger(index) || index < 0) return null; + return { chain, index }; +} + +/** The address index out of a key path, or 0 when it is unreadable. */ +function indexOf(keyPath: string): number { + return parseKeyPath(keyPath)?.index ?? 0; +} diff --git a/src/servers/sidecar/wallet/chain-source.ts b/src/servers/sidecar/wallet/chain-source.ts new file mode 100644 index 00000000..94633ae1 --- /dev/null +++ b/src/servers/sidecar/wallet/chain-source.ts @@ -0,0 +1,123 @@ +// What a chain source owes the on-chain wallet, and the vocabulary the two halves speak. +// +// THE SPLIT IS AT THE SCAN, NOT AT THE HTTP CLIENT. This is the whole point of the file and it is not +// obvious, so: Esplora is address-level — you ask about one script at a time, and finding a wallet means +// walking the gap limit yourself, 43-160 requests a refresh. NBXplorer is wallet-level — you register the +// account xpub once and then every question is a single call. There is no common denominator at the +// request layer, because NBXplorer simply has no "tell me about this one address" endpoint to implement. +// +// So the seam is drawn one level up, at `scan()`: "read this wallet off the chain and hand back what you +// found". Esplora satisfies it with a gap-limit walk; NBXplorer satisfies it with three calls and no walk +// at all. Everything downstream — balances, coin selection, PSBT construction, signing, broadcast — works +// off the ScanResult and does not know or care which one answered. +// +// Deriving addresses is NOT a source's job. The keys belong to the backend, and `ScanContext.derive` is +// how a source asks for one. A source that could derive independently could disagree with the wallet +// about which addresses are the wallet's, which is the kind of bug that loses coins rather than failing. + +import type { SpendableUtxo } from './psbt'; +import type { AddressType, FeeEstimates, OnchainTx } from './types'; + +/** BIP44 chain index: 0 is the receive chain, 1 the internal (change) chain. */ +export type ChainIndex = 0 | 1; + +/** Hard stop on a runaway scan — a misconfigured xpub against a busy chain must not loop forever. */ +export const MAX_SCAN_INDEX = 1_000; + +/** Parallel upstream requests. Public Esplora instances rate-limit, so this stays modest. */ +export const REQUEST_CONCURRENCY = 6; + +/** One address the wallet owns, fully derived. Produced by the backend, consumed by the source. */ +export type AddressEntry = { + type: AddressType; + chain: ChainIndex; + index: number; + address: string; + /** Compressed 33-byte pubkey, hex. */ + pubkeyHex: string; + scriptPubKeyHex: string; + /** Full path from the wallet root — what the signer derives with. */ + path: string; + /** Path relative to the account xpub, which is what the public Utxo type carries. */ + relPath: string; +}; + +/** An address the source looked up, with what the chain says about it. */ +export type ScannedAddress = AddressEntry & { + confirmedSats: number; + unconfirmedSats: number; + txCount: number; + used: boolean; +}; + +/** + * The account keys, in the form a wallet-level source needs them. + * + * `accountXpub` is the string as stored, which for every BIP this wallet supports is plain `xpub`-version + * bytes — NBXplorer's derivation schemes are built straight from it with no SLIP-132 conversion. + */ +export type ScanAccount = { type: AddressType; accountXpub: string }; + +/** + * Everything a source is handed to perform one scan. Passed per call rather than held at construction so + * a source stays stateless with respect to the wallet — the same source instance is safe to share. + */ +export type ScanContext = { + accounts: readonly ScanAccount[]; + /** The wallet's own script type. Address-level sources walk this one unconditionally. */ + defaultType: AddressType; + derive: (type: AddressType, chain: ChainIndex, index: number) => AddressEntry; +}; + +/** One coherent read of the chain — balances, coins and history as of the same moment. */ +export type ScanResult = { + tipHeight: number; + addresses: ScannedAddress[]; + utxos: SpendableUtxo[]; + txs: OnchainTx[]; +}; + +/** + * The chain, as the on-chain wallet needs it. + * + * Five methods, and only `scan` is interesting. The other four are point lookups that every backend in + * existence offers in some form, so they map one-to-one onto whatever is upstream. + */ +export interface WalletChainSource { + /** Human-readable, for logs and the sync-state surface. e.g. `esplora(mempool.space)`. */ + readonly label: string; + + /** Current best block height. The cheapest liveness probe the wallet has. */ + getTipHeight(): Promise; + + getFeeEstimates(): Promise; + + /** + * Raw hex of one transaction. Called only for `p2pkh` inputs, which commit to the whole previous + * transaction and so need it in the PSBT; segwit inputs carry their own value and script. + */ + getTxHex(txid: string): Promise; + + /** Publish a signed transaction. Resolves to the txid, throws if the network rejected it. */ + broadcast(rawHex: string): Promise; + + /** Read the whole wallet off the chain. */ + scan(ctx: ScanContext): Promise; +} + +/** Bounded-concurrency map that preserves input order. Address-level sources fan out wide. */ +export async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { + const out = new Array(items.length); + let cursor = 0; + const worker = async (): Promise => { + for (;;) { + const i = cursor++; + if (i >= items.length) return; + const item = items[i]; + if (item === undefined) continue; + out[i] = await fn(item); + } + }; + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); + return out; +} diff --git a/src/servers/sidecar/wallet/resolve.ts b/src/servers/sidecar/wallet/resolve.ts index 5bb1d26a..09c8cf3e 100644 --- a/src/servers/sidecar/wallet/resolve.ts +++ b/src/servers/sidecar/wallet/resolve.ts @@ -8,6 +8,7 @@ import { type WalletSummary, } from 'officerdb'; import { EsploraChain } from './chain'; +import { EsploraChainSource } from './chain-source-esplora'; import { LndBackend } from './backends/lnd'; import { ClnRestBackend } from './backends/clnrest'; import { LndHubBackend } from './backends/lndhub'; @@ -127,7 +128,10 @@ function build(wallet: WalletSummary, config: Record | null, es throw new BackendError('wallet has no account xpubs', 500, 'BAD_CONFIG'); } return new OnchainBackend({ - chain: new EsploraChain({ baseUrl: esploraUrl, network }), + chain: new EsploraChainSource({ + chain: new EsploraChain({ baseUrl: esploraUrl, network }), + label: `esplora(${hostOf(esploraUrl)})`, + }), network, accountXpub, // The session is the signer. While locked it holds no key material, so watch-only reads below @@ -141,3 +145,12 @@ function build(wallet: WalletSummary, config: Record | null, es throw new BackendError(`unknown wallet kind "${wallet.kind}"`, 400, 'BAD_CONFIG'); } } + +/** Host only, for a chain-source label. A malformed URL is labelled with itself rather than throwing. */ +function hostOf(url: string): string { + try { + return new URL(url).host; + } catch { + return url; + } +}