SPB Git

Repository Public

cardano-adapter.test.ts 21.7 KB 273 lines typescript

1// Cardano adapter against two scripted sources (Koios, Yoroi backend; no network): agreement rules, coin selection (plain ADA2// only), fee and minimum-ADA rules checked with the official serialization library, refusals, tracking, broadcast, deposits.3import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';4import CSL from '@emurgo/cardano-serialization-lib-nodejs';5import { buildCardanoTx, cardanoParseInputs, cardanoSignedTx, CARDANO_SLOT_ZERO_UNIX, NETWORKS, type Intent } from '@spw/core';6import type { Adapter } from '../src/chains/rpc.ts';78const n = NETWORKS.cardano;9const [K, Y] = n.rpc as [string, string];10const FROM = 'addr1qy8ac7qqy0vtulyl7wntmsxc6wex80gvcyjy33qffrhm7sh927ysx5sftuw0dlft05dz3c7revpf7jx0xnlcjz3g69mq4afdhv';11const TO = 'addr1qx2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3n0d3vllmyqwsx5wktcd8cc3sq835lu7drv2xwl2wywfgse35a3x';12const OTHER = 'addr1vx2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzers66hrl8';13const PIN = { tx: '52430b19d39d66530ae4764a60488e199ff1e9a1b5d9121d867b87f70fd4e836', block: '97e160fc41b9cd751270100dd639202c1b8ae81932f20340bd6dd9f44e429a1d' };14const TIP = 14_187_800, HASH = 'ec'.repeat(32);15const tx = (k: number) => k.toString(16).padStart(2, '0').repeat(32);16interface U { txid: string; index: number; value: string; assets?: boolean; datum?: boolean }17type Reply = { status?: number; body: unknown; text?: string } | 'down';18type Handler = (body: any, url: URL, raw: unknown) => Reply;1920function world(over: Record<string, Handler | 'down'> = {}, init: { utxos?: U[]; yoroiUtxos?: U[] } = {}) {21  const state = { now: Math.floor(Date.now() / 1000), utxos: init.utxos ?? [{ txid: tx(1), index: 0, value: '50000000' }], yoroiUtxos: init.yoroiUtxos, conf: {} as Record<string, number | null>, submitted: [] as string[], calls: [] as string[] };22  const slot = () => state.now - CARDANO_SLOT_ZERO_UNIX - 15;23  const routes: Record<string, Handler | 'down'> = {24    [K + '/tip']: () => ({ body: [{ hash: HASH, epoch_no: 667, abs_slot: slot(), block_no: TIP, block_time: slot() + CARDANO_SLOT_ZERO_UNIX }] }),25    [K + '/genesis']: () => ({ body: [{ networkmagic: '764824073', networkid: 'Mainnet' }] }),26    [K + '/epoch_params']: () => ({ body: [{ epoch_no: 667, min_fee_a: 44, min_fee_b: 155381, coins_per_utxo_size: '4310', max_tx_size: 16384 }] }),27    [K + '/tx_info']: (b) => ({ body: (b._tx_hashes as string[]).filter((h) => h === PIN.tx).map((h) => ({ tx_hash: h, block_hash: PIN.block })) }),28    [K + '/address_utxos']: (b) => ({ body: state.utxos.map((u) => ({ tx_hash: u.txid, tx_index: u.index, address: b._addresses[0], value: u.value, asset_list: u.assets ? [{ policy_id: 'ab'.repeat(28), asset_name: '41', quantity: '5' }] : [], datum_hash: u.datum ? 'cd'.repeat(32) : null, inline_datum: null, reference_script: null, is_spent: false })) }),29    [K + '/address_info']: (b) => ({ body: [{ address: b._addresses[0], balance: state.utxos.reduce((s, u) => s + BigInt(u.value), 0n).toString() }] }),30    [K + '/tx_status']: (b) => ({ body: (b._tx_hashes as string[]).map((h) => ({ tx_hash: h, num_confirmations: state.conf[h] ?? null })) }),31    [K + '/ogmios']: () => ({ body: { jsonrpc: '2.0', method: 'evaluateTransaction', result: [] } }),32    [K + '/submittx']: (_b, _u, raw) => { const hex = Buffer.from(raw as Uint8Array).toString('hex'); state.submitted.push(hex); return { status: 202, body: CSL.FixedTransaction.from_hex(hex).transaction_hash().to_hex() }; },33    [K + '/address_txs']: () => ({ body: [] }),34    [Y + '/v2/bestblock']: () => ({ body: { epoch: 667, slot: 1, globalSlot: slot(), hash: HASH, height: TIP } }),35    [Y + '/v2/txs/get']: (b) => ({ body: Object.fromEntries((b.txHashes as string[]).filter((h) => h === PIN.tx).map((h) => [h, { hash: h, block_hash: PIN.block, tx_state: 'Successful' }])) }),36    [Y + '/txs/utxoForAddresses']: (b) => ({ body: (state.yoroiUtxos ?? state.utxos).map((u) => ({ utxo_id: `${u.txid}:${u.index}`, tx_hash: u.txid, tx_index: u.index, receiver: b.addresses[0], amount: u.value, assets: u.assets ? [{ assetId: 'x', amount: '5' }] : [], block_num: 1, script_ref: null })) }),37    [Y + '/txs/utxoSumForAddresses']: () => ({ body: { sum: state.utxos.reduce((s, u) => s + BigInt(u.value), 0n).toString(), tokensBalance: [] } }),38    [Y + '/tx/status']: (b) => ({ body: { depth: Object.fromEntries((b.txHashes as string[]).filter((h) => state.conf[h] != null).map((h) => [h, state.conf[h]])) } }),39    [Y + '/v2/txs/signed']: (b) => { state.submitted.push(Buffer.from(b.signedTx, 'base64').toString('hex')); return { body: {} }; },40    ...over,41  };42  vi.stubGlobal('fetch', async (url: string, req: RequestInit = {}) => {43    const u = new URL(url), key = u.origin + u.pathname;44    state.calls.push(key);45    const h = routes[key];46    if (!h || h === 'down') throw new TypeError('fetch failed');47    const isJson = typeof req.body === 'string';48    const r = h(isJson ? JSON.parse(req.body as string) : undefined, u, req.body);49    if (r === 'down') throw new TypeError('fetch failed');50    return new Response(r.text ?? JSON.stringify(r.body), { status: r.status ?? 200 });51  });52  return state;53}5455let make: () => Adapter;56beforeAll(async () => {57  Object.assign(process.env, { SPW_ORIGIN: 'https://wallet.test', SPW_RP_ID: 'wallet.test', DATABASE_URL: 'postgres://unused', SPW_SIGNER_SECRET: 'x'.repeat(48), SPW_BROADCAST: 'off', SPW_WORKERS: 'off', SPW_CARDANO_RETRY_MS: '0' });58  const { cardanoAdapter } = await import('../src/chains/cardano.ts');59  make = () => cardanoAdapter(n);60});61afterEach(() => vi.unstubAllGlobals());62const req = { asset: 'native', from: FROM, to: TO, amount: 2_500_000n };63const code = async (p: Promise<unknown>) => p.then(() => 'ok', (e: { code?: string; message: string }) => e.code ?? `error:${e.message}`);64const msg = async (p: Promise<unknown>) => p.then(() => 'ok', (e: Error) => e.message);65const lin = CSL.LinearFee.new(CSL.BigNum.from_str('44'), CSL.BigNum.from_str('155381'));66/** the intent the API would form from a prepared section; built by the plugin exactly as the signer does */67function built(p: { section: Record<string, unknown>; maxFee: bigint }, d = req) {68  const now = Math.floor(Date.now() / 1000);69  const i: Intent = { v: 1, proposalId: '6f1c1f0e-8f5f-4b1e-9c56-0d6b5f0f1a11', keyId: 'spw-0123456789abcdef', createdAt: now, expiresAt: now + 120, policyVersion: 4, network: 'cardano', chainRef: n.chainRef,70    asset: 'native', symbol: 'ADA', decimals: 6, amount: d.amount.toString(), from: d.from, to: d.to, maxFee: p.maxFee.toString(), x: p.section.x as Record<string, string> };71  const b = buildCardanoTx(i, n);72  return { i, b, sdk: CSL.Transaction.from_hex(Buffer.from(cardanoSignedTx(b.body, new Uint8Array(32).fill(1), new Uint8Array(64).fill(2))).toString('hex')) };73}7475describe('cardano adapter: health and identity', () => {76  it('is ok only when both sources are up, on mainnet, and know the pinned block', async () => {77    world();78    expect(await make().health()).toMatchObject({ ok: true, readable: true, identityVerified: true, height: TIP });79    world({ [Y + '/v2/bestblock']: 'down' });80    expect(await make().health()).toMatchObject({ ok: false, readable: true, identityVerified: false });81    world({ [K + '/genesis']: () => ({ body: [{ networkmagic: '1', networkid: 'Testnet' }] }) });82    expect(await make().health()).toMatchObject({ ok: false, identityVerified: false });83    world({ [Y + '/v2/txs/get']: () => ({ body: { [PIN.tx]: { hash: PIN.tx, block_hash: 'aa'.repeat(32) } } }) });84    expect(await make().health()).toMatchObject({ ok: false, identityVerified: false });85    world({ [K + '/tip']: () => ({ body: [{ hash: HASH, epoch_no: 667, abs_slot: 100, block_no: TIP, block_time: 100 + CARDANO_SLOT_ZERO_UNIX + 1 }] }) }); // slot length changed86    expect(await make().health()).toMatchObject({ ok: false });87    const s = world(); s.now -= 3600; // both sources stalled an hour ago88    expect(await make().health()).toMatchObject({ ok: false, readable: false });89  });90  it('reads balances from either source', async () => {91    world({}, { utxos: [{ txid: tx(1), index: 0, value: '50000000' }, { txid: tx(2), index: 1, value: '1500000', assets: true }] });92    expect(await make().balances(FROM)).toEqual({ native: 51_500_000n });93    world({ [Y + '/txs/utxoSumForAddresses']: 'down' });94    expect(await make().balances(FROM)).toEqual({ native: 50_000_000n });95    world({ [Y + '/txs/utxoSumForAddresses']: 'down', [K + '/address_info']: () => ({ body: [] }) });96    expect(await make().balances(FROM)).toEqual({ native: 0n });97    await expect(make().balances('addr1xyz')).rejects.toThrow(/address/);98  });99});100101describe('cardano adapter: prepare', () => {102  it('selects plain ADA outputs agreed by both sources and pays the exact minimum fee for the bytes signed', async () => {103    world();104    const p = await make().prepare(req);105    const { b, sdk } = built(p);106    expect(p.section.x).toMatchObject({ inputs: `${tx(1)}:0:50000000` });107    expect(p.ttlSec).toBe(120);108    expect(p.maxFee).toBe(p.feeEstimate);109    expect(p.feeEstimate.toString()).toBe(CSL.min_fee(sdk, lin).to_str());                         // the official library's minimum for these bytes110    expect(p.feeEstimate).toBe(44n * BigInt(b.size) + 155381n);111    expect(b.amount + b.fee + b.change).toBe(50_000_000n);112    expect(b.change).toBe(50_000_000n - 2_500_000n - p.feeEstimate);113    const ttl = Number((p.section.x as Record<string, string>).ttl);114    expect(Math.abs(ttl + CARDANO_SLOT_ZERO_UNIX - (Math.floor(Date.now() / 1000) + 900))).toBeLessThanOrEqual(2);115    expect(p.warnings).toEqual([]);116  });117118  it('never spends an output that carries native tokens or script data, and says what stays locked', async () => {119    world({}, { utxos: [{ txid: tx(1), index: 0, value: '90000000', assets: true }, { txid: tx(2), index: 0, value: '70000000', datum: true }, { txid: tx(3), index: 2, value: '4000000' }] });120    const p = await make().prepare(req);121    expect(cardanoParseInputs((p.section.x as Record<string, string>).inputs)).toEqual([{ txid: tx(3), index: 2, value: 4_000_000n }]);122    expect(p.warnings).toEqual(['cd_token_outputs']);123    world({}, { utxos: [{ txid: tx(1), index: 0, value: '90000000', assets: true }] });124    expect(await msg(make().prepare(req))).toMatch(/held in outputs that carry native tokens/);125    expect(await code(make().prepare(req))).toBe('insufficient_funds');126  });127128  it('adds inputs largest first, up to the amount, and keeps x.inputs sorted', async () => {129    world({}, { utxos: [{ txid: tx(9), index: 0, value: '3000000' }, { txid: tx(4), index: 1, value: '5000000' }, { txid: tx(4), index: 0, value: '2000000' }, { txid: tx(7), index: 0, value: '1000000' }] });130    const p = await make().prepare({ ...req, amount: 8_500_000n });131    const ins = cardanoParseInputs((p.section.x as Record<string, string>).inputs);132    expect(ins.map((u) => `${u.txid.slice(0, 2)}:${u.index}`)).toEqual(['04:0', '04:1', '09:0']); // 5 + 3 + 2 ADA, sorted by (txid, index)133    const { b, sdk } = built(p, { ...req, amount: 8_500_000n });134    expect(p.feeEstimate.toString()).toBe(CSL.min_fee(sdk, lin).to_str());135    expect(b.change).toBeGreaterThanOrEqual(969_750n);136  });137138  it('enforces the minimum ADA of every output: payment, change, and the exact "send everything" case', async () => {139    world();140    expect(await msg(make().prepare({ ...req, amount: 900_000n }))).toMatch(/smallest payment Cardano accepts for this address is 0\.96975 ADA/);141    expect(await code(make().prepare({ ...req, amount: 969_749n }))).toBe('bad_amount');142    expect(await code(make().prepare({ ...req, amount: 969_750n }))).toBe('ok');143    expect(await code(make().prepare({ ...req, to: OTHER, amount: 849_070n }))).toBe('ok');              // enterprise address: shorter, smaller minimum144    // change would be 0.5 ADA: refused with the two amounts that do work145    const m = await msg(make().prepare({ ...req, amount: 49_330_000n }));146    expect(m).toMatch(/would leave 0\.\d+ ADA of change, less than the 0\.96975 ADA an output must carry: send at most (\d+\.\d+) ADA, or exactly (\d+\.\d+) ADA to send everything/);147    const [, most, exact] = /send at most (\d+\.\d+) ADA, or exactly (\d+\.\d+) ADA/.exec(m)!;148    const units = (s: string) => BigInt(s.split('.')[0]!) * 1_000_000n + BigInt((s.split('.')[1] ?? '').padEnd(6, '0'));149    const all = await make().prepare({ ...req, amount: units(exact!) });                                     // everything: one output, no change150    expect((all.section.x as Record<string, string>).change).toBe('0');151    expect(units(exact!) + all.feeEstimate).toBe(50_000_000n);152    expect(built(all, { ...req, amount: units(exact!) }).sdk.body().outputs().len()).toBe(1);153    const mostOk = await make().prepare({ ...req, amount: units(most!) });154    expect(BigInt((mostOk.section.x as Record<string, string>).change!)).toBeGreaterThanOrEqual(969_750n);155    expect(await code(make().prepare({ ...req, amount: units(most!) + 1_000n }))).toBe('bad_amount');156    expect(await code(make().prepare({ ...req, amount: 50_000_000n }))).toBe('insufficient_funds');157    world({}, { utxos: [] });158    expect(await msg(make().prepare(req))).toMatch(/has no ADA/);159  });160161  it('refuses when the sources are down or disagree on anything that feeds the signature', async () => {162    world({ [Y + '/txs/utxoForAddresses']: 'down' });163    expect(await code(make().prepare(req))).toBe('rpc_degraded');164    world({ [K + '/address_utxos']: 'down' });165    expect(await code(make().prepare(req))).toBe('rpc_degraded');166    world({ [K + '/tip']: 'down' });167    expect(await code(make().prepare(req))).toBe('rpc_degraded');168    world({}, { yoroiUtxos: [{ txid: tx(1), index: 0, value: '50000001' }] });                               // another value169    expect(await code(make().prepare(req))).toBe('rpc_disagreement');170    world({}, { yoroiUtxos: [{ txid: tx(1), index: 0, value: '50000000' }, { txid: tx(2), index: 0, value: '9000000' }] }); // an output only one source sees171    expect(await code(make().prepare(req))).toBe('rpc_disagreement');172    world({}, { yoroiUtxos: [{ txid: tx(1), index: 0, value: '50000000', assets: true }] });                  // tokens seen by one source only173    expect(await code(make().prepare(req))).toBe('rpc_disagreement');174    world({ [Y + '/v2/bestblock']: () => ({ body: { globalSlot: Math.floor(Date.now() / 1000) - CARDANO_SLOT_ZERO_UNIX - 20, hash: 'ab'.repeat(32), height: TIP - 10 } }) });175    expect(await code(make().prepare(req))).toBe('rpc_disagreement');                                       // ten blocks apart176    world({ [K + '/genesis']: () => ({ body: [{ networkmagic: '2', networkid: 'Testnet' }] }) });177    expect(await code(make().prepare(req))).toBe('rpc_degraded');                                           // not mainnet178  });179180  it('stops when the protocol parameters are not the reviewed ones', async () => {181    for (const patch of [{ min_fee_a: 45 }, { min_fee_b: 155382 }, { coins_per_utxo_size: '4311' }, { max_tx_size: 32768 }]) {182      world({ [K + '/epoch_params']: () => ({ body: [{ epoch_no: 667, min_fee_a: 44, min_fee_b: 155381, coins_per_utxo_size: '4310', max_tx_size: 16384, ...patch }] }) });183      expect(await code(make().prepare(req))).toBe('protocol_changed');184    }185    world({ [K + '/epoch_params']: 'down' });186    expect(await code(make().prepare(req))).toBe('rpc_degraded');187  });188189  it('refuses bad requests', async () => {190    world();191    expect(await code(make().prepare({ ...req, asset: 'f66d78b4a3cb3d37afa0ec36461e51ecbde00f26c8f0a68f94b6988069555344' }))).toBe('unsupported_asset');192    expect(await code(make().prepare({ ...req, amount: 0n }))).toBe('bad_amount');193    expect(await code(make().prepare({ ...req, memo: 'hi' }))).toBe('bad_memo');194    expect(await code(make().prepare({ ...req, to: FROM }))).toBe('bad_address');195    expect(await code(make().prepare({ ...req, to: 'addr1w8phkx6acpnf78fuvxn0mkew3l0fd058hzquvz7w36x4gtcyjy7wx' }))).toBe('bad_address');196    expect(await code(make().prepare({ ...req, from: OTHER }))).toBe('bad_address');197  });198199  it('uses the node dry run: a ledger rejection refuses the transfer, an unreachable endpoint does not', async () => {200    const seen: string[] = [];201    world({ [K + '/ogmios']: (b) => { seen.push(b.params.transaction.cbor); return { body: { result: [] } }; } });202    const p = await make().prepare(req);203    expect(CSL.FixedTransaction.from_hex(seen[0]!).transaction_hash().to_hex()).toBe(built(p).b.txid);       // the exact body that will be signed204    world({ [K + '/ogmios']: () => ({ status: 400, body: { jsonrpc: '2.0', error: { code: 3117, message: 'The transaction contains unknown UTxO references as inputs.', data: { unknownOutputReferences: [] } } } }) });205    expect(await code(make().prepare(req))).toBe('pending_tx');206    world({ [K + '/ogmios']: () => ({ status: 400, body: { jsonrpc: '2.0', error: { code: -32602, message: 'Invalid transaction; malformed in ALL eras' } } }) });207    expect(await code(make().prepare(req))).toBe('simulation_failed');208    world({ [K + '/ogmios']: 'down' });209    expect(await code(make().prepare(req))).toBe('ok');210    world({ [K + '/ogmios']: () => ({ status: 503, body: {}, text: 'upstream unavailable' }) });211    expect(await code(make().prepare(req))).toBe('ok');212  });213});214215describe('cardano adapter: broadcast, tracking, deposits', () => {216  const signed = async () => { world(); const p = await make().prepare(req); const { b } = built(p); return { raw: Buffer.from(cardanoSignedTx(b.body, new Uint8Array(32).fill(1), new Uint8Array(64).fill(2))).toString('hex'), txid: b.txid, x: p.section.x as Record<string, string> }; };217218  it('broadcasts to both sources; a refusal is fine only when the transaction is already on chain', async () => {219    const { raw, txid } = await signed();220    let s = world();221    await make().broadcast(raw);222    expect(s.submitted).toEqual([raw, raw]);223    s = world({ [K + '/submittx']: () => ({ status: 400, body: {}, text: 'BadInputsUTxO' }) });224    await make().broadcast(raw);                                                                             // one source is enough225    s = world({ [K + '/submittx']: () => ({ status: 400, body: {}, text: 'BadInputsUTxO' }), [Y + '/v2/txs/signed']: () => ({ status: 500, body: { error: 'x' } }) });226    await expect(make().broadcast(raw)).rejects.toThrow(/koios 400: BadInputsUTxO/);227    s.conf[txid] = 0;                                                                                        // already in a block228    await make().broadcast(raw);229    await expect(make().broadcast('00')).rejects.toThrow(/not a Cardano signed transaction/);230  });231232  it('tracks confirming → finalized, reorged, and expiry only when both sources agree the window is closed', async () => {233    const { txid, x } = await signed();234    const p = { txid, intent: { x }, blockRef: null, broadcastAt: 0 };235    let s = world();236    expect(await make().track(p)).toEqual({ state: 'broadcast', confirmations: 0, rebroadcast: true });237    s.conf[txid] = 0;238    expect(await make().track(p)).toEqual({ state: 'confirming', confirmations: 1, blockRef: 'seen' });239    s.conf[txid] = 14;240    expect(await make().track(p)).toMatchObject({ state: 'finalized', confirmations: 15 });241    s = world();242    expect(await make().track({ ...p, blockRef: 'seen' })).toMatchObject({ state: 'reorged', rebroadcast: true });   // was in a block, no longer known243    s = world({ [Y + '/tx/status']: 'down' });244    expect(await make().track({ ...p, intent: { x: { ...x, ttl: '5' } } })).toMatchObject({ state: 'broadcast' });  // one source cannot tell: no verdict245    s = world();246    expect(await make().track({ ...p, intent: { x: { ...x, ttl: String(Number(x.ttl) - 2000) } } })).toEqual({ state: 'failed', confirmations: 0, error: 'expired before inclusion (validity window closed); no funds moved' });247    expect(await make().track({ ...p, intent: { x: { ...x, ttl: String(Number(x.ttl) - 950) } } })).toMatchObject({ state: 'broadcast' }); // closed 50 s ago: a late block may still carry it248    void s;249  });250251  it('reports finalized incoming payments confirmed by both sources, never its own change', async () => {252    const dep = tx(0xd1), own = tx(0xd2), young = tx(0xd3);253    const info = (h: string, inputAddr: string, outs: [string, string][], height: number) => ({ tx_hash: h, block_height: height, tx_timestamp: 1_794_000_000, inputs: [{ payment_addr: { bech32: inputAddr }, value: '1', tx_index: 0 }], outputs: outs.map(([a, v], k) => ({ payment_addr: { bech32: a }, value: v, tx_index: k })) });254    const infos = [info(dep, TO, [[TO, '7000000'], [FROM, '3000000']], TIP - 100), info(own, FROM, [[TO, '1000000'], [FROM, '2000000']], TIP - 90)];255    const routes = (yoroiAmount = '3000000'): Record<string, Handler> => ({256      [K + '/address_txs']: () => ({ body: [{ tx_hash: young, block_height: TIP - 3 }, { tx_hash: own, block_height: TIP - 90 }, { tx_hash: dep, block_height: TIP - 100 }] }),257      [K + '/tx_info']: (b) => ({ body: infos.filter((t) => (b._tx_hashes as string[]).includes(t.tx_hash)) }),258      [Y + '/v2/txs/get']: () => ({ body: { [dep]: { tx_state: 'Successful', outputs: [{ address: TO, amount: '7000000' }, { address: FROM, amount: yoroiAmount }] }, [own]: { tx_state: 'Successful', outputs: [] } } }),259    });260    world(routes());261    const a = make(), r = await a.deposits(FROM, TIP - 500);262    expect(r.items).toEqual([{ txid: dep, idx: 1, asset: 'native', amount: 3_000_000n, from: TO, block: TIP - 100, time: 1_794_000_000 }]);263    expect(r).toMatchObject({ cursor: TIP - 14, finalizedHeight: TIP - 14 });                                // 15 confirmations264    const s = world(routes());265    expect(await a.deposits(FROM, r.cursor)).toMatchObject({ items: [], cursor: r.cursor });                 // throttled: no request at all266    expect(s.calls).toEqual([]);267    world(routes('2999999'));268    await expect(make().deposits(FROM, TIP - 500)).rejects.toThrow(/sources disagree on a deposit/);269    world(routes());270    expect((await make().deposits(FROM, null)).items.length).toBe(1);                                        // first scan: about one day back271  });272});273
Files main
Menu

Theme