/** * @jest-environment jsdom */ import ZeroX from '.' import Mpc from '../../../mpc' import portalMock from '../../../__mocks/portal/portal' import * as waitEvm from '../../../internal/waitForEvmTxConfirmation' import { mockEip155Address, mockHost, mockSourcesRes, mockZeroExQuoteV2Request, mockZeroExQuoteV2Response, mockZeroExOptions, mockZeroExPriceRequest, mockZeroExPriceResponse, } from '../../../__mocks/constants' import type { ZeroExPriceResponse } from '../../../../zero-x' describe('ZeroX', () => { let zeroX: ZeroX let mpc: Mpc beforeEach(() => { jest.clearAllMocks() portalMock.host = mockHost mpc = new Mpc({ portal: portalMock, }) zeroX = new ZeroX({ mpc }) }) describe('getQuote', () => { it('should correctly call mpc.getSwapsQuoteV2 with args and options', async () => { const spy = jest .spyOn(mpc, 'getSwapsQuoteV2') .mockResolvedValue(mockZeroExQuoteV2Response) const result = await zeroX.getQuote( mockZeroExQuoteV2Request, mockZeroExOptions, ) expect(spy).toHaveBeenCalledTimes(1) expect(spy).toHaveBeenCalledWith( mockZeroExQuoteV2Request, mockZeroExOptions, ) expect(result).toEqual(mockZeroExQuoteV2Response) }) it('should correctly call mpc.getSwapsQuoteV2 without options', async () => { const spy = jest .spyOn(mpc, 'getSwapsQuoteV2') .mockResolvedValue(mockZeroExQuoteV2Response) const result = await zeroX.getQuote(mockZeroExQuoteV2Request) expect(spy).toHaveBeenCalledTimes(1) expect(spy).toHaveBeenCalledWith(mockZeroExQuoteV2Request, undefined) expect(result).toEqual(mockZeroExQuoteV2Response) }) it('should propagate errors from mpc.getSwapsQuoteV2', async () => { const error = new Error('Test error') jest.spyOn(mpc, 'getSwapsQuoteV2').mockRejectedValue(error) await expect(zeroX.getQuote(mockZeroExQuoteV2Request)).rejects.toThrow( 'Test error', ) }) }) describe('getSources', () => { it('should correctly call mpc.getSources with chainId and apiKey', async () => { const spy = jest .spyOn(mpc, 'getSwapsSourcesV2') .mockResolvedValue(mockSourcesRes) const result = await zeroX.getSources('eip155:1', { zeroXApiKey: 'test-api-key', }) expect(spy).toHaveBeenCalledTimes(1) expect(spy).toHaveBeenCalledWith( { chainId: 'eip155:1' }, { zeroXApiKey: 'test-api-key' }, ) expect(result).toEqual(mockSourcesRes) }) it('should correctly call mpc.getSources without apiKey', async () => { const spy = jest .spyOn(mpc, 'getSwapsSourcesV2') .mockResolvedValue(mockSourcesRes) const result = await zeroX.getSources('eip155:1') expect(spy).toHaveBeenCalledTimes(1) expect(spy).toHaveBeenCalledWith({ chainId: 'eip155:1' }, undefined) expect(result).toEqual(mockSourcesRes) }) it('should propagate errors from mpc.getSources', async () => { const error = new Error('Test error') jest.spyOn(mpc, 'getSwapsSourcesV2').mockRejectedValue(error) await expect(zeroX.getSources('eip155:1')).rejects.toThrow('Test error') }) }) describe('getPrice', () => { it('should correctly call mpc.getSwapsPrice with args and options', async () => { const spy = jest .spyOn(mpc, 'getSwapsPrice') .mockResolvedValue(mockZeroExPriceResponse as ZeroExPriceResponse) const result = await zeroX.getPrice( mockZeroExPriceRequest, mockZeroExOptions, ) expect(spy).toHaveBeenCalledTimes(1) expect(spy).toHaveBeenCalledWith( mockZeroExPriceRequest, mockZeroExOptions, ) expect(result).toEqual(mockZeroExPriceResponse) }) it('should correctly call mpc.getSwapsPrice without options', async () => { const spy = jest .spyOn(mpc, 'getSwapsPrice') .mockResolvedValue(mockZeroExPriceResponse as ZeroExPriceResponse) const result = await zeroX.getPrice(mockZeroExPriceRequest) expect(spy).toHaveBeenCalledTimes(1) expect(spy).toHaveBeenCalledWith(mockZeroExPriceRequest, undefined) expect(result).toEqual(mockZeroExPriceResponse) }) it('should propagate errors from mpc.getSwapsPrice', async () => { const error = new Error('Test error') jest.spyOn(mpc, 'getSwapsPrice').mockRejectedValue(error) await expect(zeroX.getPrice(mockZeroExPriceRequest)).rejects.toThrow( 'Test error', ) }) }) describe('tradeAsset', () => { it('throws when waitForConfirmation returns false and emits failed, not confirmed', async () => { const onProgress = jest.fn() jest .spyOn(mpc, 'getSwapsQuoteV2') .mockResolvedValue(mockZeroExQuoteV2Response) await expect( zeroX.tradeAsset( { ...mockZeroExQuoteV2Request, fromAddress: mockEip155Address, onProgress, }, { signAndSendTransaction: async () => '0xabc', waitForConfirmation: async () => false, }, ), ).rejects.toThrow(/on-chain confirmation/) const statuses = onProgress.mock.calls.map((c) => c[0]) expect(statuses).toContain('failed') expect(statuses).not.toContain('confirmed') }) it('emits confirmed after waitForConfirmation succeeds', async () => { const onProgress = jest.fn() jest .spyOn(mpc, 'getSwapsQuoteV2') .mockResolvedValue(mockZeroExQuoteV2Response) await zeroX.tradeAsset( { ...mockZeroExQuoteV2Request, fromAddress: mockEip155Address, onProgress, }, { signAndSendTransaction: async () => '0xabc', waitForConfirmation: async () => true, }, ) expect(onProgress).toHaveBeenCalledWith('confirmed', { txHash: '0xabc' }) }) it('throws before signing when quote has error string', async () => { jest.spyOn(mpc, 'getSwapsQuoteV2').mockResolvedValue({ error: 'insufficient liquidity', } as never) await expect( zeroX.tradeAsset( { ...mockZeroExQuoteV2Request, fromAddress: mockEip155Address, }, { signAndSendTransaction: async () => '0x', waitForConfirmation: async () => true, }, ), ).rejects.toThrow(/Quote error: insufficient liquidity/) }) it('throws before signing when neither waitForConfirmation nor evmRequestFn', async () => { const quoteSpy = jest .spyOn(mpc, 'getSwapsQuoteV2') .mockResolvedValue(mockZeroExQuoteV2Response) await expect( zeroX.tradeAsset( { ...mockZeroExQuoteV2Request, fromAddress: mockEip155Address, }, { signAndSendTransaction: async () => '0xshouldnotrun', }, ), ).rejects.toThrow(/requires waitForConfirmation.*or evmRequestFn/) expect(quoteSpy).not.toHaveBeenCalled() }) it('throws before signing when rawResponse is missing', async () => { jest.spyOn(mpc, 'getSwapsQuoteV2').mockResolvedValue({ data: {}, } as never) await expect( zeroX.tradeAsset( { ...mockZeroExQuoteV2Request, fromAddress: mockEip155Address, }, { signAndSendTransaction: async () => '0x', waitForConfirmation: async () => true, }, ), ).rejects.toThrow(/missing data.rawResponse/) }) it('throws before signing when transaction.to is invalid', async () => { jest.spyOn(mpc, 'getSwapsQuoteV2').mockResolvedValue({ data: { rawResponse: { ...mockZeroExQuoteV2Response.data!.rawResponse, transaction: { to: '' }, }, }, } as never) const onProgress = jest.fn() await expect( zeroX.tradeAsset( { ...mockZeroExQuoteV2Request, fromAddress: mockEip155Address, onProgress, }, { signAndSendTransaction: async () => '0x', waitForConfirmation: async () => true, }, ), ).rejects.toThrow(/missing valid transaction/) expect(onProgress).toHaveBeenCalledWith( 'failed', expect.objectContaining({ errorMessage: expect.stringContaining('valid transaction'), }), ) }) it('signer throws: rethrows and emits failed', async () => { const onProgress = jest.fn() jest .spyOn(mpc, 'getSwapsQuoteV2') .mockResolvedValue(mockZeroExQuoteV2Response) await expect( zeroX.tradeAsset( { ...mockZeroExQuoteV2Request, fromAddress: mockEip155Address, onProgress, }, { signAndSendTransaction: async () => { throw new Error('user rejected') }, waitForConfirmation: async () => true, }, ), ).rejects.toThrow('user rejected') expect(onProgress).toHaveBeenCalledWith( 'failed', expect.objectContaining({ errorMessage: 'user rejected' }), ) }) it('empty hash after sign: throws and emits failed', async () => { const onProgress = jest.fn() jest .spyOn(mpc, 'getSwapsQuoteV2') .mockResolvedValue(mockZeroExQuoteV2Response) await expect( zeroX.tradeAsset( { ...mockZeroExQuoteV2Request, fromAddress: mockEip155Address, onProgress, }, { signAndSendTransaction: async () => ' ', waitForConfirmation: async () => true, }, ), ).rejects.toThrow(/empty or invalid transaction hash/) expect(onProgress).toHaveBeenCalledWith( 'failed', expect.objectContaining({ errorMessage: expect.any(String) }), ) }) it('evmRequestFn path: waits for receipt with onTimeout throw', async () => { const waitSpy = jest .spyOn(waitEvm, 'waitForEvmTxConfirmation') .mockResolvedValue(true) jest .spyOn(mpc, 'getSwapsQuoteV2') .mockResolvedValue(mockZeroExQuoteV2Response) await zeroX.tradeAsset( { ...mockZeroExQuoteV2Request, fromAddress: mockEip155Address, }, { signAndSendTransaction: async () => '0xhash', evmRequestFn: async () => ({}), }, ) expect(waitSpy).toHaveBeenCalledWith( '0xhash', 'eip155:1', expect.any(Function), expect.objectContaining({ onTimeout: 'throw' }), ) waitSpy.mockRestore() }) it('instance zeroXApiKey wins over params.zeroXApiKey when both set', async () => { const spy = jest .spyOn(mpc, 'getSwapsQuoteV2') .mockResolvedValue(mockZeroExQuoteV2Response) const z = new ZeroX({ mpc, zeroXApiKey: 'from-instance', }) await z.tradeAsset( { ...mockZeroExQuoteV2Request, fromAddress: mockEip155Address, zeroXApiKey: 'from-params', }, { signAndSendTransaction: async () => '0xh', waitForConfirmation: async () => true, }, ) expect(spy).toHaveBeenCalledWith( expect.any(Object), expect.objectContaining({ zeroXApiKey: 'from-instance' }), ) }) it('uses params.zeroXApiKey when instance has no key', async () => { const spy = jest .spyOn(mpc, 'getSwapsQuoteV2') .mockResolvedValue(mockZeroExQuoteV2Response) await zeroX.tradeAsset( { ...mockZeroExQuoteV2Request, fromAddress: mockEip155Address, zeroXApiKey: 'params-only', }, { signAndSendTransaction: async () => '0xh', waitForConfirmation: async () => true, }, ) expect(spy).toHaveBeenCalledWith( expect.any(Object), expect.objectContaining({ zeroXApiKey: 'params-only' }), ) }) it('normalizes numeric chainId to eip155 for signer', async () => { const sign = jest.fn().mockResolvedValue('0xh') jest.spyOn(mpc, 'getSwapsQuoteV2').mockResolvedValue({ ...mockZeroExQuoteV2Response, }) await zeroX.tradeAsset( { ...mockZeroExQuoteV2Request, chainId: '1', fromAddress: mockEip155Address, }, { signAndSendTransaction: sign, waitForConfirmation: async () => true, }, ) expect(sign).toHaveBeenCalledWith( expect.anything(), 'eip155:1', ) }) }) })