SPB Git

Repository Public

aptos.test.ts 27.8 KB 350 lines typescript

1// Aptos adapter against two scripted REST nodes (no network): agreement rules, simulation of the exact transaction, fail-closed2// paths, tracking, broadcast and deposits. The scripted nodes decode the simulated transaction with the official SDK.3import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';4import { Deserializer, RawTransaction, type TransactionPayloadEntryFunction } from '@aptos-labs/ts-sdk';5import { aptosAptStore, aptosRawBytes, aptosTxid, buildAptosTx, NETWORKS, pluginFor, toHex, type Intent } from '@spw/core';6import { deriveKeys } from '../../isolated-signer/src/keys.ts';7import type { Adapter } from '../src/chains/rpc.ts';89const n = NETWORKS.aptos, [A, B] = n.rpc.map((u) => new URL(u).origin) as [string, string], ARCH = 'https://archive.mainnet.aptoslabs.com';10const keys = deriveKeys('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about');11const ME = keys.addresses.aptos!, DEST = '0x' + '5a'.repeat(32), NEW = '0x' + '6b'.repeat(32), LOWZ = '0x0' + '7c'.repeat(31) + '7';12const NOW = 1_800_000_000, GENESIS = '0xf650d76ea0a3176f0412b7e6bea5eb6fbf1d7adb0c39ab18e378adefb9247309';13/** the REST API prints addresses without leading zeros */14const short = (a: string) => '0x' + (a.slice(2).replace(/^0+/, '') || '0');1516interface Sim { success: boolean; vm_status: string; gas_used: string; events?: any[]; patch?: Record<string, unknown> }17interface Fake {18  down: boolean; chainId: number; onChainId: number; version: number; height: number; epoch: number; timeSec: number; blockHash: string; genesis: string; gas: number;19  accounts: Record<string, { sequence_number: string; authentication_key: string }>; balances: Record<string, string>; hasStore: Set<string>;20  sim: (f: { sender: string; to: string; amount: bigint; maxGas: bigint }) => Sim; sims: string[]; versions: string[];21  txs: Record<string, any>; byVersion: Record<number, any>; pruned: Set<number>; submit: (hex: string) => { status: number; body: any }; posts: string[]; seen: string[];22  index: { last: number; rows: number[]; fail?: boolean; queries: string[] };23}24const fresh = (): Fake => ({25  down: false, chainId: 1, onChainId: 1, version: 8_000_000_500, height: 1_176_000_000, epoch: 17_960, timeSec: NOW - 1, blockHash: '0x' + 'b1'.repeat(32), genesis: GENESIS, gas: 100,26  accounts: { [ME]: { sequence_number: '7', authentication_key: ME } }, balances: { [ME]: '500000000' }, hasStore: new Set([DEST, LOWZ]),27  sim: () => ({ success: true, vm_status: 'Executed successfully', gas_used: '' }), sims: [], versions: [],28  txs: {}, byVersion: {}, pruned: new Set(), submit: () => ({ status: 202, body: {} }), posts: [], seen: [],29  index: { last: 8_000_000_400, rows: [], queries: [] },30});31let h: Record<string, Fake>;3233async function fakeFetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {34  const url = new URL(String(input)), f = h[url.origin];35  const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });36  const err = (status: number, error_code: string) => json(status, { message: error_code, error_code, vm_error_code: null });37  if (!f || f.down) throw new TypeError('fetch failed');38  const p = url.pathname.replace(/^\/v1/, '') || '/';39  f.seen.push(p);40  const lv = url.searchParams.get('ledger_version');41  if (lv !== null) f.versions.push(lv);42  if (init?.method === 'POST') {43    if (p === '/graphql') {44      const q = JSON.parse(String(init.body)).query as string;45      f.index.queries.push(q);46      if (f.index.fail) return json(200, { errors: [{ message: 'rate limit' }] });47      const gt = Number(/transaction_version:\{_gt:(\d+)\}/.exec(q)?.[1] ?? -1), limit = Number(/limit:(\d+)/.exec(q)![1]), desc = /transaction_version:desc/.test(q);48      const rows = f.index.rows.filter((v) => v > gt).sort((x, y) => (desc ? y - x : x - y)).slice(0, limit);49      return json(200, { data: { processor_status: [{ last_success_version: f.index.last }], fungible_asset_activities: rows.map((v) => ({ transaction_version: v })) } });50    }51    if (p === '/view') { const b = JSON.parse(String(init.body)); return json(200, [f.balances[b.arguments[0]] ?? '0']); }52    const body = new Uint8Array(init.body as Uint8Array), hex = toHex(body);53    if (p === '/transactions') { f.posts.push('0x' + hex); const r = f.submit('0x' + hex); return json(r.status, r.body); }54    if (p === '/transactions/simulate') {55      f.sims.push(hex);56      const d = new Deserializer(body), tx = RawTransaction.deserialize(d);57      if (toHex(d.deserializeFixedBytes(d.remaining())) !== '0404') return err(400, 'invalid_input'); // only the keyless simulation authenticator58      const ef = (tx.payload as TransactionPayloadEntryFunction).entryFunction, sender = tx.sender.toStringLong();59      const to = '0x' + toHex(ef.args[0]!.bcsToBytes()), amount = Buffer.from(ef.args[1]!.bcsToBytes()).readBigUInt64LE();60      const s = f.sim({ sender, to, amount, maxGas: tx.max_gas_amount });61      const events = s.events ?? [62        { type: '0x1::fungible_asset::Withdraw', data: { amount: amount.toString(), store: short(aptosAptStore(sender)) } },63        { type: '0x1::fungible_asset::Deposit', data: { amount: amount.toString(), store: short(aptosAptStore(to)) } },64        { type: '0x1::transaction_fee::FeeStatement', data: { total_charge_gas_units: '0' } },65      ];66      return json(200, [{ type: 'user_transaction', hash: aptosTxid(body), success: s.success, vm_status: s.vm_status, gas_used: s.gas_used || (f.hasStore.has(to) ? '62' : '5414'), sender: short(sender),67        sequence_number: tx.sequence_number.toString(), max_gas_amount: tx.max_gas_amount.toString(), gas_unit_price: tx.gas_unit_price.toString(), expiration_timestamp_secs: tx.expiration_timestamp_secs.toString(),68        payload: { type: 'entry_function_payload', function: `${ef.module_name.address.toString()}::${ef.module_name.name.identifier}::${ef.function_name.identifier}`, type_arguments: [], arguments: [short(to), amount.toString()] },69        events: s.success ? events : [], ...s.patch }]);70    }71    return json(500, {});72  }73  if (p === '/') return json(200, { chain_id: f.chainId, epoch: String(f.epoch), ledger_version: String(f.version), ledger_timestamp: String(f.timeSec * 1_000_000 + 123), block_height: String(f.height), node_role: 'full_node' });74  if (p === '/estimate_gas_price') return json(200, { gas_estimate: f.gas, deprioritized_gas_estimate: 100, prioritized_gas_estimate: 150 });75  if (p === '/accounts/0x1/resource/0x1::chain_id::ChainId') return json(200, { type: '0x1::chain_id::ChainId', data: { id: f.onChainId } });76  let m = /^\/accounts\/(0x[0-9a-f]{64})$/.exec(p);77  if (m) return json(200, f.accounts[m[1]!] ?? { sequence_number: '0', authentication_key: m[1] });78  m = /^\/transactions\/by_hash\/(0x[0-9a-f]{64})$/.exec(p);79  if (m) return f.txs[m[1]!] ? json(200, f.txs[m[1]!]) : err(404, 'transaction_not_found');80  m = /^\/transactions\/by_version\/(\d+)$/.exec(p);81  if (m) { const v = Number(m[1]); return f.pruned.has(v) ? err(410, 'version_pruned') : v === 0 ? json(200, { version: '0', hash: f.genesis, type: 'genesis_transaction' }) : f.byVersion[v] ? json(200, f.byVersion[v]) : err(404, 'transaction_not_found'); }82  m = /^\/blocks\/by_height\/(\d+)$/.exec(p);83  if (m) return json(200, { block_height: m[1], block_hash: f.blockHash, block_timestamp: '1799999999000000', first_version: '8000000100', last_version: '8000000110' });84  return json(500, {});85}8687let a: Adapter, make: () => Adapter;88beforeAll(async () => {89  Object.assign(process.env, { SPW_ORIGIN: 'https://wallet.test', SPW_RP_ID: 'wallet.test', DATABASE_URL: 'postgres:///unused', SPW_SIGNER_SECRET: 'k'.repeat(48) });90  vi.useFakeTimers({ toFake: ['Date'] });91  vi.setSystemTime(NOW * 1000);92  vi.stubGlobal('fetch', fakeFetch);93  const { aptosAdapter } = await import('../src/chains/aptos.ts');94  make = () => aptosAdapter(n);95  a = make();96});97afterAll(() => { vi.unstubAllGlobals(); vi.useRealTimers(); });98beforeEach(() => { h = { [A]: fresh(), [B]: fresh() }; vi.setSystemTime(NOW * 1000); });99100const prep = (over: Partial<{ to: string; amount: bigint; memo: string; asset: string; from: string }> = {}) => a.prepare({ asset: 'native', from: ME, to: DEST, amount: 10_000_000n, ...over });101const code = async (p: Promise<unknown>) => { try { await p; } catch (e) { return (e as { code?: string }).code ?? `threw: ${(e as Error).message}`; } return 'ok'; };102const intentOf = (r: Awaited<ReturnType<typeof prep>>, to = DEST, amount = '10000000'): Intent => ({ v: 1, proposalId: '6f1c1f0e-8f5f-4b1e-9c56-0d6b5f0f1a11', keyId: keys.keyId, network: 'aptos', chainRef: n.chainRef,103  asset: 'native', symbol: 'APT', decimals: 8, amount, from: ME, to, maxFee: r.maxFee.toString(), createdAt: NOW, expiresAt: NOW + r.ttlSec, policyVersion: 4, ...(r.section as { x: Record<string, string> }) });104105describe('aptos adapter: prepare', () => {106  it('agrees on both sources at one ledger version and simulates the exact transaction the signer builds', async () => {107    h[B]!.version = 8_000_000_460; h[B]!.gas = 150; // sources a few versions apart, different gas estimates: still fine108    const r = await prep();109    expect(r.section).toEqual({ x: { sequence_number: '7', max_gas_amount: '200', gas_unit_price: '150', expiration_timestamp_secs: String(NOW + 180) } });110    expect([r.maxFee, r.feeEstimate, r.ttlSec, r.warnings]).toEqual([30_000n, 9_300n, 120, []]);111    // account and balance of both sources were read at the lower of the two ledger versions112    for (const u of [A, B]) expect(new Set(h[u]!.versions)).toEqual(new Set(['8000000460']));113    // the last simulation on each source is the transaction the signer's plugin rebuilds from the approved intent114    const intent = intentOf(r), built = buildAptosTx(intent, n);115    for (const u of [A, B]) { expect(h[u]!.sims).toHaveLength(2); expect(h[u]!.sims[1]).toBe(toHex(built.rawTx) + '0404'); }116    expect(() => pluginFor(n).build(intent, n)).not.toThrow();117    expect(built.maxFee).toBe(r.maxFee);118  });119  it('sizes the gas limit from the simulation (a recipient without an APT store costs a storage fee)', async () => {120    const r = await prep({ to: NEW });121    expect((r.section as any).x.max_gas_amount).toBe('8122');122    expect([r.maxFee, r.feeEstimate]).toEqual([812_200n, 541_400n]);123    expect(h[A]!.sims[1]).toBe(toHex(buildAptosTx(intentOf(r, NEW), n).rawTx) + '0404');124    // addresses the node prints without leading zeros are compared in canonical form125    expect(await code(prep({ to: LOWZ }))).toBe('ok');126  });127  it('gas price is floored at the network minimum and capped', async () => {128    for (const u of [A, B]) h[u]!.gas = 1;129    expect(((await prep()).section as any).x.gas_unit_price).toBe('100');130    h[A]!.gas = 5000;131    const r = await prep();132    expect([(r.section as any).x.gas_unit_price, r.warnings]).toEqual(['1000', ['network_congested']]);133    for (const u of [A, B]) h[u]!.sim = () => ({ success: true, vm_status: 'Executed successfully', gas_used: '9000' });134    expect(await code(prep())).toBe('fee_too_high'); // 13 501 units × 1 000 octas is above the 0.1 APT limit135  });136  it('fails closed when a source is down, is another chain, answers oddly or disagrees', async () => {137    h[B]!.down = true; expect(await code(prep())).toBe('rpc_degraded');138    h[B] = { ...fresh(), chainId: 2 }; expect(await code(prep())).toBe('rpc_degraded');139    h[B] = { ...fresh(), height: 1_174_999_999 }; expect(await code(prep())).toBe('rpc_degraded'); // behind the pinned checkpoint140    h[B] = fresh(); h[B]!.accounts[ME] = { sequence_number: '8', authentication_key: ME }; expect(await code(prep())).toBe('rpc_disagreement');141    h[B] = fresh(); h[B]!.balances[ME] = '499999999'; expect(await code(prep())).toBe('rpc_disagreement');142    h[B] = fresh(); h[B]!.accounts[ME] = { sequence_number: '7x', authentication_key: ME }; expect(await code(prep())).toBe('rpc_degraded');143    h[B] = fresh(); h[B]!.timeSec = NOW - 61; expect(await code(prep())).toBe('rpc_degraded'); // stale source or wrong local clock144    h[B] = fresh(); h[B]!.gas = -1; expect(await code(prep())).toBe('rpc_degraded');145    h[B] = fresh(); expect(await code(prep())).toBe('ok');146  });147  it('refuses when a simulation fails, is for another transaction, or does not credit the recipient exactly', async () => {148    const ok = { success: true, vm_status: 'Executed successfully', gas_used: '62' };149    h[B]!.sim = () => ({ success: false, vm_status: 'Move abort in 0x1::object: 0x60001', gas_used: '3' }); expect(await code(prep())).toBe('simulation_failed');150    h[B]!.sim = () => ({ success: false, vm_status: 'INSUFFICIENT_BALANCE_FOR_TRANSACTION_FEE', gas_used: '0' }); expect(await code(prep())).toBe('insufficient_funds');151    h[B]!.sim = () => ({ success: false, vm_status: 'SEQUENCE_NUMBER_TOO_OLD', gas_used: '0' }); expect(await code(prep())).toBe('pending_tx');152    h[B]!.sim = () => ({ ...ok, events: [] }); expect(await code(prep())).toBe('simulation_failed');153    h[B]!.sim = ({ to, amount }) => ({ ...ok, events: [{ type: '0x1::fungible_asset::Deposit', data: { amount: (amount - 1n).toString(), store: aptosAptStore(to) } }] }); expect(await code(prep())).toBe('simulation_failed');154    h[B]!.sim = ({ amount }) => ({ ...ok, events: [{ type: '0x1::fungible_asset::Deposit', data: { amount: amount.toString(), store: aptosAptStore(NEW) } }] }); expect(await code(prep())).toBe('simulation_failed');155    h[B]!.sim = ({ to, amount }) => ({ ...ok, events: [{ type: '0x1::coin::CoinDeposit', data: { amount: amount.toString(), account: to, coin_type: '0x1::aptos_coin::AptosCoin' } }] }); expect(await code(prep())).toBe('ok'); // legacy CoinStore recipient156    h[B]!.sim = ({ to, amount }) => ({ ...ok, events: [{ type: '0x1::coin::CoinDeposit', data: { amount: amount.toString(), account: to, coin_type: '0x1::other::Coin' } }] }); expect(await code(prep())).toBe('simulation_failed');157    h[B]!.sim = () => ({ ...ok, patch: { sequence_number: '8' } }); expect(await code(prep())).toBe('rpc_disagreement');158    h[B]!.sim = () => ({ ...ok, patch: { payload: { function: '0x1::coin::transfer', type_arguments: [], arguments: [DEST, '10000000'] } } }); expect(await code(prep())).toBe('rpc_disagreement');159    h[B]!.sim = () => ({ ...ok, patch: { hash: '0x' + 'cd'.repeat(32) } }); expect(await code(prep())).toBe('rpc_degraded');160    h[B]!.sim = ({ maxGas }) => ({ ...ok, gas_used: (maxGas + 1n).toString() }); expect(await code(prep())).toBe('rpc_degraded');161    h[B]!.sim = () => ok; expect(await code(prep())).toBe('ok');162  });163  it('checks the balance covers the amount plus the highest possible fee', async () => {164    const set = (v: string) => { for (const u of [A, B]) h[u]!.balances[ME] = v; };165    set('10020000'); expect(await code(prep())).toBe('ok'); // 0.1 APT + 200 units × 100 octas166    set('10019999'); expect(await code(prep())).toBe('insufficient_funds');167    set('10000000'); expect(await code(prep())).toBe('insufficient_funds');168    set('0'); expect(await code(prep())).toBe('insufficient_funds');169    expect(h[A]!.sims).toHaveLength(3); // nothing is simulated once the balance cannot even cover the amount170  });171  it('refuses rotated keys, tokens, memos and bad addresses', async () => {172    for (const u of [A, B]) h[u]!.accounts[ME] = { sequence_number: '7', authentication_key: DEST };173    expect(await code(prep())).toBe('unsupported_account');174    for (const u of [A, B]) h[u]!.accounts[ME] = { sequence_number: '7', authentication_key: ME };175    expect(await code(prep({ asset: '0x1::aptos_coin::AptosCoin' }))).toBe('unsupported_asset');176    expect(await code(prep({ memo: '12345' }))).toBe('bad_memo');177    expect(await code(prep({ to: ME }))).toBe('bad_address');178    expect(await code(prep({ to: '0x' + DEST.slice(2).toUpperCase() }))).toBe('bad_address');179    expect(await code(prep({ to: '0x1' }))).toBe('bad_address');180    expect(await code(prep({ to: '0x' + '0'.repeat(63) + 'a' }))).toBe('bad_address');181    expect(await code(prep({ amount: 0n }))).toBe('bad_amount');182    expect(await code(prep({ amount: 1n << 64n }))).toBe('bad_amount');183  });184});185186describe('aptos adapter: health and identity', () => {187  it('ok only with both sources up, chain id 1 on-chain and the same block on both', async () => {188    expect(await make().health()).toMatchObject({ ok: true, readable: true, identityVerified: true, height: 1_176_000_000 });189    h[B]!.blockHash = '0x' + 'b2'.repeat(32);190    expect(await make().health()).toMatchObject({ ok: false, readable: true, identityVerified: false });191    h[B] = { ...fresh(), onChainId: 2 }; expect((await make().health()).ok).toBe(false);192    h[B] = { ...fresh(), chainId: 2 }; expect(await make().health()).toMatchObject({ ok: false, readable: true });193    h[B] = { ...fresh(), timeSec: NOW - 121 }; expect((await make().health()).ok).toBe(false); // stale194    h[B] = { ...fresh(), down: true }; expect(await make().health()).toMatchObject({ ok: false, readable: true, identityVerified: false });195    h[A]!.down = true; expect(await make().health()).toMatchObject({ ok: false, readable: false, height: null });196  });197  it('when the archive node answers, it must show the pinned genesis and the same block', async () => {198    h[ARCH] = fresh(); expect((await make().health()).ok).toBe(true);199    h[ARCH] = { ...fresh(), genesis: '0x' + 'ee'.repeat(32) }; expect(await make().health()).toMatchObject({ ok: false, identityVerified: false });200    h[ARCH] = { ...fresh(), blockHash: '0x' + 'b3'.repeat(32) }; expect((await make().health()).ok).toBe(false);201  });202  it('re-verifies the identity periodically and withdraws it when the check fails', async () => {203    const ad = make();204    expect((await ad.health()).ok).toBe(true);205    h[B]!.blockHash = '0x' + 'b2'.repeat(32);206    expect((await ad.health()).ok).toBe(true); // within the re-check interval207    vi.setSystemTime((NOW + 601) * 1000);208    for (const u of [A, B]) h[u]!.timeSec = NOW + 600;209    expect(await ad.health()).toMatchObject({ ok: false, identityVerified: false });210  });211  it('reads a balance from any source', async () => {212    h[A]!.down = true;213    expect(await a.balances(ME)).toEqual({ native: 500_000_000n });214    expect(await a.balances(NEW)).toEqual({ native: 0n });215    await expect(a.balances('0x1')).rejects.toThrow(/Aptos address/);216  });217});218219describe('aptos adapter: track', () => {220  const txid = '0x' + 'ab'.repeat(32), exp = NOW + 100;221  const track = () => a.track({ txid, intent: { x: { expiration_timestamp_secs: String(exp) } }, blockRef: null, broadcastAt: NOW });222  const rec = (success: boolean, version = '8000000300') => ({ type: 'user_transaction', hash: txid, success, version, vm_status: success ? 'Executed successfully' : 'Move abort' });223  it('finalized only when every reachable source shows a successful commit', async () => {224    h[A]!.txs[txid] = rec(true);225    expect(await track()).toEqual({ state: 'confirming', confirmations: 1, blockRef: '8000000300' }); // the other source has not caught up226    h[B]!.txs[txid] = { type: 'pending_transaction', hash: txid };227    expect((await track()).state).toBe('confirming');228    h[B]!.txs[txid] = rec(true);229    expect(await track()).toEqual({ state: 'finalized', confirmations: 201, blockRef: '8000000300' });230    h[B]!.down = true;231    expect((await track()).state).toBe('finalized');232    h[B] = fresh(); h[B]!.txs[txid] = rec(true, '8000000299');233    await expect(track()).rejects.toThrow(/disagree/);234    h[B]!.txs[txid] = { ...rec(true), hash: '0x' + 'cd'.repeat(32) };235    await expect(track()).rejects.toThrow(/bad transaction record/);236  });237  it('an on-chain failure is reported only when both sources agree', async () => {238    h[A]!.txs[txid] = rec(false); h[B]!.txs[txid] = rec(false);239    expect(await track()).toMatchObject({ state: 'failed', blockRef: '8000000300' });240    h[B]!.down = true;241    expect((await track()).state).toBe('confirming');242    h[B] = fresh(); h[B]!.txs[txid] = rec(true);243    await expect(track()).rejects.toThrow(/disagree/);244  });245  it('a hash alone is never success; expiry needs both sources past the expiration', async () => {246    expect(await track()).toEqual({ state: 'broadcast', confirmations: 0, rebroadcast: true });247    h[A]!.txs[txid] = { type: 'pending_transaction', hash: txid };248    expect(await track()).toEqual({ state: 'broadcast', confirmations: 0, rebroadcast: true }); // in a mempool is not committed249    for (const u of [A, B]) h[u]!.timeSec = exp + 21;250    vi.setSystemTime((exp + 25) * 1000);251    expect(await track()).toEqual({ state: 'broadcast', confirmations: 0, rebroadcast: false }); // still pending on one source: not failed252    delete h[A]!.txs[txid];253    h[A]!.timeSec = exp + 20; // exactly at the margin: not yet254    expect((await track()).state).toBe('broadcast');255    h[A]!.timeSec = exp + 21;256    expect(await track()).toMatchObject({ state: 'failed', confirmations: 0 });257    h[B]!.down = true;258    expect((await track()).state).toBe('broadcast'); // never "failed" on a single source259    h[B] = fresh(); h[B]!.timeSec = exp + 30; h[B]!.txs[txid] = rec(true);260    expect((await track()).state).toBe('confirming'); // committed after all on a source that was behind261    // every source reads its ledger position before looking the transaction up262    for (const u of [A, B]) expect(h[u]!.seen.slice(-2)).toEqual(['/', `/transactions/by_hash/${txid}`]);263    await expect(a.track({ txid, intent: { evm: {} }, blockRef: null, broadcastAt: NOW })).rejects.toThrow(/expiration/);264    await expect(a.track({ txid: 'ab'.repeat(32), intent: { x: { expiration_timestamp_secs: String(exp) } }, blockRef: null, broadcastAt: NOW })).rejects.toThrow(/transaction id/);265  });266});267268describe('aptos adapter: broadcast', () => {269  const intent: Intent = { v: 1, proposalId: '6f1c1f0e-8f5f-4b1e-9c56-0d6b5f0f1a11', keyId: keys.keyId, network: 'aptos', chainRef: n.chainRef, asset: 'native', symbol: 'APT', decimals: 8, amount: '10000000',270    from: ME, to: DEST, maxFee: '20000', createdAt: NOW, expiresAt: NOW + 120, policyVersion: 4, x: { sequence_number: '7', max_gas_amount: '200', gas_unit_price: '100', expiration_timestamp_secs: String(NOW + 180) } };271  const signed = pluginFor(n).sign(intent, n, keys.handle(n));272  const accepted = { status: 202, body: { hash: signed.txid, type: 'pending_transaction' } };273  const tooOld = { status: 400, body: { message: 'Invalid transaction: Type: Validation Code: SEQUENCE_NUMBER_TOO_OLD', error_code: 'vm_error', vm_error_code: 3 } };274  it('sends the exact signed bytes to every source; one acceptance is enough', async () => {275    for (const u of [A, B]) h[u]!.submit = () => accepted;276    await a.broadcast(signed.raw);277    expect([h[A]!.posts, h[B]!.posts]).toEqual([[signed.raw], [signed.raw]]);278    expect(aptosTxid(aptosRawBytes(h[A]!.posts[0]!))).toBe(signed.txid);279    h[A]!.down = true;280    await a.broadcast(signed.raw);281    h[A] = fresh(); h[A]!.submit = () => tooOld; h[B]!.submit = () => accepted;282    await a.broadcast(signed.raw);283  });284  it('a refusal is an error unless a source already knows the transaction', async () => {285    for (const u of [A, B]) h[u]!.submit = () => tooOld;286    await expect(a.broadcast(signed.raw)).rejects.toThrow(/SEQUENCE_NUMBER_TOO_OLD/);287    h[B]!.txs[signed.txid] = { type: 'user_transaction', hash: signed.txid, success: true, version: '8000000300' };288    await a.broadcast(signed.raw); // already known289    h[B]!.txs = {};290    for (const u of [A, B]) h[u]!.submit = () => ({ status: 202, body: { hash: '0x' + 'cd'.repeat(32) } });291    await expect(a.broadcast(signed.raw)).rejects.toThrow(/another transaction/);292    for (const u of [A, B]) h[u]!.submit = () => ({ status: 507, body: { message: 'Mempool is full', error_code: 'mempool_is_full' } });293    await expect(a.broadcast(signed.raw)).rejects.toThrow(/mempool_is_full/);294    await expect(a.broadcast('AAAA')).rejects.toThrow(/signed transaction/);295    await expect(a.broadcast(signed.raw.slice(2))).rejects.toThrow(/signed transaction/);296  });297});298299describe('aptos adapter: deposits', () => {300  const SENDER = '0x' + '9d'.repeat(32);301  const tx = (v: number, events: any[], over: Record<string, unknown> = {}) => ({ type: 'user_transaction', version: String(v), hash: '0x' + v.toString(16).padStart(64, '0'), success: true, sender: SENDER, timestamp: String((NOW - 50) * 1_000_000), events, ...over });302  const fa = (amount: string, store = aptosAptStore(ME)) => ({ type: '0x1::fungible_asset::Deposit', data: { amount, store } });303  const put = (t: ReturnType<typeof tx>) => { for (const u of [A, B]) h[u]!.byVersion[Number(t.version)] = t; h[A]!.index.rows.push(Number(t.version)); };304  it('discovers through the index and records only what a full node shows', async () => {305    put(tx(8_000_000_100, [{ type: '0x1::fungible_asset::Withdraw', data: { amount: '5', store: aptosAptStore(SENDER) } }, fa('5'), fa('7', aptosAptStore(DEST))]));306    put(tx(8_000_000_200, [fa('11')], { success: false })); // aborted307    put(tx(8_000_000_210, [fa('13')], { sender: short(ME) })); // sent by this wallet308    put(tx(8_000_000_220, [fa('17', aptosAptStore(DEST))])); // the index names it, the node shows no credit to this wallet309    put(tx(8_000_000_230, [{ type: '0x1::coin::CoinDeposit', data: { amount: '19', account: short(ME), coin_type: '0x1::aptos_coin::AptosCoin' } }, { type: '0x1::coin::CoinDeposit', data: { amount: '23', account: ME, coin_type: '0x1::other::Coin' } }]));310    put(tx(8_000_000_450, [fa('29')])); // beyond what the index has fully processed (8 000 000 400): next pass311    const r = await a.deposits(ME, null);312    expect(r.items).toEqual([313      { txid: '0x' + (8_000_000_100).toString(16).padStart(64, '0'), idx: 1, asset: 'native', amount: 5n, from: SENDER, block: 8_000_000_100, time: NOW - 50 },314      { txid: '0x' + (8_000_000_230).toString(16).padStart(64, '0'), idx: 0, asset: 'native', amount: 19n, from: SENDER, block: 8_000_000_230, time: NOW - 50 },315    ]);316    expect([r.cursor, r.finalizedHeight]).toEqual([8_000_000_400, 8_000_000_500]);317    expect(h[B]!.seen.filter((p) => p.startsWith('/transactions/by_version/'))).toHaveLength(5); // verified on the source that is not the index318    expect(h[A]!.index.queries[0]).toContain(`owner_address:{_eq:"${ME}"}`);319    h[A]!.index.last = 8_000_000_480;320    const next = await a.deposits(ME, r.cursor);321    expect(next.items.map((d) => [d.block, d.amount])).toEqual([[8_000_000_450, 29n]]);322    expect(next.cursor).toBe(8_000_000_480);323    expect(h[A]!.index.queries.at(-1)).toContain('transaction_version:{_gt:8000000400}');324    expect((await a.deposits(ME, next.cursor)).items).toEqual([]);325  });326  it('pages through a long listing without skipping, and never moves the cursor past what it covered', async () => {327    for (let k = 0; k < 350; k++) put(tx(8_000_000_000 + k, [fa('1')]));328    const r = await a.deposits(ME, 7_999_999_999);329    expect([r.items.length, r.cursor]).toEqual([300, 8_000_000_299]);330    const s = await a.deposits(ME, r.cursor);331    expect([s.items.length, s.items[0]!.block, s.cursor]).toEqual([50, 8_000_000_300, 8_000_000_400]);332  });333  it('fails closed on index or node trouble; history older than the nodes keep comes from the archive', async () => {334    put(tx(8_000_000_100, [fa('5')]));335    h[A]!.index.fail = true;336    await expect(a.deposits(ME, 8_000_000_000)).rejects.toThrow(/index unavailable/);337    h[A]!.index.fail = false;338    for (const u of [A, B]) h[u]!.pruned.add(8_000_000_100);339    await expect(a.deposits(ME, 8_000_000_000)).rejects.toThrow(); // archive unreachable: nothing is skipped, the cursor does not move340    expect((await a.deposits(ME, null)).items).toEqual([]); // first scan: history only341    h[ARCH] = fresh(); h[ARCH]!.byVersion[8_000_000_100] = h[A]!.byVersion[8_000_000_100];342    expect((await a.deposits(ME, 8_000_000_000)).items.map((d) => d.amount)).toEqual([5n]);343    h[ARCH]!.byVersion[8_000_000_100] = { ...h[A]!.byVersion[8_000_000_100], version: '8000000101' };344    await expect(a.deposits(ME, 8_000_000_000)).rejects.toThrow(/bad transaction record/);345    h[B]!.down = true; h[B]!.pruned.clear(); h[A]!.pruned.clear();346    expect((await a.deposits(ME, 8_000_000_000)).items).toHaveLength(1); // the other source is down: the index operator's node verifies347    await expect(a.deposits('0x1', null)).rejects.toThrow(/Aptos address/);348  });349});350
Files main
Menu

Theme