SPB Git

Repository Public

smoke-native-swap.mts 11.2 KB 113 lines typescript

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
Files main
Menu

Theme