SPB Git

Repository Public

cosmos-adapter.test.ts 23.0 KB 266 lines typescript

1// Cosmos Hub adapter against two scripted REST sources (no network): agreement rules, refusals, fee and gas, tracking and broadcast outcomes.2import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';3import { decodeTxRaw } from '@cosmjs/proto-signing';4import { cosmosTxid, NETWORKS } from '@spw/core';5import type { Adapter } from '../src/chains/rpc.ts';67const n = NETWORKS.cosmos;8const [A, B] = n.rpc as [string, string];9const FROM = 'cosmos19rl4cm2hmr8afy4kldpxz3fka4jguq0auqdal4', TO = 'cosmos1wnacchusmacys78rxcr640gjpz000f87gnz6zf', MODULE = 'cosmos13pxn9n3qw79e03844rdadagmg0nshmwf7qvuye';10const NEW = 'cosmos1uqdsdwwuujcrk95ur6wf6kwhjpajkmjmcjy3ed';11const TIP = 33_821_300;12const hashOf = (h: number) => Buffer.from(String(h).padStart(32, '0')).toString('base64');13const RAW = Buffer.from('signed transaction bytes').toString('base64'), TXID = cosmosTxid(RAW);1415interface Req { url: string; base: string; path: string; query: URLSearchParams; height: number | undefined; body: any }16type Reply = { status?: number; body: unknown; height?: number | null } | 'down';17type Handler = (r: Req) => Reply;18const notFound = (message = 'not found'): Reply => ({ status: 404, body: { code: 5, message, details: [] } });19const txError = (message: string): Reply => ({ status: 500, body: { code: 2, message, details: [] } });20const base = (addr: string, accountNumber: string, sequence: string) => ({ account: { '@type': '/cosmos.auth.v1beta1.BaseAccount', address: addr, pub_key: null, account_number: accountNumber, sequence } });2122/** A healthy pair of sources; individual tests override routes per source. Routes are matched by path prefix. */23function world(over: Record<string, Handler> = {}, perUrl: Record<string, Record<string, Handler | 'down'>> = {}) {24  const state = { tip: { [A]: TIP, [B]: TIP } as Record<string, number>, now: Date.now(),25    accounts: { [FROM]: base(FROM, '1425847', '7'), [TO]: base(TO, '99', '0'),26      [MODULE]: { account: { '@type': '/cosmos.auth.v1beta1.ModuleAccount', base_account: { address: MODULE, account_number: '2844531', sequence: '0' }, name: 'feemarket-fee-collector' } } } as Record<string, unknown>,27    balances: { [FROM]: '30000000' } as Record<string, string> };28  const block = (r: Req, h: number): Reply => ({ body: { block_id: { hash: hashOf(h) }, block: { header: { height: String(h), chain_id: 'cosmoshub-4', time: new Date(state.now).toISOString() } } }, height: null });29  const routes: Record<string, Handler> = {30    '/cosmos/base/tendermint/v1beta1/node_info': () => ({ body: { default_node_info: { network: 'cosmoshub-4' } }, height: null }),31    '/cosmos/base/tendermint/v1beta1/blocks/latest': (r) => block(r, state.tip[r.base]!),32    '/cosmos/base/tendermint/v1beta1/blocks/': (r) => block(r, Number(r.path.split('/').pop())),33    '/cosmos/auth/v1beta1/accounts/': (r) => { const a = state.accounts[r.path.split('/').pop()!]; return a ? { body: a } : notFound('account not found'); },34    '/cosmos/bank/v1beta1/spendable_balances/': (r) => ({ body: { balance: { denom: 'uatom', amount: state.balances[r.path.split('/')[5]!] ?? '0' } } }),35    '/feemarket/v1/gas_price/uatom': () => ({ body: { price: { denom: 'uatom', amount: '0.005000000000000000' } } }),36    '/cosmos/base/node/v1beta1/config': (r) => ({ body: { minimum_gas_price: r.base === A ? '0.005000000000000000uatom' : '0.0025uatom,0.1ibc/ABC' } }),37    '/cosmos/tx/v1beta1/simulate': () => ({ body: { gas_info: { gas_wanted: '75000000', gas_used: '86054' } } }),38    '/cosmos/tx/v1beta1/txs/': () => notFound('tx not found'),39    '/cosmos/tx/v1beta1/txs': (r) => (r.body ? { body: { tx_response: { code: 0, txhash: TXID, raw_log: '' } } } : { body: { txs: [], tx_responses: [], total: '0' } }),40    ...over,41  };42  const find = (table: Record<string, Handler | 'down'>, path: string) => Object.keys(table).filter((k) => (k.endsWith('/') ? path.startsWith(k) : path === k)).sort((x, y) => y.length - x.length)[0];43  const calls: Req[] = [];44  vi.stubGlobal('fetch', async (url: string, init: RequestInit = {}) => {45    const u = new URL(url), baseUrl = [A, B].find((s) => url.startsWith(s))!;46    const hdr = (init.headers ?? {}) as Record<string, string>;47    const req: Req = { url, base: baseUrl, path: u.pathname, query: u.searchParams, height: hdr['x-cosmos-block-height'] ? Number(hdr['x-cosmos-block-height']) : undefined, body: init.body ? JSON.parse(String(init.body)) : undefined };48    calls.push(req);49    const mine = perUrl[baseUrl] ?? {}, k = find(mine, u.pathname);50    const h = k !== undefined ? mine[k]! : routes[find(routes, u.pathname)!];51    if (h === 'down' || !h) throw new TypeError('fetch failed');52    const r = h(req);53    if (r === 'down') throw new TypeError('fetch failed');54    const height = r.height === undefined ? (req.height ?? state.tip[baseUrl]!) : r.height;55    return new Response(JSON.stringify(r.body), { status: r.status ?? 200, headers: height === null || (r.status ?? 200) !== 200 ? {} : { 'x-cosmos-block-height': String(height) } });56  });57  return { state, calls };58}5960let make: () => Adapter;61beforeAll(async () => {62  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' });63  const { cosmosAdapter } = await import('../src/chains/cosmos.ts');64  make = () => cosmosAdapter(n);65});66afterEach(() => vi.unstubAllGlobals());67const req = { asset: 'native', from: FROM, to: TO, amount: 2_000_000n };68const code = async (p: Promise<unknown>) => p.then(() => 'ok', (e: { code?: string; message: string }) => e.code ?? `error:${e.message}`);69const ACCOUNTS = '/cosmos/auth/v1beta1/accounts/', BLOCKS = '/cosmos/base/tendermint/v1beta1/blocks/', LATEST = BLOCKS + 'latest', SIM = '/cosmos/tx/v1beta1/simulate', TXS = '/cosmos/tx/v1beta1/txs';70const X = { account_number: '1425847', sequence: '7', gas_limit: '200000', fee_amount: '1500', timeout_height: String(TIP + 30) };7172describe('cosmos adapter: health and identity', () => {73  it('is ok only when both sources are up, report the chain id and agree on a recent block', async () => {74    world();75    expect(await make().health()).toMatchObject({ ok: true, readable: true, identityVerified: true, height: TIP });76    world({}, { [B]: { [LATEST]: 'down' } });77    expect(await make().health()).toMatchObject({ ok: false, readable: true, identityVerified: false });78    world({}, { [B]: { '/cosmos/base/tendermint/v1beta1/node_info': () => ({ body: { default_node_info: { network: 'theta-testnet-001' } } }) } });79    expect(await make().health()).toMatchObject({ ok: false, identityVerified: false });80    world({}, { [B]: { [BLOCKS]: (r) => ({ body: { block_id: { hash: hashOf(1) }, block: { header: { height: r.path.split('/').pop() === 'latest' ? String(TIP) : r.path.split('/').pop(), chain_id: 'cosmoshub-4', time: new Date().toISOString() } } } }) } });81    expect(await make().health()).toMatchObject({ ok: false, readable: true, identityVerified: false }); // same chain id, another block at that height (fork / impostor)82    world({}, { [A]: { [LATEST]: (r) => ({ body: { block_id: { hash: hashOf(TIP) }, block: { header: { height: String(TIP), chain_id: 'cosmoshub-4', time: new Date(Date.now() - 600_000).toISOString() } } } }) } });83    expect(await make().health()).toMatchObject({ ok: false, readable: true }); // a stalled node is not a source84    world({}, { [A]: { [LATEST]: 'down' }, [B]: { [LATEST]: 'down' } });85    expect(await make().health()).toMatchObject({ ok: false, readable: false });86  });87  it('prepare refuses when the identity cannot be verified on both sources', async () => {88    world({}, { [B]: { '/cosmos/base/tendermint/v1beta1/node_info': () => ({ body: { default_node_info: { network: 'cosmoshub-3' } } }) } });89    expect(await code(make().prepare(req))).toBe('rpc_degraded');90  });91});9293describe('cosmos adapter: prepare', () => {94  it('returns account number, sequence, gas, fee and timeout = reference block + 30, all in the intent section', async () => {95    const w = world();96    const p = await make().prepare({ ...req, memo: '104588310' });97    // fee = ceil(200 000 gas × 0.005 uatom × 1.5)98    expect(p).toEqual({ section: { x: X }, maxFee: 1500n, feeEstimate: 1500n, warnings: [], ttlSec: 90 });99    // every state read that feeds the signature was made on both sources, pinned to the reference block100    for (const path of [ACCOUNTS + FROM, ACCOUNTS + TO, `/cosmos/bank/v1beta1/spendable_balances/${FROM}/by_denom`, '/feemarket/v1/gas_price/uatom'])101      expect(w.calls.filter((c) => c.path === path && c.height === TIP).map((c) => c.base).sort(), path).toEqual([A, B].sort());102    expect(w.calls.filter((c) => c.path === BLOCKS + TIP).length).toBe(2);103    // the dry run is this exact transfer, without key and signature, on both sources; nothing is broadcast104    const sims = w.calls.filter((c) => c.path === SIM);105    expect(sims.map((c) => c.base).sort()).toEqual([A, B].sort());106    const d = decodeTxRaw(Uint8Array.from(Buffer.from(sims[0]!.body.tx_bytes, 'base64')));107    expect(d.body).toMatchObject({ memo: '104588310', timeoutHeight: BigInt(TIP + 30) });108    expect(d.authInfo.fee).toMatchObject({ amount: [{ denom: 'uatom', amount: '1500' }], gasLimit: 200000n });109    expect(d.authInfo.signerInfos[0]).toMatchObject({ publicKey: undefined, sequence: 7n });110    expect(d.signatures.map((s) => s.length)).toEqual([0]);111    expect(w.calls.some((c) => c.path === TXS && c.body)).toBe(false);112  });113  it('uses the lower tip as the reference block, the highest gas of the two simulations and the highest required price', async () => {114    const w = world({}, { [B]: { [SIM]: () => ({ body: { gas_info: { gas_used: '130001' } } }) } });115    w.state.tip[B] = TIP - 2;116    const p = await make().prepare(req);117    expect(p.section).toEqual({ x: { ...X, gas_limit: '260002', fee_amount: '1951', timeout_height: String(TIP - 2 + 30) } }); // ceil(260002 × 0.0075)118    expect(p.maxFee).toBe(1951n);119    // a node whose own mempool floor is above the consensus price: its floor is what gets a transaction accepted there120    world({}, { [B]: { '/cosmos/base/node/v1beta1/config': () => ({ body: { minimum_gas_price: '0.02uatom' } }) } });121    expect((await make().prepare(req)).maxFee).toBe(6000n);122    world({ '/feemarket/v1/gas_price/uatom': () => ({ body: { price: { denom: 'uatom', amount: '0.2' } } }) });123    expect(await code(make().prepare(req))).toBe('fee_too_high'); // 60 000 uatom > ceiling: refuse rather than underpay or overpay124    world({ [SIM]: () => ({ body: { gas_info: { gas_used: '600000' } } }) });125    expect(await code(make().prepare(req))).toBe('simulation_failed'); // not what a plain send costs126  });127  it('fails closed when a source is down or the sources disagree on anything that feeds the signature', async () => {128    for (const m of [LATEST, BLOCKS, ACCOUNTS, '/cosmos/bank/v1beta1/spendable_balances/', '/feemarket/v1/gas_price/uatom', '/cosmos/base/node/v1beta1/config', SIM, '/cosmos/base/tendermint/v1beta1/node_info']) {129      world({}, { [B]: { [m]: 'down' } });130      expect(await code(make().prepare(req)), m).toBe('rpc_degraded');131    }132    const w = world(); w.state.tip[A] = TIP + 9;133    expect(await code(make().prepare(req))).toBe('rpc_disagreement'); // too far apart134    world({}, { [B]: { [BLOCKS]: (r) => ({ body: { block_id: { hash: hashOf(r.path.endsWith('latest') ? TIP : 1) }, block: { header: { height: r.path.endsWith('latest') ? String(TIP) : r.path.split('/').pop(), chain_id: 'cosmoshub-4', time: new Date().toISOString() } } } }) } });135    expect(await code(make().prepare(req))).toBe('rpc_disagreement'); // different block at the reference height (fork)136    for (const lie of [base(FROM, '1425847', '8'), base(FROM, '1425848', '7')]) {137      world({}, { [B]: { [ACCOUNTS + FROM]: () => ({ body: lie }) } });138      expect(await code(make().prepare(req)), JSON.stringify(lie)).toBe('rpc_disagreement');139    }140    world({}, { [B]: { [ACCOUNTS + TO]: () => notFound() } });141    expect(await code(make().prepare(req))).toBe('rpc_disagreement'); // destination exists on one source only142    world({}, { [B]: { '/cosmos/bank/v1beta1/spendable_balances/': () => ({ body: { balance: { denom: 'uatom', amount: '30000001' } } }) } });143    expect(await code(make().prepare(req))).toBe('rpc_disagreement');144    world({}, { [B]: { '/feemarket/v1/gas_price/uatom': () => ({ body: { price: { denom: 'uatom', amount: '0.005000000000000001' } } }) } });145    expect(await code(make().prepare(req))).toBe('rpc_disagreement');146    world({}, { [B]: { [ACCOUNTS]: (r) => ({ body: base(FROM, '1425847', '7'), height: TIP - 3 }) } });147    expect(await code(make().prepare(req))).toBe('rpc_degraded'); // answered from another height than the one asked148    world({}, { [B]: { [LATEST]: () => ({ body: { block_id: { hash: hashOf(TIP) }, block: { header: { height: String(TIP), chain_id: 'cosmoshub-5', time: new Date().toISOString() } } } }) } });149    expect(await code(make().prepare(req))).toBe('rpc_degraded'); // a source on another chain150    for (const bad of [{ balance: { denom: 'uatom', amount: '-5' } }, { balance: { denom: 'ibc/ABC', amount: '5' } }, {}]) {151      world({ '/cosmos/bank/v1beta1/spendable_balances/': () => ({ body: bad }) });152      expect(await code(make().prepare(req))).toBe('rpc_degraded');153    }154    world({ '/feemarket/v1/gas_price/uatom': () => ({ body: { price: { denom: 'uatom', amount: '0' } } }), '/cosmos/base/node/v1beta1/config': () => ({ body: { minimum_gas_price: '' } }) });155    expect(await code(make().prepare(req))).toBe('rpc_degraded'); // a zero price is not believable156    world({ [SIM]: () => ({ status: 502, body: 'bad gateway' }) });157    expect(await code(make().prepare(req))).toBe('rpc_degraded');158  });159  it('refuses what the chain would refuse, before anything is signed', async () => {160    world();161    expect(await code(make().prepare({ ...req, asset: 'ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2' }))).toBe('unsupported_asset');162    expect(await code(make().prepare({ ...req, amount: 0n }))).toBe('bad_amount');163    expect(await code(make().prepare({ ...req, memo: ' 104588310' }))).toBe('bad_memo');164    expect(await code(make().prepare({ ...req, memo: '' }))).toBe('bad_memo');165    expect(await code(make().prepare({ ...req, to: FROM }))).toBe('bad_address');166    expect(await code(make().prepare({ ...req, to: 'osmo19rl4cm2hmr8afy4kldpxz3fka4jguq0a5m7df8' }))).toBe('bad_address');167    expect(await code(make().prepare({ ...req, to: MODULE }))).toBe('bad_destination');168    expect(await code(make().prepare({ ...req, from: NEW }))).toBe('insufficient_funds'); // account not on chain yet169    expect(await code(make().prepare({ ...req, from: MODULE, to: TO }))).toBe('unsupported_account');170    expect(await code(make().prepare({ ...req, to: NEW }))).toBe('ok'); // the transfer creates the destination account171    expect(await code(make().prepare({ ...req, amount: 30_000_000n - 1500n }))).toBe('ok'); // exactly the balance172    expect(await code(make().prepare({ ...req, amount: 30_000_000n - 1499n }))).toBe('insufficient_funds');173    world({ [SIM]: () => txError('account sequence mismatch, expected 8, got 7: incorrect account sequence [cosmos/cosmos-sdk@v0.53.8/x/auth/ante/sigverify.go:370] with gas used: \'13750\'') });174    expect(await code(make().prepare(req))).toBe('pending_tx');175    world({}, { [B]: { [SIM]: () => txError('failed to execute message; message index: 0: spendable balance 3uatom is smaller than 2000000uatom: insufficient funds') } });176    expect(await code(make().prepare(req))).toBe('simulation_failed'); // one source's verdict is enough to refuse177  });178});179180describe('cosmos adapter: tracking', () => {181  const tracked = (x: Record<string, string> = X) => make().track({ txid: TXID, intent: { from: FROM, x }, blockRef: null, broadcastAt: 0 });182  const found = (c: number, extra: object = {}): Handler => () => ({ body: { tx_response: { txhash: TXID, height: String(TIP + 3), code: c, raw_log: c ? 'out of gas' : '', ...extra } } });183  it('a transaction in a block with code 0 is final; any other code is a failure', async () => {184    world({ [TXS + '/']: found(0) });185    expect(await tracked()).toEqual({ state: 'finalized', confirmations: 1, blockRef: String(TIP + 3) });186    world({}, { [A]: { [TXS + '/']: 'down' }, [B]: { [TXS + '/']: found(0) } });187    expect(await tracked()).toMatchObject({ state: 'finalized' });188    world({ [TXS + '/']: found(11, { codespace: 'sdk' }) });189    expect(await tracked()).toMatchObject({ state: 'failed', blockRef: String(TIP + 3), error: expect.stringMatching(/code 11 sdk: out of gas.*only the network fee/) });190    world({ [TXS + '/']: () => ({ body: { tx_response: { txhash: 'CD'.repeat(32), height: '5', code: 0 } } }) });191    expect(await tracked()).toMatchObject({ state: 'broadcast' }); // an answer about another transaction proves nothing192  });193  it('declares it dead only past the timeout height, on both sources, with the sequence still unused', async () => {194    let w = world();195    expect(await tracked()).toEqual({ state: 'broadcast', confirmations: 0, rebroadcast: true }); // window still open196    w = world(); w.state.tip[A] = TIP + 31; w.state.tip[B] = TIP + 30;197    expect(await tracked()).toMatchObject({ state: 'broadcast' }); // one source has not passed the timeout yet198    w = world(); w.state.tip[A] = TIP + 31; w.state.tip[B] = TIP + 31;199    expect(await tracked()).toEqual({ state: 'failed', confirmations: 0, error: 'expired before inclusion (timeout height passed); no funds moved' });200    w = world({}, { [B]: { [TXS + '/']: 'down' } }); w.state.tip[A] = TIP + 99; w.state.tip[B] = TIP + 99;201    expect(await tracked()).toMatchObject({ state: 'broadcast' }); // one source cannot say: never "failed" on half the evidence202    w = world({}, { [B]: { [LATEST]: 'down' } }); w.state.tip[A] = TIP + 99;203    expect(await tracked()).toMatchObject({ state: 'broadcast' });204    // sequence consumed although neither index knows the hash: not provably ours → wait, then "replaced", never "failed"205    w = world(); w.state.tip[A] = TIP + 35; w.state.tip[B] = TIP + 35; w.state.accounts[FROM] = base(FROM, '1425847', '8');206    expect(await tracked()).toEqual({ state: 'broadcast', confirmations: 0 });207    w = world(); w.state.tip[A] = TIP + 51; w.state.tip[B] = TIP + 51; w.state.accounts[FROM] = base(FROM, '1425847', '8');208    expect(await tracked()).toEqual({ state: 'replaced', confirmations: 0, error: 'the sequence was consumed by another transaction' });209    w = world({}, { [B]: { [ACCOUNTS]: 'down' } }); w.state.tip[A] = TIP + 99; w.state.tip[B] = TIP + 99;210    expect(await tracked()).toEqual({ state: 'broadcast', confirmations: 0 });211  });212});213214describe('cosmos adapter: broadcast, balances, deposits', () => {215  it('broadcasts to every source; success on one is enough, "already known" and "already in a block" are success', async () => {216    let w = world();217    await make().broadcast(RAW);218    expect(w.calls.filter((c) => c.path === TXS && c.body).map((c) => [c.base, c.body.tx_bytes, c.body.mode])).toEqual([[A, RAW, 'BROADCAST_MODE_SYNC'], [B, RAW, 'BROADCAST_MODE_SYNC']]);219    world({}, { [A]: { [TXS]: 'down' } });220    await make().broadcast(RAW);221    world({ [TXS]: () => ({ body: { tx_response: { code: 19, codespace: 'sdk', raw_log: 'tx already in mempool' } } }) });222    await make().broadcast(RAW);223    w = world({ [TXS]: () => ({ body: { tx_response: { code: 32, codespace: 'sdk', raw_log: 'account sequence mismatch, expected 8, got 7' } } }) });224    await expect(make().broadcast(RAW)).rejects.toThrow(/code 32 \(sdk\): account sequence mismatch/);225    world({ [TXS]: () => ({ body: { tx_response: { code: 32, codespace: 'sdk', raw_log: 'account sequence mismatch' } } }), [TXS + '/']: () => ({ body: { tx_response: { txhash: TXID, height: '5', code: 0 } } }) });226    await make().broadcast(RAW); // rejected because this very transaction is already in a block227    world({ [TXS]: () => ({ body: { tx_response: { code: 0, txhash: 'EF'.repeat(32) } } }) });228    await expect(make().broadcast(RAW)).rejects.toThrow(); // a source acknowledging another hash did not take ours229    world({ [TXS]: () => ({ body: { tx_response: { code: 13, codespace: 'sdk', raw_log: 'insufficient fee' } } }) });230    await expect(make().broadcast(RAW)).rejects.toThrow(/insufficient fee/);231    await expect(make().broadcast('not base64!')).rejects.toThrow(/invalid transaction payload/);232  });233  it('reads the spendable native balance with failover; an account not on chain reads as zero', async () => {234    world({}, { [A]: { '/cosmos/bank/v1beta1/spendable_balances/': 'down' } });235    expect(await make().balances(FROM)).toEqual({ native: 30_000_000n });236    expect(await make().balances(NEW)).toEqual({ native: 0n });237    await expect(make().balances('cosmos1xyz')).rejects.toThrow(/invalid address/);238  });239  it('lists incoming native transfers from committed, successful transactions only', async () => {240    const ev = (recipient: string, sender: string, amount: string) => ({ type: 'transfer', attributes: [{ key: 'recipient', value: recipient }, { key: 'sender', value: sender }, { key: 'amount', value: amount }] });241    const tx = (height: number, hash: string, c: number, events: unknown[]) => ({ height: String(height), txhash: hash, code: c, timestamp: '2026-11-11T00:21:53Z', events });242    const list = [243      tx(TIP - 1, 'A1'.repeat(32), 0, [ev(MODULE, TO, '700uatom'), ev(FROM, TO, '11952053uatom')]), // fee payment, then the send244      tx(TIP - 2, 'A2'.repeat(32), 0, [ev(FROM, TO, '5ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2,42uatom'), ev(FROM, MODULE, '8uatom')]), // mixed coins; two credits in one transaction245      tx(TIP - 3, 'A3'.repeat(32), 5, [ev(FROM, TO, '999uatom')]), // failed transaction246      tx(TIP - 4, 'A4'.repeat(32), 0, [ev(FROM, FROM, '1uatom'), ev(TO, FROM, '77uatom'), ev(FROM, TO, '9xuatom'), ev(FROM, TO, '0uatom')]), // self, outgoing, another denom, zero247      tx(TIP - 500, 'A5'.repeat(32), 0, [ev(FROM, TO, '3uatom')]),248      tx(TIP + 7, 'A6'.repeat(32), 0, [ev(FROM, TO, '4uatom')]), // "above the tip": not believable249    ];250    const w = world({ [TXS]: (r) => ({ body: { tx_responses: r.query.get('page') === '1' ? list : [], total: String(list.length) } }) });251    const first = await make().deposits(FROM, null);252    expect(first.cursor).toBe(TIP);253    expect(first.finalizedHeight).toBe(TIP);254    expect(first.items).toEqual([255      { txid: 'A1'.repeat(32), idx: 1, asset: 'native', amount: 11952053n, from: TO, block: TIP - 1, time: 1794356513 },256      { txid: 'A2'.repeat(32), idx: 0, asset: 'native', amount: 42n, from: TO, block: TIP - 2, time: 1794356513 },257      { txid: 'A2'.repeat(32), idx: 1, asset: 'native', amount: 8n, from: MODULE, block: TIP - 2, time: 1794356513 },258      { txid: 'A5'.repeat(32), idx: 0, asset: 'native', amount: 3n, from: TO, block: TIP - 500, time: 1794356513 },259    ]);260    const q = w.calls.find((c) => c.path === TXS)!.query;261    expect([q.get('query'), q.get('order_by'), q.get('page'), q.get('limit')]).toEqual([`transfer.recipient='${FROM}'`, 'ORDER_BY_DESC', '1', '50']);262    expect((await make().deposits(FROM, TIP - 2)).items.map((d) => d.txid)).toEqual(['A1'.repeat(32)]); // only what is above the cursor263    expect(await make().deposits(FROM, TIP)).toEqual({ items: [], cursor: TIP, finalizedHeight: TIP });264  });265});266
Files main
Menu

Theme