Repository Public
1// Live READ-ONLY check of the XRP Ledger adapter against mainnet: health + identity, balances, deposit scan, tracking of a2// validated and of an unknown transaction, and prepare() for funded public addresses (proves that the two sources agree).3// Nothing is signed with a real key and nothing is broadcast.4// Run: SPW_ORIGIN=https://smoke.invalid SPW_RP_ID=smoke.invalid DATABASE_URL=postgres://unused SPW_SIGNER_SECRET=unused npx tsx scripts/smoke-xrpl.mts5// (the API config module insists on those variables; none of them is used here)6import { encodeAccountID } from 'xrpl';7import { NETWORKS, formatUnits, pluginFor } from '@spw/core';8import { adapters } from '../services/api/src/chains/index.ts';910const n = NETWORKS.xrpl, a = adapters.xrpl!;11const GENESIS = 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh'; // the ledger's genesis account (requires a destination tag, master key disabled)12const TEST_MNEMONIC_ADDR = 'rHsMGQEkVNJmpGWs8XUBoTBiAAbwxZN5v3'; // m/44'/144'/0'/0/0 of the public test mnemonic (exists on-ledger, master key disabled)13const UNFUNDED = encodeAccountID(new Uint8Array(20).fill(0x5a)); // an account id nobody holds a key for: does not exist on-ledger14// well-known exchange accounts (funded, master key enabled, destination tag required)15const EXCHANGES = ['rEb8TK3gBgk5auZkwc6sHnwrGVJH8DuaLh', 'rDsbeomae4FXwgQTJp9Rs64Qg9vDiTCdBv', 'rLHzPsX6oXkzU2qL12kHCH8G8cnZv1rBJh'];16const xrp = (v: bigint) => formatUnits(v, 6);17const json = (v: unknown) => JSON.stringify(v, (_k, x) => (typeof x === 'bigint' ? x.toString() : x));18const step = async (name: string, f: () => Promise<unknown>) => {19 try { const r = await f(); console.log(name.padEnd(36), typeof r === 'string' ? r : json(r)); }20 catch (e) { console.log(name.padEnd(36), 'REFUSED', (e as { code?: string }).code ?? '', '-', (e as Error).message); }21};2223console.log('sources', n.rpc.join(' , '), '| chainRef', n.chainRef);24const h = await a.health();25console.log('health', json(h));26if (!h.ok || !h.identityVerified) { console.log('FAIL: health'); process.exit(1); }2728const post = async (u: string, method: string, p: object) => ((await (await fetch(u, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ method, params: [p] }) })).json()) as { result: any }).result;29const state = await Promise.all(n.rpc.map((u) => post(u, 'server_state', {})));30console.log('reserves (drops)', state.map((s) => `${s.state.validated_ledger.reserve_base} base + ${s.state.validated_ledger.reserve_inc}/object, network_id=${s.state.network_id}, build=${s.state.build_version}`).join(' | '), '| activation in registry:', n.activation, 'XRP');3132for (const addr of [GENESIS, TEST_MNEMONIC_ADDR, UNFUNDED]) await step(`balance ${addr}`, async () => xrp((await a.balances(addr)).native!) + ' XRP');3334const first = await a.deposits(TEST_MNEMONIC_ADDR, null);35console.log('deposits (first scan)'.padEnd(36), `${first.items.length} incoming XRP payments in the latest page, cursor=${first.cursor}, finalized=${first.finalizedHeight}` +36 (first.items[0] ? `; latest: ${xrp(first.items[0].amount)} XRP from ${first.items[0].from} in ledger ${first.items[0].block} (${first.items[0].txid})` : ''));37const older = await a.deposits(TEST_MNEMONIC_ADDR, first.cursor - 300_000);38console.log('deposits (incremental)'.padEnd(36), `${older.items.length} since ledger ${first.cursor - 300_000}, cursor=${older.cursor}` + older.items.map((d) => `; ${xrp(d.amount)} XRP from ${d.from} in ledger ${d.block} (${d.txid})`).join(''));3940// tracking: a validated transaction found through the deposit scan, then a hash that does not exist41const dep = first.items[0] ?? older.items[0];42if (dep) await step('track validated tx', () => a.track({ txid: dep.txid, intent: { x: { lastLedgerSequence: String(dep.block + 3) } }, blockRef: null, broadcastAt: 0 }));43const tip = h.height!;44await step('track unknown tx (window open)', () => a.track({ txid: 'AB'.repeat(32), intent: { x: { lastLedgerSequence: String(tip + 15) } }, blockRef: null, broadcastAt: 0 }));45await step('track unknown tx (window passed)', () => a.track({ txid: 'AB'.repeat(32), intent: { x: { lastLedgerSequence: String(tip - 50) } }, blockRef: null, broadcastAt: 0 }));4647// prepare(): refusals that need both sources to agree on the accounts first48await step('prepare test-mnemonic → genesis', () => a.prepare({ asset: 'native', from: TEST_MNEMONIC_ADDR, to: GENESIS, amount: 1_000_000n, memo: '1' }));49await step('prepare from unfunded account', () => a.prepare({ asset: 'native', from: UNFUNDED, to: GENESIS, amount: 1_000_000n, memo: '1' }));5051// Full path for funded public accounts whose master key is enabled (exchange accounts, then senders of recent payments):52// two-source agreement, reserves, fee, open-ledger sequence, simulate; returns the section of the intent.53const senders = [...new Set([...EXCHANGES, ...[...first.items, ...older.items].map((d) => d.from)])].slice(0, 8);54let done = 0;55for (const from of senders) {56 if (done >= 1) break;57 try {58 const p = await a.prepare({ asset: 'native', from, to: TEST_MNEMONIC_ADDR, amount: 1_000_000n });59 console.log(`prepare ${from} → ${TEST_MNEMONIC_ADDR} (1 XRP): OK`, json(p));60 // the plugin accepts exactly this section (unsigned build only; no key involved)61 const intent = { v: 1 as const, proposalId: '6f1c1f0e-8f5f-4b1e-9c56-0d6b5f0f1a11', keyId: 'spw-0123456789abcdef', createdAt: 1, expiresAt: 2, policyVersion: 3, network: n.id, chainRef: n.chainRef,62 asset: 'native', symbol: 'XRP', decimals: 6, amount: '1000000', from, to: TEST_MNEMONIC_ADDR, maxFee: p.maxFee.toString(), ...(p.section as { x: Record<string, string> }) };63 console.log('plugin.build on that section:', json(pluginFor(n).build(intent, n).describe));64 await step('prepare same → genesis, no tag', () => a.prepare({ asset: 'native', from, to: GENESIS, amount: 1_000_000n }));65 await step('prepare same → genesis, tag 7', () => a.prepare({ asset: 'native', from, to: GENESIS, amount: 1_000_000n, memo: '7' }));66 await step('prepare same → new account, 1', () => a.prepare({ asset: 'native', from, to: UNFUNDED, amount: 1_000_000n }));67 await step('prepare same → new account, 0.5', () => a.prepare({ asset: 'native', from, to: UNFUNDED, amount: 500_000n }));68 await step('prepare same, more than balance', () => a.prepare({ asset: 'native', from, to: TEST_MNEMONIC_ADDR, amount: 90_000_000_000_000_000n }));69 done += 1;70 } catch (e) { console.log(`prepare ${from} → …: REFUSED ${(e as { code?: string }).code ?? ''} - ${(e as Error).message}`); }71}72console.log(done ? 'SMOKE OK' : 'SMOKE INCOMPLETE: no candidate account passed prepare()');73process.exit(done ? 0 : 1);74