SPB Git

Repository Public

maya.test.ts 12.3 KB 150 lines typescript

1// Maya Protocol adapter, exercised with answers recorded from the live MAYANode API on 2026-11-11 UTC (fixtures/maya).2// The engine is shared with THORChain (thorchain.test.ts covers it in depth); this file covers what differs on Maya:3// endpoints and pools, the HTTP-200 error envelope, always-requested streaming, and the limit being enforced before the outbound fee.4import { describe, expect, it } from 'vitest';5import { NETWORKS } from '@spw/core';6import { checkQuote, DEFAULT_POLICY, httpClient, ProviderRuntime, type AssetRef, type SwapQuote } from '../src/index.ts';7import { createMayaProvider, MAYA_API, MAYA_CONFIG, MAYA_STREAMING_INTERVAL } from '../src/providers/maya.ts';8import { chainBlocked, createNativeSwapProvider, InboundSchema, liquidityToleranceLimit, mapPools, parseSwapMemo } from '../src/providers/thorchain.ts';9import { clone, loadFixture, quoteCall, replay, requestFor, type Fixture } from './native-swap-helpers.ts';1011const DIR = 'maya';12const fx = (name: string) => loadFixture(DIR, name);13const provider = () => createNativeSwapProvider(MAYA_CONFIG);14const families = Object.fromEntries(Object.entries(NETWORKS).map(([k, n]) => [k, n.family]));15const token = (network: string, symbol: string): AssetRef => ({ network, asset: NETWORKS[network]!.tokens.find((t) => t.symbol === symbol)!.id });16const BTC: AssetRef = { network: 'bitcoin', asset: 'native' }, ETH: AssetRef = { network: 'ethereum', asset: 'native' }, ARB_ETH: AssetRef = { network: 'arbitrum', asset: 'native' };17const GOOD = ['quote-btc-eth', 'quote-eth-btc', 'quote-btc-usdc', 'quote-usdc-btc', 'quote-eth-arbusdc', 'quote-arbeth-btc', 'quote-btc-eth-streaming'];1819async function run(name: string, mutate?: (f: Fixture) => void, opts: Parameters<typeof replay>[3] = {}) {20  const p = provider(), f = clone(fx(name)); mutate?.(f);21  const req = requestFor(p, fx(name), families), r = replay('maya', DIR, f, opts);22  return { p, req, r, f, quotes: await p.quote(req, r.ctx) };23}24const one = (q: SwapQuote[]) => { expect(q).toHaveLength(1); return q[0]!; };2526describe('asset mapping', () => {27  const p = provider();28  it('serves BTC, ETH, Arbitrum ETH and the registry stablecoins that have an Available Maya pool', () => {29    expect([...p.assets.values()].map((a) => a.notation).sort()).toEqual([30      'ARB.ETH', 'ARB.USDC-0XAF88D065E77C8CC2239327C5EDB3A432268E5831', 'ARB.USDT-0XFD086BC7CD5C481DCC9C85EBE478A1C0B69FCBB9', // USDT0 in the registry (same contract)31      'ARB.WBTC-0X2F2A2543B76A4166549F7AAB2E75BEF0AEFC5B0F', 'ARB.WSTETH-0X5979D7B546E38E414F7E9822514BE443A4800529', 'BTC.BTC', 'ETH.ETH',32      'ETH.USDC-0XA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48', 'ETH.USDT-0XDAC17F958D2EE523A2206206994597C13D831EC7', 'ETH.WSTETH-0X7F39C581F595B53C5CB19BD0B3F8DA6C935E2CA0']);33    expect(p.capabilities()).toMatchObject({ networks: ['bitcoin', 'ethereum', 'arbitrum'], crossChain: true, needsKey: false, configured: true, kinds: ['native_swap'] });34    expect(createMayaProvider().id).toBe('maya');35  });36  it('pinned pools were Available in the recorded pool list; nothing Available for the wallet is missing; decimals agree', () => {37    const pools = fx('pools').body as { asset: string; status: string; decimals?: number }[];38    for (const n of MAYA_CONFIG.pools) expect(pools.find((x) => x.asset === n)?.status, n).toBe('Available');39    const chains = Object.keys(MAYA_CONFIG.chains);40    const live = mapPools({ chains: MAYA_CONFIG.chains, pools: pools.filter((x) => x.status === 'Available' && chains.includes(x.asset.split('.')[0]!)).map((x) => x.asset) });41    expect([...live.keys()].sort()).toEqual([...p.assets.keys()].sort());42    for (const a of p.assets.values()) { const d = pools.find((x) => x.asset === a.notation)!.decimals; if (d) expect(d, a.notation).toBe(a.decimals); }43  });44  it('does not serve THORChain-only chains, Staged pools, or tokens outside the registry', () => {45    expect(p.supportsPair(BTC, ETH)).toBe(true); expect(p.supportsPair(ARB_ETH, token('ethereum', 'USDT'))).toBe(true);46    expect(p.supportsPair({ network: 'dogecoin', asset: 'native' }, BTC)).toBe(false);47    expect(p.supportsPair(BTC, token('ethereum', 'PEPE'))).toBe(false);          // ETH.PEPE is Staged on Maya48    expect(p.supportsPair(BTC, token('arbitrum', 'LINK'))).toBe(false);49    expect(p.supportsPair(BTC, { network: 'base', asset: 'native' })).toBe(false);50  });51});5253describe('quote mapping from recorded answers', () => {54  it('BTC → ETH: asks for streaming, and reports the floor NET of the outbound fee', async () => {55    const { req, r, quotes, f } = await run('quote-btc-eth');56    expect(quoteCall(r)).toBe(f.url);57    expect(new URL(f.url).searchParams.get('streaming_interval')).toBe(String(MAYA_STREAMING_INTERVAL));58    const q = one(quotes);59    expect(q).toMatchObject({ provider: 'maya', atomic: false, refundable: true, priceImpactBps: 1, estimatedSeconds: 600, expiresAt: 1794360512000 });60    expect(q.amountOut).toBe(19_710_080_000_000_000n);61    // memo limit 1961152 = floor(1971008 × 0.995) is checked by MAYAChain on the swap output BEFORE the 4892 outbound fee62    expect(parseSwapMemo(f.body.memo)).toMatchObject({ limit: 1961152n, interval: 3, quantity: 0 });63    expect(q.minAmountOut).toBe((1961152n - 4892n) * 10n ** 10n);64    expect(q.fees).toEqual([65      { kind: 'protocol', asset: ETH, amount: 2_090_000_000_000n, includedInOutput: true, estimated: true },66      { kind: 'network', asset: ETH, amount: 48_920_000_000_000n, includedInOutput: true, estimated: true }]);67    expect(q.steps[0]).toMatchObject({ kind: 'native_swap', via: ['Maya Protocol', 'BTC.BTC → ETH.ETH', 'single swap'],68      contracts: [{ network: 'bitcoin', address: 'bc1q0ys0mp5knhhxda5u8ug204ry5vznkr3yhukp6x', role: 'vault' }] });69    expect(q.raw).toMatchObject({ limit1e8: '1961152', guaranteed1e8: '1956260', streaming: { quoted: false, interval: 3, quantity: 1 } });70    expect(q.executability.class).toBe('quote_only');71    expect(checkQuote(q, req, r.ctx.now())).toBeNull();72  });73  it('every recorded pair maps, with the limit arithmetic of the protocol and Maya\'s routers', async () => {74    const expected: Record<string, [bigint, bigint]> = {75      'quote-eth-btc': [55_042n, 54_766n - 605n], 'quote-usdc-btc': [53_856n, 53_586n - 605n], 'quote-arbeth-btc': [54_963n, 54_688n - 605n],76      'quote-btc-usdc': [40_240_786n, (3_983_837_814n - 9_992_200n) / 100n], 'quote-eth-arbusdc': [40_400_061n, (4_019_806_069n - 9_909_100n) / 100n],77    };78    for (const name of GOOD) {79      const { req, r, quotes, f } = await run(name); const q = one(quotes);80      expect(quoteCall(r), name).toBe(f.url);81      expect(liquidityToleranceLimit(BigInt(f.body.expected_amount_out), req.slippageBps), name).toBe(parseSwapMemo(f.body.memo).limit);82      if (expected[name]) expect([q.amountOut, q.minAmountOut], name).toEqual(expected[name]);83      expect(checkQuote(q, req, r.ctx.now()), name).toBeNull();84    }85    expect(one((await run('quote-eth-btc')).quotes).steps[0]!.contracts[1]).toEqual({ network: 'ethereum', address: '0xe3985E6b61b814F7Cdb188766562ba71b446B46d', role: 'router' });86    const arb = one((await run('quote-arbeth-btc')).quotes);87    expect(arb.steps[0]!.contracts[1]).toEqual({ network: 'arbitrum', address: '0x700E97ef07219440487840Dc472E7120A7FF11F4', role: 'router' });88    expect(arb.estimatedSeconds).toBeNull(); // MAYANode gave no total_swap_seconds for this pair: never invented89  });90  it('reports streaming when the network would split the swap (2 BTC → ETH: 193 sub-swaps; 0.02 ETH → ARB.USDC: 2)', async () => {91    const big = one((await run('quote-btc-eth-streaming')).quotes);92    expect(big.steps[0]!.via[2]).toBe('streaming swap: 193 sub-swaps, every 3 block(s)');93    expect(big.raw).toMatchObject({ streaming: { quoted: true, interval: 3, quantity: 193, maxQuantity: 193, seconds: 3456 } });94    expect(big.estimatedSeconds).toBe(4056);95    expect(big.amountOut).toBe(71_579_458_990_000_000_000n); expect(big.minAmountOut).toBe((6_943_207_522n - 4_879n) * 10n ** 10n);96    expect(one((await run('quote-eth-arbusdc')).quotes).raw).toMatchObject({ streaming: { quoted: true, quantity: 2 } });97  });98});99100describe('no route / failures (Maya answers refusals with HTTP 200 {error})', () => {101  it('recorded refusals: pool not Available, below dust, not enough to pay the outbound fee', async () => {102    for (const name of ['error-pool-not-available', 'error-below-dust', 'error-not-enough-fee']) {103      expect(fx(name).status, name).toBe(200); expect(typeof fx(name).body.error, name).toBe('string');104      expect((await run('quote-btc-eth', (f) => Object.assign(f, fx(name)))).quotes, name).toEqual([]);105    }106  });107  it('amount at or below dust, below the recommended minimum, or a floor eaten by the outbound fee', async () => {108    expect((await run('quote-btc-eth', (f) => { f.body.dust_threshold = '55000'; })).quotes).toEqual([]);109    expect((await run('quote-btc-eth', (f) => { f.body.recommended_min_amount_in = '55001'; })).quotes).toEqual([]);110    expect((await run('quote-btc-eth', (f) => { f.body.fees.outbound = '1961152'; f.body.fees.total = String(1961152 + 209); })).quotes).toEqual([]);111  });112  it('halted chain per inbound_addresses (Maya has no trading-pause flags there)', async () => {113    const inb = clone(fx('inbound_addresses')); inb.body.find((x: any) => x.chain === 'ETH').halted = true;114    expect((await run('quote-btc-eth', undefined, { inbound: inb })).quotes).toEqual([]);115    const parsed = InboundSchema.parse(fx('inbound_addresses').body);116    expect(['BTC', 'ETH', 'ARB'].map((c) => chainBlocked(parsed, c))).toEqual([null, null, null]);117  });118  it('other refusals and transport failures throw', async () => {119    await expect(run('quote-btc-eth', (f) => Object.assign(f, fx('error-bad-destination')))).rejects.toMatchObject({ provider: 'maya', code: 'http' });120    const p = provider(), req = requestFor(p, fx('quote-btc-eth'), families);121    await expect(p.quote(req, replay('maya', DIR, () => new Response('', { status: 429 })).ctx)).rejects.toMatchObject({ code: 'rate_limited' });122    const hang = (_u: string, init?: RequestInit) => new Promise<Response>((_res, rej) => init?.signal?.addEventListener('abort', () => rej(Object.assign(new Error('aborted'), { name: 'AbortError' }))));123    const rt = new ProviderRuntime(provider(), { ...DEFAULT_POLICY, timeoutMs: 40 }, httpClient('maya', hang as unknown as typeof fetch));124    await expect(rt.quote(req)).rejects.toMatchObject({ code: 'timeout' });125  });126});127128describe('hostile or malformed answers', () => {129  const bad = async (name: string, mutate: (f: Fixture) => void) => { await expect(run(name, mutate), mutate.toString()).rejects.toMatchObject({ provider: 'maya', code: 'schema' }); };130  it('recipient, asset, limit, streaming interval, router, amounts', async () => {131    await bad('quote-btc-eth', (f) => { f.body.memo = f.body.memo.replace('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', '0x000000000000000000000000000000000000dEaD'); });132    await bad('quote-btc-eth', (f) => { f.body.memo = f.body.memo.replace('=:e:', '=:a:'); });           // Arbitrum ETH instead of Ethereum ETH133    await bad('quote-btc-usdc', (f) => { f.body.memo = f.body.memo.replace('ETH.USDC', 'ARB.USDC'); });134    await bad('quote-btc-eth', (f) => { f.body.memo = f.body.memo.replace(':1961152/', ':1/'); });135    await bad('quote-btc-eth', (f) => { f.body.memo = f.body.memo.replace('/3/0', ''); });                 // we asked for interval 3136    await bad('quote-btc-eth', (f) => { f.body.memo = f.body.memo.replace('/3/0', '/3/0:maya:100'); });137    await bad('quote-eth-btc', (f) => { f.body.router = '0xD37BbE5744D730a1d98d8DC97c42F0Ca46aD7146'; }); // THORChain's router is not Maya's138    await bad('quote-arbeth-btc', (f) => { f.body.router = '0xe3985E6b61b814F7Cdb188766562ba71b446B46d'; }); // Maya's Ethereum router on Arbitrum139    await bad('quote-btc-eth', (f) => { f.body.expected_amount_out = '1971008.9'; });140    await bad('quote-btc-eth', (f) => { f.body.expected_amount_out = -1971008; });141    await bad('quote-btc-eth', (f) => { delete f.body.fees.outbound; });142    await bad('quote-btc-eth', (f) => { f.body.fees.asset = 'ARB.ETH'; });143    await bad('quote-btc-eth', (f) => { f.body.error = 'ignored'; f.body.expected_amount_out = 'x'; });144  });145  it('pins the API base', async () => {146    const { r } = await run('quote-btc-eth');147    expect(r.calls.every((u) => u.startsWith(MAYA_API + '/'))).toBe(true); expect(MAYA_API).toBe('https://mayanode.mayachain.info/mayachain');148  });149});150
Files main
Menu

Theme