/** * @jest-environment jsdom */ import LiFi, { normalizeChainToNetwork } from './index' import Mpc from '../../../mpc' import portalMock from '../../../__mocks/portal/portal' import { mockEip155Address, mockHost, mockLifiAction, mockLifiGetRouteStepResponse, mockLifiGetRoutesResponse, mockLifiGetStatusResponse, mockLifiRoute, mockLifiStep, } from '../../../__mocks/constants' describe('normalizeChainToNetwork', () => { it('maps hex chain ids to eip155', () => { expect(normalizeChainToNetwork('0x1')).toBe('eip155:1') expect(normalizeChainToNetwork('0xa')).toBe('eip155:10') expect(normalizeChainToNetwork('0x89')).toBe('eip155:137') }) it('preserves existing eip155 and numeric decimal strings', () => { expect(normalizeChainToNetwork('eip155:42161')).toBe('eip155:42161') expect(normalizeChainToNetwork('42161')).toBe('eip155:42161') }) }) describe('LiFi tradeAsset', () => { let mpc: Mpc let lifi: LiFi beforeEach(() => { jest.clearAllMocks() portalMock.host = mockHost mpc = new Mpc({ portal: portalMock }) lifi = new LiFi({ mpc, signAndSendTransaction: async () => '0xsent', waitForConfirmation: async () => false, }) }) it('throws when waitForConfirmation returns false and does not poll bridge status', async () => { jest .spyOn(mpc, 'getLifiRoutes') .mockResolvedValue(mockLifiGetRoutesResponse as never) jest .spyOn(mpc, 'getLifiRouteStep') .mockResolvedValue(mockLifiGetRouteStepResponse as never) const pollSpy = jest .spyOn(lifi, 'pollStatus') .mockRejectedValue(new Error('pollStatus should not run')) await expect( lifi.tradeAsset({ fromChain: 'eip155:8453', toChain: 'eip155:42161', fromToken: 'ETH', toToken: 'USDC', amount: '1000000000000000', fromAddress: mockEip155Address, }), ).rejects.toThrow(/on-chain confirmation did not complete/) expect(pollSpy).not.toHaveBeenCalled() }) it('strips nonce from EVM transaction request before sign', async () => { const swapStep = { ...mockLifiStep, type: 'swap' as const, action: { ...mockLifiAction, fromChainId: 'eip155:1', toChainId: 'eip155:1', }, } const route = { ...mockLifiRoute, fromChainId: 'eip155:1', toChainId: 'eip155:1', steps: [swapStep], } const routesRes = { data: { rawResponse: { routes: [route], unavailableRoutes: { filteredOut: [], failed: [] }, }, }, } const stepRes = { data: { rawResponse: { ...swapStep, transactionRequest: { ...swapStep.transactionRequest, nonce: '0xdeadbeef', }, }, }, } jest.spyOn(mpc, 'getLifiRoutes').mockResolvedValue(routesRes as never) jest.spyOn(mpc, 'getLifiRouteStep').mockResolvedValue(stepRes as never) const signer = jest.fn().mockResolvedValue('0xsigned') const waiter = jest.fn().mockResolvedValue(true) const lifiOk = new LiFi({ mpc, signAndSendTransaction: signer, waitForConfirmation: waiter, }) await lifiOk.tradeAsset({ fromChain: 'eip155:1', toChain: 'eip155:1', fromToken: 'ETH', toToken: 'USDC', amount: '1000000000000000', fromAddress: mockEip155Address, }) expect(signer).toHaveBeenCalledTimes(1) const sent = signer.mock.calls[0]![0] as Record expect(sent.nonce).toBeUndefined() }) it('emits onProgress failed on unexpected errors (outer catch)', async () => { const onProgress = jest.fn() jest.spyOn(mpc, 'getLifiRoutes').mockRejectedValue(new Error('network down')) await expect( lifi.tradeAsset( { fromChain: 'eip155:8453', toChain: 'eip155:42161', fromToken: 'ETH', toToken: 'USDC', amount: '1000000000000000', fromAddress: mockEip155Address, onProgress, }, { signAndSendTransaction: async () => '0x', waitForConfirmation: async () => true, }, ), ).rejects.toThrow('network down') expect(onProgress).toHaveBeenCalledWith( 'failed', expect.objectContaining({ errorMessage: 'network down' }), ) }) it('throws when routeIndex is out of bounds', async () => { jest .spyOn(mpc, 'getLifiRoutes') .mockResolvedValue(mockLifiGetRoutesResponse as never) const lifiOk = new LiFi({ mpc, signAndSendTransaction: async () => '0x', waitForConfirmation: async () => true, }) await expect( lifiOk.tradeAsset({ fromChain: 'eip155:8453', toChain: 'eip155:42161', fromToken: 'ETH', toToken: 'USDC', amount: '1000000000000000', fromAddress: mockEip155Address, routeIndex: 99, }), ).rejects.toThrow(/no route available at index 99/) }) it('throws when getRouteStep returns error', async () => { jest .spyOn(mpc, 'getLifiRoutes') .mockResolvedValue(mockLifiGetRoutesResponse as never) jest.spyOn(mpc, 'getLifiRouteStep').mockResolvedValue({ error: 'step build failed', } as never) const lifiOk = new LiFi({ mpc, signAndSendTransaction: async () => '0x', waitForConfirmation: async () => true, }) await expect( lifiOk.tradeAsset({ fromChain: 'eip155:8453', toChain: 'eip155:42161', fromToken: 'ETH', toToken: 'USDC', amount: '1000000000000000', fromAddress: mockEip155Address, }), ).rejects.toThrow('getRouteStep: step build failed') }) it('cross-chain step calls pollStatus with fast statusPoll options', async () => { jest .spyOn(mpc, 'getLifiRoutes') .mockResolvedValue(mockLifiGetRoutesResponse as never) jest .spyOn(mpc, 'getLifiRouteStep') .mockResolvedValue(mockLifiGetRouteStepResponse as never) jest .spyOn(mpc, 'getLifiStatus') .mockResolvedValue(mockLifiGetStatusResponse as never) const pollSpy = jest.spyOn(LiFi.prototype, 'pollStatus') const signer = jest.fn().mockResolvedValue('0xstep1') const lifiOk = new LiFi({ mpc, signAndSendTransaction: signer, waitForConfirmation: async () => true, }) await lifiOk.tradeAsset({ fromChain: 'eip155:8453', toChain: 'eip155:42161', fromToken: 'ETH', toToken: 'USDC', amount: '1000000000000000', fromAddress: mockEip155Address, statusPoll: { initialDelayMs: 0, everyMs: 5, timeoutMs: 20000, }, }) expect(signer).toHaveBeenCalled() expect(pollSpy).toHaveBeenCalled() pollSpy.mockRestore() }) it('same-chain swap does not call pollStatus', async () => { const swapStep = { ...mockLifiStep, type: 'swap' as const, action: { ...mockLifiAction, fromChainId: 'eip155:1', toChainId: 'eip155:1', }, } const route = { ...mockLifiRoute, fromChainId: 'eip155:1', toChainId: 'eip155:1', steps: [swapStep], } const routesRes = { data: { rawResponse: { routes: [route], unavailableRoutes: { filteredOut: [], failed: [] }, }, }, } const stepRes = { data: { rawResponse: { ...swapStep, transactionRequest: { ...swapStep.transactionRequest, chainId: 'eip155:1' }, }, }, } jest.spyOn(mpc, 'getLifiRoutes').mockResolvedValue(routesRes as never) jest.spyOn(mpc, 'getLifiRouteStep').mockResolvedValue(stepRes as never) const pollSpy = jest.spyOn(LiFi.prototype, 'pollStatus') const lifiOk = new LiFi({ mpc, signAndSendTransaction: async () => '0xswap', waitForConfirmation: async () => true, }) await lifiOk.tradeAsset({ fromChain: 'eip155:1', toChain: 'eip155:1', fromToken: 'ETH', toToken: 'USDC', amount: '1000000000000000', fromAddress: mockEip155Address, }) expect(pollSpy).not.toHaveBeenCalled() pollSpy.mockRestore() }) it('throws when signer returns empty hash', async () => { jest .spyOn(mpc, 'getLifiRoutes') .mockResolvedValue(mockLifiGetRoutesResponse as never) jest .spyOn(mpc, 'getLifiRouteStep') .mockResolvedValue(mockLifiGetRouteStepResponse as never) const onProgress = jest.fn() const lifiOk = new LiFi({ mpc, signAndSendTransaction: async () => ' ', waitForConfirmation: async () => true, }) await expect( lifiOk.tradeAsset({ fromChain: 'eip155:8453', toChain: 'eip155:42161', fromToken: 'ETH', toToken: 'USDC', amount: '1000000000000000', fromAddress: mockEip155Address, onProgress, }), ).rejects.toThrow(/Invalid transaction hash/) expect(onProgress).toHaveBeenCalledWith( 'failed', expect.objectContaining({ errorMessage: expect.stringContaining('Invalid') }), ) }) it('emits failed only ONCE when signer returns empty hash (no duplicate)', async () => { jest .spyOn(mpc, 'getLifiRoutes') .mockResolvedValue(mockLifiGetRoutesResponse as never) jest .spyOn(mpc, 'getLifiRouteStep') .mockResolvedValue(mockLifiGetRouteStepResponse as never) const onProgress = jest.fn() const lifiOk = new LiFi({ mpc, signAndSendTransaction: async () => '', waitForConfirmation: async () => true, }) await expect( lifiOk.tradeAsset({ fromChain: 'eip155:8453', toChain: 'eip155:42161', fromToken: 'ETH', toToken: 'USDC', amount: '1000000000000000', fromAddress: mockEip155Address, onProgress, }), ).rejects.toThrow(/Invalid transaction hash/) const failedCalls = onProgress.mock.calls.filter( (call) => call[0] === 'failed', ) expect(failedCalls).toHaveLength(1) expect(failedCalls[0]![1]).toMatchObject({ errorMessage: expect.stringContaining('Invalid transaction hash'), stepIndex: 0, totalSteps: 1, }) }) it('emits failed only ONCE when waitForConfirmation returns false (no duplicate)', async () => { jest .spyOn(mpc, 'getLifiRoutes') .mockResolvedValue(mockLifiGetRoutesResponse as never) jest .spyOn(mpc, 'getLifiRouteStep') .mockResolvedValue(mockLifiGetRouteStepResponse as never) const onProgress = jest.fn() const lifiOk = new LiFi({ mpc, signAndSendTransaction: async () => '0xhash', waitForConfirmation: async () => false, }) await expect( lifiOk.tradeAsset({ fromChain: 'eip155:8453', toChain: 'eip155:42161', fromToken: 'ETH', toToken: 'USDC', amount: '1000000000000000', fromAddress: mockEip155Address, onProgress, }), ).rejects.toThrow(/on-chain confirmation did not complete/) const failedCalls = onProgress.mock.calls.filter( (call) => call[0] === 'failed', ) expect(failedCalls).toHaveLength(1) expect(failedCalls[0]![1]).toMatchObject({ errorMessage: expect.stringContaining('on-chain confirmation'), txHash: '0xhash', stepIndex: 0, totalSteps: 1, }) }) it('throws when waitForConfirmation throws', async () => { jest .spyOn(mpc, 'getLifiRoutes') .mockResolvedValue(mockLifiGetRoutesResponse as never) jest .spyOn(mpc, 'getLifiRouteStep') .mockResolvedValue(mockLifiGetRouteStepResponse as never) const lifiOk = new LiFi({ mpc, signAndSendTransaction: async () => '0xabc', waitForConfirmation: async () => { throw new Error('waiter boom') }, }) await expect( lifiOk.tradeAsset({ fromChain: 'eip155:8453', toChain: 'eip155:42161', fromToken: 'ETH', toToken: 'USDC', amount: '1000000000000000', fromAddress: mockEip155Address, }), ).rejects.toThrow('waiter boom') }) })