Repository Public
1// Shared body of scripts/smoke-swap-thorchain.mts and scripts/smoke-swap-maya.mts.2// Live and READ-ONLY: GET requests to the network's public node and to CoinGecko. Nothing is built, signed or sent.3// 1. node version vs the pinned one, 2. live pool list vs the pinned asset table (Available only, registry-mapped),4// 3. inbound_addresses: halts, pauses, routers vs the pinned routers, 4. mimir halt/pause keys that are set,5// 5. real quotes for realistic pairs (≈ 40 USD) through the provider runtime, with latency, checked by the aggregator's6// structural validation and compared with a CoinGecko reference rate. Exits 1 on any failure.7import { formatUnits, NETWORKS } from '@spw/core';8import { assetKey as assetKeyOf, checkQuote, DEFAULT_POLICY, httpClient, ProviderError, ProviderRuntime, resolveAsset, type AssetRef, type SwapQuoteRequest } from '../../packages/swap/src/index.ts';9import { chainBlocked, createNativeSwapProvider, InboundSchema, mapPools, type NativeSwapConfig } from '../../packages/swap/src/providers/thorchain.ts';1011export interface SmokePair { from: AssetRef; to: AssetRef; usd?: number; units?: number; slippageBps?: number; expect?: 'quote' | 'no_route' | 'unsupported'; note?: string }12export const native = (network: string): AssetRef => ({ network, asset: 'native' });13export const token = (network: string, symbol: string): AssetRef => ({ network, asset: NETWORKS[network]!.tokens.find((t) => t.symbol === symbol)!.id });1415const COINGECKO: Record<string, string> = { BTC: 'bitcoin', ETH: 'ethereum', DOGE: 'dogecoin', LTC: 'litecoin', BCH: 'bitcoin-cash', USDC: 'usd-coin', USDT: 'tether', DAI: 'dai',16 XRP: 'ripple', TRX: 'tron', SOL: 'solana', ATOM: 'cosmos', AVAX: 'avalanche-2', BNB: 'binancecoin', WBTC: 'wrapped-bitcoin', AAVE: 'aave' };17// well-known public addresses, used only as quote parameters (the quote endpoint validates the destination's format)18const ADDRESS: Record<string, string> = {19 evm: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', bitcoin: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', dogecoin: 'DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L',20 xrpl: 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh', tron: 'TV6MuMXfmLbBqPZvBHdwFsDnQeVfnmiuSi', solana: 'vines1vzrYbzLMRdu58ou5XTby4qAqVRLmqo36NKPTg',21 litecoin: 'ltc1qw508d6qejxtdg4y5r3zarvary0c5xw7kgmn4n9', bitcoincash: 'qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a',22};23const addressOn = (network: string) => ADDRESS[NETWORKS[network]!.family === 'evm' ? 'evm' : network] ?? 'UNKNOWN';24const label = (a: AssetRef) => { const { net, def } = resolveAsset(a); return a.asset === 'native' && net.family !== 'evm' ? def.symbol : `${def.symbol}(${net.id})`; };2526export async function smokeNativeSwap(cfg: NativeSwapConfig, pinnedVersion: string, pairs: SmokePair[]): Promise<void> {27 let failures = 0, warnings = 0;28 const check = (ok: boolean, what: string, detail = '') => { console.log(`${ok ? 'PASS' : 'FAIL'} ${what}${detail ? ' ' + detail : ''}`); if (!ok) failures += 1; };29 const warn = (what: string) => { console.log(`WARN ${what}`); warnings += 1; };30 const get = async (url: string): Promise<{ body: any; ms: number }> => {31 for (let k = 0; ; k++) {32 const t0 = Date.now();33 try { const r = await fetch(url, { headers: { accept: 'application/json', 'x-client-id': 'sp-wallet' }, redirect: 'error', signal: AbortSignal.timeout(20_000) }); if (!r.ok) throw new Error(`http ${r.status}`); return { body: await r.json(), ms: Date.now() - t0 }; }34 catch (e) { if (k >= 2) throw new Error(`${url}: ${(e as Error).message}`); await new Promise((res) => setTimeout(res, 1500)); }35 }36 };37 const provider = createNativeSwapProvider(cfg);38 console.log(`== ${cfg.name} (${cfg.id}) — ${cfg.api} — ${new Date().toISOString()}`);3940 // ---- 1. version41 const v = await get(`${cfg.api}/version`);42 if (v.body.current === pinnedVersion) check(true, 'node version is the pinned one', `${v.body.current} (${v.ms} ms)`); else warn(`node version ${v.body.current} differs from the pinned ${pinnedVersion}: re-verify the adapter against the release notes`);4344 // ---- 2. pools45 const pools = (await get(`${cfg.api}/pools`)).body as { asset: string; status: string; decimals?: number; trading_halted?: boolean }[];46 const chains = Object.keys(cfg.chains);47 for (const n of cfg.pools) { const p = pools.find((x) => x.asset === n); check(p?.status === 'Available', `pinned pool ${n} is Available`, p ? `status=${p.status}` : 'missing'); if (p?.trading_halted) warn(`${n}: trading halted right now — quotes on this pool return no route`); }48 const live = mapPools({ chains: cfg.chains, pools: pools.filter((x) => x.status === 'Available' && chains.includes(x.asset.split('.')[0]!)).map((x) => x.asset) });49 for (const [k, a] of live) if (!provider.assets.has(k)) warn(`${a.notation} is Available and in the wallet registry but not pinned in the adapter`);50 for (const a of provider.assets.values()) { const d = pools.find((x) => x.asset === a.notation)?.decimals; check(d ? d === a.decimals : a.decimals >= 8, `decimals of ${a.notation}`, `registry ${a.decimals}, pool ${d ?? '(≥ 8, not reported)'}`); }51 console.log(` ${pools.length} pools live, ${pools.filter((x) => x.status === 'Available').length} Available, ${provider.assets.size} served by the wallet: ${[...provider.assets.values()].map((a) => a.notation.split('-')[0]).join(' ')}`);5253 // ---- 3. inbound addresses54 const inb = InboundSchema.parse((await get(`${cfg.api}/inbound_addresses`)).body);55 for (const chain of chains) {56 const e = inb.find((x) => x.chain === chain);57 if (!e) { warn(`${chain}: no inbound address`); continue; }58 const blocked = e.halted || e.global_trading_paused || e.chain_trading_paused;59 if (blocked) warn(`${chain}: halted=${e.halted} global_trading_paused=${e.global_trading_paused} chain_trading_paused=${e.chain_trading_paused} — quotes on this chain return no route`);60 const pinned = cfg.routers[cfg.chains[chain]!];61 if (pinned || e.router) check(!!pinned && e.router?.toLowerCase() === pinned.toLowerCase(), `${chain} router is the pinned one`, `${e.router ?? '(none)'}`);62 console.log(` ${chain.padEnd(5)} vault ${e.address} dust ${e.dust_threshold ?? '-'} outbound_fee ${e.outbound_fee ?? '-'}${blocked ? ' BLOCKED' : ''}`);63 }6465 // ---- 4. mimir66 const mimir = (await get(`${cfg.api}/mimir`)).body as Record<string, number>;67 const set = Object.entries(mimir).filter(([k, val]) => /^(HALT|PAUSE|SOLVENCYHALT|NODEPAUSE)/.test(k) && val !== 0 && /TRADING|CHAIN|SIGNING|GLOBAL/.test(k) && !/LP|WASM|CHURN/.test(k));68 console.log(` mimir halt/pause keys set: ${set.length ? set.map(([k, val]) => `${k}=${val}`).join(' ') : 'none'}`);6970 // ---- 5. quotes71 const ids = [...new Set(pairs.flatMap((p) => [p.from, p.to]).map((a) => COINGECKO[resolveAsset(a).def.symbol]).filter(Boolean))];72 const prices = (await get(`https://api.coingecko.com/api/v3/simple/price?ids=${ids.join(',')}&vs_currencies=usd`)).body as Record<string, { usd: number }>;73 const usd = (a: AssetRef) => prices[COINGECKO[resolveAsset(a).def.symbol]!]?.usd;74 console.log(` CoinGecko: ${ids.map((i) => `${i}=${prices[i]?.usd}`).join(' ')}`);75 const rt = new ProviderRuntime(provider, { ...DEFAULT_POLICY, timeoutMs: 12_000, ratePerSec: 5, burst: 20, breakerFailures: 50 }, httpClient(cfg.id));76 const lat: number[] = [];77 for (const p of pairs) {78 const from = resolveAsset(p.from).def, to = resolveAsset(p.to).def, name = `${label(p.from)} → ${label(p.to)}`;79 const units = p.units ?? (p.usd ?? 40) / usd(p.from)!;80 const amountIn = BigInt(Math.round(units * 1e6)) * 10n ** BigInt(from.decimals) / 1_000_000n; // sizing only; the adapter itself never uses floats81 const req: SwapQuoteRequest = { from: p.from, to: p.to, amountIn, sender: addressOn(p.from.network), recipient: addressOn(p.to.network), slippageBps: p.slippageBps ?? 100 };82 const want = p.expect ?? 'quote';83 if (!provider.supportsPair(p.from, p.to)) { check(want === 'unsupported', `${name}: not served by ${cfg.name}`, p.note ?? ''); continue; }84 let quotes, ms = 0, tries = 0;85 for (;;) {86 const t0 = Date.now(); tries += 1;87 try { quotes = await rt.quote(req); ms = Date.now() - t0; break; }88 catch (e) { const err = e as ProviderError; if ((err.code === 'unavailable' || err.code === 'timeout') && tries < 8) { console.log(` ${name}: ${err.code} (${err.message.slice(0, 70)}) — retry ${tries}`); await new Promise((res) => setTimeout(res, 4000)); continue; } check(false, `${name}: provider error`, `${err.code}: ${err.message}`); break; }89 }90 if (!quotes) continue;91 if (quotes.length === 0) {92 // a live halt is a correct "no route": confirm it on a fresh read rather than failing the run93 const now = InboundSchema.parse((await get(`${cfg.api}/inbound_addresses`)).body);94 const halted = [provider.assets.get(assetKeyOf(p.from))!.chain, provider.assets.get(assetKeyOf(p.to))!.chain].map((c) => chainBlocked(now, c)).filter(Boolean);95 if (want === 'quote' && halted.length) { warn(`${name}: no route (${ms} ms) because ${halted.join('; ')} right now`); continue; }96 check(want === 'no_route', `${name}: no route for ${formatUnits(amountIn, from.decimals)} ${from.symbol}`, `${ms} ms ${p.note ?? ''}`); continue;97 }98 lat.push(ms);99 const q = quotes[0]!, raw = q.raw as { quote: { memo: string; inbound_address: string; router?: string; expiry: number }; streaming: { quoted: boolean; quantity: number; interval: number | null }; dustThreshold1e8: string | null; recommendedMinAmountIn1e8: string; inboundConfirmationSeconds: number | null; outboundDelaySeconds: number | null };100 const bad = checkQuote(q, req, Date.now());101 const inUsd = units * usd(p.from)!, outUsd = Number(formatUnits(q.amountOut, to.decimals)) * usd(p.to)!, dev = (outUsd / inUsd - 1) * 100;102 const band = want === 'quote' && inUsd > 0 && dev > (inUsd < 100 ? -8 : -3) && dev < 2;103 check(want === 'quote' && !bad && band, `${name}: ${formatUnits(amountIn, from.decimals)} ${from.symbol} → ${formatUnits(q.amountOut, to.decimals)} ${to.symbol}`, `${ms} ms${tries > 1 ? ` (try ${tries})` : ''}${bad ? ' INVALID: ' + bad : ''}`);104 console.log(` min ${formatUnits(q.minAmountOut, to.decimals)} ${to.symbol} (${req.slippageBps} bps) | fees ${q.fees.map((f) => `${f.kind} ${formatUnits(f.amount, to.decimals)}`).join(', ')} ${to.symbol} | slip ${q.priceImpactBps} bps | eta ${q.estimatedSeconds ?? 'n/a'} s (inbound conf ${raw.inboundConfirmationSeconds ?? 'n/a'} s, outbound delay ${raw.outboundDelaySeconds ?? 'n/a'} s)`);105 console.log(` ${q.steps[0]!.via[2]} | vault ${raw.quote.inbound_address}${raw.quote.router ? ` router ${raw.quote.router}` : ''} | memo ${raw.quote.memo} | dust ${raw.dustThreshold1e8 ?? '-'} min_in ${raw.recommendedMinAmountIn1e8} (1e8) | expires in ${Math.round((q.expiresAt - Date.now()) / 1000)} s`);106 console.log(` vs CoinGecko: in ${inUsd.toFixed(2)} USD, out ${outUsd.toFixed(2)} USD, ${dev >= 0 ? '+' : ''}${dev.toFixed(2)} % | ${q.executability.class}, atomic=${q.atomic}, refundable=${q.refundable}`);107 }108 lat.sort((a, b) => a - b);109 if (lat.length) console.log(` quote latency over ${lat.length} quotes: min ${lat[0]} ms, median ${lat[Math.floor(lat.length / 2)]} ms, max ${lat[lat.length - 1]} ms`);110 console.log(`${failures ? 'FAILED' : 'OK'}: ${failures} failure(s), ${warnings} warning(s)`);111 if (failures) process.exit(1);112}113