Repository Public
1// XRP Ledger adapter against two scripted JSON-RPC sources (no network): agreement rules, refusals, tracking and broadcast outcomes.2import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';3import { NETWORKS, xrplTxid } from '@spw/core';4import type { Adapter } from '../src/chains/rpc.ts';56const n = NETWORKS.xrpl;7const [A, B] = n.rpc as [string, string];8const FROM = 'rHsMGQEkVNJmpGWs8XUBoTBiAAbwxZN5v3', TO = 'rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe';9const TIP = 108_249_000, HASH = 'CD'.repeat(32);10type Handler = (params: Record<string, any>, url: string) => unknown;11const err = (error: string, extra: object = {}) => ({ status: 'error', error, error_message: error, ...extra });1213/** A healthy pair of sources; individual tests override methods per source. */14function world(over: Partial<Record<string, Handler>> = {}, perUrl: Record<string, Partial<Record<string, Handler | 'down'>>> = {}) {15 const accounts: Record<string, { Sequence: number; Balance: string; Flags: number; OwnerCount: number } | undefined> = {16 [FROM]: { Sequence: 7, Balance: '30000000', Flags: 0, OwnerCount: 2 }, [TO]: { Sequence: 1, Balance: '5000000', Flags: 0, OwnerCount: 0 } };17 const base: Record<string, Handler> = {18 ledger: (p) => (p.ledger_index === 32570 ? { ledger_hash: n.chainRef, ledger: { ledger_hash: n.chainRef, ledger_index: '32570' } }19 : { ledger_hash: HASH, ledger_index: TIP, validated: true, ledger: { ledger_hash: HASH, ledger_index: String(TIP) } }),20 server_state: () => ({ state: { network_id: 0, validated_ledger: { seq: TIP, hash: HASH, base_fee: 10, reserve_base: 1_000_000, reserve_inc: 200_000 } } }),21 fee: () => ({ drops: { base_fee: '10', minimum_fee: '10', open_ledger_fee: '10' } }),22 account_info: (p) => { const a = accounts[p.account]; if (!a) return err('actNotFound');23 return p.ledger_index === 'current' ? { account_data: a, ledger_current_index: TIP + 1 } : { account_data: a, ledger_hash: HASH, ledger_index: p.ledger_index === 'validated' ? TIP : p.ledger_index, validated: true }; },24 simulate: () => ({ engine_result: 'tesSUCCESS', applied: false }),25 tx: () => err('txnNotFound', { searched_all: true }),26 submit: () => ({ engine_result: 'tesSUCCESS' }),27 account_tx: () => ({ transactions: [] }),28 ...over,29 };30 const calls: { url: string; method: string; params: Record<string, any> }[] = [];31 vi.stubGlobal('fetch', async (url: string, init: RequestInit) => {32 const { method, params } = JSON.parse(String(init.body)) as { method: string; params: [Record<string, any>] };33 calls.push({ url, method, params: params[0] });34 const h = perUrl[url]?.[method] ?? base[method];35 if (h === 'down' || !h) throw new TypeError('fetch failed');36 return new Response(JSON.stringify({ result: h(params[0], url) }), { status: 200 });37 });38 return { accounts, calls };39}4041let make: () => Adapter;42beforeAll(async () => {43 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' });44 const { xrplAdapter } = await import('../src/chains/xrpl.ts');45 make = () => xrplAdapter(n);46});47afterEach(() => vi.unstubAllGlobals());48const req = { asset: 'native', from: FROM, to: TO, amount: 2_000_000n };49const code = async (p: Promise<unknown>) => p.then(() => 'ok', (e: { code?: string; message: string }) => e.code ?? `error:${e.message}`);5051describe('xrpl adapter: health and identity', () => {52 it('is ok only when both sources are up and carry the pinned ledger', async () => {53 world();54 expect(await make().health()).toMatchObject({ ok: true, readable: true, identityVerified: true, height: TIP });55 world({}, { [B]: { ledger: 'down' } });56 expect(await make().health()).toMatchObject({ ok: false, readable: true, identityVerified: false });57 world({}, { [B]: { ledger: (p) => (p.ledger_index === 32570 ? { ledger_hash: 'EE'.repeat(32) } : { ledger_hash: HASH, ledger_index: TIP }) } }); // another network58 expect(await make().health()).toMatchObject({ ok: false, identityVerified: false });59 world({}, { [A]: { server_state: () => ({ state: { network_id: 1, validated_ledger: {} } }) } }); // testnet id60 expect(await make().health()).toMatchObject({ ok: false, identityVerified: false });61 world({}, { [A]: { ledger: 'down' }, [B]: { ledger: 'down' } });62 expect(await make().health()).toMatchObject({ ok: false, readable: false });63 });64 it('prepare refuses when the identity cannot be verified on both sources', async () => {65 world({}, { [B]: { ledger: (p) => (p.ledger_index === 32570 ? { ledger_hash: 'EE'.repeat(32) } : { ledger_hash: HASH, ledger_index: TIP }) } });66 expect(await code(make().prepare(req))).toBe('rpc_degraded');67 });68});6970describe('xrpl adapter: prepare', () => {71 it('returns sequence, LastLedgerSequence = validated + 20 and the fee, all in the intent section', async () => {72 const w = world();73 const p = await make().prepare({ ...req, memo: '42' });74 expect(p).toEqual({ section: { x: { sequence: '7', lastLedgerSequence: String(TIP + 20), fee: '12' } }, maxFee: 12n, feeEstimate: 12n, warnings: [], ttlSec: 45 });75 const sim = w.calls.find((c) => c.method === 'simulate')!.params.tx_json;76 expect(sim).toEqual({ TransactionType: 'Payment', Account: FROM, Destination: TO, Amount: '2000000', Fee: '12', Flags: 0x80000000, Sequence: 7, LastLedgerSequence: TIP + 20, DestinationTag: 42 });77 expect(w.calls.some((c) => c.method === 'submit')).toBe(false);78 // both sources were asked for the account at the same explicit ledger79 expect(w.calls.filter((c) => c.method === 'account_info' && c.params.account === FROM && c.params.ledger_index === TIP).map((c) => c.url).sort()).toEqual([A, B].sort());80 });81 it('uses the lower validated ledger when one source is a ledger behind, and the higher open-ledger fee, capped', async () => {82 world({}, { [B]: { ledger: (p) => (p.ledger_index === 32570 ? { ledger_hash: n.chainRef } : { ledger_hash: 'AA'.repeat(32), ledger_index: TIP - 1 }),83 fee: () => ({ drops: { base_fee: '10', minimum_fee: '10', open_ledger_fee: '5020' } }) } });84 const p = await make().prepare(req);85 expect(p.section).toEqual({ x: { sequence: '7', lastLedgerSequence: String(TIP - 1 + 20), fee: '6024' } });86 world({ fee: () => ({ drops: { base_fee: '10', minimum_fee: '10', open_ledger_fee: '90000000' } }) });87 expect((await make().prepare(req)).maxFee).toBe(100_000n);88 });89 it('fails closed when a source is down or the sources disagree on anything that feeds the signature', async () => {90 for (const m of ['ledger', 'account_info', 'fee', 'server_state']) { world({}, { [B]: { [m]: 'down' } }); expect(await code(make().prepare(req)), m).toBe('rpc_degraded'); }91 world({}, { [A]: { ledger: (p) => (p.ledger_index === 32570 ? { ledger_hash: n.chainRef } : { ledger_hash: HASH, ledger_index: TIP + 9 }) } });92 expect(await code(make().prepare(req))).toBe('rpc_disagreement'); // too far apart93 const lying = (patch: object): Handler => (p) => (p.ledger_index === 'current' ? { account_data: { Sequence: 7, Balance: '30000000', Flags: 0, OwnerCount: 2 } }94 : { account_data: { Sequence: 7, Balance: '30000000', Flags: 0, OwnerCount: 2, ...(p.account === FROM ? patch : {}) }, ledger_hash: HASH, ledger_index: p.ledger_index });95 for (const patch of [{ Sequence: 8 }, { Balance: '30000001' }, { Flags: 0x00100000 }, { OwnerCount: 3 }]) {96 world({}, { [B]: { account_info: lying(patch) } });97 expect(await code(make().prepare(req)), JSON.stringify(patch)).toBe('rpc_disagreement');98 }99 world({}, { [B]: { account_info: (p) => ({ account_data: { Sequence: 7, Balance: '30000000', Flags: 0, OwnerCount: 2 }, ledger_hash: 'BB'.repeat(32), ledger_index: p.ledger_index }) } });100 expect(await code(make().prepare(req))).toBe('rpc_disagreement'); // same numbers, different ledger hash (fork)101 world({}, { [B]: { account_info: (p) => ({ account_data: { Sequence: 7, Balance: '30000000', Flags: 0, OwnerCount: 2 }, ledger_hash: HASH, ledger_index: TIP - 3 }) } });102 expect(await code(make().prepare(req))).toBe('rpc_degraded'); // answered for another ledger than the one asked103 world({}, { [B]: { account_info: (p) => (p.account === TO ? err('actNotFound') : { account_data: { Sequence: 7, Balance: '30000000', Flags: 0, OwnerCount: 2 }, ledger_hash: HASH, ledger_index: p.ledger_index }) } });104 expect(await code(make().prepare(req))).toBe('rpc_disagreement'); // destination exists on one source only105 world({}, { [B]: { server_state: () => ({ state: { network_id: 0, validated_ledger: { base_fee: 10, reserve_base: 10_000_000, reserve_inc: 200_000 } } }) } });106 expect(await code(make().prepare(req))).toBe('rpc_disagreement');107 world({}, { [B]: { fee: () => ({ drops: { base_fee: '11', minimum_fee: '10', open_ledger_fee: '10' } }) } });108 expect(await code(make().prepare(req))).toBe('rpc_disagreement');109 world({ simulate: () => { throw new Error('boom'); } }, { [A]: { simulate: 'down' }, [B]: { simulate: 'down' } });110 expect(await code(make().prepare(req))).toBe('rpc_degraded');111 });112 it('keeps the reserve of the source account (base + owned objects)', async () => {113 const w = world(); // balance 30 XRP, reserve 1 + 2 × 0.2 = 1.4 XRP, fee 12 drops114 expect(await code(make().prepare({ ...req, amount: 30_000_000n - 1_400_000n - 12n }))).toBe('ok');115 expect(await code(make().prepare({ ...req, amount: 30_000_000n - 1_400_000n - 11n }))).toBe('insufficient_funds');116 w.accounts[FROM] = undefined;117 expect(await code(make().prepare(req))).toBe('insufficient_funds'); // not activated118 });119 it('refuses a destination that needs a tag, blocks deposits or refuses XRP; and a new account below the reserve', async () => {120 const w = world();121 w.accounts[TO]!.Flags = 0x00020000; // lsfRequireDestTag122 expect(await code(make().prepare(req))).toBe('bad_memo');123 expect(await code(make().prepare({ ...req, memo: '0' }))).toBe('ok');124 w.accounts[TO]!.Flags = 0x01000000; // lsfDepositAuth125 expect(await code(make().prepare({ ...req, memo: '1' }))).toBe('bad_destination');126 w.accounts[TO]!.Flags = 0x00080000; // lsfDisallowXRP127 expect(await code(make().prepare(req))).toBe('bad_destination');128 w.accounts[TO] = undefined;129 expect(await code(make().prepare({ ...req, amount: 999_999n }))).toBe('below_rent');130 expect(await code(make().prepare({ ...req, amount: 1_000_000n }))).toBe('ok');131 });132 it('refuses a disabled master key, a pending transaction, bad input and a failing simulation', async () => {133 const w = world();134 w.accounts[FROM]!.Flags = 0x00100000; // lsfDisableMaster135 expect(await code(make().prepare(req))).toBe('bad_source');136 world({}, { [B]: { account_info: (p) => (p.ledger_index === 'current' ? { account_data: { Sequence: 8, Balance: '1', Flags: 0, OwnerCount: 2 } }137 : { account_data: p.account === FROM ? { Sequence: 7, Balance: '30000000', Flags: 0, OwnerCount: 2 } : { Sequence: 1, Balance: '5000000', Flags: 0, OwnerCount: 0 }, ledger_hash: HASH, ledger_index: p.ledger_index }) } });138 expect(await code(make().prepare(req))).toBe('pending_tx');139 world({ simulate: () => ({ engine_result: 'tecNO_PERMISSION', engine_result_message: 'no' }) });140 expect(await code(make().prepare(req))).toBe('simulation_failed');141 world();142 expect(await code(make().prepare({ ...req, asset: 'rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De' }))).toBe('unsupported_asset');143 expect(await code(make().prepare({ ...req, memo: '4294967296' }))).toBe('bad_memo');144 expect(await code(make().prepare({ ...req, memo: '1.5' }))).toBe('bad_memo');145 expect(await code(make().prepare({ ...req, amount: 0n }))).toBe('bad_amount');146 expect(await code(make().prepare({ ...req, to: FROM }))).toBe('bad_address');147 });148});149150describe('xrpl adapter: tracking', () => {151 const TXID = 'AB'.repeat(32), intent = { x: { lastLedgerSequence: String(TIP - 5) } };152 const t = (a: Adapter, i: unknown = intent) => a.track({ txid: TXID, intent: i, blockRef: null, broadcastAt: 0 });153 const found = (result: string, validated = true) => () => ({ hash: TXID, validated, ledger_index: TIP - 8, meta: { TransactionResult: result, delivered_amount: '2000000' } });154 it('finalized only for a validated tesSUCCESS; a validated failure code is failed', async () => {155 world({ tx: found('tesSUCCESS') });156 expect(await t(make())).toEqual({ state: 'finalized', confirmations: 1, blockRef: String(TIP - 8) });157 world({}, { [B]: { tx: found('tesSUCCESS') } }); // one source has it validated: enough158 expect((await t(make())).state).toBe('finalized');159 world({ tx: found('tecNO_DST_INSUF_XRP') });160 expect(await t(make())).toMatchObject({ state: 'failed', blockRef: String(TIP - 8) });161 world({ tx: found('tesSUCCESS', false) }); // seen but not in a validated ledger162 expect(await t(make())).toEqual({ state: 'confirming', confirmations: 0 });163 world({ tx: () => ({ hash: 'EF'.repeat(32), validated: true, ledger_index: 1, meta: { TransactionResult: 'tesSUCCESS' } }) }); // answer for another hash164 await expect(t(make())).rejects.toThrow(/another hash/);165 });166 it('declares expiry only when every source searched the whole window and is past LastLedgerSequence', async () => {167 const w = world();168 expect(await t(make())).toMatchObject({ state: 'failed', error: expect.stringMatching(/expired.*no funds moved/) });169 expect(w.calls.filter((c) => c.method === 'tx').every((c) => c.params.max_ledger === TIP - 5 && c.params.min_ledger === TIP - 45)).toBe(true);170 const waiting = { state: 'broadcast', confirmations: 0, rebroadcast: true };171 world({}, { [B]: { tx: () => err('txnNotFound', { searched_all: false }) } });172 expect(await t(make())).toEqual(waiting);173 world({}, { [B]: { tx: () => err('txnNotFound') } });174 expect(await t(make())).toEqual(waiting);175 world({}, { [B]: { tx: 'down' } });176 expect(await t(make())).toEqual(waiting);177 world({}, { [B]: { ledger: 'down' } });178 expect(await t(make())).toEqual(waiting);179 world();180 expect(await t(make(), { x: { lastLedgerSequence: String(TIP) } })).toEqual(waiting); // LastLedgerSequence = tip: not past it yet181 expect(await t(make(), { x: { lastLedgerSequence: String(TIP + 10) } })).toEqual(waiting);182 expect(await t(make(), {})).toEqual(waiting); // no window known: never declared dead183 });184});185186describe('xrpl adapter: broadcast, balances, deposits', () => {187 const RAW = '1200002280000000';188 it('accepts applied / queued / already-known; a rejection on every source is an error', async () => {189 for (const r of ['tesSUCCESS', 'terQUEUED', 'tefALREADY', 'tecUNFUNDED_PAYMENT']) { world({ submit: () => ({ engine_result: r }) }); await expect(make().broadcast(RAW), r).resolves.toBeUndefined(); }190 world({ submit: () => ({ engine_result: 'temBAD_SIGNATURE' }) }, { [B]: { submit: () => ({ engine_result: 'tesSUCCESS' }) } });191 await expect(make().broadcast(RAW)).resolves.toBeUndefined();192 for (const r of ['temBAD_SIGNATURE', 'tefPAST_SEQ', 'tefMAX_LEDGER', 'terPRE_SEQ', 'telINSUF_FEE_P', '']) { world({ submit: () => ({ engine_result: r, engine_result_message: 'm' }) }); await expect(make().broadcast(RAW), r).rejects.toThrow(); }193 world({ submit: () => err('invalidTransaction') });194 await expect(make().broadcast(RAW)).rejects.toThrow();195 world({}, { [A]: { submit: 'down' }, [B]: { submit: 'down' } });196 await expect(make().broadcast(RAW)).rejects.toThrow();197 // "sequence already used" is success only if this very transaction is on the ledger198 const w = world({ submit: () => ({ engine_result: 'tefPAST_SEQ' }), tx: (p) => (p.transaction === xrplTxid(RAW) ? { hash: xrplTxid(RAW), validated: true } : err('txnNotFound')) });199 await expect(make().broadcast(RAW)).resolves.toBeUndefined();200 expect(w.calls.find((c) => c.method === 'submit')!.params).toEqual({ tx_blob: RAW });201 });202 it('reads balances (0 for an account that does not exist) with failover', async () => {203 const w = world({}, { [A]: { account_info: 'down' } });204 expect(await make().balances(FROM)).toEqual({ native: 30_000_000n });205 w.accounts[FROM] = undefined;206 expect(await make().balances(FROM)).toEqual({ native: 0n });207 });208 it('lists validated incoming XRP payments only, by delivered_amount', async () => {209 const pay = (hash: string, over: object = {}, meta: object = {}, validated = true) => ({ validated, tx: { TransactionType: 'Payment', Account: TO, Destination: FROM, Amount: '9000000', hash, ledger_index: TIP - 3, date: 847_000_000, ...over },210 meta: { TransactionResult: 'tesSUCCESS', delivered_amount: '1500000', ...meta } });211 const H = (c: string) => c.repeat(64);212 const w = world({ account_tx: () => ({ transactions: [213 pay(H('1')), // good: amount is what was delivered, not the Amount field214 pay(H('2'), {}, { delivered_amount: { currency: 'USD', issuer: TO, value: '5' } }), // issued currency215 pay(H('3'), {}, { TransactionResult: 'tecPATH_DRY' }), // failed216 pay(H('4'), { Account: FROM, Destination: TO }), // outgoing217 pay(H('5'), { TransactionType: 'OfferCreate' }),218 pay(H('6'), {}, {}, false), // not validated219 pay(H('7'), {}, { delivered_amount: 'unavailable' }),220 pay(H('8'), { Destination: TO, Account: 'rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn' }), // between third parties221 { validated: true, hash: H('9'), ledger_index: TIP - 2, tx_json: { TransactionType: 'Payment', Account: TO, Destination: FROM, date: 847_000_001 }, meta: { TransactionResult: 'tesSUCCESS', delivered_amount: '7' } }, // API v2 shape222 ] }) });223 const d = await make().deposits(FROM, null);224 expect(d).toEqual({ cursor: TIP, finalizedHeight: TIP, items: [225 { txid: H('1'), idx: 0, asset: 'native', amount: 1_500_000n, from: TO, block: TIP - 3, time: 847_000_000 + 946_684_800 },226 { txid: H('9'), idx: 0, asset: 'native', amount: 7n, from: TO, block: TIP - 2, time: 847_000_001 + 946_684_800 }] });227 expect(w.calls.find((c) => c.method === 'account_tx')!.params).toMatchObject({ account: FROM, ledger_index_min: -1, ledger_index_max: TIP, forward: false });228 await make().deposits(FROM, TIP - 100);229 expect(w.calls.filter((c) => c.method === 'account_tx').at(-1)!.params).toMatchObject({ ledger_index_min: TIP - 99, ledger_index_max: TIP, forward: true });230 expect(await make().deposits(FROM, TIP)).toEqual({ items: [], cursor: TIP, finalizedHeight: TIP });231 world({ account_tx: () => err('actNotFound') });232 expect(await make().deposits(FROM, null)).toEqual({ items: [], cursor: TIP, finalizedHeight: TIP });233 });234});235