SPB Git

Repository Public

utxo.test.ts 19.8 KB 288 lines typescript

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
Files main
Menu

Theme