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;
|
||||
}
|
||||
|
||||
@@ -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<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');
|
||||
}
|
||||
|
||||
async broadcast(): Promise<string> {
|
||||
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<string, Funding>) {
|
||||
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<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));
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, ScannedAddress>;
|
||||
};
|
||||
|
||||
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<number> {
|
||||
return this.chain.getTipHeight();
|
||||
}
|
||||
|
||||
getFeeEstimates(): Promise<FeeEstimates> {
|
||||
return this.chain.getFeeEstimates();
|
||||
}
|
||||
|
||||
getTxHex(txid: string): Promise<string> {
|
||||
return this.chain.getTxHex(txid);
|
||||
}
|
||||
|
||||
broadcast(rawHex: string): Promise<string> {
|
||||
return this.chain.broadcast(rawHex);
|
||||
}
|
||||
|
||||
async scan(ctx: ScanContext): Promise<ScanResult> {
|
||||
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<AddressScan> {
|
||||
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<string, ScannedAddress>();
|
||||
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<boolean> {
|
||||
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<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) => 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<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();
|
||||
}
|
||||
|
||||
/** History for every address the scan found had been touched. Only those can appear in it. */
|
||||
private async collectTxs(scan: AddressScan): 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) => 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,
|
||||
};
|
||||
}
|
||||
@@ -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<number> {
|
||||
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<FeeEstimates> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<ScanResult> {
|
||||
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<string, ScannedAddress>();
|
||||
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<ChainIndex, number> = {
|
||||
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<string, { confirmed: number; unconfirmed: number }>();
|
||||
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<SpendableUtxo>((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<string, ScannedAddress>, 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;
|
||||
}
|
||||
@@ -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<number>;
|
||||
|
||||
getFeeEstimates(): Promise<FeeEstimates>;
|
||||
|
||||
/**
|
||||
* 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<string>;
|
||||
|
||||
/** Publish a signed transaction. Resolves to the txid, throws if the network rejected it. */
|
||||
broadcast(rawHex: string): Promise<string>;
|
||||
|
||||
/** Read the whole wallet off the chain. */
|
||||
scan(ctx: ScanContext): Promise<ScanResult>;
|
||||
}
|
||||
|
||||
/** Bounded-concurrency map that preserves input order. Address-level sources fan out wide. */
|
||||
export 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;
|
||||
}
|
||||
@@ -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<string, unknown> | null, es
|
||||
throw new BackendError('wallet has no account xpubs', 500, 'BAD_CONFIG');
|
||||
}
|
||||
return new OnchainBackend({
|
||||
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<string, unknown> | 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user