Repository Public
1import { UserError } from '../config.ts';23const TIMEOUT = 9000;4/** One retry on connection-level failures (never on an HTTP answer). Safe for reads; re-sending signed bytes is idempotent on-chain. */5export async function http(url: string, init: RequestInit = {}): Promise<Response> {6 try { return await fetch(url, { ...init, signal: AbortSignal.timeout(TIMEOUT), redirect: 'error' }); }7 catch { await new Promise((r) => setTimeout(r, 300)); return fetch(url, { ...init, signal: AbortSignal.timeout(TIMEOUT), redirect: 'error' }); }8}910let idc = 0;11export async function rpcOne<T>(url: string, method: string, params: unknown[]): Promise<T> {12 const r = await http(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: ++idc, method, params }) });13 if (!r.ok) throw new Error(`rpc http ${r.status}`);14 const j = (await r.json()) as { result?: T; error?: { code: number; message: string } };15 if (j.error) throw Object.assign(new Error(j.error.message), { rpcCode: j.error.code });16 if (j.result === undefined) throw new Error('rpc: empty result');17 return j.result;18}1920/** First endpoint that answers wins (failover). Use for display-only reads. */21export async function rpcAny<T>(urls: string[], method: string, params: unknown[]): Promise<T> {22 let last: unknown;23 for (const u of urls) { try { return await rpcOne<T>(u, method, params); } catch (e) { last = e; if ((e as { rpcCode?: number }).rpcCode !== undefined) throw e; } }24 throw last;25}2627/** Every endpoint must answer and agree. Use for anything that feeds a signature (nonce, chain identity). */28export async function rpcQuorum<T>(urls: string[], method: string, params: unknown[], same: (a: T, b: T) => boolean = (a, b) => JSON.stringify(a) === JSON.stringify(b)): Promise<T> {29 const rs = await Promise.allSettled(urls.map((u) => rpcOne<T>(u, method, params)));30 const ok = rs.flatMap((r) => (r.status === 'fulfilled' ? [r.value as T] : []));31 if (ok.length < urls.length || ok.length === 0) throw new UserError('rpc_degraded', `network data source unavailable (${method}); transfer refused`, 503);32 for (const v of ok) if (!same(ok[0]!, v)) throw new UserError('rpc_disagreement', `network data sources disagree (${method}); transfer refused`, 503);33 return ok[0]!;34}3536export interface Health { network: string; /** every source up and identity verified: transfers possible */ ok: boolean; /** at least one source up: balances readable */ readable: boolean; identityVerified: boolean; height: number | null; endpoints: { url: string; ok: boolean; ms: number | null }[]; checkedAt: string }37export interface Prepared {38 /** chain section merged into the intent (hashed and approved) */39 section: Record<string, unknown>; maxFee: bigint; feeEstimate: bigint; warnings: string[]; ttlSec: number;40 /** unauthenticated helper data for the signer (it re-verifies everything in it against the intent) */41 aux?: Record<string, unknown>;42}43export interface Tracked { state: 'broadcast' | 'confirming' | 'finalized' | 'failed' | 'replaced' | 'reorged'; confirmations: number; blockRef?: string; error?: string; rebroadcast?: boolean }44export interface Deposit { txid: string; idx: number; asset: string; amount: bigint; from: string; block: number; time: number | null }45export interface Adapter {46 health(): Promise<Health>;47 balances(addr: string): Promise<Record<string, bigint>>;48 /** asset ids hidden because their on-chain identity check failed */49 disabledAssets?(): string[];50 prepare(d: { asset: string; from: string; to: string; amount: bigint; memo?: string;51 /** UTXO chains, THORChain vault payment: extra output bytes (the memo), a minimum fee rate, and change always returned to the wallet */52 thor?: { extraBytes: number; minRate: number } }): Promise<Prepared>;53 broadcast(raw: string): Promise<void>;54 track(p: { txid: string; intent: any; blockRef: string | null; broadcastAt: number }): Promise<Tracked>;55 deposits(addr: string, cursor: number | null): Promise<{ items: Deposit[]; cursor: number; finalizedHeight: number }>;56}57export const hostOf = (u: string) => new URL(u.replace(/^[a-z]+:(?=https?:)/, '')).host;58