SPB Git

Repository Public

thorchain.test.ts 29.4 KB 378 lines typescript

1// THORChain adapter, exercised with answers recorded from the live THORNode API on 2026-11-11 UTC (fixtures/thorchain).2import { describe, expect, it } from 'vitest';3import fc from 'fast-check';4import { NETWORKS } from '@spw/core';5import { checkQuote, DEFAULT_POLICY, httpClient, ProviderRuntime, QuoteAggregator, type AssetRef, type SwapQuote } from '../src/index.ts';6import { createProviders } from '../src/providers/index.ts';7import {8  chainBlocked, createNativeSwapProvider, fromProtocolAmount, InboundSchema, liquidityToleranceLimit, mapPools, memoAssetMatches, parseSwapMemo,9  THORCHAIN_API, THORCHAIN_CONFIG, toProtocolAmount,10} from '../src/providers/thorchain.ts';11import { clone, loadFixture, quoteCall, replay, requestFor, type Fixture } from './native-swap-helpers.ts';1213const DIR = 'thorchain';14const fx = (name: string) => loadFixture(DIR, name);15const provider = () => createNativeSwapProvider(THORCHAIN_CONFIG);16const families = Object.fromEntries(Object.entries(NETWORKS).map(([k, n]) => [k, n.family]));17const token = (network: string, symbol: string): AssetRef => ({ network, asset: NETWORKS[network]!.tokens.find((t) => t.symbol === symbol)!.id });18const BTC: AssetRef = { network: 'bitcoin', asset: 'native' }, ETH: AssetRef = { network: 'ethereum', asset: 'native' }, USDC = token('ethereum', 'USDC');1920/** Replay one recorded quote through the adapter with the request that produced it. */21async function run(name: string, mutate?: (f: Fixture) => void, opts: Parameters<typeof replay>[3] = {}) {22  const p = provider(), f = clone(fx(name)); mutate?.(f);23  const req = requestFor(p, fx(name), families), r = replay('thorchain', DIR, f, opts);24  return { p, req, r, f, quotes: await p.quote(req, r.ctx) };25}26const one = (q: SwapQuote[]) => { expect(q).toHaveLength(1); return q[0]!; };2728describe('1e8 fixed point ⇄ asset decimals', () => {29  it('scales up exactly for assets with more than 8 decimals and floors the sub-1e-8 remainder on the way in', () => {30    expect(fromProtocolAmount(1959847n, 18)).toBe(19598470000000000n);            // 0.01959847 ETH31    expect(fromProtocolAmount(50_000_000n, 9)).toBe(500_000_000n);                // 0.5 SOL32    expect(toProtocolAmount(20_000_000_000_000_000n, 18)).toBe(2_000_000n);       // 0.02 ETH33    expect(toProtocolAmount(20_000_009_999_999_999n, 18)).toBe(2_000_000n);       // the last 9 999 999 999 wei are not credited34    expect(toProtocolAmount(9_999_999_999n, 18)).toBe(0n);35    expect(toProtocolAmount(500_000_000n, 9)).toBe(50_000_000n);36  });37  it('is the identity at 8 decimals and scales down exactly below 8, flooring on the way out', () => {38    expect(toProtocolAmount(55_000n, 8)).toBe(55_000n); expect(fromProtocolAmount(54_503n, 8)).toBe(54_503n);39    expect(toProtocolAmount(40_000_000n, 6)).toBe(4_000_000_000n);                // 40 USDC40    expect(fromProtocolAmount(4_019_330_000n, 6)).toBe(40_193_300n);              // 40.1933 USDC41    expect(fromProtocolAmount(3_979_136_799n, 6)).toBe(39_791_367n);              // never rounds up42    expect(fromProtocolAmount(99n, 6)).toBe(0n);43    expect(fromProtocolAmount(12_345n, 2)).toBe(0n); expect(fromProtocolAmount(1_234_567_890n, 2)).toBe(1234n); // GUSD-like, 2 decimals44    expect(toProtocolAmount(7n, 0)).toBe(700_000_000n);45  });46  it('never touches a JS number: values far beyond 2^53 stay exact', () => {47    const wei = 123_456_789_012_345_678_901_234_567_890n;48    expect(toProtocolAmount(wei, 18)).toBe(12_345_678_901_234_567_890n);49    expect(fromProtocolAmount(12_345_678_901_234_567_890n, 18)).toBe(123_456_789_012_345_678_900_000_000_000n);50  });51  it('round-trips: out→in is exact, in→out loses strictly less than one protocol unit', () => {52    fc.assert(fc.property(fc.bigInt({ min: 0n, max: 10n ** 30n }), fc.integer({ min: 0, max: 24 }), (v, d) => {53      expect(toProtocolAmount(fromProtocolAmount(v, d), d)).toBe(d >= 8 ? v : (v / 10n ** BigInt(8 - d)) * 10n ** BigInt(8 - d));54      const back = fromProtocolAmount(toProtocolAmount(v, d), d);55      expect(back <= v).toBe(true);56      expect(v - back < (d > 8 ? 10n ** BigInt(d - 8) : 1n)).toBe(true);57    }));58  });59  it('rejects negative amounts and absurd decimals', () => {60    expect(() => toProtocolAmount(-1n, 8)).toThrow(); expect(() => fromProtocolAmount(-1n, 8)).toThrow();61    expect(() => toProtocolAmount(1n, 1.5)).toThrow(); expect(() => fromProtocolAmount(1n, 99)).toThrow();62  });63});6465describe('limit arithmetic (liquidity_tolerance_bps)', () => {66  it('reproduces the limit THORNode wrote in every recorded memo', () => {67    for (const name of ['quote-btc-eth', 'quote-eth-btc', 'quote-btc-usdc', 'quote-doge-btc', 'quote-usdc-btc', 'quote-sol-trx', 'quote-xrp-atom', 'quote-btc-eth-streaming']) {68      const f = fx(name), bps = Number(new URL(f.url).searchParams.get('liquidity_tolerance_bps'));69      expect(liquidityToleranceLimit(BigInt(f.body.expected_amount_out), bps), name).toBe(parseSwapMemo(f.body.memo).limit);70    }71  });72  it('floors, and refuses tolerances the protocol cannot express', () => {73    expect(liquidityToleranceLimit(1959847n, 50)).toBe(1950047n);   // 1959847 × 0.995 = 1950047.76574    expect(liquidityToleranceLimit(199n, 9999)).toBe(0n);75    for (const bad of [0, 10_000, -1, 0.5, NaN]) expect(() => liquidityToleranceLimit(1n, bad)).toThrow();76  });77});7879describe('memo parsing', () => {80  it('reads plain and streaming limits', () => {81    expect(parseSwapMemo('=:e:0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045:1950047')).toEqual({ asset: 'e', destination: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', limit: 1950047n, interval: null, quantity: null });82    expect(parseSwapMemo(fx('quote-btc-eth-streaming').body.memo)).toMatchObject({ limit: 69499775758n, interval: 0, quantity: 370 });83  });84  it('rejects everything this adapter never asks for', () => {85    for (const m of ['=:e:0xabc', '=:e:0xabc:100:t:10', '=:e:0xabc/bc1qrefund:100', '+:e:0xabc:100', 'SWAP:e:0xabc:100', '=<:e:0xabc:100', '=:e:0xabc:1e6', '=:e:0xabc:100/1', '=:e:0xabc:-5', '=:e:0xabc:1.5', '=:e:0xabc:', '=:e:0xabc:100/0/370/9'])86      expect(() => parseSwapMemo(m), m).toThrow();87  });88  it('accepts only spellings of the requested asset', () => {89    const usdc = 'ETH.USDC-0XA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48', sc = THORCHAIN_CONFIG.shortCodes;90    for (const ok of [usdc, usdc.toLowerCase(), 'ETH.USDC', 'ETH.USDC-6EB48', 'eth.usdc-eb48']) expect(memoAssetMatches(ok, usdc, sc), ok).toBe(true);91    for (const bad of ['ETH.USDT', 'AVAX.USDC', 'ETH.USDC-', 'ETH.USDC-EC7', 'e', 'b', 'ETH', 'USDC', 'ETH.USDC-0XA0B86991C6218B36C1D19D4A2E9EB0CE3606EB49']) expect(memoAssetMatches(bad, usdc, sc), bad).toBe(false);92    expect(memoAssetMatches('e', 'ETH.ETH', sc)).toBe(true); expect(memoAssetMatches('ETH.ETH', 'ETH.ETH', sc)).toBe(true);93    expect(memoAssetMatches('f', 'ETH.ETH', sc)).toBe(false); // BASE.ETH94    expect(memoAssetMatches('ETH', 'ETH.ETH', sc)).toBe(false);95  });96});9798describe('asset mapping against the wallet registry', () => {99  const p = provider(), mapped = [...p.assets.values()];100  it('serves exactly the registry assets that have an Available pool, matched by chain + contract', () => {101    expect(mapped.map((a) => a.notation).sort()).toEqual([102      'AVAX.AVAX', 'AVAX.USDC-0XB97EF9EF8734C71904D8002F8B6BC66DD9C48A6E', 'AVAX.USDT-0X9702230A8EA53601F5CD2DC00FDBC13D4DF4A8C7',103      'BASE.ETH', 'BASE.USDC-0X833589FCD6EDB6E08F4C7C32D4F71B54BDA02913', 'BCH.BCH', 'BSC.BNB', 'BSC.BTCB-0X7130D2A12B9BCBFAE4F2634D864A1EE1CE3EAD9C',104      'BSC.ETH-0X2170ED0880AC9A755FD29B2688956BD959F933F8', 'BSC.USDC-0X8AC76A51CC950D9822D68B83FE1AD97B32CD580D', 'BSC.USDT-0X55D398326F99059FF775485246999027B3197955', 'BTC.BTC', 'DOGE.DOGE',105      'ETH.AAVE-0X7FC66500C84A76AD7E9C93437BFC5AC33E2DDAE9', 'ETH.DAI-0X6B175474E89094C44DA98B954EEDEAC495271D0F', 'ETH.ETH',106      'ETH.USDC-0XA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48', 'ETH.USDT-0XDAC17F958D2EE523A2206206994597C13D831EC7',107      'ETH.WBTC-0X2260FAC5E5542A773AA44FBCFEDF7C193BC2C599', 'GAIA.ATOM', 'LTC.LTC', 'SOL.SOL', 'TRON.TRX',108      'TRON.USDT-TR7NHQJEKQXGTCI8Q8ZY4PL8OTSZGJLJ6T', 'XRP.XRP',109    ]);110    for (const a of mapped) {111      const net = NETWORKS[a.ref.network]!, def = a.ref.asset === 'native' ? net.native : net.tokens.find((t) => t.id === a.ref.asset)!;112      expect(def.decimals, a.notation).toBe(a.decimals);113      if (a.ref.asset !== 'native') expect(a.notation.split('-')[1], a.notation).toBe(a.ref.asset.toUpperCase());114    }115    expect(p.capabilities()).toMatchObject({ crossChain: true, needsKey: false, configured: true, kinds: ['native_swap'] });116    expect(p.capabilities().networks.sort()).toEqual(['avalanche', 'base', 'bitcoin', 'bitcoincash', 'bsc', 'cosmos', 'dogecoin', 'ethereum', 'litecoin', 'solana', 'tron', 'xrpl']);117  });118  it('every pinned pool was Available in the recorded pool list, and recorded pool decimals agree with the registry', () => {119    const pools = fx('pools').body as { asset: string; status: string; decimals?: number; trading_halted?: boolean }[];120    for (const n of THORCHAIN_CONFIG.pools) expect(pools.find((x) => x.asset === n), n).toMatchObject({ status: 'Available', trading_halted: false });121    // nothing Available on a wallet chain is missing from the pinned list122    const chains = Object.keys(THORCHAIN_CONFIG.chains);123    const live = mapPools({ chains: THORCHAIN_CONFIG.chains, pools: pools.filter((x) => x.status === 'Available' && chains.includes(x.asset.split('.')[0]!)).map((x) => x.asset) });124    expect([...live.keys()].sort()).toEqual([...p.assets.keys()].sort());125    // THORNode reports `decimals` only for assets with fewer than 8 (or chain-specific) decimals126    for (const a of mapped) { const d = pools.find((x) => x.asset === a.notation)!.decimals; if (d) expect(d, a.notation).toBe(a.decimals); else expect(a.decimals, a.notation).toBeGreaterThanOrEqual(8); }127  });128  it('never maps by ticker: a pool with a known ticker but an unknown contract, or on an unmapped chain, is dropped', () => {129    const m = mapPools({ chains: THORCHAIN_CONFIG.chains, pools: ['ETH.USDC-0X000000000000000000000000000000000000DEAD', 'ETH.USDC', 'ZEC.ZEC', 'THOR.RUJI', 'ETH.LINK', 'BSC.ETH-0X000000000000000000000000000000000000DEAD', 'ETH.BTC', 'eth.eth', 'ETH.ETH'] });130    expect([...m.values()].map((a) => a.notation)).toEqual(['ETH.ETH']);131  });132  it('supportsPair is local and strict', () => {133    expect(p.supportsPair(BTC, ETH)).toBe(true); expect(p.supportsPair(BTC, USDC)).toBe(true);134    expect(p.supportsPair(BTC, BTC)).toBe(false);135    expect(p.supportsPair(BTC, token('ethereum', 'LINK'))).toBe(false);          // pool is Staged136    expect(p.supportsPair(BTC, { network: 'stellar', asset: 'native' })).toBe(false);137    expect(p.supportsPair(BTC, { network: 'ethereum', asset: '0x000000000000000000000000000000000000dEaD' })).toBe(false);138    expect(p.supportsPair({ network: 'nope', asset: 'native' }, BTC)).toBe(false);139    expect(p.supportsPair(BTC, { network: 'ethereum', asset: USDC.asset.toLowerCase() })).toBe(true);140  });141  it('is registered', () => { expect(createProviders().map((x) => x.id)).toEqual(expect.arrayContaining(['thorchain', 'maya'])); });142});143144describe('quote mapping from recorded answers', () => {145  it('BTC → ETH: sends exactly the recorded request and maps every field', async () => {146    const { req, r, quotes, f } = await run('quote-btc-eth');147    expect(quoteCall(r)).toBe(f.url);148    expect(r.calls).toHaveLength(2); // one round: quote + inbound_addresses, concurrently149    const q = one(quotes);150    expect(q).toMatchObject({ provider: 'thorchain', request: req, atomic: false, refundable: true, firmness: 'indicative', priceImpactBps: 19, estimatedSeconds: 606, expiresAt: 1794360125000 });151    expect(q.amountOut).toBe(19_598_470_000_000_000n);       // 0.01959847 ETH152    expect(q.minAmountOut).toBe(19_500_470_000_000_000n);    // memo limit 1950047 = floor(1959847 × 9950 / 10000)153    expect(q.fees).toEqual([154      { kind: 'protocol', asset: ETH, amount: 39_500_000_000_000n, includedInOutput: true, estimated: true },155      { kind: 'network', asset: ETH, amount: 121_830_000_000_000n, includedInOutput: true, estimated: true },156    ]);157    expect(q.executability.class).toBe('quote_only');158    expect(q.steps).toEqual([{ kind: 'native_swap', provider: 'thorchain', from: BTC, to: ETH, via: ['THORChain', 'BTC.BTC → ETH.ETH', 'single swap'],159      contracts: [{ network: 'bitcoin', address: 'bc1qup236qj2mr87ml9388ydm33tuhtdckk33j68qx', role: 'vault' }] }]);160    expect(q.raw).toMatchObject({ quote: f.body, amount1e8: '55000', limit1e8: '1950047', guaranteed1e8: '1950047', dustThreshold1e8: '1000', recommendedMinAmountIn1e8: '6108',161      inboundConfirmationSeconds: 600, outboundDelaySeconds: 6, streaming: { quoted: false, quantity: 1, maxQuantity: 1 } });162    expect(checkQuote(q, req, r.ctx.now())).toBeNull();163  });164  it('ETH → BTC: 18-decimal input, router named and pinned', async () => {165    const { req, r, quotes, f } = await run('quote-eth-btc');166    expect(req.amountIn).toBe(20_000_000_000_000_000n); expect(quoteCall(r)).toBe(f.url);167    const q = one(quotes);168    expect([q.amountOut, q.minAmountOut]).toEqual([54_503n, 54_230n]);169    expect(q.fees.map((x) => [x.kind, x.amount])).toEqual([['protocol', 111n], ['network', 1043n]]);170    expect(q.steps[0]!.contracts).toEqual([171      { network: 'ethereum', address: '0x6b4068070d95d87b321909bb21c8979fba2b8876', role: 'vault' },172      { network: 'ethereum', address: '0xD37BbE5744D730a1d98d8DC97c42F0Ca46aD7146', role: 'router' }]);173    expect(q.estimatedSeconds).toBe(30);174  });175  it('BTC → USDC (6 decimals): output and fees floored to real base units, memo abbreviation accepted', async () => {176    const { req, quotes, f } = await run('quote-btc-usdc');177    expect(f.body.memo.startsWith('=:ETH.USDC:')).toBe(true); expect(req.slippageBps).toBe(100);178    const q = one(quotes);179    expect(q.amountOut).toBe(40_193_300n);      // 40.1933 USDC180    expect(q.minAmountOut).toBe(39_791_367n);   // 3979136700 / 100181    expect(q.fees.map((x) => x.amount)).toEqual([81_010n, 250_071n]);182    expect(q.fees.every((x) => x.asset.asset === USDC.asset)).toBe(true);183  });184  it('USDC → BTC: 6-decimal input scaled ×100, ERC-20 source goes through the router', async () => {185    const { req, r, quotes, f } = await run('quote-usdc-btc');186    expect(req.amountIn).toBe(40_000_000n); expect(quoteCall(r)).toBe(f.url);187    const q = one(quotes);188    expect([q.amountOut, q.minAmountOut]).toEqual([53_112n, 52_846n]);189    expect(q.steps[0]!.contracts.map((c) => c.role)).toEqual(['vault', 'router']);190  });191  it('DOGE → BTC, SOL → TRX, XRP → ATOM: per-chain decimals', async () => {192    const d = one((await run('quote-doge-btc')).quotes);193    expect([d.request.amountIn, d.amountOut, d.minAmountOut, d.estimatedSeconds]).toEqual([60_000_000_000n, 54_375n, 54_103n, 126]);194    const s = await run('quote-sol-trx'); const sq = one(s.quotes);195    expect(s.req.amountIn).toBe(500_000_000n);                                  // 0.5 SOL, 9 decimals196    expect([sq.amountOut, sq.minAmountOut]).toEqual([129_263_282n, 128_616_965n]); // TRX, 6 decimals: 12861696559 → floor197    expect(sq.fees.map((x) => x.amount)).toEqual([261_752n, 1_384_481n]);198    const x = await run('quote-xrp-atom'); const xq = one(x.quotes);199    expect(x.req.amountIn).toBe(40_000_000n);200    expect([xq.amountOut, xq.minAmountOut, xq.priceImpactBps]).toEqual([29_693_808n, 29_545_338n, 20]);201  });202  it('reports a streaming swap when the network quotes one (20 BTC → ETH, 370 sub-swaps)', async () => {203    const { req, quotes } = await run('quote-btc-eth-streaming');204    const q = one(quotes);205    expect(req.slippageBps).toBe(300);206    expect(q.amountOut).toBe(716_492_533_590_000_000_000n);207    expect(q.minAmountOut).toBe(694_997_757_580_000_000_000n);208    expect(q.steps[0]!.via[2]).toBe('streaming swap: 370 sub-swaps');209    expect(q.raw).toMatchObject({ streaming: { quoted: true, interval: 0, quantity: 370, maxQuantity: 370, seconds: 744 }, outboundDelaySeconds: 1200 });210    expect(q.estimatedSeconds).toBe(2400);211  });212  it('all good quotes survive the aggregator through the real runtime', async () => {213    const p = provider(), f = fx('quote-btc-eth'), req = requestFor(p, f, families), r = replay('thorchain', DIR, f);214    const agg = new QuoteAggregator([new ProviderRuntime(p, DEFAULT_POLICY, r.ctx.http, r.ctx.now)], r.ctx.now);215    const { quotes, events } = await agg.collect(req);216    expect(quotes).toHaveLength(1); expect(events.filter((e) => e.type === 'provider')).toEqual([]);217  });218  it('re-uses the halt flags for 15 s but asks for a fresh quote (and vault) every time', async () => {219    const p = provider(), f = fx('quote-btc-eth'), req = requestFor(p, f, families);220    let now = Date.parse(f.recordedAt); const r = replay('thorchain', DIR, f); const ctx = { ...r.ctx, now: () => now };221    await p.quote(req, ctx); now += 10_000; await p.quote(req, ctx);222    expect(r.calls.filter((u) => u.endsWith('/inbound_addresses'))).toHaveLength(1); expect(r.calls.filter((u) => u.includes('/quote/swap?'))).toHaveLength(2);223    now += 6_000; await p.quote(req, ctx);224    expect(r.calls.filter((u) => u.endsWith('/inbound_addresses'))).toHaveLength(2);225  });226});227228describe('no route', () => {229  it('pool not Available (recorded refusal for the Staged ETH.LINK pool)', async () => {230    expect(fx('error-pool-not-available').body.message).toMatch(/pool\(ETH\.LINK-.*\) is not available/);231    expect((await run('quote-btc-eth', (f) => Object.assign(f, fx('error-pool-not-available')))).quotes).toEqual([]);232  });233  it('amount below the dust threshold / below the minimum swap amount (recorded refusals)', async () => {234    expect((await run('quote-btc-eth', (f) => Object.assign(f, fx('error-below-dust')))).quotes).toEqual([]);235    expect(fx('error-doge-below-min').body.message).toMatch(/min swap amount \(recommended_min_amount_in: \d+\)/);236    expect((await run('quote-doge-btc', (f) => Object.assign(f, fx('error-doge-below-min')))).quotes).toEqual([]);237  });238  it('amount below recommended_min_amount_in even though THORNode still answers a quote (3000 sats, minimum 6117)', async () => {239    const { quotes, f } = await run('quote-below-recommended-min');240    expect(BigInt(f.body.expected_amount_out) > 0n).toBe(true); expect(quotes).toEqual([]);241  });242  it('amount at the dust threshold is refused even if a quote comes back', async () => {243    expect((await run('quote-btc-eth', (f) => { f.body.dust_threshold = '55000'; f.body.recommended_min_amount_in = '1'; })).quotes).toEqual([]);244  });245  it('source or destination chain halted, or trading paused, per inbound_addresses', async () => {246    const flip = (chain: string, key: string) => { const inb = clone(fx('inbound_addresses')); inb.body.find((x: any) => x.chain === chain)[key] = true; return inb; };247    for (const [chain, key] of [['BTC', 'halted'], ['ETH', 'halted'], ['ETH', 'chain_trading_paused'], ['BTC', 'global_trading_paused']] as const)248      expect((await run('quote-btc-eth', undefined, { inbound: flip(chain, key) })).quotes, `${chain}.${key}`).toEqual([]);249    const gone = clone(fx('inbound_addresses')); gone.body = gone.body.filter((x: any) => x.chain !== 'ETH');250    expect((await run('quote-btc-eth', undefined, { inbound: gone })).quotes).toEqual([]);251    // a halt elsewhere does not matter252    expect((await run('quote-btc-eth', undefined, { inbound: flip('DOGE', 'halted') })).quotes).toHaveLength(1);253    const inb = InboundSchema.parse(fx('inbound_addresses').body);254    expect(inb.map((x) => chainBlocked(inb, x.chain)).filter(Boolean)).toEqual([]);255    expect(chainBlocked(inb, 'KUJI')).toMatch(/not active/);256  });257  it('a trading-halt refusal from the quote simulation itself', async () => {258    expect((await run('quote-btc-eth', (f) => { f.status = 400; f.body = { code: 3, message: 'failed to simulate swap: failed to validate message: trading is halted, can\'t process swap: invalid request', details: [] }; })).quotes).toEqual([]);259  });260  it('unsupported pairs, zero tolerance, sub-unit amounts: no HTTP at all', async () => {261    const p = provider(), r = replay('thorchain', DIR, fx('quote-btc-eth')), base = requestFor(p, fx('quote-btc-eth'), families);262    expect(await p.quote({ ...base, to: { network: 'stellar', asset: 'native' } }, r.ctx)).toEqual([]);263    expect(await p.quote({ ...base, slippageBps: 0 }, r.ctx)).toEqual([]);264    expect(await p.quote({ ...base, slippageBps: 12.5 }, r.ctx)).toEqual([]);265    expect(await p.quote({ ...base, slippageBps: 10_000 }, r.ctx)).toEqual([]);266    expect(await p.quote({ ...base, amountIn: 0n }, r.ctx)).toEqual([]);267    expect(await p.quote({ ...base, from: ETH, to: BTC, amountIn: 9_999_999_999n }, r.ctx)).toEqual([]); // < 1e-8 ETH268    expect(r.calls).toEqual([]);269  });270});271272describe('provider failures throw', () => {273  it('stale node (recorded): retried once on the gateway, then → unavailable', async () => {274    const p = provider(), req = requestFor(p, fx('quote-btc-eth'), families), stale = fx('error-stale-node'), good = fx('quote-btc-eth');275    const answer = (f: Fixture) => new Response(JSON.stringify(f.body), { status: f.status });276    let n = 0; const twice = replay('thorchain', DIR, () => { n += 1; return answer(stale); }, { now: Date.parse(good.recordedAt) });277    await expect(p.quote(req, twice.ctx)).rejects.toMatchObject({ provider: 'thorchain', code: 'unavailable' }); expect(n).toBe(2);278    let m = 0; const once = replay('thorchain', DIR, () => { m += 1; return answer(m === 1 ? stale : good); }, { now: Date.parse(good.recordedAt) });279    expect(await provider().quote(req, once.ctx)).toHaveLength(1); expect(m).toBe(2);280  });281  it('a refusal that is neither a halt nor a minimum (recorded: destination on the wrong chain) → http', async () => {282    await expect(run('quote-btc-eth', (f) => Object.assign(f, fx('error-bad-destination')))).rejects.toMatchObject({ code: 'http' });283  });284  it('rate limit, gateway HTML, 5xx', async () => {285    const p = provider(), req = requestFor(p, fx('quote-btc-eth'), families);286    await expect(p.quote(req, replay('thorchain', DIR, () => new Response('', { status: 429 })).ctx)).rejects.toMatchObject({ code: 'rate_limited' });287    await expect(provider().quote(req, replay('thorchain', DIR, () => new Response('<html>Just a moment...</html>', { status: 200 })).ctx)).rejects.toMatchObject({ code: 'bad_response' });288    await expect(provider().quote(req, replay('thorchain', DIR, () => new Response('{"oops":1}', { status: 502 })).ctx)).rejects.toMatchObject({ code: 'http' });289  });290  it('fails closed when the halt flags cannot be read', async () => {291    const p = provider(), req = requestFor(p, fx('quote-btc-eth'), families);292    await expect(p.quote(req, replay('thorchain', DIR, fx('quote-btc-eth'), { inbound: () => new Response('{}', { status: 503 }) }).ctx)).rejects.toMatchObject({ code: 'http' });293    await expect(p.quote(req, replay('thorchain', DIR, fx('quote-btc-eth'), { inbound: () => new Response('[{"chain":"BTC","address":"x"}]', { status: 200 }) }).ctx)).rejects.toMatchObject({ code: 'schema' });294  });295  it('times out through the runtime and reports an abort as a timeout', async () => {296    const hang = (_url: string, init?: RequestInit) => new Promise<Response>((_res, rej) => init?.signal?.addEventListener('abort', () => rej(Object.assign(new Error('aborted'), { name: 'AbortError' }))));297    const p = provider(), req = requestFor(p, fx('quote-btc-eth'), families);298    const rt = new ProviderRuntime(p, { ...DEFAULT_POLICY, timeoutMs: 40 }, httpClient('thorchain', hang as unknown as typeof fetch));299    await expect(rt.quote(req)).rejects.toMatchObject({ code: 'timeout' }); expect(rt.stats().timeouts).toBe(1);300    const ac = new AbortController(); const pending = p.quote(req, { signal: ac.signal, http: httpClient('thorchain', hang as unknown as typeof fetch), now: Date.now }); ac.abort();301    await expect(pending).rejects.toMatchObject({ code: 'timeout' });302  });303});304305describe('hostile or malformed answers are rejected (schema)', () => {306  const bad = async (name: string, mutate: (f: Fixture) => void, code = 'schema') => { await expect(run(name, mutate), mutate.toString()).rejects.toMatchObject({ provider: 'thorchain', code }); };307  it('output for another recipient, or a refund/affiliate/aggregator smuggled into the memo', async () => {308    await bad('quote-btc-eth', (f) => { f.body.memo = '=:e:0x000000000000000000000000000000000000dEaD:1950047'; });309    await bad('quote-btc-eth', (f) => { f.body.memo += ':t:50'; });310    await bad('quote-btc-eth', (f) => { f.body.memo = f.body.memo.replace(':1950047', '/bc1qattacker:1950047'); });311    await bad('quote-btc-eth', (f) => { f.body.memo = f.body.memo.replace('=:', '+:'); });312    await bad('quote-btc-eth', (f) => { f.body.memo += '/1/0'; }); // an interval we did not ask for313    // the recipient check is case-insensitive only where the chain is (EVM), exact elsewhere314    expect((await run('quote-btc-eth', (f) => { f.body.memo = f.body.memo.replace('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', '0xd8da6bf26964af9d7eed9e03e53415d37aa96045'); })).quotes).toHaveLength(1);315    await bad('quote-eth-btc', (f) => { f.body.memo = f.body.memo.replace('bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', 'BC1QXY2KGDYGJRSQTZQ2N0YRF2493P83KKFJHX0WLH'); });316  });317  it('wrong target asset: other token, ticker look-alike, other chain', async () => {318    await bad('quote-btc-usdc', (f) => { f.body.memo = f.body.memo.replace('ETH.USDC', 'ETH.USDT'); });319    await bad('quote-btc-usdc', (f) => { f.body.memo = f.body.memo.replace('ETH.USDC', 'AVAX.USDC'); });320    await bad('quote-btc-usdc', (f) => { f.body.memo = f.body.memo.replace('ETH.USDC', 'ETH.USDC-0X000000000000000000000000000000000000DEAD'); });321    await bad('quote-btc-usdc', (f) => { f.body.fees.asset = 'ETH.USDC-0X000000000000000000000000000000000000DEAD'; });322    await bad('quote-btc-eth', (f) => { f.body.memo = f.body.memo.replace('=:e:', '=:f:'); });323    await bad('quote-btc-eth', (f) => { f.body.fees.asset = 'BASE.ETH'; });324  });325  it('a limit that is not the requested tolerance', async () => {326    await bad('quote-btc-eth', (f) => { f.body.memo = f.body.memo.replace(':1950047', ':1'); });327    await bad('quote-btc-eth', (f) => { f.body.memo = f.body.memo.replace(':1950047', ':1950046'); });328    await bad('quote-btc-eth', (f) => { f.body.expected_amount_out = '9959847'; }); // inflated output, memo limit left behind329    await bad('quote-btc-eth', (f) => { f.body.memo = f.body.memo.replace(':1950047', ''); });330  });331  it('float, negative, numeric or missing amounts', async () => {332    await bad('quote-btc-eth', (f) => { f.body.expected_amount_out = '1959847.5'; });333    await bad('quote-btc-eth', (f) => { f.body.expected_amount_out = '-1959847'; });334    await bad('quote-btc-eth', (f) => { f.body.expected_amount_out = 1959847; });335    await bad('quote-btc-eth', (f) => { f.body.expected_amount_out = '1.959847e6'; });336    await bad('quote-btc-eth', (f) => { f.body.expected_amount_out = '01959847'; });337    await bad('quote-btc-eth', (f) => { delete f.body.expected_amount_out; });338    await bad('quote-btc-eth', (f) => { delete f.body.memo; });339    await bad('quote-btc-eth', (f) => { delete f.body.fees; });340    await bad('quote-btc-eth', (f) => { delete f.body.recommended_min_amount_in; });341    await bad('quote-btc-eth', (f) => { f.body.fees.outbound = '-1'; });342    await bad('quote-btc-eth', (f) => { f.body.fees.liquidity = 3950; });343    await bad('quote-btc-eth', (f) => { f.body.fees.slippage_bps = 19.5; });344    await bad('quote-btc-eth', (f) => { f.body.fees.slippage_bps = 10_001; });345    await bad('quote-btc-eth', (f) => { f.body.expiry = '1794360125'; });346    await bad('quote-btc-eth', (f) => { f.body.total_swap_seconds = -5; });347    await bad('quote-btc-eth', (f) => { f.body = [f.body]; });348    await bad('quote-btc-eth', (f) => { f.body = null; });349  });350  it('fees that do not add up, or an affiliate nobody asked for', async () => {351    await bad('quote-btc-eth', (f) => { f.body.fees.total = '16134'; });352    await bad('quote-btc-eth', (f) => { f.body.fees.affiliate = '100'; f.body.fees.total = '16233'; });353  });354  it('router not pinned, router on a UTXO source, missing router on an EVM source, vault = router or = sender', async () => {355    await bad('quote-eth-btc', (f) => { f.body.router = '0x000000000000000000000000000000000000dEaD'; });356    await bad('quote-eth-btc', (f) => { delete f.body.router; });357    await bad('quote-eth-btc', (f) => { f.body.router = 'not-an-address'; });358    await bad('quote-eth-btc', (f) => { f.body.inbound_address = f.body.router; });359    await bad('quote-eth-btc', (f) => { f.body.inbound_address = 'bc1qup236qj2mr87ml9388ydm33tuhtdckk33j68qx'; });360    await bad('quote-eth-btc', (f) => { f.body.inbound_address = '0x000000000000000000000000000000000000dEaD'; }); // the sender361    await bad('quote-btc-eth', (f) => { f.body.router = '0xD37BbE5744D730a1d98d8DC97c42F0Ca46aD7146'; });362    await bad('quote-btc-eth', (f) => { f.body.inbound_address = 'x'; });363    await bad('quote-btc-eth', (f) => { f.body.inbound_address = 'bc1q<script>alert(1)</script>aaaaaaaaaaaaaaaaaaaa'; });364    // inbound_addresses and the quote must name the same router365    const inb = clone(fx('inbound_addresses')); inb.body.find((x: any) => x.chain === 'ETH').router = '0x000000000000000000000000000000000000dEaD';366    await expect(run('quote-eth-btc', undefined, { inbound: inb })).rejects.toMatchObject({ code: 'schema' });367  });368  it('expiry in the past (stale answer) or absurdly far ahead', async () => {369    await bad('quote-btc-eth', (f) => { f.body.expiry = 1794359000; }, 'bad_response');370    await bad('quote-btc-eth', (f) => { f.body.expiry = 1794360125 + 86_400; });371  });372  it('pins the API base and identifies itself', async () => {373    const { r } = await run('quote-btc-eth');374    expect(r.calls.every((u) => u.startsWith(THORCHAIN_API + '/'))).toBe(true);375    expect(THORCHAIN_API).toBe('https://gateway.liquify.com/chain/thorchain_api/thorchain');376  });377});378
Files main
Menu

Theme