SPB Git

Repository Public

stellar.ts 18.0 KB 239 lines typescript

1// Stellar adapter: two independent Horizon instances (n.rpc). Everything that feeds the signature (sequence number, destination2// existence, reserve, base fee, ledger time) is read from both and must agree, or the transfer is refused.3// Classic Stellar operations have no simulation endpoint: prepare() checks the documented failure causes instead4// (source/destination existence, spendable balance after reserve, signing weight, required memo).5import { formatUnits, parseUnits, STELLAR_MIN_CREATE, STELLAR_MIN_FEE, stellarDecodeAccount, stellarEnvelopeTxid, stellarMemoType, type NetworkDef } from '@spw/core';6import { UserError } from '../config.ts';7import { hostOf, http, type Adapter, type Deposit, type Health, type Prepared, type Tracked } from './rpc.ts';89interface Root { network_passphrase: string; history_latest_ledger: number; history_latest_ledger_closed_at: string }10interface Account {11  sequence: string; subentry_count: number; num_sponsoring?: number; num_sponsored?: number;12  balances: { asset_type: string; balance: string; selling_liabilities?: string }[];13  thresholds: { med_threshold: number }; signers: { key: string; weight: number }[]; data?: Record<string, string>;14}15interface Ledger { sequence: number; closed_at: string; base_fee_in_stroops: number; base_reserve_in_stroops: number }16interface FeeStats { last_ledger_base_fee: string; fee_charged: { p90: string } }17interface TxRecord { successful: boolean; ledger: number; hash: string }18interface Op {19  id: string; paging_token: string; type: string; transaction_successful: boolean; transaction_hash: string; created_at: string;20  asset_type?: string; from?: string; to?: string; amount?: string; funder?: string; account?: string; starting_balance?: string;21}22interface Page<T> { _embedded: { records: T[] } }2324/** approval window; the transaction itself stays valid a little longer so it can still be included after a late approval */25const TTL_SEC = 120, VALIDITY_SEC = 180;26/** fee bid bounds in stroops: never below 0.0001 XLM, never above 0.02 XLM (the network charges only what inclusion requires) */27const FEE_FLOOR = 1_000n, FEE_CAP = 200_000n;28/** sources may be this many ledgers apart (≈ 5 s each); local clock and ledger close time may differ by this many seconds */29const MAX_LEDGER_GAP = 6, MAX_CLOCK_SKEW_SEC = 60, STALE_SEC = 300;30/** a "not included" verdict needs every source to have ingested a ledger closed this long after the time bound */31const EXPIRY_MARGIN_SEC = 20;32const TX_CODES: Record<number, string> = { [-2]: 'tx_too_early', [-3]: 'tx_too_late', [-5]: 'tx_bad_seq', [-6]: 'tx_bad_auth', [-7]: 'tx_insufficient_balance', [-8]: 'tx_no_source_account', [-9]: 'tx_insufficient_fee', [-1]: 'tx_failed' };3334/** GET a Horizon resource. Returns null only for Horizon's own "resource missing" answer; any other failure throws. */35async function get<T>(base: string, path: string): Promise<T | null> {36  const r = await http(base + path, { headers: { accept: 'application/json' } });37  if (r.status === 404) {38    const j = (await r.json().catch(() => null)) as { status?: number; type?: string } | null;39    if (j?.status === 404 && typeof j.type === 'string' && j.type.endsWith('/not_found')) return null;40    throw new Error('horizon: unexpected 404');41  }42  if (!r.ok) throw new Error(`horizon http ${r.status}`);43  return (await r.json()) as T;44}45const must = async <T>(base: string, path: string): Promise<T> => { const v = await get<T>(base, path); if (v === null) throw new Error(`horizon: ${path} missing`); return v; };46const secOf = (iso: string): number => { const t = Date.parse(iso); if (!Number.isFinite(t)) throw new Error('horizon: bad timestamp'); return Math.floor(t / 1000); };47const stroops = (s: string | undefined): bigint => parseUnits(s ?? '0', 7);48const uint = (v: unknown, what: string): bigint => { if (typeof v === 'number' ? !Number.isSafeInteger(v) || v < 0 : typeof v !== 'string' || !/^\d{1,19}$/.test(v)) throw new Error(`horizon: bad ${what}`); return BigInt(v as string | number); };4950export function stellarAdapter(n: NetworkDef): Adapter {51  const sources = n.rpc;52  const any = async <T>(f: (u: string) => Promise<T>): Promise<T> => { let last: unknown; for (const u of sources) { try { return await f(u); } catch (e) { last = e; } } throw last; };53  /** every source must answer */54  const all = async <T>(f: (u: string) => Promise<T>, what: string): Promise<T[]> => {55    const rs = await Promise.allSettled(sources.map(f));56    if (sources.length < 2 || rs.some((r) => r.status === 'rejected')) throw new UserError('rpc_degraded', `${n.name} data source unavailable (${what}); transfer refused`, 503);57    return rs.map((r) => (r as PromiseFulfilledResult<T>).value);58  };59  const agree = <T>(vals: T[], what: string): T => {60    for (const v of vals) if (JSON.stringify(v) !== JSON.stringify(vals[0])) throw new UserError('rpc_disagreement', `${n.name} data sources disagree (${what}); transfer refused`, 503);61    return vals[0]!;62  };63  const root = async (u: string): Promise<Root> => {64    const r = await must<Root>(u, '/');65    if (r.network_passphrase !== n.chainRef) throw new Error('network passphrase mismatch');66    if (!Number.isSafeInteger(r.history_latest_ledger) || r.history_latest_ledger <= 0) throw new Error('horizon: bad ledger');67    secOf(r.history_latest_ledger_closed_at);68    return r;69  };70  const account = (u: string, a: string) => get<Account>(u, `/accounts/${a}`);71  const nativeOf = (a: Account) => { const b = a.balances.filter((x) => x.asset_type === 'native'); if (b.length !== 1) throw new Error('horizon: bad balances'); return b[0]!; };72  let identity = false;7374  return {75    async health(): Promise<Health> {76      const endpoints = await Promise.all(sources.map(async (url) => {77        const t = Date.now();78        try {79          const r = await root(url); // verifies the network passphrase on every check80          if (Date.now() / 1000 - secOf(r.history_latest_ledger_closed_at) > STALE_SEC) throw new Error('stale');81          return { url: hostOf(url), ok: true, ms: Date.now() - t, h: r.history_latest_ledger };82        } catch { return { url: hostOf(url), ok: false, ms: null, h: null as number | null }; }83      }));84      const ok = endpoints.length >= 2 && endpoints.every((e) => e.ok);85      if (ok) identity = true;86      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,87        endpoints: endpoints.map(({ h: _h, ...e }) => e), checkedAt: new Date().toISOString() };88    },8990    async balances(addr) {91      const a = await any((u) => account(u, addr));92      return { native: a ? stroops(nativeOf(a).balance) : 0n }; // an account that does not exist yet holds nothing93    },9495    async prepare(d): Promise<Prepared> {96      if (d.asset !== 'native') throw new UserError('unsupported_asset', 'only XLM can be sent on Stellar in this version');97      try { stellarDecodeAccount(d.from); stellarDecodeAccount(d.to); } catch (e) { throw new UserError('bad_address', (e as Error).message); }98      if (d.from === d.to) throw new UserError('bad_address', 'destination is this wallet');99      if (d.amount <= 0n) throw new UserError('bad_amount', 'amount must be greater than zero');100      let memoType: 'none' | 'text' | 'id';101      try { memoType = stellarMemoType(d.memo); } catch (e) { throw new UserError('bad_memo', (e as Error).message); }102      const warnings: string[] = [];103104      // one snapshot per source; both must answer and agree on everything that ends up in the signed bytes105      const snaps = await all(async (u) => {106        const [r, led, fees, src, dst] = await Promise.all([root(u), must<Page<Ledger>>(u, '/ledgers?order=desc&limit=1'), must<FeeStats>(u, '/fee_stats'), account(u, d.from), account(u, d.to)]);107        const l = led._embedded.records[0];108        if (!l) throw new Error('horizon: no ledger');109        const native = src ? nativeOf(src) : null;110        return {111          ledger: Number(uint(l.sequence, 'ledger')), closedAt: secOf(l.closed_at), rootLedger: r.history_latest_ledger,112          protocol: { baseReserve: uint(l.base_reserve_in_stroops, 'reserve').toString(), baseFee: uint(l.base_fee_in_stroops, 'base fee').toString() },113          feeBid: [uint(fees.last_ledger_base_fee, 'fee'), uint(fees.fee_charged.p90, 'fee')].reduce((a, b) => (a > b ? a : b)),114          src: src && native ? {115            sequence: uint(src.sequence, 'sequence').toString(), balance: stroops(native.balance).toString(), selling: stroops(native.selling_liabilities).toString(),116            entries: (uint(src.subentry_count, 'subentries') + uint(src.num_sponsoring ?? 0, 'sponsoring')).toString(), sponsored: uint(src.num_sponsored ?? 0, 'sponsored').toString(),117            medThreshold: Number(uint(src.thresholds.med_threshold, 'threshold')), masterWeight: Number(uint(src.signers.find((s) => s.key === d.from)?.weight ?? 0, 'weight')),118          } : null,119          dstExists: dst !== null, memoRequired: dst?.data?.['config.memo_required'] === 'MQ==',120        };121      }, 'account state');122123      const now = Math.floor(Date.now() / 1000);124      const ledgers = snaps.map((s) => s.ledger);125      if (Math.max(...ledgers) - Math.min(...ledgers) > MAX_LEDGER_GAP) throw new UserError('rpc_disagreement', `${n.name} data sources are not at the same ledger; transfer refused`, 503);126      for (const s of snaps) if (Math.abs(now - s.closedAt) > MAX_CLOCK_SKEW_SEC) throw new UserError('rpc_degraded', `${n.name} ledger time and server clock differ; transfer refused`, 503);127      const protocol = agree(snaps.map((s) => s.protocol), 'network parameters');128      const src = agree(snaps.map((s) => s.src), 'source account');129      const dstExists = agree(snaps.map((s) => s.dstExists), 'destination account');130      const memoRequired = agree(snaps.map((s) => s.memoRequired), 'destination account');131      const baseReserve = BigInt(protocol.baseReserve), baseFee = BigInt(protocol.baseFee);132      if (baseReserve <= 0n || baseFee < STELLAR_MIN_FEE) throw new UserError('rpc_degraded', `${n.name} network parameters look wrong; transfer refused`, 503);133134      if (!src) throw new UserError('insufficient_funds', `this ${n.name} account is not activated yet: it must first receive at least ${n.activation ?? '1'} ${n.native.symbol}`);135      if (src.masterWeight < 1 || src.masterWeight < src.medThreshold) throw new UserError('unsupported_account', 'this Stellar account requires other signers (multi-signature); it cannot send from this wallet');136      if (memoRequired && memoType === 'none') throw new UserError('memo_required', 'the destination requires a memo (it is flagged memo-required, typically an exchange): add the memo it gave you');137      if (memoType === 'id') warnings.push('memo_numeric_id');138139      const op = dstExists ? 'payment' : 'createAccount';140      if (!dstExists) {141        const minCreate = STELLAR_MIN_CREATE > 2n * baseReserve ? STELLAR_MIN_CREATE : 2n * baseReserve;142        if (d.amount < minCreate) throw new UserError('below_reserve', `the destination account does not exist yet: send at least ${formatUnits(minCreate, 7)} ${n.native.symbol} to create it`);143        warnings.push('creates_account');144      }145146      // fee bid: twice the larger of the base fee and the recent 90th-percentile charged fee, within fixed bounds147      const want = 2n * snaps.map((s) => s.feeBid).reduce((a, b) => (a > b ? a : b));148      if (want > FEE_CAP) warnings.push('network_congested');149      const fee = want > FEE_CAP ? FEE_CAP : want < FEE_FLOOR ? FEE_FLOOR : want;150151      // spendable = balance - selling liabilities - (2 + subentries + sponsoring - sponsored) × base reserve152      const reserve = (2n + BigInt(src.entries) - BigInt(src.sponsored)) * baseReserve;153      const spendable = BigInt(src.balance) - BigInt(src.selling) - reserve;154      if (spendable < d.amount + fee) throw new UserError('insufficient_funds',155        `insufficient ${n.native.symbol}: ${formatUnits(spendable > 0n ? spendable : 0n, 7)} spendable (${formatUnits(reserve, 7)} stays locked as the account reserve) for amount plus network fee`);156157      const sequence = BigInt(src.sequence) + 1n;158      return { section: { x: { op, sequence: sequence.toString(), fee: fee.toString(), maxTime: String(now + VALIDITY_SEC), memoType } }, maxFee: fee, feeEstimate: fee, warnings, ttlSec: TTL_SEC };159    },160161    async broadcast(raw) {162      const txid = stellarEnvelopeTxid(n, raw);163      const rs = await Promise.allSettled(sources.map(async (u) => {164        const r = await http(u + '/transactions_async', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, body: 'tx=' + encodeURIComponent(raw) });165        const j = (await r.json().catch(() => null)) as { tx_status?: string; hash?: string; errorResultXdr?: string; error_result_xdr?: string; title?: string } | null;166        if (j?.tx_status === 'PENDING' || j?.tx_status === 'DUPLICATE') { if (j.hash !== txid) throw new Error('horizon answered for another transaction'); return; }167        if (j?.tx_status === 'ERROR') {168          const b = Buffer.from(j.errorResultXdr ?? j.error_result_xdr ?? '', 'base64');169          const code = b.length >= 12 ? b.readInt32BE(8) : 0;170          throw new Error(`rejected by the network: ${TX_CODES[code] ?? `code ${code}`}`);171        }172        throw new Error(j?.tx_status === 'TRY_AGAIN_LATER' ? 'network busy, will retry' : `horizon ${r.status} ${j?.title ?? ''}`.trim());173      }));174      if (rs.some((r) => r.status === 'fulfilled')) return;175      // "already known": a re-submission after inclusion is refused (bad sequence); the ledger is the judge176      const known = await Promise.allSettled(sources.map((u) => get<TxRecord>(u, `/transactions/${txid}`)));177      if (known.some((k) => k.status === 'fulfilled' && k.value?.hash === txid)) return;178      throw new Error(String((rs[0] as PromiseRejectedResult).reason?.message));179    },180181    async track(p): Promise<Tracked> {182      const maxTime = Number(p.intent?.x?.maxTime);183      if (!Number.isSafeInteger(maxTime) || maxTime <= 0) throw new Error('intent has no time bound');184      // per source: ledger position FIRST, then the lookup — a miss is then known to cover every ledger up to that position185      const rs = await Promise.allSettled(sources.map(async (u) => { const r = await root(u); return { r, tx: await get<TxRecord>(u, `/transactions/${p.txid}`) }; }));186      const up = rs.flatMap((r) => (r.status === 'fulfilled' ? [r.value] : []));187      const allUp = up.length === sources.length && up.length >= 2;188      const found = up.filter((s) => s.tx !== null);189      for (const s of found) if (s.tx!.hash !== p.txid || !Number.isSafeInteger(s.tx!.ledger) || typeof s.tx!.successful !== 'boolean') throw new Error('horizon: bad transaction record');190      if (found.length > 0) {191        const ledger = found[0]!.tx!.ledger, good = found[0]!.tx!.successful;192        if (found.some((s) => s.tx!.ledger !== ledger || s.tx!.successful !== good)) throw new Error('data sources disagree on the transaction result');193        const blockRef = String(ledger);194        // a ledger that closed is final on Stellar; every reachable source must show the transaction before we say so195        if (found.length < up.length) return { state: 'confirming', confirmations: 1, blockRef };196        if (good) return { state: 'finalized', confirmations: Math.max(1, Math.max(...up.map((s) => s.r.history_latest_ledger)) - ledger + 1), blockRef };197        // included but failed (fee charged, nothing transferred): only final once every source is reachable and agrees198        if (!allUp) return { state: 'confirming', confirmations: 1, blockRef };199        return { state: 'failed', confirmations: 0, blockRef, error: 'included in a ledger but the operation failed; only the network fee was charged, no funds moved' };200      }201      if (allUp && up.every((s) => secOf(s.r.history_latest_ledger_closed_at) > maxTime + EXPIRY_MARGIN_SEC))202        return { state: 'failed', confirmations: 0, error: 'not included before its time bound; no funds moved' };203      return { state: 'broadcast', confirmations: 0, rebroadcast: Date.now() / 1000 <= maxTime };204    },205206    async deposits(addr, cursor) {207      return any(async (u) => {208        const latest = (await root(u)).history_latest_ledger; // read before the listing: nothing closed up to here can be missed209        const items: Deposit[] = [];210        const add = (o: Op) => {211          if (o.transaction_successful !== true || !/^\d{1,19}$/.test(o.id)) return;212          const id = BigInt(o.id), block = Number(id >> 32n), idx = Number(id & 0xfffn); // operation id = ledger << 32 | tx order << 12 | op index213          const base = { txid: o.transaction_hash, idx, asset: 'native', block, time: secOf(o.created_at) };214          if (o.type === 'payment' && o.asset_type === 'native' && o.to === addr && o.from !== addr && o.amount) items.push({ ...base, amount: stroops(o.amount), from: o.from ?? '' });215          else if (o.type === 'create_account' && o.account === addr && o.starting_balance) items.push({ ...base, amount: stroops(o.starting_balance), from: o.funder ?? '' });216        };217        const path = (q: string) => `/accounts/${addr}/payments?${q}`;218        if (cursor === null) {219          for (const o of (await get<Page<Op>>(u, path('order=desc&limit=50')))?._embedded.records ?? []) add(o);220        } else {221          let token = ((BigInt(cursor) + 1n) << 32n).toString(); // everything in ledgers after `cursor`222          for (let page = 0; page < 5; page++) {223            const recs = (await get<Page<Op>>(u, path(`order=asc&limit=200&cursor=${token}`)))?._embedded.records ?? [];224            for (const o of recs) add(o);225            if (recs.length < 200) break;226            token = recs[recs.length - 1]!.paging_token;227            if (page === 4) { // still more to read: resume next time from the last ledger fully covered228              const last = Number(BigInt(token) >> 32n) - 1;229              return { items, cursor: Math.max(cursor, last), finalizedHeight: latest };230            }231          }232        }233        // two ledgers of overlap in case a replica behind the same hostname lags (inserts are idempotent)234        return { items, cursor: Math.max(cursor ?? 0, latest - 2), finalizedHeight: latest };235      });236    },237  };238}239
Files main
Menu

Theme