Repository Public
1// Dogecoin / Bitcoin Cash adapters without any network access: stub indexers and a stubbed fetch.2import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';3import * as bitcoin from 'bitcoinjs-lib';4import { buildBtcTx, IntentSchema, NETWORKS, pluginFor, type Intent } from '@spw/core';56process.env.SPW_ORIGIN ??= 'https://wallet.test';7process.env.SPW_RP_ID ??= 'wallet.test';8process.env.DATABASE_URL ??= 'postgres://unused';9process.env.SPW_SIGNER_SECRET ??= 's'.repeat(48);1011type Btc = typeof import('../src/chains/btc.ts');12type Source = import('../src/chains/btc.ts').Source;13let btc: Btc, bchAdapter: typeof import('../src/chains/bch.ts').bchAdapter;14beforeAll(async () => { btc = await import('../src/chains/btc.ts'); ({ bchAdapter } = await import('../src/chains/bch.ts')); });15afterEach(() => vi.unstubAllGlobals());1617const DOGE = NETWORKS.dogecoin, BCH = NETWORKS.bitcoincash;18const DOGE_JS: bitcoin.Network = { messagePrefix: '', bech32: 'doge', bip32: { public: 0x02facafd, private: 0x02fac398 }, pubKeyHash: 0x1e, scriptHash: 0x16, wif: 0x9e };19const DOGE_FROM = 'DBus3bamQjgJULBJtYXpEzDWQRwF5iwxgC', DOGE_TO = 'DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L';20const BCH_FROM = 'bitcoincash:qqyx49mu0kkn9ftfj6hje6g2wfer34yfnq5tahq3q6', BCH_TO = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy';2122function prevTx(value: bigint, salt: number) {23 const t = new bitcoin.Transaction();24 t.version = 1;25 t.addInput(new Uint8Array(32).fill(salt), 0, 0xffffffff, Uint8Array.of(0x51));26 t.addOutput(bitcoin.address.toOutputScript(DOGE_FROM, DOGE_JS), value);27 return { hex: t.toHex(), txid: t.getId() };28}2930interface Opt { tip?: number; ref?: string; utxos?: { txid: string; vout: number; value: number; confirmed: boolean }[]; fee?: number | Error; raw?: Record<string, string>; down?: boolean }31function stub(name: string, o: Opt, asked: number[] = []): Source {32 const fail = () => { throw new Error('down'); };33 return {34 url: `https://${name}.test/api`,35 tip: async () => (o.down ? fail() : o.tip ?? 1_000_000),36 blockHash: async (h) => { asked.push(h); return o.ref ?? ''; },37 utxos: async () => (o.down ? fail() : o.utxos ?? []),38 balance: async () => (o.utxos ?? []).reduce((s, u) => s + BigInt(u.value), 0n),39 feeRate: async () => { if (o.fee instanceof Error) throw o.fee; return o.fee ?? 1; },40 broadcast: async () => fail(),41 status: async () => ({ found: false, confirmed: false }),42 rawTx: async (id) => { const r = o.raw?.[id]; if (!r) throw new Error('not served'); return r; },43 };44}45const intentOf = (network: string, from: string, to: string, amount: bigint, prep: { section: Record<string, unknown>; maxFee: bigint }): Intent => {46 const n = NETWORKS[network]!;47 return IntentSchema.parse({ v: 1, proposalId: '6f1c1f0e-8f5f-4b1e-9c56-0d6b5f0f1a11', keyId: 'spw-0123456789abcdef', network, chainRef: n.chainRef, asset: 'native',48 symbol: n.native.symbol, decimals: n.native.decimals, amount: amount.toString(), from, to, maxFee: prep.maxFee.toString(), createdAt: 1_800_000_000, expiresAt: 1_800_000_120, policyVersion: 4, ...prep.section });49};5051describe('Dogecoin adapter (two sources must agree)', () => {52 const a = prevTx(30_000_000_000n, 1), b = prevTx(900_000_000n, 2);53 const coins = [{ txid: a.txid, vout: 0, value: 30_000_000_000, confirmed: true }, { txid: b.txid, vout: 0, value: 900_000_000, confirmed: true }];54 const raw = { [a.txid]: a.hex, [b.txid]: b.hex };55 const mk = (x: Opt, y: Opt) => btc.btcAdapter(DOGE, [stub('one', { ref: DOGE.chainRef, ...x }), stub('two', { ref: DOGE.chainRef, ...y })]);56 const req = { asset: 'native', from: DOGE_FROM, to: DOGE_TO, amount: 10_000_000_000n };5758 it('prepares a legacy transfer whose previous transactions the builder accepts', async () => {59 // the second source lists its coins in another order, sees an extra unconfirmed coin and serves no raw transactions60 const ad = mk({ utxos: coins, raw, fee: 39960.905 }, { utxos: [...coins].reverse().concat({ txid: 'ee'.repeat(32), vout: 0, value: 5, confirmed: false }) });61 await expect(ad.prepare(req)).rejects.toMatchObject({ code: 'rpc_degraded' }); // identity not verified yet62 const h = await ad.health();63 expect(h).toMatchObject({ ok: true, identityVerified: true, height: 1_000_000 });64 const p = await ad.prepare(req);65 const s = p.section as { btc: { inputs: { txid: string; value: string }[]; change: string; fee: string } };66 expect(s.btc.inputs).toEqual([{ txid: a.txid, vout: 0, value: '30000000000' }]); // largest coin first, one is enough67 expect(BigInt(s.btc.change) + BigInt(s.btc.fee) + req.amount).toBe(30_000_000_000n);68 // the node's estimate (0.4 DOGE/kB here) is clamped to the registry bounds: 10 000 koinu per byte at most69 expect(p.maxFee).toBe(BigInt(s.btc.fee));70 expect(Number(s.btc.fee)).toBeLessThanOrEqual(230 * 10_000);71 expect(Number(s.btc.fee)).toBeGreaterThanOrEqual(220 * 1000);72 expect(p.maxFee <= BigInt('100000000')).toBe(true);73 expect((p.aux as { prevTx: Record<string, string> }).prevTx).toEqual({ [a.txid]: a.hex });74 const intent = intentOf('dogecoin', DOGE_FROM, DOGE_TO, req.amount, p);75 expect(buildBtcTx(intent, p.aux).outputsLength).toBe(2);76 // an indexer lying about a previous transaction is caught by the builder, not trusted77 expect(() => buildBtcTx(intent, { prevTx: { [a.txid]: b.hex } })).toThrow(/does not match input txid/);78 });7980 it('uses the recommended fee floor when no estimate is available, and spends several coins when needed', async () => {81 const ad = mk({ utxos: coins, raw, fee: new Error('no estimate') }, { utxos: coins, fee: new Error('no estimate') });82 await ad.health();83 const p = await ad.prepare({ ...req, amount: 30_500_000_000n });84 const s = p.section as { btc: { inputs: unknown[]; fee: string } };85 expect(s.btc.inputs.length).toBe(2);86 expect(Number(s.btc.fee)).toBeGreaterThanOrEqual(370 * 1000); // 0.01 DOGE/kB87 expect(Number(s.btc.fee)).toBeLessThanOrEqual(380 * 1000);88 expect(Object.keys((p.aux as { prevTx: object }).prevTx).length).toBe(2);89 await expect(ad.prepare({ ...req, amount: 30_900_000_000n })).rejects.toMatchObject({ code: 'insufficient_funds' });90 });9192 it('fails closed on disagreement, on a missing source, on a wrong chain and on a stale source', async () => {93 for (const other of [94 [coins[0]!], // a coin missing95 [...coins, { txid: 'ab'.repeat(32), vout: 1, value: 7, confirmed: true }], // an extra coin96 [{ ...coins[0]!, value: 30_000_000_001 }, coins[1]!], // another value97 [{ ...coins[0]!, vout: 1 }, coins[1]!],98 [{ ...coins[0]!, confirmed: false }, coins[1]!], // not confirmed for one of them99 [],100 ]) {101 const ad = mk({ utxos: coins, raw }, { utxos: other });102 await ad.health();103 await expect(ad.prepare(req), JSON.stringify(other)).rejects.toMatchObject({ code: 'rpc_disagreement', status: 503 });104 }105 // a source that answers the first time and is unreachable the second time106 const flaky = stub('two', { ref: DOGE.chainRef, utxos: coins });107 let calls = 0;108 flaky.utxos = async () => { if (++calls > 1) throw new Error('down'); return coins; };109 const downLater = btc.btcAdapter(DOGE, [stub('one', { ref: DOGE.chainRef, utxos: coins, raw }), flaky]);110 await downLater.health();111 await downLater.prepare(req);112 await expect(downLater.prepare(req)).rejects.toMatchObject({ code: 'rpc_degraded', status: 503 });113114 const wrongChain = mk({ utxos: coins, raw }, { utxos: coins, ref: NETWORKS.litecoin.chainRef });115 expect(await wrongChain.health()).toMatchObject({ ok: false, identityVerified: false, readable: true });116 await expect(wrongChain.prepare(req)).rejects.toMatchObject({ code: 'rpc_degraded' });117118 const oneDown = mk({ utxos: coins, raw }, { down: true });119 expect(await oneDown.health()).toMatchObject({ ok: false, readable: true, identityVerified: false });120121 // a source trailing by more than the confirmation depth counts as down (20 blocks for Dogecoin)122 const stale = mk({ utxos: coins, raw, tip: 6_452_441 }, { utxos: coins, tip: 6_452_441 - 21 });123 const hs = await stale.health();124 expect(hs).toMatchObject({ ok: false, identityVerified: false, height: 6_452_441 });125 expect(hs.endpoints.map((e) => e.ok)).toEqual([true, false]);126 const lagging = mk({ utxos: coins, raw, tip: 6_452_441 }, { utxos: coins, tip: 6_452_441 - 20 });127 expect((await lagging.health()).ok).toBe(true);128 });129});130131describe('existing native SegWit chains keep their behaviour', () => {132 it('Litecoin: witness inputs, no helper data, market fee estimate, genesis identity', async () => {133 const LTC = NETWORKS.litecoin, asked: number[] = [];134 const from = 'ltc1qjmxnz78nmc8nq77wuxh25n2es7rzm5c2rkk4wh', to = 'ltc1qg42tkwuuxefutzxezdkdel39gfstuap288mfea';135 const coins = [{ txid: 'cd'.repeat(32), vout: 0, value: 5_000_000, confirmed: true }];136 const ad = btc.btcAdapter(LTC, [stub('one', { ref: LTC.chainRef, utxos: coins, fee: 2 }, asked), stub('two', { ref: LTC.chainRef, utxos: coins, fee: 50 }, asked)]);137 expect((await ad.health()).ok).toBe(true);138 expect(asked).toEqual([0, 0]);139 const p = await ad.prepare({ asset: 'native', from, to, amount: 2_000_000n });140 expect(p.aux).toBeUndefined();141 expect(p.section).toEqual({ btc: { inputs: [{ txid: 'cd'.repeat(32), vout: 0, value: '5000000' }], change: '2999687', fee: '313' } }); // 142 vB × 2.2 (first source's estimate + 10 %), as before the refactor142 expect(buildBtcTx(intentOf('litecoin', from, to, 2_000_000n, p)).outputsLength).toBe(2);143 const noEstimate = btc.btcAdapter(LTC, [stub('one', { ref: LTC.chainRef, utxos: coins, fee: new Error('x') }), stub('two', { ref: LTC.chainRef, utxos: coins, fee: new Error('x') })]);144 await noEstimate.health();145 await expect(noEstimate.prepare({ asset: 'native', from, to, amount: 2_000_000n })).rejects.toThrow(); // no registry bounds: an estimate is required146 });147});148149describe('Bitcoin Cash adapter', () => {150 const coins = [{ txid: 'aa'.repeat(32), vout: 1, value: 150_000, confirmed: true }, { txid: 'bb'.repeat(32), vout: 0, value: 60_000, confirmed: true }];151 const req = { asset: 'native', from: BCH_FROM, to: BCH_TO, amount: 180_000n };152153 it('checks identity at the first block after the last chain split and prepares a section the plugin rebuilds', async () => {154 const asked: number[] = [];155 const ad = bchAdapter(BCH, [stub('one', { ref: BCH.chainRef, utxos: coins, tip: 976_826 }, asked), stub('two', { ref: BCH.chainRef, utxos: [...coins].reverse(), tip: 976_826 }, asked)]);156 expect((await ad.health()).ok).toBe(true);157 expect(asked).toEqual([661648, 661648]);158 const p = await ad.prepare(req);159 expect(p.aux).toBeUndefined();160 const x = (p.section as { x: Record<string, string> }).x;161 expect(Object.keys(x).sort()).toEqual(['change', 'fee', 'inputs']);162 expect(x.inputs).toBe(`${'aa'.repeat(32)}:1:150000,${'bb'.repeat(32)}:0:60000`);163 expect(BigInt(x.change!) + BigInt(x.fee!) + 180_000n).toBe(210_000n);164 expect(Number(x.fee)).toBeGreaterThanOrEqual(374); // ≥ 1 sat/byte on the upper-bound size (2 inputs, 2 outputs)165 expect(Number(x.fee)).toBeLessThanOrEqual(500);166 const intent = intentOf('bitcoincash', BCH_FROM, BCH_TO, req.amount, p);167 expect(pluginFor(BCH).build(intent, BCH, p.aux).signDocs.length).toBe(2);168 // dust change is given to the fee instead of creating an unspendable output169 const q = await ad.prepare({ ...req, amount: 209_400n });170 expect((q.section as { x: Record<string, string> }).x).toMatchObject({ change: '0', fee: '600' });171 expect(pluginFor(BCH).build(intentOf('bitcoincash', BCH_FROM, BCH_TO, 209_400n, q), BCH).signDocs.length).toBe(2);172 });173174 it('a node of another chain with the same genesis block is not accepted as Bitcoin Cash', async () => {175 expect(BCH.chainRef).not.toBe(NETWORKS.bitcoin.chainRef);176 const otherChain = '00'.repeat(31) + '01'; // any other block hash at that height177 const ad = bchAdapter(BCH, [stub('one', { ref: BCH.chainRef, utxos: coins }), stub('two', { ref: otherChain, utxos: coins })]);178 expect(await ad.health()).toMatchObject({ ok: false, identityVerified: false });179 await expect(ad.prepare(req)).rejects.toMatchObject({ code: 'rpc_degraded' });180 });181182 it('fails closed on disagreement', async () => {183 const ad = bchAdapter(BCH, [stub('one', { ref: BCH.chainRef, utxos: coins }), stub('two', { ref: BCH.chainRef, utxos: [coins[0]!] })]);184 await ad.health();185 await expect(ad.prepare(req)).rejects.toMatchObject({ code: 'rpc_disagreement' });186 });187});188189describe('indexer clients (stubbed fetch)', () => {190 const serve = (routes: Record<string, { status?: number; body: unknown }>) => {191 const calls: { url: string; init?: RequestInit }[] = [];192 vi.stubGlobal('fetch', async (url: string, init?: RequestInit) => {193 calls.push({ url, init });194 const key = Object.keys(routes).find((k) => url.endsWith(k));195 if (!key) return new Response('not found', { status: 404 });196 const r = routes[key]!;197 return new Response(typeof r.body === 'string' ? r.body : JSON.stringify(r.body), { status: r.status ?? 200 });198 });199 return calls;200 };201 const coin = (o: object) => ({ mintTxid: 'cc'.repeat(32), mintIndex: 0, mintHeight: 600000, spentHeight: -2, value: 926600, ...o });202203 it('Bitcore: strips the CashAddr prefix, keeps only unspent coins, refuses a truncated page and inexact amounts', async () => {204 const s = btc.sourceOf('bitcore:https://bitcore.test/api/BCH/mainnet');205 const path = '/address/qzeer89eku4hkh3w7mrzra8ppwty27jq6vdpg7tpa0/?unspent=true&limit=1000';206 const calls = serve({ [path]: { body: [coin({}), coin({ mintIndex: 1, spentHeight: -1 }), coin({ mintIndex: 2, mintHeight: -1 }), coin({ mintIndex: 3, mintHeight: -3 }), coin({ mintIndex: 4, spentHeight: 700000 })] } });207 expect(await s.utxos('bitcoincash:qzeer89eku4hkh3w7mrzra8ppwty27jq6vdpg7tpa0')).toEqual([208 { txid: 'cc'.repeat(32), vout: 0, value: 926600, confirmed: true }, { txid: 'cc'.repeat(32), vout: 2, value: 926600, confirmed: false }]);209 expect(calls[0]!.url).toBe('https://bitcore.test/api/BCH/mainnet' + path);210 serve({ [path]: { body: Array.from({ length: 1000 }, (_, k) => coin({ mintIndex: k })) } });211 await expect(s.utxos('qzeer89eku4hkh3w7mrzra8ppwty27jq6vdpg7tpa0')).rejects.toThrow(/too many coins/);212 for (const value of [1.5, 2 ** 53, -1, '12']) {213 serve({ [path]: { body: [coin({ value })] } });214 if (value === '12') expect((await s.utxos('qzeer89eku4hkh3w7mrzra8ppwty27jq6vdpg7tpa0'))[0]!.value).toBe(12);215 else await expect(s.utxos('qzeer89eku4hkh3w7mrzra8ppwty27jq6vdpg7tpa0'), String(value)).rejects.toThrow(/exact integer/);216 }217 serve({ [path]: { body: [coin({ mintTxid: 'zz' })] } });218 await expect(s.utxos('qzeer89eku4hkh3w7mrzra8ppwty27jq6vdpg7tpa0')).rejects.toThrow(/malformed/);219 });220221 it('Bitcore: tip, identity block, fee, status; a broadcast counts only when a transaction id comes back', async () => {222 const s = btc.sourceOf('bitcore:https://bitcore.test/api/DOGE/mainnet');223 const id = 'ab'.repeat(32);224 const calls = serve({225 '/block/tip': { body: { height: 6452441 } }, '/block/0': { body: { hash: DOGE.chainRef } }, '/fee/3': { body: { feerate: 0.01 } },226 [`/tx/${id}`]: { body: { blockHeight: 6423225, blockHash: 'ff'.repeat(32) } }, [`/tx/${'cd'.repeat(32)}`]: { body: { blockHeight: -1 } },227 '/tx/send': { body: { txid: id } },228 });229 expect(await s.tip()).toBe(6452441);230 expect(await s.blockHash(0)).toBe(DOGE.chainRef);231 expect(await s.feeRate()).toBe(1000); // 0.01 DOGE/kB = 1000 koinu per byte232 expect(await s.status(id)).toEqual({ found: true, confirmed: true, height: 6423225, hash: 'ff'.repeat(32) });233 expect(await s.status('cd'.repeat(32))).toEqual({ found: true, confirmed: false });234 expect(await s.status('ef'.repeat(32))).toEqual({ found: false, confirmed: false });235 await expect(s.rawTx(id)).rejects.toThrow(/not served/);236 await s.broadcast('0100');237 const sent = calls[calls.length - 1]!;238 expect([sent.url, sent.init?.method, sent.init?.body]).toEqual(['https://bitcore.test/api/DOGE/mainnet/tx/send', 'POST', '{"rawTx":"0100"}']);239 serve({ '/tx/send': { status: 500, body: 'TX decode failed' } });240 await expect(s.broadcast('00')).rejects.toThrow(/TX decode failed/);241 serve({ '/tx/send': { body: {} } });242 await expect(s.broadcast('00')).rejects.toThrow(/not acknowledged/);243 serve({ '/tx/send': { body: 'ok' } });244 await expect(s.broadcast('00')).rejects.toThrow(/not acknowledged/);245 serve({ '/fee/3': { body: { feerate: -1 } } });246 await expect(s.feeRate()).rejects.toThrow(/no fee estimate/);247 });248249 it('adapter broadcast: success needs one acknowledgement; "already known" is success; anything else is an error', async () => {250 const mk = (m1: string | null, m2: string | null) => btc.btcAdapter(DOGE, [m1, m2].map((m, k) => ({ ...stub(`s${k}`, {}), broadcast: async () => { if (m !== null) throw new Error(m); } })));251 await mk(null, 'down').broadcast('00');252 await mk('down', null).broadcast('00');253 await mk('down', 'transaction already in block chain').broadcast('00');254 await mk('txn-already-known', 'down').broadcast('00');255 await expect(mk('TX decode failed', '-26: dust').broadcast('00')).rejects.toThrow(/TX decode failed/);256 });257258 it('Blockbook: exact amounts only, identity block by height, deposit history without the wallet\'s own sends', async () => {259 const s = btc.sourceOf('blockbook:https://bb.test/api/v2');260 const me = BCH_FROM, other = BCH_TO;261 serve({262 '/block-index/661648': { body: { blockHash: BCH.chainRef } },263 [`/utxo/${me}`]: { body: [{ txid: 'aa'.repeat(32), vout: 0, value: '926600', confirmations: 3 }, { txid: 'bb'.repeat(32), vout: 1, value: '5', confirmations: 0 }] },264 [`/address/${me}?details=txs&pageSize=50`]: { body: { transactions: [265 { txid: 'a1'.repeat(32), vin: [{ addresses: [other] }], vout: [{ value: '700', n: 0, addresses: [other] }, { value: '926600', n: 1, addresses: [me] }], blockHeight: 600000, confirmations: 9, blockTime: 1568338904 },266 { txid: 'a2'.repeat(32), vin: [{ addresses: [me] }], vout: [{ value: '100', n: 0, addresses: [other] }, { value: '50', n: 1, addresses: [me] }], blockHeight: 600001, confirmations: 8 }, // own send: change is not a deposit267 { txid: 'a3'.repeat(32), vin: [{ addresses: [other] }], vout: [{ value: '9', n: 0, addresses: [me] }], blockHeight: -1, confirmations: 0 }, // unconfirmed268 ] } },269 });270 expect(await s.blockHash(661648)).toBe(BCH.chainRef);271 expect(await s.utxos(me)).toEqual([{ txid: 'aa'.repeat(32), vout: 0, value: 926600, confirmed: true }, { txid: 'bb'.repeat(32), vout: 1, value: 5, confirmed: false }]);272 expect(await s.history!(me)).toEqual([{ txid: 'a1'.repeat(32), idx: 1, asset: 'native', amount: 926600n, from: other, block: 600000, time: 1568338904 }]);273 serve({ [`/utxo/${me}`]: { body: [{ txid: 'aa'.repeat(32), vout: 0, value: '9007199254740993', confirmations: 3 }] } });274 await expect(s.utxos(me)).rejects.toThrow(/exact integer/);275 const ad = bchAdapter(BCH, [s, stub('two', { tip: 600010 })]);276 serve({ '/api/v2': { body: { blockbook: { bestHeight: 600010 } } }, [`/address/${me}?details=txs&pageSize=50`]: { body: { transactions: [] } } });277 expect(await ad.deposits(me, null)).toEqual({ items: [], cursor: 600010, finalizedHeight: 600005 });278 });279280 it('source specifications in the registry resolve to the intended clients', () => {281 expect(DOGE.rpc.map((r) => r.split(':')[0])).toEqual(['blockbook', 'bitcore']);282 expect(BCH.rpc.map((r) => r.split(':')[0])).toEqual(['blockbook', 'bitcore']);283 expect(new Set([...DOGE.rpc, ...BCH.rpc].map((r) => new URL(r.replace(/^[a-z]+:(?=https:)/, '')).host.split('.').slice(-2).join('.'))).size).toBe(2); // two operators284 for (const r of [...DOGE.rpc, ...BCH.rpc]) expect(btc.sourceOf(r).url.startsWith('https://')).toBe(true);285 expect(btc.sourceOf('https://mempool.space/api').history).toBeTypeOf('function');286 });287});288