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