Repository Public
1import { dustOf, estimateBtcVsize, type NetworkDef } from '@spw/core';2import { UserError } from '../config.ts';3import { hostOf, http, type Adapter, type Deposit, type Health, type Prepared, type Tracked } from './rpc.ts';45export interface Utxo { txid: string; vout: number; value: number; confirmed: boolean }6interface Status { found: boolean; confirmed: boolean; height?: number; hash?: string }7/** One indexer of a Bitcoin-style chain. Two independent ones are required for anything that feeds a signature. */8export interface Source {9 url: string;10 tip(): Promise<number>;11 /** hash of the main-chain block at a height (chain identity: genesis, or the first block after a fork) */12 blockHash(height: number): Promise<string>;13 utxos(addr: string): Promise<Utxo[]>;14 /** confirmed balance minus what is already being spent in the mempool */15 balance(addr: string): Promise<bigint>;16 /** fee rate in base units per virtual byte, ~3 block target */17 feeRate(): Promise<number>;18 broadcast(hex: string): Promise<void>;19 status(txid: string): Promise<Status>;20 rawTx(txid: string): Promise<string>;21 /** confirmed incoming transfers to an address (most recent page), when the indexer can list history */22 history?(addr: string): Promise<Deposit[]>;23}24const j = async <T>(url: string, init?: RequestInit): Promise<T> => { const r = await http(url, init); if (!r.ok) throw Object.assign(new Error(`indexer ${r.status}`), { status: r.status }); return (await r.json()) as T; };25const txt = async (url: string, init?: RequestInit): Promise<string> => { const r = await http(url, init); if (!r.ok) throw Object.assign(new Error((await r.text()).slice(0, 200) || `indexer ${r.status}`), { status: r.status }); return (await r.text()).trim(); };26/** Amount in base units as an exact JavaScript integer; anything else is refused rather than rounded. */27const exact = (v: string | number): number => { const n = Number(v); if (!Number.isSafeInteger(n) || n < 0 || String(n) !== String(v)) throw new Error('indexer returned an amount that is not an exact integer'); return n; };28const feePerKb = (v: string | number): number => { const perKb = Number(v); if (!(perKb > 0)) throw new Error('no fee estimate'); return (perKb * 1e8) / 1000; };29const TXID = /^[0-9a-f]{64}$/;3031interface EsTx { txid: string; vin: { prevout: { scriptpubkey_address?: string; value: number } | null }[]; vout: { scriptpubkey_address?: string; value: number }[]; status: { confirmed: boolean; block_height?: number; block_time?: number } }3233/** Esplora / mempool.space-style REST API. */34export function esplora(u: string): Source {35 return {36 url: u,37 tip: async () => Number(await txt(u + '/blocks/tip/height')),38 blockHash: (h) => txt(`${u}/block-height/${h}`),39 utxos: async (a) => (await j<{ txid: string; vout: number; value: number; status: { confirmed: boolean } }[]>(`${u}/address/${a}/utxo`)).map((x) => ({ txid: x.txid, vout: x.vout, value: x.value, confirmed: x.status.confirmed })),40 balance: async (a) => { const s = await j<{ chain_stats: { funded_txo_sum: number; spent_txo_sum: number }; mempool_stats: { spent_txo_sum: number } }>(`${u}/address/${a}`); return BigInt(s.chain_stats.funded_txo_sum - s.chain_stats.spent_txo_sum - s.mempool_stats.spent_txo_sum); },41 feeRate: async () => { const f = await j<Record<string, number>>(u + '/fee-estimates'); return f['3'] ?? f['6'] ?? 5; },42 broadcast: async (hex) => { await txt(u + '/tx', { method: 'POST', body: hex }); },43 status: async (id) => { try { const s = await j<{ confirmed: boolean; block_height?: number; block_hash?: string }>(`${u}/tx/${id}/status`); return { found: true, confirmed: s.confirmed, height: s.block_height, hash: s.block_hash }; } catch (e) { if ((e as { status?: number }).status === 404) return { found: false, confirmed: false }; throw e; } },44 rawTx: (id) => txt(`${u}/tx/${id}/hex`),45 history: async (addr) => {46 const items: Deposit[] = [];47 for (const t of await j<EsTx[]>(`${u}/address/${addr}/txs`)) {48 if (!t.status.confirmed) continue;49 if (t.vin.some((v) => v.prevout?.scriptpubkey_address === addr)) continue; // our own send (change output), not a deposit50 t.vout.forEach((o, idx) => { if (o.scriptpubkey_address === addr) items.push({ txid: t.txid, idx, asset: 'native', amount: BigInt(o.value), from: t.vin[0]?.prevout?.scriptpubkey_address ?? '', block: t.status.block_height!, time: t.status.block_time ?? null }); });51 }52 return items;53 },54 };55}5657interface BbTx { txid: string; vin: { addresses?: string[] }[]; vout: { value: string; n: number; addresses?: string[] }[]; blockHeight: number; confirmations: number; blockTime?: number }5859/** Blockbook REST API v2 (amounts are decimal strings in base units). */60export function blockbook(u: string): Source {61 return {62 url: u,63 tip: async () => (await j<{ blockbook: { bestHeight: number } }>(u)).blockbook.bestHeight,64 blockHash: async (h) => (await j<{ blockHash: string }>(`${u}/block-index/${h}`)).blockHash,65 utxos: async (a) => (await j<{ txid: string; vout: number; value: string; confirmations: number }[]>(`${u}/utxo/${a}`)).map((x) => ({ txid: x.txid, vout: x.vout, value: exact(x.value), confirmed: x.confirmations > 0 })),66 balance: async (a) => { const s = await j<{ balance: string; unconfirmedBalance: string }>(`${u}/address/${a}?details=basic`); const un = BigInt(s.unconfirmedBalance); return BigInt(s.balance) + (un < 0n ? un : 0n); },67 feeRate: async () => feePerKb((await j<{ result: string }>(u + '/estimatefee/3')).result),68 broadcast: async (hex) => { await txt(u + '/sendtx/', { method: 'POST', body: hex }); },69 status: async (id) => { try { const s = await j<{ blockHeight: number; blockHash?: string; confirmations: number }>(`${u}/tx/${id}`); return { found: true, confirmed: s.confirmations > 0, height: s.blockHeight, hash: s.blockHash }; } catch (e) { if ([400, 404].includes((e as { status?: number }).status ?? 0)) return { found: false, confirmed: false }; throw e; } },70 rawTx: async (id) => (await j<{ hex: string }>(`${u}/tx/${id}`)).hex,71 history: async (addr) => {72 const items: Deposit[] = [];73 const page = await j<{ transactions?: BbTx[] }>(`${u}/address/${addr}?details=txs&pageSize=50`);74 for (const t of page.transactions ?? []) {75 if (!(t.confirmations > 0)) continue;76 if (t.vin.some((v) => v.addresses?.includes(addr))) continue; // our own send (change output), not a deposit77 for (const o of t.vout) if (o.addresses?.length === 1 && o.addresses[0] === addr) items.push({ txid: t.txid, idx: o.n, asset: 'native', amount: BigInt(o.value), from: t.vin[0]?.addresses?.[0] ?? '', block: t.blockHeight, time: t.blockTime ?? null });78 }79 return items;80 },81 };82}8384/** Coins per address this adapter is willing to compare; a wallet address holding more is refused rather than truncated. */85const BITCORE_PAGE = 1000;86interface BcCoin { mintTxid: string; mintIndex: number; mintHeight: number; spentHeight: number; value: number }8788/**89 * Bitcore REST API (BitPay's indexer, https://github.com/bitpay/bitcore — `/api/<CHAIN>/mainnet`).90 * It serves no raw transactions (rawTx throws; the other source provides them and the signer verifies them by hash).91 * Heights: mintHeight ≥ 0 = confirmed, -1 = mempool; spentHeight -2 = unspent, -1 = being spent in the mempool.92 * Addresses are passed without their CashAddr prefix (with it, Bitcore answers an empty list).93 */94export function bitcore(u: string): Source {95 const bare = (a: string) => a.slice(a.indexOf(':') + 1);96 return {97 url: u,98 tip: async () => (await j<{ height: number }>(u + '/block/tip')).height,99 blockHash: async (h) => (await j<{ hash: string }>(`${u}/block/${h}`)).hash,100 utxos: async (a) => {101 const coins = await j<BcCoin[]>(`${u}/address/${bare(a)}/?unspent=true&limit=${BITCORE_PAGE}`);102 if (!Array.isArray(coins) || coins.length >= BITCORE_PAGE) throw new Error('too many coins on this address to compare');103 return coins.filter((c) => c.spentHeight === -2 && c.mintHeight >= -1).map((c) => {104 if (!TXID.test(c.mintTxid) || !Number.isInteger(c.mintIndex)) throw new Error('malformed coin');105 return { txid: c.mintTxid, vout: c.mintIndex, value: exact(c.value), confirmed: c.mintHeight >= 0 };106 });107 },108 balance: async (a) => BigInt(exact((await j<{ confirmed: number }>(`${u}/address/${bare(a)}/balance`)).confirmed)),109 feeRate: async () => feePerKb((await j<{ feerate: number }>(u + '/fee/3')).feerate),110 broadcast: async (hex) => {111 // Bitcore answers 500 with the node's message as plain text; keep it so "already known" is recognised112 const r = await http(u + '/tx/send', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ rawTx: hex }) });113 const body = (await r.text()).trim();114 if (!r.ok) throw Object.assign(new Error(body.slice(0, 200) || `indexer ${r.status}`), { status: r.status });115 let txid: unknown;116 try { txid = (JSON.parse(body) as { txid?: unknown }).txid; } catch { /* not JSON: not an acknowledgement */ }117 if (typeof txid !== 'string' || !TXID.test(txid)) throw new Error('broadcast not acknowledged');118 },119 status: async (id) => { try { const s = await j<{ blockHeight: number; blockHash?: string }>(`${u}/tx/${id}`); return s.blockHeight > 0 ? { found: true, confirmed: true, height: s.blockHeight, hash: s.blockHash } : { found: true, confirmed: false }; } catch (e) { if ((e as { status?: number }).status === 404) return { found: false, confirmed: false }; throw e; } },120 rawTx: async () => { throw new Error('raw transactions are not served by Bitcore'); },121 };122}123export const sourceOf = (spec: string): Source => (spec.startsWith('blockbook:') ? blockbook(spec.slice(10)) : spec.startsWith('bitcore:') ? bitcore(spec.slice(8)) : esplora(spec));124125/** What differs between Bitcoin-style chains once the coins are selected: transaction size and the intent's chain section. */126export interface UtxoShape {127 /** upper bound of the serialized size in (virtual) bytes */128 size(nInputs: number, to: string, hasChange: boolean): number;129 /** chain section of the intent, plus unauthenticated helper data for the signer when the chain needs it */130 section(chosen: Utxo[], change: bigint, fee: bigint, rawTx: (txid: string) => Promise<string>): Promise<{ section: Record<string, unknown>; aux?: Record<string, unknown> }>;131}132133export function utxoAdapter(n: NetworkDef, shape: UtxoShape, sources: Source[] = n.rpc.map(sourceOf)): Adapter {134 const any = async <T>(f: (s: Source) => Promise<T>): Promise<T> => { let last: unknown; for (const s of sources) { try { return await f(s); } catch (e) { last = e; } } throw last; };135 const all = async <T>(f: (s: Source) => Promise<T>): Promise<T[]> => Promise.all(sources.map(f)).catch((e) => { throw e instanceof UserError ? e : new UserError('rpc_degraded', `${n.name} data source unavailable; transfer refused`, 503); });136 let identity = false;137 const refHeight = n.utxo?.refHeight ?? 0;138 return {139 async health(): Promise<Health> {140 const endpoints = await Promise.all(sources.map(async (s) => {141 const t = Date.now();142 try {143 const h = await s.tip();144 if (!Number.isInteger(h) || h <= refHeight) throw new Error('height');145 if (!identity && (await s.blockHash(refHeight)) !== n.chainRef) throw new Error('identity');146 return { url: hostOf(s.url), ok: true, ms: Date.now() - t, h: h as number | null };147 } catch { return { url: hostOf(s.url), ok: false, ms: null, h: null as number | null }; }148 }));149 // a source that trails the best one by more than the confirmation depth is stale: it counts as down150 const best = Math.max(0, ...endpoints.map((e) => e.h ?? 0));151 for (const e of endpoints) if (e.ok && best - e.h! > n.minConf) { e.ok = false; e.ms = null; }152 const ok = endpoints.every((e) => e.ok);153 if (ok) identity = true;154 return { network: n.id, ok: ok && identity, readable: endpoints.some((e) => e.ok), identityVerified: identity, height: best || null,155 endpoints: endpoints.map(({ h: _h, ...e }) => e), checkedAt: new Date().toISOString() };156 },157 async balances(addr) { return { native: await any((s) => s.balance(addr)) }; },158 async prepare(d): Promise<Prepared> {159 if (!identity) throw new UserError('rpc_degraded', 'chain identity not verified yet; retry shortly', 503);160 // the spendable coin set must be identical on both independent indexers161 const sets = await all((s) => s.utxos(d.from));162 const norm = (s: Utxo[]) => s.filter((u) => u.confirmed).map((u) => `${u.txid}:${u.vout}:${u.value}`).sort();163 const a = norm(sets[0]!);164 for (const s of sets) if (JSON.stringify(norm(s)) !== JSON.stringify(a)) throw new UserError('rpc_disagreement', `${n.name} data sources disagree on spendable coins; transfer refused`, 503);165 const utxos = sets[0]!.filter((u) => u.confirmed).sort((x, y) => y.value - x.value);166 const DUST = dustOf(n);167 // chains with registry bounds (fixed-fee chains) do not depend on the node's estimate being available168 const fr = n.utxo?.feeRate;169 const est = fr ? await any((s) => s.feeRate()).catch(() => 0) : await any((s) => s.feeRate());170 const cap = fr ? fr.max : n.id === 'bitcoin' ? 200 : 5000;171 let rate = fr ? Math.min(fr.max, Math.max(fr.min, est * 1.1)) : Math.min(cap, Math.max(1.1, est * 1.1));172 const extra = d.thor?.extraBytes ?? 0;173 if (d.thor) { // THORChain's recommended rate when it is higher than ours; never above this wallet's own cap174 if (!(d.thor.minRate >= 0) || d.thor.minRate > cap) throw new UserError('fee_ceiling', `THORChain recommends a fee rate above this wallet's ceiling for ${n.name}; swap refused`, 422);175 rate = Math.max(rate, d.thor.minRate);176 }177 const chosen: Utxo[] = []; let total = 0n;178 for (const u of utxos) {179 chosen.push(u); total += BigInt(u.value);180 const feeWithChange = BigInt(Math.ceil((shape.size(chosen.length, d.to, true) + extra) * rate));181 const feeNoChange = BigInt(Math.ceil((shape.size(chosen.length, d.to, false) + extra) * rate));182 if (total >= d.amount + feeNoChange) {183 let change = total - d.amount - feeWithChange, fee = feeWithChange;184 if (change < DUST && d.thor) { if (chosen.length >= 20) break; continue; } // a vault payment always returns change to the wallet: add a coin185 if (change < DUST) { change = 0n; fee = total - d.amount; } // dust change is added to the fee186 if (fee > feeNoChange * 3n + DUST * 2n) continue; // keep adding inputs rather than overpay187 const s = await shape.section(chosen, change, fee, (txid) => any((x) => x.rawTx(txid)));188 return { section: s.section, maxFee: fee, feeEstimate: fee, warnings: [], ttlSec: 240, aux: s.aux };189 }190 if (chosen.length >= 20) break;191 }192 throw new UserError('insufficient_funds', `insufficient confirmed ${n.native.symbol} for amount plus network fee`);193 },194 async broadcast(raw) {195 const rs = await Promise.allSettled(sources.map((s) => s.broadcast(raw)));196 if (rs.some((r) => r.status === 'fulfilled')) return;197 const msgs = rs.map((r) => String((r as PromiseRejectedResult).reason?.message));198 if (msgs.some((m) => /already|known|in block chain/i.test(m))) return;199 throw new Error(msgs[0]);200 },201 async track(p): Promise<Tracked> {202 const st = await any((s) => s.status(p.txid));203 if (!st.found) return { state: p.blockRef ? 'reorged' : 'broadcast', confirmations: 0, rebroadcast: true };204 if (!st.confirmed) return { state: p.blockRef ? 'reorged' : 'broadcast', confirmations: 0 };205 const conf = (await any((s) => s.tip())) - st.height! + 1;206 return { state: conf >= n.minConf ? 'finalized' : 'confirming', confirmations: conf, blockRef: st.hash };207 },208 async deposits(addr) {209 const h = await any((s) => s.tip());210 const lister = sources.find((s) => s.history); // history listing uses the first source able to list it211 const items = lister ? await lister.history!(addr) : [];212 return { items, cursor: h, finalizedHeight: h - n.minConf + 1 };213 },214 };215}216217/** Bitcoin, Litecoin (native SegWit) and Dogecoin (legacy P2PKH: the previous transactions travel as unauthenticated aux data). */218export function btcAdapter(n: NetworkDef, sources?: Source[]): Adapter {219 const legacy = n.utxo?.kind === 'pkh';220 return utxoAdapter(n, {221 size: (k, to, hasChange) => estimateBtcVsize(k, to, hasChange, n),222 section: async (chosen, change, fee, rawTx) => ({223 section: { btc: { inputs: chosen.map((c) => ({ txid: c.txid, vout: c.vout, value: String(c.value) })), change: change.toString(), fee: fee.toString() } },224 aux: legacy ? { prevTx: Object.fromEntries(await Promise.all(chosen.map(async (c) => [c.txid, await rawTx(c.txid)] as const))) } : undefined,225 }),226 }, sources);227}228