import * as TestFunding from '../../../../test/Funding.js' import * as FundingProvider from '../Provider.js' import * as Across from './across.js' import * as Relay from './relay.js' import * as Rhino from './rhino.js' import * as Squid from './squid.js' import * as Stargate from './stargate.js' import * as Symbiosis from './symbiosis.js' const now = () => new Date('2026-01-01T00:00:00.000Z') const evmUserAddress = TestFunding.relayQuoteUsers['eip155:4217'] const solanaUserAddress = '7ZwiMrYFNCzZ4TcPkeMx7BTZSZ9jDgGR8H8Nkazhr6L1' const providers = [ Across.across({ apiKey: 'across-api-key', integratorId: '0xdead', userAddress: '0x1111111111111111111111111111111111111111', }), Relay.relay({ userAddresses: TestFunding.relayQuoteUsers }), Rhino.rhino(), Squid.squid({ integratorId: 'squid-integrator-id', userAddress: '0x1111111111111111111111111111111111111111', }), Stargate.stargate(), Symbiosis.symbiosis({ userAddress: '0x1111111111111111111111111111111111111111' }), ] as const describe('funding providers', () => { test('maps successful Across quotes to available quote results', async () => { const requests: RecordedRequest[] = [] const candidate = quoteCandidate('across') const fetch = recordingFetch(requests, async () => jsonResponse(acrossQuote(candidate))) const provider = Across.across({ apiKey: 'across-api-key', baseUrl: 'https://across.test/api/', fetch, integratorId: '0xdead', now, userAddress: '0x1111111111111111111111111111111111111111', }) const result = await provider.getQuote(getQuoteInput(candidate), new AbortController().signal) expect(requests).toHaveLength(1) expect(requests[0]?.url.pathname).toBe('/api/swap/approval') expect(requests[0]?.url.searchParams.get('originChainId')).toBe('8453') expect(requests[0]?.url.searchParams.get('destinationChainId')).toBe('4217') expect(requests[0]?.url.searchParams.get('amount')).toBe('1000000') expect(requests[0]?.url.searchParams.get('integratorId')).toBe('0xdead') expect(requests[0]?.url.searchParams.get('tradeType')).toBe('exactInput') expect(requests[0]?.url.searchParams.get('skipOriginTxEstimation')).toBe('true') expect(requests[0]?.url.searchParams.get('strictTradeType')).toBe('true') expect(requests[0]?.url.searchParams.get('depositor')).toBe( '0x1111111111111111111111111111111111111111', ) expect(requests[0]?.url.searchParams.get('recipient')).toBe( '0x1111111111111111111111111111111111111111', ) expect(new Headers(requests[0]?.init?.headers).get('authorization')).toBe( 'Bearer across-api-key', ) expect(result).toMatchObject({ expiresAt: '2026-01-01T00:01:00.000Z', destinationAmountMin: '988000', destinationAmount: '990000', quality: { estimatedSeconds: 2, liquiditySource: 'providerQuote', sourceDetail: 'across:swap-approval', tier: 'liquid', }, sampledAt: '2026-01-01T00:00:00.000Z', status: 'available', }) }) test('maps recognized Across quote errors to unavailable results', async () => { const candidate = quoteCandidate('across') const fetch = recordingFetch([], async () => jsonResponse({ code: 'NO_ROUTE' }, 400)) const provider = Across.across({ apiKey: 'across-api-key', fetch, integratorId: '0xdead', now, userAddress: '0x1111111111111111111111111111111111111111', }) await expect( provider.getQuote(getQuoteInput(candidate), new AbortController().signal), ).resolves.toMatchObject({ quality: { liquiditySource: 'providerQuote', sourceDetail: 'across:swap-approval:NO_ROUTE', tier: 'unavailable', }, status: 'unavailable', }) }) test('rejects malformed Across quote payloads', async () => { const candidate = quoteCandidate('across') const fetch = recordingFetch([], async () => jsonResponse({ ...acrossQuote(candidate), expectedOutputAmount: '0' }), ) const provider = Across.across({ apiKey: 'across-api-key', fetch, integratorId: '0xdead', now, userAddress: '0x1111111111111111111111111111111111111111', }) await expect( provider.getQuote(getQuoteInput(candidate), new AbortController().signal), ).rejects.toThrowErrorMatchingInlineSnapshot(`[FundingProvider.ProviderPayloadError]`) }) test('rejects invalid Across configuration', () => { expect(() => Across.across({ apiKey: 'across-api-key', integratorId: 'tempo', userAddress: '0x1111111111111111111111111111111111111111', }), ).toThrowErrorMatchingInlineSnapshot(`[FundingProvider.ProviderConfigurationError]`) }) test('maps successful Relay quotes to available quote results', async () => { const requests: RecordedRequest[] = [] const candidate = quoteCandidate() const fetch = recordingFetch(requests, async () => jsonResponse({ details: { currencyOut: { amount: '990000', minimumAmount: '980000', }, timeEstimate: 15, totalImpact: { percent: '-1.20' }, }, }), ) const provider = Relay.relay({ apiKey: 'relay-api-key', fetch, now, userAddresses: TestFunding.relayQuoteUsers, }) const result = await provider.getQuote(getQuoteInput(candidate), new AbortController().signal) expect(requests.map((request) => request.url.pathname)).toEqual(['/quote/v2']) expect(new Headers(requests[0]?.init?.headers).get('x-api-key')).toBe('relay-api-key') expect(jsonRequestBody(requests[0])).toMatchObject({ amount: '1000000', destinationChainId: 4217, destinationCurrency: candidate.destinationToken.address, originChainId: 8453, originCurrency: candidate.sourceToken.address, tradeType: 'EXACT_INPUT', user: '0x1111111111111111111111111111111111111111', }) expect(result).toMatchObject({ destinationAmountMin: '980000', destinationAmount: '990000', quality: { estimatedSeconds: 15, liquiditySource: 'providerQuote', sourceDetail: 'relay:quote-v2', tier: 'liquid', }, sampledAt: '2026-01-01T00:00:00.000Z', status: 'available', }) }) test('keeps Relay preparation available without an indicative quote wallet', async () => { const provider = Relay.relay({ now }) const result = await provider.getQuote( getQuoteInput(quoteCandidate()), new AbortController().signal, ) expect(FundingProvider.canPrepareTransfer(provider)).toBe(true) expect(result).toMatchInlineSnapshot(` { "quality": { "liquiditySource": "providerQuote", "sourceDetail": "relay:quote-v2:QUOTE_USER_NOT_CONFIGURED", "tier": "unavailable", }, "sampledAt": "2026-01-01T00:00:00.000Z", "status": "unavailable", } `) }) test('requires a Relay quote wallet for the source chain', async () => { const requests: RecordedRequest[] = [] const candidate = quoteCandidate('relay', { destinationToken: 'pathUSD', sourceChain: 'solana', }) const provider = Relay.relay({ fetch: recordingFetch(requests, async () => jsonResponse({})), now, userAddresses: { 'eip155:4217': evmUserAddress }, }) const result = await provider.getQuote(getQuoteInput(candidate), new AbortController().signal) expect(requests).toHaveLength(0) expect(result).toMatchObject({ quality: { sourceDetail: 'relay:quote-v2:QUOTE_USER_NOT_CONFIGURED', tier: 'unavailable', }, status: 'unavailable', }) }) test('uses chain-specific Relay quote wallets', async () => { const requests: RecordedRequest[] = [] const candidate = quoteCandidate('relay', { destinationToken: 'pathUSD', sourceChain: 'solana', }) const fetch = recordingFetch(requests, async () => jsonResponse({ details: { currencyOut: { amount: '990000', minimumAmount: '980000' }, timeEstimate: 15, }, }), ) const provider = Relay.relay({ fetch, now, userAddresses: { 'eip155:4217': evmUserAddress, 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': solanaUserAddress, }, }) const result = await provider.getQuote(getQuoteInput(candidate), new AbortController().signal) expect(jsonRequestBody(requests[0])).toMatchObject({ destinationChainId: 4217, originChainId: 792703809, recipient: evmUserAddress, user: solanaUserAddress, }) expect(result).toMatchObject({ status: 'available' }) }) test('rejects invalid chain-specific Relay quote wallets', () => { expect(() => Relay.relay({ userAddresses: { 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': '0x1111111111111111111111111111111111111111', }, }), ).toThrowErrorMatchingInlineSnapshot(`[FundingProvider.ProviderConfigurationError]`) }) test('maps successful Squid quotes to available quote results', async () => { const requests: RecordedRequest[] = [] const candidate = quoteCandidate('squid') const fetch = recordingFetch(requests, async () => jsonResponse(squidQuote(candidate))) const provider = Squid.squid({ baseUrl: 'https://squid.test/v2/', fetch, integratorId: 'squid-integrator-id', now, userAddress: '0x1111111111111111111111111111111111111111', }) const result = await provider.getQuote(getQuoteInput(candidate), new AbortController().signal) expect(requests).toHaveLength(1) expect(requests[0]?.url.pathname).toBe('/v2/route') expect(new Headers(requests[0]?.init?.headers).get('x-integrator-id')).toBe( 'squid-integrator-id', ) expect(jsonRequestBody(requests[0])).toEqual({ fromAddress: '0x1111111111111111111111111111111111111111', fromAmount: '1000000', fromChain: '8453', fromToken: candidate.sourceToken.address, quoteOnly: true, slippage: 1, toAddress: '0x1111111111111111111111111111111111111111', toChain: '4217', toToken: candidate.destinationToken.address, }) expect(result).toEqual({ destinationAmountMin: '989622', destinationAmount: '999620', quality: { estimatedSeconds: 10, liquiditySource: 'providerQuote', sourceDetail: 'squid:route-v2', tier: 'liquid', }, sampledAt: '2026-01-01T00:00:00.000Z', status: 'available', }) }) test('maps unavailable Squid routes to unavailable quote results', async () => { const candidate = quoteCandidate('squid') const provider = Squid.squid({ fetch: recordingFetch([], async () => jsonResponse( { message: 'Please increase the swap amount and try again.', statusCode: 400, type: 'BAD_REQUEST', }, 400, ), ), integratorId: 'squid-integrator-id', now, userAddress: '0x1111111111111111111111111111111111111111', }) await expect( provider.getQuote(getQuoteInput(candidate), new AbortController().signal), ).resolves.toMatchObject({ quality: { sourceDetail: 'squid:route-v2:AMOUNT_TOO_LOW', tier: 'unavailable', }, status: 'unavailable', }) }) test('exposes Base and Ethereum Stargate quote candidates', () => { expect( ['base', 'ethereum'].map((sourceChain) => quoteCandidate('stargate', { sourceChain }).id), ).toEqual(['base-usdc-tempo-usdce-stargate', 'ethereum-usdc-tempo-usdce-stargate']) }) test('maps successful Symbiosis quotes to available quote results', async () => { const requests: RecordedRequest[] = [] const candidate = quoteCandidate('symbiosis') const fetch = recordingFetch(requests, async () => jsonResponse(symbiosisQuote(candidate))) const provider = Symbiosis.symbiosis({ baseUrl: 'https://symbiosis.test/crosschain/', fetch, now, userAddress: '0x1111111111111111111111111111111111111111', }) const result = await provider.getQuote(getQuoteInput(candidate), new AbortController().signal) expect(requests).toHaveLength(1) expect(requests[0]?.url.pathname).toBe('/crosschain/v2/quote') expect(new Headers(requests[0]?.init?.headers).get('x-partner-id')).toBe('tempo') expect(jsonRequestBody(requests[0])).toEqual({ from: '0x1111111111111111111111111111111111111111', slippage: 100, to: '0x1111111111111111111111111111111111111111', tokenAmountIn: { address: candidate.sourceToken.address, amount: '1000000', chainId: 8453, decimals: 6, symbol: 'USDC', }, tokenOut: { address: candidate.destinationToken.address, chainId: 4217, decimals: 6, symbol: 'USDC.e', }, }) expect(result).toEqual({ destinationAmountMin: '979000', destinationAmount: '989000', quality: { estimatedSeconds: 18, liquiditySource: 'providerQuote', sourceDetail: 'symbiosis:quote-v2', tier: 'liquid', }, sampledAt: '2026-01-01T00:00:00.000Z', status: 'available', }) }) test('maps Tron identifiers into Symbiosis quote requests', async () => { const requests: RecordedRequest[] = [] const candidate = quoteCandidate('symbiosis', { destinationToken: 'USDT0', sourceChain: 'tron', sourceToken: 'USDT', }) const fetch = recordingFetch(requests, async () => jsonResponse(symbiosisQuote(candidate))) const provider = Symbiosis.symbiosis({ fetch, now, userAddress: '0x1111111111111111111111111111111111111111', }) await provider.getQuote(getQuoteInput(candidate), new AbortController().signal) expect(jsonRequestBody(requests[0])).toMatchObject({ tokenAmountIn: { address: '0xa614f803b6fd780986a42c78ec9c7f77e6ded13c', chainId: 728126428, decimals: 6, symbol: 'USDT', }, tokenOut: { address: candidate.destinationToken.address, chainId: 4217, decimals: 6, symbol: 'USDT0', }, }) }) test('rejects Symbiosis responses without route identity fields', async () => { const candidate = quoteCandidate('symbiosis') const quote = symbiosisQuote(candidate) const response = { tokenAmountOut: quote.tokenAmountOut, tokenAmountOutMin: quote.tokenAmountOutMin, } const provider = Symbiosis.symbiosis({ fetch: recordingFetch([], async () => jsonResponse(response)), now, userAddress: '0x1111111111111111111111111111111111111111', }) await expect( provider.getQuote(getQuoteInput(candidate), new AbortController().signal), ).rejects.toThrowErrorMatchingInlineSnapshot(`[FundingProvider.ProviderPayloadError]`) }) test('enforces the Symbiosis provider quota before fetching', async () => { const requests: RecordedRequest[] = [] const candidate = quoteCandidate('symbiosis') const provider = Symbiosis.symbiosis({ fetch: recordingFetch(requests, async () => jsonResponse(symbiosisQuote(candidate))), now, userAddress: '0x1111111111111111111111111111111111111111', }) await Promise.all( Array.from({ length: 4 }, () => provider.getQuote(getQuoteInput(candidate), new AbortController().signal), ), ) const cause = await provider .getQuote(getQuoteInput(candidate), new AbortController().signal) .catch((error: unknown) => error) expect(requests).toHaveLength(4) expect(cause).toMatchObject({ name: 'FundingProvider.ProviderRateLimitError', }) expect( FundingProvider.failure({ cause, operation: 'getQuote', provider: getProvider('symbiosis'), }), ).toEqual({ failure: 'rate_limit', id: 'symbiosis', operation: 'getQuote', }) }) test('maps unavailable Symbiosis routes to unavailable quote results', async () => { const candidate = quoteCandidate('symbiosis') const provider = Symbiosis.symbiosis({ fetch: recordingFetch([], async () => jsonResponse({ code: 'NO_ROUTE' }, 400)), now, userAddress: '0x1111111111111111111111111111111111111111', }) await expect( provider.getQuote(getQuoteInput(candidate), new AbortController().signal), ).resolves.toMatchObject({ quality: { sourceDetail: 'symbiosis:quote-v2:NO_ROUTE', tier: 'unavailable', }, status: 'unavailable', }) }) test('maps Symbiosis fee-floor errors to unavailable quote results', async () => { const candidate = quoteCandidate('symbiosis') const provider = Symbiosis.symbiosis({ fetch: recordingFetch([], async () => jsonResponse({ code: 0, message: 'Amount is too low to cover fees' }, 400), ), now, userAddress: '0x1111111111111111111111111111111111111111', }) await expect( provider.getQuote(getQuoteInput(candidate), new AbortController().signal), ).resolves.toMatchObject({ quality: { sourceDetail: 'symbiosis:quote-v2:AMOUNT_TOO_LOW', tier: 'unavailable', }, status: 'unavailable', }) }) test('rejects invalid Symbiosis configuration', () => { expect(() => Symbiosis.symbiosis({ userAddress: 'invalid' }), ).toThrowErrorMatchingInlineSnapshot(`[FundingProvider.ProviderConfigurationError]`) }) test('maps successful Rhino quotes to available quote results', async () => { const requests: RecordedRequest[] = [] const candidate = quoteCandidate('rhino') const fetch = recordingFetch(requests, async () => jsonResponse({ _tag: 'bridge', chainIn: 'BASE', chainOut: 'TEMPO', estimatedDuration: 1_500, payAmount: '1.0000000', receiveAmount: '0.9830000', token: 'USDC', }), ) const provider = Rhino.rhino({ baseUrl: 'https://rhino.test/bridge/', fetch, now, }) const result = await provider.getQuote(getQuoteInput(candidate), new AbortController().signal) expect(requests).toHaveLength(1) expect(requests[0]?.url.pathname).toBe('/bridge/quote/bridge-swap/public') expect(Object.fromEntries(requests[0]!.url.searchParams)).toEqual({ amount: '1', amountNative: '0', chainIn: 'BASE', chainOut: 'TEMPO', mode: 'pay', tokenIn: 'USDC', tokenOut: 'USDC', }) expect(result).toEqual({ destinationAmount: '983000', quality: { estimatedSeconds: 2, liquiditySource: 'providerQuote', sourceDetail: 'rhino:bridge-swap-public', tier: 'liquid', }, sampledAt: '2026-01-01T00:00:00.000Z', status: 'available', }) }) test('maps unavailable Rhino routes to unavailable quote results', async () => { const candidate = quoteCandidate('rhino') const provider = Rhino.rhino({ fetch: recordingFetch([], async () => jsonResponse({ _tag: 'NoRouteFoundError' }, 404)), now, }) await expect( provider.getQuote(getQuoteInput(candidate), new AbortController().signal), ).resolves.toMatchObject({ quality: { sourceDetail: 'rhino:bridge-swap-public:NoRouteFoundError', tier: 'unavailable', }, status: 'unavailable', }) }) test('maps Solana and Tron identifiers into Rhino quote requests', async () => { const cases = [ { candidate: quoteCandidate('rhino', { sourceChain: 'solana' }), chainIn: 'SOLANA', tokenOut: 'USDC', }, { candidate: quoteCandidate('rhino', { destinationToken: 'USDT0', sourceChain: 'tron', sourceToken: 'USDT', }), chainIn: 'TRON', tokenOut: 'USDT', }, ] as const for (const testCase of cases) { const requests: RecordedRequest[] = [] const provider = Rhino.rhino({ fetch: recordingFetch(requests, async () => jsonResponse({ _tag: 'bridge', chainIn: testCase.chainIn, chainOut: 'TEMPO', payAmount: '1', receiveAmount: '0.983', token: testCase.tokenOut, }), ), now, }) await expect( provider.getQuote(getQuoteInput(testCase.candidate), new AbortController().signal), ).resolves.toMatchObject({ destinationAmount: '983000', status: 'available' }) expect(requests[0]?.url.searchParams.get('chainIn')).toBe(testCase.chainIn) expect(requests[0]?.url.searchParams.get('tokenOut')).toBe(testCase.tokenOut) } }) test('surfaces Rhino request decoding failures', async () => { const candidate = quoteCandidate('rhino') const provider = Rhino.rhino({ fetch: recordingFetch([], async () => jsonResponse({ _tag: 'HttpApiDecodeError' }, 400)), now, }) await expect( provider.getQuote(getQuoteInput(candidate), new AbortController().signal), ).rejects.toThrowErrorMatchingInlineSnapshot( `[FundingProvider.ProviderResponseError: Provider request failed with status 400]`, ) }) test('surfaces unrecognized Rhino route errors', async () => { const candidate = quoteCandidate('rhino') const failures = [ { body: null, status: 404 }, { body: { _tag: 'InvalidRequest' }, status: 422 }, ] as const for (const failure of failures) { const provider = Rhino.rhino({ fetch: recordingFetch([], async () => jsonResponse(failure.body, failure.status)), now, }) const cause = await provider .getQuote(getQuoteInput(candidate), new AbortController().signal) .catch((error: unknown) => error) expect(cause).toMatchObject({ name: 'FundingProvider.ProviderResponseError', status: failure.status, }) } }) test('rejects mismatched Rhino quote payloads', async () => { const candidate = quoteCandidate('rhino') const provider = Rhino.rhino({ fetch: recordingFetch([], async () => jsonResponse({ _tag: 'bridge', chainIn: 'SOLANA', chainOut: 'TEMPO', payAmount: '1', receiveAmount: '0.983', token: 'USDC', }), ), now, }) await expect( provider.getQuote(getQuoteInput(candidate), new AbortController().signal), ).rejects.toThrowErrorMatchingInlineSnapshot(`[FundingProvider.ProviderPayloadError]`) }) test('rejects non-representable Rhino output precision', async () => { const candidate = quoteCandidate('rhino') const provider = Rhino.rhino({ fetch: recordingFetch([], async () => jsonResponse({ _tag: 'bridge', chainIn: 'BASE', chainOut: 'TEMPO', payAmount: '1', receiveAmount: '0.9830001', token: 'USDC', }), ), now, }) await expect( provider.getQuote(getQuoteInput(candidate), new AbortController().signal), ).rejects.toThrowErrorMatchingInlineSnapshot(`[FundingProvider.ProviderPayloadError]`) }) test('calls the runtime fetch method with its global receiver', async () => { const candidate = quoteCandidate() const fetch = vi.fn(function (this: unknown) { expect(this).toBe(globalThis) return Promise.resolve( jsonResponse({ details: { currencyOut: { amount: '990000', minimumAmount: '980000' }, timeEstimate: 15, }, }), ) }) vi.stubGlobal('fetch', fetch) try { const provider = Relay.relay({ now, userAddresses: TestFunding.relayQuoteUsers, }) const result = await provider.getQuote(getQuoteInput(candidate), new AbortController().signal) expect(result.destinationAmount).toBe('990000') expect(fetch).toHaveBeenCalledTimes(1) } finally { vi.unstubAllGlobals() } }) const invalidAcrossQuotes = [ [ 'mismatched input amounts', (body: ReturnType) => (body.inputAmount = '1'), ], [ 'mismatched token addresses', (body: ReturnType) => (body.inputToken.address = '0xfde4c96c8593536e31f229ea8f37b2ada2699bb2'), ], [ 'mismatched token chains', (body: ReturnType) => (body.inputToken.chainId = 1), ], [ 'mismatched token decimals', (body: ReturnType) => (body.inputToken.decimals = 18), ], [ 'expired quotes', (body: ReturnType) => (body.quoteExpiryTimestamp = 1767225600), ], [ 'out-of-range expiries', (body: ReturnType) => (body.quoteExpiryTimestamp = Number.MAX_SAFE_INTEGER), ], [ 'invalid output bounds', (body: ReturnType) => (body.minOutputAmount = '990001'), ], ] as const for (const [name, mutate] of invalidAcrossQuotes) test(`rejects Across quotes with ${name}`, async () => { const candidate = quoteCandidate('across') const body = acrossQuote(candidate) mutate(body) const provider = Across.across({ apiKey: 'across-api-key', fetch: recordingFetch([], async () => jsonResponse(body)), integratorId: '0xdead', now, userAddress: '0x1111111111111111111111111111111111111111', }) await expect( provider.getQuote(getQuoteInput(candidate), new AbortController().signal), ).rejects.toThrowErrorMatchingInlineSnapshot(`[FundingProvider.ProviderPayloadError]`) }) const invalidSquidQuotes = [ [ 'mismatched input amounts', (body: ReturnType) => (body.route.estimate.fromAmount = '1'), ], [ 'mismatched input token addresses', (body: ReturnType) => (body.route.estimate.fromToken.address = '0xfde4c96c8593536e31f229ea8f37b2ada2699bb2'), ], [ 'mismatched input chains', (body: ReturnType) => (body.route.estimate.fromToken.chainId = 1), ], [ 'mismatched input decimals', (body: ReturnType) => (body.route.estimate.fromToken.decimals = 18), ], [ 'mismatched output token addresses', (body: ReturnType) => (body.route.estimate.toToken.address = '0x20c00000000000000000000014f22ca97301eb73'), ], [ 'mismatched output chains', (body: ReturnType) => (body.route.estimate.toToken.chainId = '1'), ], [ 'mismatched output decimals', (body: ReturnType) => (body.route.estimate.toToken.decimals = 18 as never), ], [ 'zero output amounts', (body: ReturnType) => (body.route.estimate.toAmount = '0'), ], [ 'invalid output bounds', (body: ReturnType) => (body.route.estimate.toAmountMin = '1000000'), ], ] as const for (const [name, mutate] of invalidSquidQuotes) test(`rejects Squid quotes with ${name}`, async () => { const candidate = quoteCandidate('squid') const body = squidQuote(candidate) mutate(body) const provider = Squid.squid({ fetch: recordingFetch([], async () => jsonResponse(body)), integratorId: 'squid-integrator-id', now, userAddress: '0x1111111111111111111111111111111111111111', }) await expect( provider.getQuote(getQuoteInput(candidate), new AbortController().signal), ).rejects.toThrowErrorMatchingInlineSnapshot(`[FundingProvider.ProviderPayloadError]`) }) const invalidSymbiosisQuotes = [ [ 'unsupported route kinds', (body: ReturnType) => (body.kind = 'onchain-swap' as never), ], [ 'mismatched execution types', (body: ReturnType) => (body.type = 'tron'), ], [ 'mismatched transaction chains', (body: ReturnType) => (body.tx.chainId = 1), ], ['empty routes', (body: ReturnType) => (body.routes = [])], [ 'mismatched source token addresses', (body: ReturnType) => (body.routes[0]!.tokens[0]!.address = '0xfde4c96c8593536e31f229ea8f37b2ada2699bb2'), ], [ 'mismatched source chains', (body: ReturnType) => (body.routes[0]!.tokens[0]!.chainId = 1), ], [ 'mismatched route destination tokens', (body: ReturnType) => (body.routes[0]!.tokens[1]!.address = '0x20c00000000000000000000014f22ca97301eb73'), ], [ 'mismatched output token addresses', (body: ReturnType) => (body.tokenAmountOut.address = '0x20c00000000000000000000014f22ca97301eb73'), ], [ 'mismatched minimum output chains', (body: ReturnType) => (body.tokenAmountOutMin.chainId = 1), ], [ 'mismatched output decimals', (body: ReturnType) => (body.tokenAmountOut.decimals = 18 as never), ], [ 'zero output amounts', (body: ReturnType) => (body.tokenAmountOut.amount = '0'), ], [ 'invalid output bounds', (body: ReturnType) => (body.tokenAmountOutMin.amount = '990000'), ], ] as const for (const [name, mutate] of invalidSymbiosisQuotes) test(`rejects Symbiosis quotes with ${name}`, async () => { const candidate = quoteCandidate('symbiosis') const body = symbiosisQuote(candidate) mutate(body) const provider = Symbiosis.symbiosis({ fetch: recordingFetch([], async () => jsonResponse(body)), now, userAddress: '0x1111111111111111111111111111111111111111', }) await expect( provider.getQuote(getQuoteInput(candidate), new AbortController().signal), ).rejects.toThrowErrorMatchingInlineSnapshot(`[FundingProvider.ProviderPayloadError]`) }) test('returns bounded Across failure diagnostics', async () => { const candidate = quoteCandidate('across') const provider = Across.across({ apiKey: 'across-api-key', fetch: recordingFetch([], async () => jsonResponse({ apiKey: 'secret', code: 'UNAUTHORIZED' }, 401), ), integratorId: '0xdead', now, userAddress: '0x1111111111111111111111111111111111111111', }) const cause = await provider .getQuote(getQuoteInput(candidate), new AbortController().signal) .catch((error: unknown) => error) expect( FundingProvider.failure({ cause, operation: 'getQuote', provider: getProvider('across'), }), ).toEqual({ code: 'UNAUTHORIZED', failure: 'http', id: 'across', operation: 'getQuote', status: 401, }) }) test('returns explicit timeout failure diagnostics', () => { expect( FundingProvider.failure({ cause: new FundingProvider.ProviderTimeoutError(), operation: 'getQuote', provider: getProvider('across'), }), ).toEqual({ failure: 'timeout', id: 'across', operation: 'getQuote', }) }) test('returns bounded Squid failure diagnostics', () => { expect( FundingProvider.failure({ cause: new TypeError('network failure'), operation: 'getQuote', provider: getProvider('squid'), }), ).toEqual({ failure: 'network', id: 'squid', operation: 'getQuote', }) }) test('returns bounded Stargate failure diagnostics', () => { expect( FundingProvider.failure({ cause: new TypeError('network failure'), operation: 'getQuote', provider: getProvider('stargate'), }), ).toEqual({ failure: 'network', id: 'stargate', operation: 'getQuote', }) }) test('returns bounded Symbiosis failure diagnostics', () => { expect( FundingProvider.failure({ cause: new TypeError('network failure'), operation: 'getQuote', provider: getProvider('symbiosis'), }), ).toEqual({ failure: 'network', id: 'symbiosis', operation: 'getQuote', }) }) }) type RecordedRequest = { init: RequestInit | undefined url: URL } function quoteCandidate( provider: FundingProvider.ProviderId = 'relay', options: quoteCandidate.Options = {}, ): FundingProvider.getQuote.Parameters['candidate'] { const [candidate] = FundingProvider.getQuoteCandidates({ catalog: TestFunding.snapshot, destinationToken: options.destinationToken, provider, providers, sourceAmount: '1000000', sourceAmountUnits: 'baseUnits', sourceChain: options.sourceChain ?? 'base', sourceToken: options.sourceToken ?? 'USDC', }) if (!candidate) throw new Error(`Missing ${provider} quote candidate`) return candidate } function getProvider(id: FundingProvider.ProviderId) { const provider = providers.find((entry) => entry.id === id) if (!provider) throw new Error(`Missing ${id} provider`) return provider } declare namespace quoteCandidate { type Options = { destinationToken?: string | undefined sourceChain?: string | undefined sourceToken?: string | undefined } } function acrossQuote(candidate: FundingProvider.getQuote.Parameters['candidate']) { return { amountType: 'exactInput', expectedFillTime: 2, expectedOutputAmount: '990000', inputAmount: candidate.sourceAmount.amount, inputToken: { address: candidate.sourceToken.address, chainId: 8453, decimals: candidate.sourceToken.decimals, symbol: candidate.sourceToken.symbol, }, minOutputAmount: '988000', outputToken: { address: candidate.destinationToken.address, chainId: 4217, decimals: candidate.destinationToken.decimals, symbol: candidate.destinationToken.symbol, }, quoteExpiryTimestamp: 1767225660, } } function squidQuote(candidate: FundingProvider.getQuote.Parameters['candidate']) { return { route: { estimate: { estimatedRouteDuration: 10, fromAmount: candidate.sourceAmount.amount, fromToken: { address: candidate.sourceToken.address, chainId: 8453, decimals: candidate.sourceToken.decimals, }, toAmount: '999620', toAmountMin: '989622', toToken: { address: candidate.destinationToken.address, chainId: '4217', decimals: candidate.destinationToken.decimals, }, }, }, } } function symbiosisQuote(candidate: FundingProvider.getQuote.Parameters['candidate']) { const source = candidate.sourceChain.kind === 'tron' ? { address: '0xa614f803b6fd780986a42c78ec9c7f77e6ded13c', chainId: 728126428, decimals: candidate.sourceToken.decimals, } : { address: candidate.sourceToken.address, chainId: Number(candidate.sourceChain.id.replace('eip155:', '')), decimals: candidate.sourceToken.decimals, } const destination = { address: candidate.destinationToken.address, chainId: 4217, decimals: candidate.destinationToken.decimals, } return { estimatedTime: 17.2, kind: 'crosschain-swap' as const, routes: [{ tokens: [source, destination] }], tokenAmountOut: { ...destination, amount: '989000', }, tokenAmountOutMin: { ...destination, amount: '979000', }, tx: { chainId: source.chainId }, type: candidate.sourceChain.kind, } } function getQuoteInput( candidate: FundingProvider.getQuote.Parameters['candidate'], ): FundingProvider.getQuote.Parameters { return { candidate, cache: { bypass: false }, destinationChain: candidate.destinationChain, destinationToken: candidate.destinationToken, request: { provider: candidate.provider.id, sourceAmount: candidate.sourceAmount.amount, sourceAmountUnits: 'baseUnits', sourceChain: candidate.sourceChain.id, sourceToken: candidate.sourceToken.symbol, }, route: candidate.route, sourceAmount: candidate.sourceAmount, sourceChain: candidate.sourceChain, sourceToken: candidate.sourceToken, } } function recordingFetch( requests: RecordedRequest[], respond: (request: RecordedRequest) => Response | Promise, ): typeof globalThis.fetch { return async (input, init) => { const request = { init, url: toUrl(input) } requests.push(request) return respond(request) } } function toUrl(input: Parameters[0]) { if (input instanceof Request) return new URL(input.url) return new URL(String(input)) } function jsonResponse(body: unknown, status = 200) { return new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' }, status, }) } function jsonRequestBody(request: RecordedRequest | undefined) { if (typeof request?.init?.body !== 'string') throw new Error('Expected JSON request body') return JSON.parse(request.init.body) as unknown }