SPB Git

Repository Public

xrpl.ts 19.9 KB 259 lines typescript

1// XRP Ledger adapter: native XRP only. JSON-RPC over HTTPS (rippled / Clio), two independent sources.2// Everything that feeds the signature (sequence, reference ledger, fee, reserves, account existence and flags) is read3// from both sources at the same validated ledger and must be identical, otherwise the transfer is refused.4import { xrplTxid, type NetworkDef } from '@spw/core';5import { UserError } from '../config.ts';6import { hostOf, http, type Adapter, type Deposit, type Health, type Prepared, type Tracked } from './rpc.ts';78/** Chain identity: a pinned early ledger (index → hash = chainRef) and the network id reported by the servers. */9const IDENTITY: Record<string, { ledger: number; networkId: number }> = {10  '4109C6F2045FC7EFF4CDE8F9905D19C28820D86304080FF886B299F0206E42B5': { ledger: 32570, networkId: 0 },11};12const LSF_REQUIRE_DEST_TAG = 0x0002_0000, LSF_DISALLOW_XRP = 0x0008_0000, LSF_DISABLE_MASTER = 0x0010_0000, LSF_DEPOSIT_AUTH = 0x0100_0000;13const TF_FULLY_CANONICAL_SIG = 0x8000_0000;14/** validated ledger + 20 ≈ 70–80 s; the approval window (45 s) leaves several ledgers for inclusion */15const LEDGER_WINDOW = 20, TTL_SEC = 45;16/** the fee offered never exceeds this (drops); equals the default policy ceiling */17const FEE_CAP = 100_000n;18const RIPPLE_EPOCH = 946_684_800;19/** Answers about the ledger's content (as opposed to a server being unable to answer). */20const DEFINITE = new Set(['actNotFound', 'txnNotFound']);2122interface AccountRoot { Sequence: number; Balance: string; Flags: number; OwnerCount: number }23interface AccountInfo { account_data: AccountRoot; ledger_hash?: string; ledger_index?: number; ledger_current_index?: number; validated?: boolean }24interface LedgerRes { ledger_hash?: string; ledger_index?: number | string; validated?: boolean; ledger?: { ledger_hash?: string; ledger_index?: number | string; closed?: boolean } }25interface FeeRes { drops: { base_fee: string; minimum_fee: string; open_ledger_fee: string } }26interface StateRes { state: { network_id?: number; validated_ledger?: { seq: number; hash: string; base_fee: number; reserve_base: number; reserve_inc: number } } }27interface TxRes { hash?: string; validated?: boolean; ledger_index?: number; meta?: { TransactionResult?: string; delivered_amount?: unknown }; tx_json?: { hash?: string } }28interface SubmitRes { engine_result?: string; engine_result_message?: string }29interface AccountTxEntry { validated?: boolean; hash?: string; ledger_index?: number; meta?: { TransactionResult?: string; delivered_amount?: unknown } | string;30  tx?: Record<string, unknown>; tx_json?: Record<string, unknown> }31interface AccountTxRes { transactions: AccountTxEntry[]; marker?: unknown }3233async function call<T>(url: string, method: string, params: Record<string, unknown>): Promise<T> {34  const r = await http(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ method, params: [params] }) });35  if (!r.ok) throw new Error(`rpc http ${r.status}`);36  const j = (await r.json()) as { result?: { status?: string; error?: string; error_message?: string; error_exception?: string } };37  const res = j.result;38  if (!res || typeof res !== 'object') throw new Error('rpc: empty result');39  if (res.error !== undefined || res.status === 'error')40    throw Object.assign(new Error(String(res.error_message ?? res.error_exception ?? res.error ?? 'rpc error')), { xrpl: String(res.error ?? 'error'), body: res });41  return res as T;42}43const codeOf = (e: unknown): string | undefined => (e as { xrpl?: string }).xrpl;44const isDefinite = (e: unknown) => DEFINITE.has(codeOf(e) ?? '');45const uint = (v: unknown, what: string): number => { if (typeof v !== 'number' || !Number.isSafeInteger(v) || v < 0) throw new Error(`rpc: bad ${what}`); return v; };46const dropsOf = (v: unknown, what: string): bigint => { if (typeof v !== 'string' || !/^(0|[1-9]\d{0,17})$/.test(v)) throw new Error(`rpc: bad ${what}`); return BigInt(v); };47const fmtXrp = (d: bigint) => { const f = (d % 1_000_000n).toString().padStart(6, '0').replace(/0+$/, ''); return `${d / 1_000_000n}${f ? '.' + f : ''}`; };4849export function xrplAdapter(n: NetworkDef): Adapter {50  const ident = IDENTITY[n.chainRef];51  if (!ident) throw new Error(`no pinned identity for XRP Ledger network ${n.id}`);52  const sources = n.rpc;53  if (sources.length < 2) throw new Error('XRP Ledger needs two independent sources');54  let identity = false;5556  /** First source that gives an answer about the ledger wins (display-only reads). */57  const any = async <T>(f: (u: string) => Promise<T>): Promise<T> => {58    let last: unknown;59    for (const u of sources) { try { return await f(u); } catch (e) { last = e; if (isDefinite(e)) throw e; } }60    throw last;61  };62  /** Every source must answer; used for anything that feeds a signature or declares a transfer dead. */63  const all = async <T>(what: string, f: (u: string) => Promise<T>): Promise<T[]> => {64    const rs = await Promise.allSettled(sources.map(f));65    return rs.map((r) => {66      if (r.status === 'fulfilled') return r.value;67      if (r.reason instanceof UserError) throw r.reason;68      throw new UserError('rpc_degraded', `${n.name} data source unavailable (${what}); transfer refused`, 503);69    });70  };71  const agree = <T>(what: string, vs: T[]): T => {72    for (const v of vs) if (JSON.stringify(v) !== JSON.stringify(vs[0])) throw new UserError('rpc_disagreement', `${n.name} data sources disagree (${what}); transfer refused`, 503);73    return vs[0]!;74  };7576  const validated = async (u: string): Promise<{ index: number; hash: string }> => {77    const r = await call<LedgerRes>(u, 'ledger', { ledger_index: 'validated' });78    const index = Number(r.ledger_index ?? r.ledger?.ledger_index), hash = r.ledger_hash ?? r.ledger?.ledger_hash;79    if (!Number.isSafeInteger(index) || index <= ident.ledger || typeof hash !== 'string' || !/^[0-9A-F]{64}$/.test(hash)) throw new Error('rpc: bad validated ledger');80    return { index, hash };81  };82  /** The pinned early ledger must carry the pinned hash and the server must report the expected network id. */83  const checkIdentity = async (u: string): Promise<void> => {84    const r = await call<LedgerRes>(u, 'ledger', { ledger_index: ident.ledger });85    if ((r.ledger_hash ?? r.ledger?.ledger_hash) !== n.chainRef) throw new Error('identity: pinned ledger hash mismatch');86    const s = await call<StateRes>(u, 'server_state', {});87    if (s.state.network_id !== undefined && s.state.network_id !== ident.networkId) throw new Error('identity: network id mismatch');88  };89  /** Account root at one exact validated ledger; null when the account does not exist in that ledger. */90  const accountAt = async (u: string, addr: string, ledger: number): Promise<{ seq: number; balance: string; flags: number; owners: number; ledgerHash: string } | null> => {91    try {92      const r = await call<AccountInfo>(u, 'account_info', { account: addr, ledger_index: ledger });93      const d = r.account_data;94      if (r.ledger_index !== ledger || typeof r.ledger_hash !== 'string') throw new Error('rpc: account_info answered for another ledger');95      return { seq: uint(d.Sequence, 'Sequence'), balance: dropsOf(d.Balance, 'Balance').toString(), flags: uint(d.Flags, 'Flags'), owners: uint(d.OwnerCount, 'OwnerCount'), ledgerHash: r.ledger_hash };96    } catch (e) { if (codeOf(e) === 'actNotFound') return null; throw e; }97  };98  const lookup = async (u: string, txid: string, range?: { min: number; max: number }): Promise<{ found: TxRes | null; searchedAll: boolean }> => {99    try { return { found: await call<TxRes>(u, 'tx', { transaction: txid, binary: false, ...(range ? { min_ledger: range.min, max_ledger: range.max } : {}) }), searchedAll: false }; }100    catch (e) { if (codeOf(e) === 'txnNotFound') return { found: null, searchedAll: (e as { body?: { searched_all?: unknown } }).body?.searched_all === true }; throw e; }101  };102103  return {104    async health(): Promise<Health> {105      const endpoints = await Promise.all(sources.map(async (url) => {106        const t = Date.now();107        try {108          const v = await validated(url);109          if (!identity) await checkIdentity(url);110          return { url: hostOf(url), ok: true, ms: Date.now() - t, h: v.index };111        } catch { return { url: hostOf(url), ok: false, ms: null, h: null as number | null }; }112      }));113      const ok = endpoints.every((e) => e.ok);114      if (ok) identity = true;115      return { network: n.id, ok: ok && identity, readable: endpoints.some((e) => e.ok), identityVerified: identity, height: Math.max(0, ...endpoints.map((e) => e.h ?? 0)) || null,116        endpoints: endpoints.map(({ h: _h, ...e }) => e), checkedAt: new Date().toISOString() };117    },118119    async balances(addr) {120      try {121        const r = await any((u) => call<AccountInfo>(u, 'account_info', { account: addr, ledger_index: 'validated' }));122        return { native: dropsOf(r.account_data.Balance, 'Balance') };123      } catch (e) { if (codeOf(e) === 'actNotFound') return { native: 0n }; throw e; } // not activated yet124    },125126    async prepare(d): Promise<Prepared> {127      if (d.asset !== 'native') throw new UserError('unsupported_asset', 'only XRP can be sent on the XRP Ledger');128      if (d.amount <= 0n) throw new UserError('bad_amount', 'amount must be greater than zero');129      if (d.memo !== undefined && (!/^(0|[1-9]\d{0,9})$/.test(d.memo) || Number(d.memo) > 0xffff_ffff)) throw new UserError('bad_memo', 'the destination tag must be a whole number between 0 and 4294967295');130      if (d.from === d.to) throw new UserError('bad_address', 'destination is this wallet');131      if (!identity) { await all('identity', checkIdentity); identity = true; }132133      // one reference ledger, validated on both sources, with the same hash on both134      const tips = await all('ledger', validated);135      const ledger = Math.min(...tips.map((t) => t.index));136      if (Math.max(...tips.map((t) => t.index)) - ledger > 5) throw new UserError('rpc_disagreement', `${n.name} data sources are not at the same ledger; transfer refused`, 503);137138      const [states, fees, srcs, dsts] = await Promise.all([139        all('server_state', async (u) => { const v = (await call<StateRes>(u, 'server_state', {})).state.validated_ledger; if (!v) throw new Error('no validated ledger');140          return { base: uint(v.base_fee, 'base_fee'), reserve: uint(v.reserve_base, 'reserve_base'), inc: uint(v.reserve_inc, 'reserve_inc') }; }),141        all('fee', async (u) => { const f = (await call<FeeRes>(u, 'fee', {})).drops; return { base: dropsOf(f.base_fee, 'base_fee'), open: dropsOf(f.open_ledger_fee, 'open_ledger_fee') }; }),142        all('account_info', (u) => accountAt(u, d.from, ledger)),143        all('account_info', (u) => accountAt(u, d.to, ledger)),144      ]);145      const rules = agree('reserves', states);146      const src = agree('source account', srcs), dst = agree('destination account', dsts);147      const baseFee = agree('base fee', fees.map((f) => f.base.toString()));148      if (BigInt(rules.base) !== BigInt(baseFee) || rules.reserve <= 0 || rules.inc < 0) throw new UserError('rpc_disagreement', `${n.name} data sources disagree (fee settings); transfer refused`, 503);149      const reserveBase = BigInt(rules.reserve), reserveInc = BigInt(rules.inc);150151      // fee: the highest open-ledger cost seen by either source plus 20 % (the whole fee is destroyed, so no more than that)152      let fee = BigInt(baseFee);153      for (const f of fees) if (f.open > fee) fee = f.open;154      fee = (fee * 12n + 9n) / 10n;155      if (fee <= 0n) throw new UserError('rpc_degraded', `${n.name} fee data unusable; transfer refused`, 503);156      // congestion spike: never pay more than the cap; a payment at the cap waits in the queue and, if it is not included157      // by LastLedgerSequence, expires without moving anything158      if (fee > FEE_CAP) fee = FEE_CAP;159160      if (!src) throw new UserError('insufficient_funds', `this wallet's XRP account is not activated yet: it must first receive at least ${fmtXrp(reserveBase)} XRP`);161      if (src.flags & LSF_DISABLE_MASTER) throw new UserError('bad_source', 'the master key of this XRP account is disabled; this wallet cannot sign for it');162      const warnings: string[] = [];163      if (dst) {164        if (dst.flags & LSF_REQUIRE_DEST_TAG && d.memo === undefined) throw new UserError('bad_memo', 'this destination requires a destination tag: without it the deposit would be refused or lost');165        if (dst.flags & LSF_DEPOSIT_AUTH) throw new UserError('bad_destination', 'this destination only accepts payments from pre-authorized senders (Deposit Authorization)');166        if (dst.flags & LSF_DISALLOW_XRP) throw new UserError('bad_destination', 'this destination has declared that it does not accept XRP');167      } else {168        if (d.amount < reserveBase) throw new UserError('below_rent', `destination account does not exist yet: send at least ${fmtXrp(reserveBase)} XRP to activate it`);169        // the payment creates the account; the base reserve then stays locked in it (no warning key exists in the web app for this)170      }171      // the source keeps its own reserve: base + one increment per owned object172      const locked = reserveBase + BigInt(src.owners) * reserveInc, balance = BigInt(src.balance);173      if (balance < d.amount + fee + locked) throw new UserError('insufficient_funds', `insufficient XRP: ${fmtXrp(locked)} XRP must remain in the wallet as the account reserve (spendable: ${fmtXrp(balance > locked + fee ? balance - locked - fee : 0n)} XRP)`);174175      // nothing of ours may still be in flight: the open ledger of every source must show the same sequence176      const open = await all('account_info', async (u) => uint((await call<AccountInfo>(u, 'account_info', { account: d.from, ledger_index: 'current' })).account_data.Sequence, 'Sequence'));177      if (open.some((s) => s !== src.seq)) throw new UserError('pending_tx', 'a previous transaction from this wallet is still being confirmed; retry in a few seconds', 409);178179      const lastLedgerSequence = ledger + LEDGER_WINDOW;180      // dry run of the exact payment on the current open ledger (rippled `simulate`; nothing is signed or submitted)181      const tx_json = { TransactionType: 'Payment', Account: d.from, Destination: d.to, Amount: d.amount.toString(), Fee: fee.toString(), Flags: TF_FULLY_CANONICAL_SIG,182        Sequence: src.seq, LastLedgerSequence: lastLedgerSequence, ...(d.memo !== undefined ? { DestinationTag: Number(d.memo) } : {}) };183      let sim: { engine_result?: string; engine_result_message?: string };184      try { sim = await any((u) => call<{ engine_result?: string; engine_result_message?: string }>(u, 'simulate', { tx_json })); }185      catch (e) { throw new UserError('rpc_degraded', `${n.name} simulation unavailable (${(e as Error).message.slice(0, 80)}); transfer refused`, 503); }186      if (sim.engine_result !== 'tesSUCCESS' && sim.engine_result !== 'terQUEUED') throw new UserError('simulation_failed', `simulation failed: ${String(sim.engine_result)} ${String(sim.engine_result_message ?? '').slice(0, 160)}`);187188      return { section: { x: { sequence: String(src.seq), lastLedgerSequence: String(lastLedgerSequence), fee: fee.toString() } }, maxFee: fee, feeEstimate: fee, warnings, ttlSec: TTL_SEC };189    },190191    async broadcast(raw) {192      const txid = xrplTxid(raw);193      const rs = await Promise.allSettled(sources.map(async (u) => {194        const r = await call<SubmitRes>(u, 'submit', { tx_blob: raw });195        const code = String(r.engine_result ?? '');196        // applied to the open ledger, queued, or already there. "tec" = relayed and will be included but only burns the fee: track() reports it as failed.197        if (code.startsWith('tes') || code === 'terQUEUED' || code === 'tefALREADY' || code.startsWith('tec')) return;198        // "sequence already used" / "too late": fine only if this very transaction is the one that made it into a ledger199        if ((code === 'tefPAST_SEQ' || code === 'tefMAX_LEDGER') && (await lookup(u, txid)).found) return;200        throw new Error(`${code || 'rejected'}: ${String(r.engine_result_message ?? '').slice(0, 160)}`);201      }));202      if (rs.some((r) => r.status === 'fulfilled')) return;203      throw new Error(String((rs[0] as PromiseRejectedResult).reason?.message));204    },205206    async track(p): Promise<Tracked> {207      const last = Number(p.intent?.x?.lastLedgerSequence);208      const range = Number.isSafeInteger(last) && last > 2 * LEDGER_WINDOW ? { min: last - 2 * LEDGER_WINDOW, max: last } : undefined;209      const rs = await Promise.allSettled(sources.map((u) => lookup(u, p.txid, range)));210      const seen = rs.flatMap((r) => (r.status === 'fulfilled' && r.value.found ? [r.value.found] : []));211      const done = seen.find((t) => t.validated === true);212      if (done) {213        if (String(done.hash ?? done.tx_json?.hash ?? '').toUpperCase() !== p.txid.toUpperCase()) throw new Error('rpc: tx answered for another hash');214        const code = String(done.meta?.TransactionResult ?? '');215        const blockRef = done.ledger_index !== undefined ? String(done.ledger_index) : undefined;216        if (code === 'tesSUCCESS') return { state: 'finalized', confirmations: Math.max(1, n.minConf), blockRef };217        return { state: 'failed', confirmations: 0, blockRef, error: `ledger result ${code || 'unknown'}: the payment was not made (only the network fee was charged)` };218      }219      if (seen.length > 0) return { state: 'confirming', confirmations: 0 };220      // Not found. It is dead only when every source has searched every ledger up to LastLedgerSequence and is past it.221      if (range && rs.every((r) => r.status === 'fulfilled' && r.value.searchedAll)) {222        const tips = await Promise.allSettled(sources.map(validated));223        if (tips.every((t) => t.status === 'fulfilled' && t.value.index > last))224          return { state: 'failed', confirmations: 0, error: 'expired before inclusion (LastLedgerSequence passed); no funds moved' };225      }226      return { state: 'broadcast', confirmations: 0, rebroadcast: true };227    },228229    async deposits(addr, cursor) {230      const tip = (await any(validated)).index;231      const items: Deposit[] = [];232      if (cursor !== null && cursor >= tip) return { items, cursor, finalizedHeight: tip };233      let marker: unknown, lastLedger = 0, pages = 0;234      do {235        let r: AccountTxRes;236        try {237          r = await any((u) => call<AccountTxRes>(u, 'account_tx', { account: addr, ledger_index_min: cursor === null ? -1 : cursor + 1, ledger_index_max: tip, limit: 50, forward: cursor !== null, ...(marker !== undefined ? { marker } : {}) }));238        } catch (e) { if (codeOf(e) === 'actNotFound') return { items, cursor: tip, finalizedHeight: tip }; throw e; } // not activated yet239        for (const e of r.transactions) {240          const t = e.tx ?? e.tx_json, m = e.meta;241          if (e.validated !== true || !t || !m || typeof m !== 'object') continue;242          const ledger = Number(t.ledger_index ?? e.ledger_index), txid = String(t.hash ?? e.hash ?? '');243          if (!Number.isSafeInteger(ledger) || !/^[0-9A-F]{64}$/.test(txid)) continue;244          lastLedger = Math.max(lastLedger, ledger);245          // incoming, successful, native: delivered_amount is a string of drops for XRP (an object for issued currencies, which are ignored)246          if (t.TransactionType !== 'Payment' || t.Destination !== addr || t.Account === addr || m.TransactionResult !== 'tesSUCCESS') continue;247          if (typeof m.delivered_amount !== 'string' || !/^[1-9]\d{0,17}$/.test(m.delivered_amount)) continue;248          items.push({ txid, idx: 0, asset: 'native', amount: BigInt(m.delivered_amount), from: String(t.Account ?? ''), block: ledger, time: typeof t.date === 'number' ? t.date + RIPPLE_EPOCH : null });249        }250        marker = cursor === null ? undefined : r.marker; // first scan: the most recent page only251        pages += 1;252      } while (marker !== undefined && pages < 5);253      // more history than five pages: resume from the last ledger seen (re-read on the next pass; duplicates are ignored by the caller)254      const next = marker !== undefined && cursor !== null && lastLedger - 1 > cursor ? lastLedger - 1 : tip;255      return { items, cursor: next, finalizedHeight: tip };256    },257  };258}259
Files main
Menu

Theme