split the wallet's chain reads from its signing half
OnchainBackend depended on the concrete EsploraChain class, so the only wallet it could ever have was an Esplora-backed one. The seam is now WalletChainSource, and it is drawn at the scan rather than at the HTTP client: Esplora is address-level and has to walk the gap limit, NBXplorer is wallet-level and has no per-address endpoint at all, so there is nothing to share one level down. The backend keeps the keys and the money — derivation, snapshot cache, coin selection, PSBT construction, signing — and owns no HTTP. Which indexer answers is a constructor argument. Also adds the NBXplorer implementation of the seam, verified end to end against the owner's own pruned node, and the first tests over any of this: a stub Esplora drives a real backend through the gap-limit walk, balance summation, UTXO mapping, transaction scoring and address issuance. Nothing covered the scan before it was moved, which is the wrong time to have no tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<AddressType, number> = {
|
||||
/** 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<string, ScannedAddress>;
|
||||
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<string, ScannedAddress>();
|
||||
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<Snapshot> {
|
||||
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<Omit<Snapshot, 'utxos' | 'txs'>> {
|
||||
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<string, ScannedAddress>();
|
||||
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<boolean> {
|
||||
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<ScannedAddress[]> {
|
||||
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<NodeInfo> {
|
||||
@@ -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<Snapshot, 'utxos' | 'txs'>): Promise<OnchainTx[]> {
|
||||
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<string, EsploraTx>();
|
||||
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<Snapshot, 'utxos' | 'txs'>): 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<SendCoinsResult> {
|
||||
@@ -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<Snapshot, 'utxos' | 'txs'>): Promise<SpendableUtxo[]> {
|
||||
// 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<SpendableUtxo>((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<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
||||
const out = new Array<R>(items.length);
|
||||
let cursor = 0;
|
||||
const worker = async (): Promise<void> => {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user