Repository Public
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