Repository Public
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