SPB Git

Repository Public

near-adapter.test.ts 34.8 KB 425 lines typescript

1// NEAR adapter against two scripted JSON-RPC sources, the archival node and the transfers index (no network):2// agreement rules, refusals, fee/reserve arithmetic, tracking verdicts, broadcast outcomes and deposit re-verification.3import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';4import { sha256 } from '@noble/hashes/sha2.js';5import { base58 } from '@scure/base';6import { NETWORKS, nearFeeCeiling } from '@spw/core';7import type { Adapter } from '../src/chains/rpc.ts';89const n = NETWORKS.near;10const [A, B] = n.rpc as [string, string];11const ARCHIVAL = 'https://archival-rpc.mainnet.fastnear.com', INDEX = 'https://transfers.main.fastnear.com/v0/transfers';12const h = (label: string) => base58.encode(sha256(new TextEncoder().encode(label)));13const hex = (label: string) => Buffer.from(sha256(new TextEncoder().encode(label))).toString('hex');14const FROM = hex('from'), TO_IMPLICIT = hex('to'), TO = 'alice.near';15const FROM_KEY = 'ed25519:' + base58.encode(Buffer.from(FROM, 'hex'));16const TIP = 223_990_000, NONCE = 223_000_000_000_007;17const blockHash = (height: number) => h(`block-${height}`);18const ONE = 10n ** 24n;19/** the blocks the scripted nodes know by hash */20const HEIGHT_OF = new Map<string, number>([...Array.from({ length: 800 }, (_, k) => TIP - k), TIP - 86_400, TIP - 90_000].map((height) => [blockHash(height), height]));2122type Handler = (params: any, url: string) => unknown;23class RpcErr { constructor(public cause: string, public data: unknown = '') {} }24const unknownTx = () => { throw new RpcErr('UNKNOWN_TRANSACTION'); };25const FEES = { execution: 108059500000, send_not_sir: 108059500000, send_sir: 108059500000 };26const protocol = (over: Record<string, unknown> = {}, costs: Record<string, unknown> = {}) => ({27  transaction_validity_period: 86400, ...over,28  runtime_config: { storage_amount_per_byte: '10000000000000000000', min_gas_purchase_price: '1000000000', account_creation_charge: '7000000000000000000000', ...(over.runtime ?? {}) as object,29    transaction_costs: { action_receipt_creation_config: FEES, action_creation_config: {30      transfer_cost: { execution: 115123062500, send_not_sir: 115123062500, send_sir: 115123062500 },31      create_account_cost: { execution: 7200000000000, send_not_sir: 500000000000, send_sir: 500000000000 },32      add_key_cost: { full_access_cost: { execution: 101765125000, send_not_sir: 101765125000, send_sir: 101765125000 } }, ...costs } } },33});3435interface World { accounts: Record<string, { amount: string; locked: string; storage_usage: number } | undefined>; calls: { url: string; method: string; params: any }[]; tips: Record<string, number> }36/** A healthy pair of sources at the same final block; individual tests override methods per source. */37function world(over: Partial<Record<string, Handler>> = {}, perUrl: Record<string, Partial<Record<string, Handler | 'down'>>> = {}, index: (body: any) => unknown = () => ({ transfers: [], resume_token: null })): World {38  const w: World = {39    accounts: { [FROM]: { amount: (3n * ONE).toString(), locked: '0', storage_usage: 182 }, [TO]: { amount: '1', locked: '0', storage_usage: 5000 } },40    calls: [], tips: { [A]: TIP, [B]: TIP, [ARCHIVAL]: TIP },41  };42  const header = (height: number) => ({ header: { height, hash: blockHash(height), timestamp_nanosec: String(BigInt(Date.now() - (TIP - height) * 600) * 1_000_000n), gas_price: '100000000' } });43  const base: Record<string, Handler> = {44    status: (_p, url) => ({ chain_id: 'mainnet', genesis_hash: n.chainRef, sync_info: { latest_block_height: w.tips[url]! + 2, latest_block_time: new Date().toISOString(), syncing: false } }),45    block: (p, url) => {46      if (p.finality === 'final') return header(w.tips[url]!);47      if (typeof p.block_id === 'number') { if (p.block_id > w.tips[url]!) throw new RpcErr('UNKNOWN_BLOCK'); return header(p.block_id); }48      const height = HEIGHT_OF.get(p.block_id);49      if (height === undefined) throw new RpcErr('UNKNOWN_BLOCK');50      return header(height);51    },52    query: (p) => {53      if (p.request_type === 'view_account') { const a = w.accounts[p.account_id]; if (!a) throw new RpcErr('UNKNOWN_ACCOUNT'); return { ...a, code_hash: '1'.repeat(32), block_height: TIP, block_hash: blockHash(TIP) }; }54      if (p.request_type === 'view_access_key') return p.account_id === FROM && p.public_key === FROM_KEY && w.accounts[FROM] ? { nonce: NONCE, permission: 'FullAccess' } : { error: `access key ${p.public_key} does not exist while viewing`, logs: [] };55      throw new Error('unexpected query');56    },57    EXPERIMENTAL_protocol_config: () => protocol(),58    tx: unknownTx, EXPERIMENTAL_tx_status: unknownTx,59    send_tx: () => ({ final_execution_status: 'INCLUDED' }),60    ...over,61  };62  vi.stubGlobal('fetch', async (url: string, init: RequestInit) => {63    const body = JSON.parse(String(init.body));64    if (url === INDEX) { w.calls.push({ url, method: 'index', params: body }); const r = index(body); if (r === 'down') throw new TypeError('fetch failed'); return new Response(JSON.stringify(r), { status: 200 }); }65    const { method, params } = body as { method: string; params: any };66    w.calls.push({ url, method, params });67    const f = perUrl[url]?.[method] ?? base[method];68    if (f === 'down' || !f) throw new TypeError('fetch failed');69    try { return new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, result: f(params, url) }), { status: 200 }); }70    catch (e) {71      if (!(e instanceof RpcErr)) throw e;72      return new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, error: { name: 'HANDLER_ERROR', cause: { name: e.cause, info: {} }, code: -32000, message: 'Server error', data: e.data } }), { status: e.cause === 'UNKNOWN_BLOCK' ? 422 : 200 });73    }74  });75  return w;76}7778let make: () => Adapter;79beforeAll(async () => {80  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' });81  const { nearAdapter } = await import('../src/chains/near.ts');82  make = () => nearAdapter(n);83});84afterEach(() => vi.unstubAllGlobals());85const req = { asset: 'native', from: FROM, to: TO, amount: ONE };86const code = async (p: Promise<unknown>) => p.then(() => 'ok', (e: { code?: string; message: string }) => e.code ?? `error:${e.message}`);8788describe('near adapter: health and identity', () => {89  it('is ok only when both sources are up, synced and on mainnet (chain id + genesis hash)', async () => {90    world();91    expect(await make().health()).toMatchObject({ network: 'near', ok: true, readable: true, identityVerified: true, height: TIP + 2 });92    world({}, { [B]: { status: 'down' } });93    expect(await make().health()).toMatchObject({ ok: false, readable: true, identityVerified: false });94    world({}, { [B]: { status: () => ({ chain_id: 'testnet', genesis_hash: n.chainRef, sync_info: { latest_block_height: 5, latest_block_time: new Date().toISOString(), syncing: false } }) } });95    expect(await make().health()).toMatchObject({ ok: false, readable: true, identityVerified: false });96    world({}, { [A]: { status: () => ({ chain_id: 'mainnet', genesis_hash: h('other genesis'), sync_info: { latest_block_height: 5, latest_block_time: new Date().toISOString(), syncing: false } }) } });97    expect(await make().health()).toMatchObject({ ok: false, identityVerified: false });98    world({}, { [A]: { status: () => ({ chain_id: 'mainnet', genesis_hash: n.chainRef, sync_info: { latest_block_height: 5, latest_block_time: new Date().toISOString(), syncing: true } }) } });99    expect(await make().health()).toMatchObject({ ok: false });100    world({}, { [A]: { status: () => ({ chain_id: 'mainnet', genesis_hash: n.chainRef, sync_info: { latest_block_height: 5, latest_block_time: new Date(Date.now() - 600_000).toISOString(), syncing: false } }) } });101    expect(await make().health()).toMatchObject({ ok: false }); // stale102    world({}, { [A]: { status: 'down' }, [B]: { status: 'down' } });103    expect(await make().health()).toMatchObject({ ok: false, readable: false, height: null });104  });105  it('balances: exact yoctoNEAR; an account that does not exist holds nothing; failover to the second source', async () => {106    const w = world();107    w.accounts[FROM] = { amount: '123456789012345678901234567', locked: '0', storage_usage: 182 };108    expect(await make().balances(FROM)).toEqual({ native: 123456789012345678901234567n });109    expect(await make().balances(TO_IMPLICIT)).toEqual({ native: 0n });110    world({}, { [A]: { query: 'down' } });111    expect(await make().balances(FROM)).toEqual({ native: 3n * ONE });112    world({}, { [A]: { query: 'down' }, [B]: { query: 'down' } });113    await expect(make().balances(FROM)).rejects.toThrow();114  });115});116117describe('near adapter: prepare', () => {118  it('returns nonce + 1 and the agreed final block hash; the fee ceiling is the signer bound', async () => {119    const w = world();120    const p = await make().prepare(req);121    expect(p.section).toEqual({ x: { nonce: String(NONCE + 1), blockHash: blockHash(TIP) } });122    expect(p.maxFee).toBe(nearFeeCeiling(TO));123    expect(p.maxFee).toBe(2_000_000_000_000_000_000_000n);124    expect(p.feeEstimate).toBe(446_365_125_000n * 100_000_000n); // send + execution gas at the block price125    expect([p.warnings, p.ttlSec, p.aux]).toEqual([[], 120, undefined]);126    // every state read is pinned to the agreed block, on both sources127    const queries = w.calls.filter((c) => c.method === 'query');128    expect(queries).toHaveLength(6);129    expect(queries.every((c) => c.params.block_id === blockHash(TIP) && c.params.finality === undefined)).toBe(true);130    expect(new Set(queries.map((c) => c.url))).toEqual(new Set([A, B]));131    expect(w.calls.some((c) => c.method === 'send_tx')).toBe(false);132  });133  it('uses the older final block when the sources are a few blocks apart, after checking it is canonical on both', async () => {134    const w = world();135    w.tips[B] = TIP - 7;136    expect((await make().prepare(req)).section).toEqual({ x: { nonce: String(NONCE + 1), blockHash: blockHash(TIP - 7) } });137    expect(w.calls.filter((c) => c.method === 'block' && c.params.block_id === TIP - 7).map((c) => c.url).sort()).toEqual([A, B].sort());138    w.tips[B] = TIP - 101; // too far apart139    expect(await code(make().prepare(req))).toBe('rpc_disagreement');140  });141  it('a transfer that creates an implicit account: warning, higher ceiling, creation charge in the estimate', async () => {142    world();143    const p = await make().prepare({ ...req, to: TO_IMPLICIT });144    expect(p.warnings).toEqual(['near_creates_account']);145    expect(p.maxFee).toBe(20_000_000_000_000_000_000_000n);146    // (send + exec) × price + (0.007 NEAR − create_account execution at the block price)147    expect(p.feeEstimate).toBe(8_349_895_375_000n * 100_000_000n + (7_000_000_000_000_000_000_000n - 7_200_000_000_000n * 100_000_000n));148    const w = world();149    w.accounts[TO_IMPLICIT] = { amount: '5', locked: '0', storage_usage: 182 };150    const q = await make().prepare({ ...req, to: TO_IMPLICIT });151    expect([q.warnings, q.feeEstimate, q.maxFee]).toEqual([[], 8_349_895_375_000n * 100_000_000n, 20_000_000_000_000_000_000_000n]);152  });153  it('refuses a named destination that does not exist, and every malformed or unsupported request', async () => {154    world();155    expect(await code(make().prepare({ ...req, to: 'nobody-here.near' }))).toBe('bad_destination');156    expect(await code(make().prepare({ ...req, to: 'bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu' }))).toBe('bad_destination'); // an address of another chain is not a NEAR account157    for (const to of ['Alice.near', '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', 'a', 'system', TO_IMPLICIT.toUpperCase(), 'alice..near']) expect(await code(make().prepare({ ...req, to })), to).toBe('bad_address');158    expect(await code(make().prepare({ ...req, to: FROM }))).toBe('bad_address');159    expect(await code(make().prepare({ ...req, from: 'bob.near' }))).toBe('bad_address');160    expect(await code(make().prepare({ ...req, asset: 'usdt.tether-token.near' }))).toBe('unsupported_asset');161    expect(await code(make().prepare({ ...req, amount: 0n }))).toBe('bad_amount');162    expect(await code(make().prepare({ ...req, memo: 'hi' }))).toBe('bad_memo');163  });164  it('fails closed when a source is down, on another chain, stale, or disagrees on anything', async () => {165    const cases: [string, Partial<Record<string, Handler | 'down'>>, string][] = [166      ['status down', { status: 'down' }, 'rpc_degraded'],167      ['testnet', { status: () => ({ chain_id: 'testnet', genesis_hash: h('testnet'), sync_info: { latest_block_height: 9, latest_block_time: new Date().toISOString(), syncing: false } }) }, 'rpc_degraded'],168      ['block down', { block: 'down' }, 'rpc_degraded'],169      ['query down', { query: 'down' }, 'rpc_degraded'],170      ['protocol config down', { EXPERIMENTAL_protocol_config: 'down' }, 'rpc_degraded'],171      ['rate limited', { query: () => { throw new Error('boom'); } }, 'rpc_degraded'],172      ['another block at the same height', { block: (p) => ({ header: { height: p.finality ? TIP : p.block_id, hash: h('fork'), timestamp_nanosec: String(BigInt(Date.now()) * 1_000_000n), gas_price: '100000000' } }) }, 'rpc_disagreement'],173      ['another nonce', { query: (p) => (p.request_type === 'view_access_key' ? { nonce: NONCE + 1, permission: 'FullAccess' } : { amount: (3n * ONE).toString(), locked: '0', storage_usage: p.account_id === FROM ? 182 : 5000 }) }, 'rpc_disagreement'],174      ['another balance', { query: (p) => (p.request_type === 'view_access_key' ? { nonce: NONCE, permission: 'FullAccess' } : { amount: (4n * ONE).toString(), locked: '0', storage_usage: p.account_id === FROM ? 182 : 5000 }) }, 'rpc_disagreement'],175      ['destination missing on one source', { query: (p) => { if (p.request_type === 'view_access_key') return { nonce: NONCE, permission: 'FullAccess' }; if (p.account_id === TO) throw new RpcErr('UNKNOWN_ACCOUNT'); return { amount: (3n * ONE).toString(), locked: '0', storage_usage: 182 }; } }, 'rpc_disagreement'],176      ['other fee parameters', { EXPERIMENTAL_protocol_config: () => protocol({}, { transfer_cost: { execution: 115123062501, send_not_sir: 115123062500, send_sir: 1 } }) }, 'rpc_disagreement'],177      ['malformed balance', { query: (p) => (p.request_type === 'view_access_key' ? { nonce: NONCE, permission: 'FullAccess' } : { amount: '1e24', locked: '0', storage_usage: 182 }) }, 'rpc_degraded'],178      ['nonce beyond exact integers', { query: (p) => (p.request_type === 'view_access_key' ? { nonce: 2 ** 60, permission: 'FullAccess' } : { amount: (3n * ONE).toString(), locked: '0', storage_usage: p.account_id === FROM ? 182 : 5000 }) }, 'rpc_degraded'],179    ];180    for (const [name, o, want] of cases) for (const src of [A, B]) { world({}, { [src]: o }); expect(await code(make().prepare(req)), `${name} on ${src}`).toBe(want); }181    // both sources stale in the same way: agreement is not enough182    world({ block: (p) => ({ header: { height: p.finality ? TIP : p.block_id, hash: blockHash(TIP), timestamp_nanosec: String(BigInt(Date.now() - 200_000) * 1_000_000n), gas_price: '100000000' } }) });183    expect(await code(make().prepare(req))).toBe('rpc_degraded');184  });185  it('refuses when the live protocol parameters leave the bounds the signer enforces', async () => {186    const outside: [string, Handler][] = [187      ['gas above the bound', () => protocol({}, { transfer_cost: { execution: 900_000_000_000, send_not_sir: 115123062500, send_sir: 1 } })],188      ['minimum purchase price above the price cap', () => protocol({ runtime: { min_gas_purchase_price: '2000000001' } })],189      ['another validity period', () => protocol({ transaction_validity_period: 43200 })],190      ['missing parameter', () => { const p = protocol(); delete (p.runtime_config as any).min_gas_purchase_price; return p; }],191    ];192    for (const [name, f] of outside) { world({ EXPERIMENTAL_protocol_config: f }); expect(await code(make().prepare(req)), name).toBe('rpc_degraded'); }193    world({ block: (p) => ({ header: { height: p.finality ? TIP : p.block_id, hash: blockHash(TIP), timestamp_nanosec: String(BigInt(Date.now()) * 1_000_000n), gas_price: '2000000001' } }) });194    expect(await code(make().prepare(req))).toBe('rpc_degraded'); // a gas price above the protocol cap means the pinned cap is wrong195  });196  it('account state: not activated, key missing or restricted', async () => {197    const w = world();198    delete w.accounts[FROM];199    expect(await code(make().prepare(req))).toBe('insufficient_funds');200    world({ query: (p) => (p.request_type === 'view_access_key' ? { error: 'access key does not exist while viewing', logs: [] } : { amount: (3n * ONE).toString(), locked: '0', storage_usage: 182 }) });201    expect(await code(make().prepare(req))).toBe('unsupported_account');202    world({ query: (p) => (p.request_type === 'view_access_key' ? { nonce: NONCE, permission: { FunctionCall: { allowance: null, receiver_id: 'x.near', method_names: [] } } } : { amount: (3n * ONE).toString(), locked: '0', storage_usage: 182 }) });203    expect(await code(make().prepare(req))).toBe('unsupported_account');204  });205  it('balance must cover amount + upfront gas purchase (+ storage when the account is not a zero-balance account), to the yoctoNEAR', async () => {206    // named destination at 10^8: burnt 223182562500 gas at 2×10^8 (headroom) + 223182562500 gas bought at 10^9207    const upfront = 223_182_562_500n * 200_000_000n + 223_182_562_500n * 1_000_000_000n;208    const w = world();209    w.accounts[FROM] = { amount: (ONE + upfront).toString(), locked: '0', storage_usage: 182 };210    expect(await code(make().prepare(req))).toBe('ok'); // a small account can be emptied (zero-balance account)211    w.accounts[FROM] = { amount: (ONE + upfront - 1n).toString(), locked: '0', storage_usage: 182 };212    expect(await code(make().prepare(req))).toBe('insufficient_funds');213    // 771 bytes: 771 × 10^19 yoctoNEAR must remain214    const reserve = 771n * 10_000_000_000_000_000_000n;215    w.accounts[FROM] = { amount: (ONE + upfront + reserve).toString(), locked: '0', storage_usage: 771 };216    expect(await code(make().prepare(req))).toBe('ok');217    w.accounts[FROM] = { amount: (ONE + upfront + reserve - 1n).toString(), locked: '0', storage_usage: 771 };218    expect(await code(make().prepare(req))).toBe('insufficient_funds');219    w.accounts[FROM] = { amount: (ONE + upfront).toString(), locked: reserve.toString(), storage_usage: 771 }; // a locked (staked) balance counts toward storage220    expect(await code(make().prepare(req))).toBe('ok');221    w.accounts[FROM] = { amount: (ONE + upfront).toString(), locked: '0', storage_usage: 770 };222    expect(await code(make().prepare(req))).toBe('ok');223    // creating an implicit account buys far more gas upfront224    const upfrontImplicit = 824_947_687_500n * 200_000_000n + 7_524_947_687_500n * 1_000_000_000n;225    w.accounts[FROM] = { amount: (ONE + upfrontImplicit - 1n).toString(), locked: '0', storage_usage: 182 };226    expect(await code(make().prepare({ ...req, to: TO_IMPLICIT }))).toBe('insufficient_funds');227    w.accounts[FROM] = { amount: (ONE + upfrontImplicit).toString(), locked: '0', storage_usage: 182 };228    expect(await code(make().prepare({ ...req, to: TO_IMPLICIT }))).toBe('ok');229  });230});231232const AMOUNT = '1000000000000000000000000';233const txView = (o: { txid: string; final?: string; status?: object; to?: string; amount?: string; from?: string; receipts?: unknown[]; outcomes?: unknown[] }) => ({234  final_execution_status: o.final ?? 'FINAL', status: o.status ?? { SuccessValue: '' },235  transaction: { hash: o.txid, signer_id: o.from ?? FROM, receiver_id: o.to ?? TO, nonce: NONCE + 1, actions: [{ Transfer: { deposit: o.amount ?? AMOUNT } }] },236  transaction_outcome: { block_hash: blockHash(TIP - 3), id: o.txid, outcome: { status: { SuccessReceiptId: h('r') } } },237  receipts: o.receipts, receipts_outcome: o.outcomes ?? [],238});239const TXID = h('tx-1');240const tracked = (over: Record<string, unknown> = {}) => ({ txid: TXID, intent: { from: FROM, to: TO, amount: AMOUNT, createdAt: Math.floor(Date.now() / 1000) - 30, x: { nonce: String(NONCE + 1), blockHash: blockHash(TIP - 20) } },241  blockRef: null as string | null, broadcastAt: Date.now() / 1000 - 20, ...over });242243describe('near adapter: track', () => {244  it('finalized only when every reachable source shows a final success; a hash alone is never success', async () => {245    world({ tx: () => txView({ txid: TXID }) });246    expect(await make().track(tracked())).toEqual({ state: 'finalized', confirmations: n.minConf, blockRef: blockHash(TIP - 3) });247    world({ tx: () => txView({ txid: TXID }) }, { [B]: { tx: unknownTx } });248    expect(await make().track(tracked())).toMatchObject({ state: 'confirming', confirmations: 1 });249    for (const final of ['NONE', 'INCLUDED', 'EXECUTED_OPTIMISTIC', 'INCLUDED_FINAL', 'EXECUTED']) {250      world({ tx: () => txView({ txid: TXID, final }) });251      expect((await make().track(tracked())).state, final).toBe('confirming');252    }253    world({ tx: () => txView({ txid: TXID, final: 'FINAL', status: { Unknown: null } }) });254    expect((await make().track(tracked())).state).toBe('confirming');255    world({ tx: () => txView({ txid: TXID }) }, { [B]: { tx: () => txView({ txid: TXID, final: 'EXECUTED_OPTIMISTIC' }) } });256    expect((await make().track(tracked())).state).toBe('confirming');257    world({ tx: () => txView({ txid: TXID }) }, { [B]: { tx: 'down', block: 'down' } }); // one source reachable and final: success may be shown258    expect((await make().track(tracked())).state).toBe('finalized');259  });260  it('failed when the transfer was included and failed, on both sources', async () => {261    const failure = { Failure: { ActionError: { index: 0, kind: { AccountDoesNotExist: { account_id: TO } } } } };262    world({ tx: () => txView({ txid: TXID, status: failure }) });263    const t = await make().track(tracked());264    expect(t).toMatchObject({ state: 'failed', confirmations: 0, blockRef: blockHash(TIP - 3) });265    expect(t.error).toMatch(/AccountDoesNotExist/);266    world({ tx: () => txView({ txid: TXID, status: failure }) }, { [B]: { tx: 'down' } });267    expect((await make().track(tracked())).state).toBe('confirming'); // a failure verdict needs both sources268    world({ tx: () => txView({ txid: TXID, status: failure }) }, { [B]: { tx: () => txView({ txid: TXID }) } });269    expect((await make().track(tracked())).state).toBe('confirming'); // contradictory results are never announced270  });271  it('a record that is not the approved transfer is not accepted', async () => {272    for (const bad of [txView({ txid: TXID, to: 'mallory.near' }), txView({ txid: TXID, amount: '1' }), txView({ txid: h('other') }), txView({ txid: TXID, from: TO_IMPLICIT })]) {273      world({ tx: () => bad });274      expect((await make().track(tracked())).state).toBe('broadcast');275    }276  });277  it('unknown transaction: stays pending while it can still be included', async () => {278    world();279    expect(await make().track(tracked())).toEqual({ state: 'broadcast', confirmations: 0, rebroadcast: true });280    world({}, { [B]: { tx: 'down' } });281    expect(await make().track(tracked({ intent: { ...tracked().intent, x: { nonce: String(NONCE + 1), blockHash: blockHash(TIP - 90_000) } } }))).toMatchObject({ state: 'broadcast' }); // never "failed" on one source282    world();283    expect(await make().track(tracked({ blockRef: blockHash(TIP - 3) }))).toMatchObject({ state: 'reorged', rebroadcast: true }); // it had been seen in a block284  });285  it('failed only when the nonce is still unused at a final block past the validity period, on both sources', async () => {286    const expired = () => tracked({ intent: { ...tracked().intent, x: { nonce: String(NONCE + 1), blockHash: blockHash(TIP - 90_000) } }, broadcastAt: Date.now() / 1000 - 60_000 });287    world();288    expect(await make().track(expired())).toMatchObject({ state: 'failed', confirmations: 0, error: expect.stringMatching(/no funds moved/) });289    const w = world();290    w.tips[B] = TIP - 3500; // the second source is not yet past reference + 86 400 + margin291    expect((await make().track({ ...expired(), txid: h('tx-2') })).state).toBe('broadcast');292    world();293    expect((await make().track({ ...tracked({ intent: { ...tracked().intent, x: { nonce: String(NONCE + 1), blockHash: blockHash(TIP - 86_400) } } }), txid: h('tx-3') })).state).toBe('broadcast'); // exactly at the limit: still valid294    world({}, { [A]: { query: 'down' } });295    expect((await make().track({ ...expired(), txid: h('tx-4') })).state).toBe('broadcast'); // nonce unreadable: no verdict296  });297  it('nonce already used: asks the archival node; included → final result, otherwise replaced (never before two minutes)', async () => {298    const used = (over: Record<string, unknown> = {}) => tracked({ intent: { ...tracked().intent, x: { nonce: String(NONCE), blockHash: blockHash(TIP - 20) } }, broadcastAt: Date.now() / 1000 - 600, ...over });299    world({}, { [ARCHIVAL]: { tx: () => txView({ txid: TXID }) } });300    expect(await make().track(used())).toMatchObject({ state: 'finalized', blockRef: blockHash(TIP - 3) });301    world({}, { [ARCHIVAL]: { tx: () => txView({ txid: TXID, status: { Failure: { ActionError: {} } } }) } });302    expect((await make().track(used())).state).toBe('failed');303    world({}, { [ARCHIVAL]: { tx: () => txView({ txid: TXID, final: 'EXECUTED' }) } });304    expect((await make().track(used())).state).toBe('confirming');305    world();306    expect(await make().track(used())).toMatchObject({ state: 'replaced', error: expect.stringMatching(/nonce was used/) });307    world();308    expect((await make().track(used({ blockRef: blockHash(TIP - 3) }))).state).toBe('failed'); // same verdict, legal transition from "reorged"309    world();310    expect((await make().track(used({ broadcastAt: Date.now() / 1000 - 60 }))).state).toBe('broadcast'); // too early to conclude311    world({}, { [ARCHIVAL]: { tx: 'down' } });312    expect(await make().track({ ...used(), txid: h('tx-5') })).toMatchObject({ state: 'broadcast', rebroadcast: false }); // archival unreachable: no verdict313  });314  it('rejects an intent without NEAR parameters', async () => {315    world();316    await expect(make().track(tracked({ intent: { from: FROM, to: TO, amount: AMOUNT, x: {} } }))).rejects.toThrow();317    await expect(make().track(tracked({ intent: { from: 'bob.near', to: TO, amount: AMOUNT, x: tracked().intent.x } }))).rejects.toThrow();318  });319});320321describe('near adapter: broadcast', () => {322  // a syntactically valid SignedTransaction for FROM (the adapter derives the hash and the signer from the bytes)323  const body = Buffer.concat([Buffer.from([64, 0, 0, 0]), Buffer.from(FROM), Buffer.from([0]), Buffer.from(FROM, 'hex'), Buffer.alloc(8, 1), Buffer.from([10, 0, 0, 0]), Buffer.from('alice.near'),324    Buffer.alloc(32, 7), Buffer.from([1, 0, 0, 0, 3]), Buffer.alloc(16, 2)]);325  const RAW = Buffer.concat([body, Buffer.from([0]), Buffer.alloc(64, 9)]).toString('base64'), RAW_TXID = base58.encode(sha256(body));326  it('relays the exact bytes to both sources and waits for inclusion', async () => {327    const w = world();328    await make().broadcast(RAW);329    const sent = w.calls.filter((c) => c.method === 'send_tx');330    expect(sent.map((c) => c.url).sort()).toEqual([A, B].sort());331    expect(sent.every((c) => c.params.signed_tx_base64 === RAW && c.params.wait_until === 'INCLUDED')).toBe(true);332  });333  it('one source accepting is enough; a node timeout after validation counts as accepted', async () => {334    world({}, { [A]: { send_tx: 'down' } });335    await expect(make().broadcast(RAW)).resolves.toBeUndefined();336    world({ send_tx: () => { throw new RpcErr('TIMEOUT_ERROR'); } });337    await expect(make().broadcast(RAW)).resolves.toBeUndefined();338  });339  it('reports the network refusal; "already known" is success when the chain has the transaction', async () => {340    const invalid = () => { throw new RpcErr('INVALID_TRANSACTION', { TxExecutionError: { InvalidTxError: { InvalidNonce: { ak_nonce: 9, tx_nonce: 8 } } } }); };341    world({ send_tx: invalid });342    await expect(make().broadcast(RAW)).rejects.toThrow(/rejected by the network: INVALID_TRANSACTION.*InvalidNonce/);343    const w = world({ send_tx: invalid, tx: (p) => txView({ txid: p.tx_hash }) });344    await expect(make().broadcast(RAW)).resolves.toBeUndefined();345    expect(w.calls.find((c) => c.method === 'tx')!.params).toEqual({ tx_hash: RAW_TXID, sender_account_id: FROM, wait_until: 'NONE' });346    world({}, { [A]: { send_tx: 'down' }, [B]: { send_tx: 'down' } });347    await expect(make().broadcast(RAW)).rejects.toThrow();348    await expect(make().broadcast('AAAA')).rejects.toThrow(/not a NEAR signed transaction/);349  });350});351352describe('near adapter: deposits', () => {353  const ME = FROM, SENDER = TO_IMPLICIT;354  const row = (k: number, over: Record<string, unknown> = {}) => ({ block_height: String(TIP - 500 - k), transaction_id: h(`dtx-${k}`), receipt_id: h(`drc-${k}`), signer_id: SENDER, account_id: ME, asset_id: 'native:near',355    amount: '999', transfer_type: 'NativeTransfer', ...over });356  const receipt = (k: number, over: Record<string, unknown> = {}, actions: unknown[] = [{ Transfer: { deposit: String(BigInt(k + 1) * ONE + 7n) } }]) =>357    ({ predecessor_id: SENDER, receiver_id: ME, receipt_id: h(`drc-${k}`), receipt: { Action: { actions } }, ...over });358  const outcome = (k: number, status: object = { SuccessValue: '' }, executor = ME) => ({ id: h(`drc-${k}`), block_hash: blockHash(TIP - 100 - k), outcome: { executor_id: executor, status } });359  const status = (views: Record<string, unknown>): Handler => (p) => { const v = views[p.tx_hash]; if (!v) throw new RpcErr('UNKNOWN_TRANSACTION'); return v; };360  const view = (k: number, receipts: unknown[], outcomes: unknown[], final = 'FINAL') => ({ ...txView({ txid: h(`dtx-${k}`), from: SENDER, to: ME, receipts, outcomes, final }) });361362  it('reports only transfers that RPC confirms: amount, sender and block come from the node, not from the index', async () => {363    const views = {364      [h('dtx-0')]: view(0, [receipt(0), { ...receipt(0), receipt_id: h('refund'), predecessor_id: 'system', receiver_id: SENDER }], [outcome(0)]),365      [h('dtx-1')]: view(1, [receipt(1, { predecessor_id: 'system' })], [outcome(1)]), // gas refund366      [h('dtx-2')]: view(2, [receipt(2)], [outcome(2, { Failure: { ActionError: {} } })]), // failed receipt367      [h('dtx-3')]: view(3, [receipt(3, { receiver_id: 'someone-else.near' })], [outcome(3, undefined, 'someone-else.near')]), // not to this account368      [h('dtx-4')]: view(4, [receipt(4, {}, [{ FunctionCall: { method_name: 'x', args: '', gas: 1, deposit: '5' } }])], [outcome(4)]), // not a plain transfer369      [h('dtx-5')]: view(5, [receipt(5, { predecessor_id: ME })], [outcome(5)]), // from the account itself370      [h('dtx-6')]: view(6, [receipt(6, {}, [{ Transfer: { deposit: '5' } }, { Transfer: { deposit: '6' } }])], [outcome(6)]), // two transfers in one receipt371      [h('dtx-7')]: view(7, [{ ...receipt(7), receipt_id: h('other') }], [outcome(7)]), // the indexed receipt is not in the transaction372      // dtx-8: unknown to every node373    };374    const w = world({ EXPERIMENTAL_tx_status: status(views) }, {}, () => ({ transfers: [0, 1, 2, 3, 4, 5, 6, 7, 8].map((k) => row(k)).concat([row(0), row(9, { account_id: 'x.near' }), row(10, { asset_id: 'usdt.tether-token.near' }), row(11, { transaction_id: null })] as never), resume_token: null }));375    const d = await make().deposits(ME, null);376    expect(d.items).toEqual([377      { txid: h('dtx-0'), idx: 0, asset: 'native', amount: ONE + 7n, from: SENDER, block: TIP - 100, time: expect.any(Number) },378      { txid: h('dtx-6'), idx: 0, asset: 'native', amount: 11n, from: SENDER, block: TIP - 106, time: expect.any(Number) },379    ]);380    expect([d.cursor, d.finalizedHeight]).toEqual([TIP - 3000, TIP]);381    expect(w.calls.find((c) => c.method === 'index')!.params).toEqual({ account_id: ME, direction: 'receiver', asset_id: 'native:near', ignore_system: true, desc: true, limit: 20 });382  });383  it('a transfer that is not final yet, or that no source can check, is not reported and the cursor waits for it', async () => {384    const pendingView = { [h('dtx-0')]: view(0, [receipt(0)], [outcome(0)], 'EXECUTED_OPTIMISTIC') };385    const old = { block_height: String(TIP - 4000) }; // older than the window that is re-read anyway386    world({ EXPERIMENTAL_tx_status: status(pendingView) }, {}, () => ({ transfers: [row(0, old)], resume_token: null }));387    const a = make();388    const d = await a.deposits(ME, TIP - 5000);389    expect([d.items, d.cursor]).toEqual([[], TIP - 4001]); // just below the unverified row390    world({ EXPERIMENTAL_tx_status: status({ [h('dtx-0')]: view(0, [receipt(0)], [outcome(0)]) }) }, {}, () => ({ transfers: [row(0, old)], resume_token: null }));391    const e = await a.deposits(ME, d.cursor);392    expect(e.items.map((i) => i.txid)).toEqual([h('dtx-0')]);393    expect(e.cursor).toBe(TIP - 3000);394    world({ EXPERIMENTAL_tx_status: 'down' as never }, {}, () => ({ transfers: [row(1, old)], resume_token: null }));395    expect(await make().deposits(ME, TIP - 5000)).toMatchObject({ items: [], cursor: TIP - 4001 });396    // a recent unverified row is inside the re-read window: the cursor advances normally and the row is read again next time397    world({ EXPERIMENTAL_tx_status: status(pendingView) }, {}, () => ({ transfers: [row(0)], resume_token: null }));398    expect(await make().deposits(ME, TIP - 5000)).toMatchObject({ items: [], cursor: TIP - 3000 });399    // a row that still cannot be checked after about three days no longer holds the cursor400    world({ EXPERIMENTAL_tx_status: 'down' as never }, {}, () => ({ transfers: [row(2, { block_height: String(TIP - 430_001) })], resume_token: null }));401    expect(await make().deposits(ME, TIP - 500_000)).toMatchObject({ items: [], cursor: TIP - 3000 });402  });403  it('reads only rows above the cursor, pages through the index, and never skips when there is more than one scan can read', async () => {404    const all = Array.from({ length: 250 }, (_, k) => row(k));405    const views = Object.fromEntries(all.map((_, k) => [h(`dtx-${k}`), view(k, [receipt(k)], [outcome(k)])]));406    const page = (b: any) => { const from = Number(b.resume_token ?? 0); return { transfers: all.slice(from, from + 100), resume_token: from + 100 < all.length ? String(from + 100) : null }; };407    const w = world({ EXPERIMENTAL_tx_status: status(views) }, {}, page);408    const d = await make().deposits(ME, TIP - 500 - 120); // rows 0..119 are above the cursor409    expect(d.items).toHaveLength(120);410    expect(w.calls.filter((c) => c.method === 'index')).toHaveLength(2);411    expect(d.cursor).toBe(TIP - 620); // a cursor never moves backwards412    world({}, {}, () => ({ transfers: [], resume_token: null }));413    expect((await make().deposits(ME, TIP - 10)).cursor).toBe(TIP - 10);414    // endless index: the cursor is held415    world({ EXPERIMENTAL_tx_status: unknownTx }, {}, (b) => ({ transfers: [row(Number(b.resume_token ?? 0))], resume_token: String(Number(b.resume_token ?? 0) + 1) }));416    expect((await make().deposits(ME, 5)).cursor).toBe(5);417  });418  it('fails (to be retried) when the index or every RPC source is unreachable', async () => {419    world({}, {}, () => 'down');420    await expect(make().deposits(ME, null)).rejects.toThrow();421    world({}, { [A]: { block: 'down' }, [B]: { block: 'down' } });422    await expect(make().deposits(ME, null)).rejects.toThrow();423  });424});425
Files main
Menu

Theme