UNPKG

@yoroi/swap

Version:
1,328 lines (1,317 loc) 43.5 kB
"use strict"; import { isLeft } from '@yoroi/common'; import { Api, Swap } from '@yoroi/types'; import { dexhunterApiMaker } from './adapters/api/dexhunter/api-maker'; import { api as dhApiMocks, primaryTokenInfo } from './adapters/api/dexhunter/api.mocks'; import { minswapApiMaker } from './adapters/api/minswap/api-maker'; import { muesliswapApiMaker } from './adapters/api/muesliswap/api-maker'; import { api as msApiMocks } from './adapters/api/muesliswap/api.mocks'; import { standarizeError, swapManagerMaker } from './manager'; jest.mock('./adapters/api/dexhunter/api-maker', () => ({ dexhunterApiMaker: jest.fn() })); jest.mock('./adapters/api/muesliswap/api-maker', () => ({ muesliswapApiMaker: jest.fn() })); jest.mock('./adapters/api/minswap/api-maker', () => ({ minswapApiMaker: jest.fn() })); describe('swapManagerMaker', () => { let mockDexhunterApi; let mockMuesliswapApi; let mockMinswapApi; const baseConfig = { address: 'someAddress', addressHex: 'someAddressHex', network: 'mainnet', primaryTokenInfo, isPrimaryToken: () => false, stakingKey: 'someStakingKey', storage: { clear: jest.fn(), settings: { save: jest.fn(), read: jest.fn(() => new Promise(resolve => resolve({ routingPreferences: 'auto', slippage: 1 }))) } }, partners: { [Swap.Aggregator.Dexhunter]: 'somePartnerId', [Swap.Aggregator.Muesliswap]: 'somePartnerId', [Swap.Aggregator.Minswap]: 'somePartnerId' } }; beforeEach(() => { jest.clearAllMocks(); mockDexhunterApi = { tokens: jest.fn(), orders: jest.fn(), limitOptions: jest.fn(), estimate: jest.fn(), create: jest.fn(), cancel: jest.fn() }; mockMuesliswapApi = { tokens: jest.fn(), orders: jest.fn(), limitOptions: jest.fn(), estimate: jest.fn(), create: jest.fn(), cancel: jest.fn() }; mockMinswapApi = { tokens: jest.fn(), orders: jest.fn(), limitOptions: jest.fn(), estimate: jest.fn(), create: jest.fn(), cancel: jest.fn() }; mockDexhunterApi.tokens.mockResolvedValue({ tag: 'right', value: { status: Api.HttpStatusCode.Ok, data: dhApiMocks.results.tokens } }); mockMuesliswapApi.tokens.mockResolvedValue({ tag: 'right', value: { status: Api.HttpStatusCode.Ok, data: msApiMocks.results.tokens } }); mockDexhunterApi.estimate.mockResolvedValue({ tag: 'right', value: { status: Api.HttpStatusCode.Ok, data: dhApiMocks.results.estimate } }); mockMuesliswapApi.estimate.mockResolvedValue({ tag: 'right', value: { status: Api.HttpStatusCode.Ok, data: msApiMocks.results.estimate } }); mockDexhunterApi.create.mockResolvedValue({ tag: 'right', value: { status: Api.HttpStatusCode.Ok, data: dhApiMocks.results.create } }); mockMuesliswapApi.create.mockResolvedValue({ tag: 'right', value: { status: Api.HttpStatusCode.Ok, data: msApiMocks.results.create } }); // Minswap mocks mockMinswapApi.tokens.mockResolvedValue({ tag: 'right', value: { status: Api.HttpStatusCode.Ok, data: [] } }); mockMinswapApi.orders.mockResolvedValue({ tag: 'right', value: { status: Api.HttpStatusCode.Ok, data: [] } }); mockMinswapApi.limitOptions.mockResolvedValue({ tag: 'right', value: { status: Api.HttpStatusCode.Ok, data: { defaultProtocol: Swap.Protocol.Minswap_v2, wantedPrice: 1, options: [] } } }); mockMinswapApi.estimate.mockResolvedValue({ tag: 'right', value: { status: Api.HttpStatusCode.Ok, data: { splits: [{ protocol: Swap.Protocol.Minswap_v2, initialPrice: 1.2, batcherFee: 0, amountIn: 50, deposits: 0, expectedOutput: 60, expectedOutputWithoutSlippage: 59, fee: 0, finalPrice: 1.2, poolFee: 0, poolId: 'test-pool', priceDistortion: 0, priceImpact: 0 }], batcherFee: 0, deposits: 0, aggregatorFee: 0, frontendFee: 0, netPrice: 1.2, priceImpact: 0, totalFee: 0, totalOutput: 0, totalOutputWithoutSlippage: 0, totalInput: 0 } } }); mockMinswapApi.create.mockResolvedValue({ tag: 'right', value: { status: Api.HttpStatusCode.Ok, data: { splits: [], batcherFee: 0, deposits: 0, aggregatorFee: 0, frontendFee: 0, netPrice: 0, priceImpact: 0, totalFee: 0, totalInput: 0, totalOutput: 0, totalOutputWithoutSlippage: 0, aggregator: Swap.Aggregator.Minswap, cbor: 'test-cbor' } } }); mockMinswapApi.cancel.mockResolvedValue({ tag: 'right', value: { status: Api.HttpStatusCode.Ok, data: { cbor: 'test-cancel-cbor', additionalCancellationFee: undefined } } }); dexhunterApiMaker.mockReturnValue(mockDexhunterApi); muesliswapApiMaker.mockReturnValue(mockMuesliswapApi); minswapApiMaker.mockReturnValue(mockMinswapApi); }); it('creates a manager with an API proxy', () => { const manager = swapManagerMaker(baseConfig); expect(manager).toHaveProperty('api'); expect(manager).toHaveProperty('assignSettings'); expect(manager).toHaveProperty('settings'); expect(manager).toHaveProperty('clearStorage'); }); it('defaults to routingPreference: "auto"', () => { const manager = swapManagerMaker(baseConfig); expect(manager.settings.routingPreference).toBe('auto'); }); describe('tokens()', () => { it('merges both aggregator tokens if both are right', async () => { const manager = swapManagerMaker(baseConfig); const result = await manager.api.tokens(); expect(result.tag).toBe('right'); if (result.tag === 'right') { expect(result.value.data).toEqual(expect.arrayContaining([...dhApiMocks.results.tokens, ...msApiMocks.results.tokens])); } }); it('calls muesliswap api if routing preference is muesliswap', async () => { const manager = swapManagerMaker(baseConfig); manager.assignSettings({ routingPreference: ['muesliswap'] }); const result = await manager.api.tokens(); expect(result.tag).toBe('right'); expect(mockMuesliswapApi.tokens).toHaveBeenCalled(); }); it('calls dexhunter api if routing preference is dexhunter', async () => { const manager = swapManagerMaker(baseConfig); manager.assignSettings({ routingPreference: ['dexhunter'] }); const result = await manager.api.tokens(); expect(result.tag).toBe('right'); expect(mockDexhunterApi.tokens).toHaveBeenCalled(); }); it('calls minswap api if routing preference is minswap', async () => { const manager = swapManagerMaker(baseConfig); manager.assignSettings({ routingPreference: ['minswap'] }); const result = await manager.api.tokens(); expect(result.tag).toBe('right'); expect(mockMinswapApi.tokens).toHaveBeenCalled(); }); it('excludes aggregator if routing preference array does not include it', async () => { const manager = swapManagerMaker(baseConfig); manager.assignSettings({ routingPreference: ['muesliswap'] }); const result = await manager.api.tokens(); expect(result.tag).toBe('right'); expect(mockMuesliswapApi.tokens).toHaveBeenCalled(); // Dexhunter should be excluded because it's not in the routing preference array expect(mockDexhunterApi.tokens).not.toHaveBeenCalled(); }); it('returns left if both are left', async () => { mockDexhunterApi.tokens.mockResolvedValue({ tag: 'left', error: { status: 500, message: 'dh tokens error', responseData: {} } }); mockMuesliswapApi.tokens.mockResolvedValue({ tag: 'left', error: { status: 500, message: 'ms tokens error', responseData: {} } }); const manager = swapManagerMaker(baseConfig); manager.assignSettings({ routingPreference: ['dexhunter', 'muesliswap'] }); const result = await manager.api.tokens(); expect(result.tag).toBe('left'); if (result.tag === 'left') { expect(result.error.message).toBe('Unknown error'); } }); it('returns the right aggregator if the other aggregator is left', async () => { mockDexhunterApi.tokens.mockResolvedValue({ tag: 'left', error: { status: 500, message: 'dh error', responseData: {} } }); const manager = swapManagerMaker(baseConfig); const result = await manager.api.tokens(); expect(result.tag).toBe('right'); expect(mockMuesliswapApi.tokens).toHaveBeenCalled(); }); it('handles Promise.allSettled rejections gracefully', async () => { // Mock one API to reject (simulate Promise.allSettled rejection) mockDexhunterApi.tokens.mockRejectedValue(new Error('Network error')); mockMuesliswapApi.tokens.mockResolvedValue({ tag: 'right', value: { data: msApiMocks.results.tokens, status: 200 } }); const manager = swapManagerMaker(baseConfig); manager.assignSettings({ routingPreference: ['dexhunter', 'muesliswap'] }); const result = await manager.api.tokens(); expect(result.tag).toBe('right'); expect(mockMuesliswapApi.tokens).toHaveBeenCalled(); }); }); describe('create()', () => { it('gets the best swap when routing is "auto"', async () => { const manager = swapManagerMaker(baseConfig); const result = await manager.api.create(msApiMocks.inputs.create[0]); expect(result.tag).toBe('right'); if (result.tag === 'right') { expect(result.value.data).toEqual(dhApiMocks.results.create); } }); it('gets the selected swap when routing is not "auto"', async () => { const manager = swapManagerMaker(baseConfig); manager.assignSettings({ routingPreference: ['dexhunter'] }); const result = await manager.api.create(msApiMocks.inputs.create[0]); expect(result.tag).toBe('right'); if (result.tag === 'right') { expect(result.value.data).toEqual(dhApiMocks.results.create); } }); it('should return api error', async () => { mockMuesliswapApi.create.mockResolvedValue({ tag: 'left', error: { status: 400, message: 'ms create error', responseData: {} } }); mockDexhunterApi.create.mockResolvedValue({ tag: 'left', error: { status: -3, message: 'Aggregator excluded from call', responseData: {} } }); const manager = swapManagerMaker(baseConfig); manager.assignSettings({ routingPreference: ['dexhunter', 'muesliswap'] }); const result = await manager.api.create(msApiMocks.inputs.create[0]); expect(result.tag).toBe('left'); if (result.tag === 'left') { expect(result.error.message).toBe('ms create error'); expect(result.error.status).toBe(400); } }); it('should return "invalid" error', async () => { mockMuesliswapApi.create.mockResolvedValue({ tag: 'left', error: { status: -3, message: 'Aggregator excluded from call', responseData: {} } }); mockDexhunterApi.create.mockResolvedValue({ tag: 'left', error: { status: -3, message: 'Aggregator excluded from call', responseData: {} } }); const manager = swapManagerMaker(baseConfig); manager.assignSettings({ routingPreference: ['dexhunter', 'muesliswap'] }); const result = await manager.api.create(msApiMocks.inputs.create[0]); expect(result.tag).toBe('left'); if (result.tag === 'left') { expect(result.error.message).toBe('Unknown error'); expect(result.error.status).toBe(-3); } }); }); describe('orders()', () => { it('merges both aggregator orders when both are right', async () => { const dhDataDuplicated = [...dhApiMocks.results.orders, { actualAmountOut: 0, aggregator: 'dexhunter', amountIn: -0.04999999999999982, customId: 'customId-1', expectedAmountOut: 1.889324, lastUpdate: undefined, outputIndex: 0, placedAt: undefined, protocol: 'vyfi-v1', status: 'canceled', tokenIn: '.', tokenOut: '1d7f33bd23d85e1a25d87d86fac4f199c3197a2f7afeb662a0f34e1e.776f726c646d6f62696c65746f6b656e', txHash: '0b2bd77dd3bd670cbbe30cac001af1565c9b807dd666193fd9ac8dad319ab24b', updateTxHash: '0b2bd77dd3bd670cbbe30cac001af1565c9b807dd666193fd9ac8dad319ab24b' }, { actualAmountOut: 0, aggregator: 'dexhunter', amountIn: -0.04999999999999982, customId: 'customId-2', expectedAmountOut: 1.889324, lastUpdate: undefined, outputIndex: 0, placedAt: undefined, protocol: 'vyfi-v1', status: 'canceled', tokenIn: '.', tokenOut: '1d7f33bd23d85e1a25d87d86fac4f199c3197a2f7afeb662a0f34e1e.776f726c646d6f62696c65746f6b656e', txHash: '8956d68753d718afbaafde0e83dc1cb1d205da3c89fb08c924ab1d63fd953ed2', updateTxHash: '8956d68753d718afbaafde0e83dc1cb1d205da3c89fb08c924ab1d63fd953ed2' }, { // duplicated actualAmountOut: 0, aggregator: 'dexhunter', amountIn: -0.04999999999999982, customId: 'customId-3', expectedAmountOut: 1.889324, lastUpdate: undefined, outputIndex: 0, placedAt: undefined, protocol: 'vyfi-v1', status: 'canceled', tokenIn: '.', tokenOut: '1d7f33bd23d85e1a25d87d86fac4f199c3197a2f7afeb662a0f34e1e.776f726c646d6f62696c65746f6b656e', txHash: '8956d68753d718afbaafde0e83dc1cb1d205da3c89fb08c924ab1d63fd953ed2', updateTxHash: '8956d68753d718afbaafde0e83dc1cb1d205da3c89fb08c924ab1d63fd953ed2' }]; const dhDataOrdered = [{ actualAmountOut: 0, aggregator: 'muesliswap', amountIn: 0.008137, expectedAmountOut: 1, lastUpdate: undefined, outputIndex: 0, placedAt: 1737538157000, protocol: 'minswap-v2', status: 'canceled', tokenIn: '.', tokenOut: '49e423161ef818adc475c783571cb479d5f15ad52a01a240eacc0d3b.434f434b', txHash: '475ffb1f1820eee1790729d86ced473e9f7724ddcd7bf59b477e3293415f16bf', updateTxHash: '475ffb1f1820eee1790729d86ced473e9f7724ddcd7bf59b477e3293415f16bf' }, { actualAmountOut: 11, aggregator: 'muesliswap', amountIn: 0.000036, expectedAmountOut: 10, lastUpdate: 1722503915000, outputIndex: 0, placedAt: 1722503907000, protocol: 'sundaeswap-v1', status: 'matched', tokenIn: '.', tokenOut: '4cb48d60d1f7823d1307c61b9ecf472ff78cf22d1ccc5786d59461f8.4144414d4f4f4e', txHash: '29f51a2a9e46ced05f03abc9b419ae57164dc056534121f041d69e307b9722f8', updateTxHash: '8d3b20bafb8378366f819f506da327a43e94d6948c002bac00a9b1de401bc571' }, { actualAmountOut: 0.00037900000000012923, aggregator: 'muesliswap', amountIn: 1, customId: '66cf043794579f05fc204f72', expectedAmountOut: 0.000368, lastUpdate: 1719137534000, outputIndex: 0, placedAt: 1719137466000, protocol: 'sundaeswap-v1', status: 'matched', tokenIn: 'af2e27f580f7f08e93190a81f72462f153026d06450924726645891b.44524950', tokenOut: '.', txHash: '8751fbef1ebec0d2da9218a69493ef36070012ce24fdbc44ec6df519377b92bf', updateTxHash: '92bd050ec1da6d25abf6265a6f8318a79a3068459254a79427088407c4241b37' }, { actualAmountOut: 5.801912, aggregator: 'muesliswap', amountIn: 15.330409, customId: '66d0e36894579f05fc822e6e', expectedAmountOut: 3.756354, lastUpdate: 1702736216000, outputIndex: 0, placedAt: 1702736197000, protocol: 'muesliswap', status: 'matched', tokenIn: '1d7f33bd23d85e1a25d87d86fac4f199c3197a2f7afeb662a0f34e1e.776f726c646d6f62696c65746f6b656e', tokenOut: '.', txHash: 'f7826e21a464939b64274b00033d7ddebbc90924260d30530fdf7a8cd2824d51', updateTxHash: 'a8b77336d8600f1c8dac0ed90d0ab9c4f1e815bb25f4e168aaaadd130f81457d' }, { actualAmountOut: 0, aggregator: 'dexhunter', amountIn: -0.04999999999999982, customId: 'customId-3', expectedAmountOut: 1.889324, lastUpdate: undefined, outputIndex: 0, placedAt: undefined, protocol: 'vyfi-v1', status: 'canceled', tokenIn: '.', tokenOut: '1d7f33bd23d85e1a25d87d86fac4f199c3197a2f7afeb662a0f34e1e.776f726c646d6f62696c65746f6b656e', txHash: '8956d68753d718afbaafde0e83dc1cb1d205da3c89fb08c924ab1d63fd953ed2', updateTxHash: '8956d68753d718afbaafde0e83dc1cb1d205da3c89fb08c924ab1d63fd953ed2' }, { actualAmountOut: 0, aggregator: 'dexhunter', amountIn: -0.04999999999999982, customId: 'customId-1', expectedAmountOut: 1.889324, lastUpdate: undefined, outputIndex: 0, placedAt: undefined, protocol: 'vyfi-v1', status: 'canceled', tokenIn: '.', tokenOut: '1d7f33bd23d85e1a25d87d86fac4f199c3197a2f7afeb662a0f34e1e.776f726c646d6f62696c65746f6b656e', txHash: '0b2bd77dd3bd670cbbe30cac001af1565c9b807dd666193fd9ac8dad319ab24b', updateTxHash: '0b2bd77dd3bd670cbbe30cac001af1565c9b807dd666193fd9ac8dad319ab24b' }]; mockDexhunterApi.orders.mockResolvedValue({ tag: 'right', value: { status: 200, data: dhDataDuplicated } }); mockMuesliswapApi.orders.mockResolvedValue({ tag: 'right', value: { status: 200, data: msApiMocks.results.orders } }); const manager = swapManagerMaker(baseConfig); const result = await manager.api.orders(); expect(result.tag).toBe('right'); if (result.tag === 'right') { expect(result.value.data).toEqual(dhDataOrdered); } }); it('returns left if all aggregators are left', async () => { mockDexhunterApi.orders.mockResolvedValue({ tag: 'left', error: { status: 400, message: 'dh orders error', responseData: {} } }); mockMuesliswapApi.orders.mockResolvedValue({ tag: 'left', error: { status: 400, message: 'ms orders error', responseData: {} } }); mockMinswapApi.orders.mockResolvedValue({ tag: 'left', error: { status: 400, message: 'mn orders error', responseData: {} } }); const manager = swapManagerMaker(baseConfig); const result = await manager.api.orders(); expect(result.tag).toBe('left'); }); it('returns muesliswap orders if dexhunter api result is left', async () => { mockDexhunterApi.orders.mockResolvedValue({ tag: 'left', error: { status: 400, message: 'dh orders error', responseData: {} } }); mockMuesliswapApi.orders.mockResolvedValue({ tag: 'right', value: { status: 200, data: msApiMocks.results.orders } }); const manager = swapManagerMaker(baseConfig); const result = await manager.api.orders(); expect(result.tag).toBe('right'); if (result.tag === 'right') { expect(result.value.data).toEqual(expect.arrayContaining([...msApiMocks.results.orders])); } }); it('should prioritize dexhunter orders over existing orders', async () => { const existingOrder = { actualAmountOut: 0, aggregator: 'muesliswap', amountIn: 0.008137, expectedAmountOut: 1, lastUpdate: undefined, outputIndex: 0, placedAt: 1737538157000, protocol: 'minswap-v2', status: 'canceled', tokenIn: '.', tokenOut: '49e423161ef818adc475c783571cb479d5f15ad52a01a240eacc0d3b.434f434b', txHash: '475ffb1f1820eee1790729d86ced473e9f7724ddcd7bf59b477e3293415f16bf', updateTxHash: '475ffb1f1820eee1790729d86ced473e9f7724ddcd7bf59b477e3293415f16bf' }; const dexhunterOrder = { actualAmountOut: 0, aggregator: 'dexhunter', amountIn: -0.04999999999999982, customId: 'customId-1', expectedAmountOut: 1.889324, lastUpdate: undefined, outputIndex: 0, placedAt: undefined, protocol: 'vyfi-v1', status: 'canceled', tokenIn: '.', tokenOut: '1d7f33bd23d85e1a25d87d86fac4f199c3197a2f7afeb662a0f34e1e.776f726c646d6f62696c65746f6b656e', txHash: '475ffb1f1820eee1790729d86ced473e9f7724ddcd7bf59b477e3293415f16bf', updateTxHash: '475ffb1f1820eee1790729d86ced473e9f7724ddcd7bf59b477e3293415f16bf' }; mockMuesliswapApi.orders.mockResolvedValue({ tag: 'right', value: { status: 200, data: [existingOrder] } }); mockDexhunterApi.orders.mockResolvedValue({ tag: 'right', value: { status: 200, data: [dexhunterOrder] } }); const manager = swapManagerMaker(baseConfig); const result = await manager.api.orders(); expect(result.tag).toBe('right'); if (result.tag === 'right') { // Should contain the dexhunter order, not the muesliswap order with same txHash expect(result.value.data).toHaveLength(1); expect(result.value.data[0]).toEqual(dexhunterOrder); } }); }); describe('limitOptions()', () => { it('merges both aggregator limit options when both are right', async () => { const dhData = { defaultProtocol: 'minswap-v2', wantedPrice: 1.5, options: [{ protocol: 'minswap-v2', initialPrice: 50, batcherFee: 0 }] }; const msData = { defaultProtocol: 'minswap-v2', wantedPrice: 1.2, options: [{ protocol: 'minswap-v2', initialPrice: 30, batcherFee: 0 }] }; mockDexhunterApi.limitOptions.mockResolvedValue({ tag: 'right', value: { status: 200, data: dhData } }); mockMuesliswapApi.limitOptions.mockResolvedValue({ tag: 'right', value: { status: 200, data: msData } }); const manager = swapManagerMaker(baseConfig); manager.assignSettings({ routingPreference: ['dexhunter', 'muesliswap'] }); const result = await manager.api.limitOptions({ tokenIn: '.', tokenOut: '.' }); expect(result.tag).toBe('right'); if (result.tag === 'right') { expect(result.value.data.defaultProtocol).toBe('minswap-v2'); expect(result.value.data.wantedPrice).toBe(1.2); // min of 1.5 and 1.2 expect(result.value.data.options).toEqual([{ protocol: 'minswap-v2', initialPrice: 30, batcherFee: 0 }]); } }); it('returns left if both are left', async () => { mockDexhunterApi.limitOptions.mockResolvedValue({ tag: 'left', error: { status: 400, message: 'dh limit options error', responseData: {} } }); mockMuesliswapApi.limitOptions.mockResolvedValue({ tag: 'left', error: { status: 400, message: 'ms limit options error', responseData: {} } }); const manager = swapManagerMaker(baseConfig); manager.assignSettings({ routingPreference: ['dexhunter', 'muesliswap'] }); const result = await manager.api.limitOptions({ tokenIn: '.', tokenOut: '.' }); expect(result.tag).toBe('left'); if (result.tag === 'left') { expect(result.error.message).toBe('dh limit options error'); expect(result.error.status).toBe(400); } }); it('returns muesliswap options if dexhunter api result is left', async () => { const msData = { defaultProtocol: 'minswap-v2', wantedPrice: 1.2, options: [{ protocol: 'minswap-v2', initialPrice: 30, batcherFee: 0 }] }; mockDexhunterApi.limitOptions.mockResolvedValue({ tag: 'left', error: { status: 400, message: 'dh limit options error', responseData: {} } }); mockMuesliswapApi.limitOptions.mockResolvedValue({ tag: 'right', value: { status: 200, data: msData } }); const manager = swapManagerMaker(baseConfig); manager.assignSettings({ routingPreference: ['dexhunter', 'muesliswap'] }); const result = await manager.api.limitOptions({ tokenIn: '.', tokenOut: '.' }); expect(result.tag).toBe('right'); if (result.tag === 'right') { expect(result.value.data).toEqual(msData); } }); it('returns dexhunter options if muesliswap api result is left', async () => { const dhData = { defaultProtocol: 'minswap-v2', wantedPrice: 1.5, options: [{ protocol: 'minswap-v2', initialPrice: 50, batcherFee: 0 }] }; mockDexhunterApi.limitOptions.mockResolvedValue({ tag: 'right', value: { status: 200, data: dhData } }); mockMuesliswapApi.limitOptions.mockResolvedValue({ tag: 'left', error: { status: 400, message: 'ms limit options error', responseData: {} } }); const manager = swapManagerMaker(baseConfig); manager.assignSettings({ routingPreference: ['dexhunter', 'muesliswap'] }); const result = await manager.api.limitOptions({ tokenIn: '.', tokenOut: '.' }); expect(result.tag).toBe('right'); if (result.tag === 'right') { expect(result.value.data).toEqual(dhData); } }); it('returns invalid if no valid responses are found', async () => { mockDexhunterApi.limitOptions.mockResolvedValue({ tag: 'left', error: { status: -3, message: 'Aggregator excluded from call', responseData: {} } }); mockMuesliswapApi.limitOptions.mockResolvedValue({ tag: 'left', error: { status: -3, message: 'Aggregator excluded from call', responseData: {} } }); const manager = swapManagerMaker(baseConfig); manager.assignSettings({ routingPreference: ['dexhunter', 'muesliswap'] }); const result = await manager.api.limitOptions({ tokenIn: '.', tokenOut: '.' }); expect(result.tag).toBe('left'); if (result.tag === 'left') { expect(result.error.message).toBe('Unknown error'); expect(result.error.status).toBe(-3); } }); it('filters out unsupported protocols from options', async () => { const dhData = { defaultProtocol: 'minswap-v2', wantedPrice: 1.5, options: [{ protocol: 'minswap-v2', initialPrice: 50, batcherFee: 0 }, { protocol: 'unsupported', initialPrice: 30, batcherFee: 0 }] }; mockDexhunterApi.limitOptions.mockResolvedValue({ tag: 'right', value: { status: 200, data: dhData } }); mockMuesliswapApi.limitOptions.mockResolvedValue({ tag: 'left', error: { status: -3, message: 'Aggregator excluded from call', responseData: {} } }); const manager = swapManagerMaker(baseConfig); manager.assignSettings({ routingPreference: ['dexhunter', 'muesliswap'] }); const result = await manager.api.limitOptions({ tokenIn: '.', tokenOut: '.' }); expect(result.tag).toBe('right'); if (result.tag === 'right') { expect(result.value.data.options).toHaveLength(1); expect(result.value.data.options[0]?.protocol).toBe('minswap-v2'); } }); }); describe('estimate()', () => { it('if aggregatorSelected muesliswap, calls only that aggregator', async () => { const manager = swapManagerMaker(baseConfig); manager.assignSettings({ routingPreference: ['muesliswap'] }); await manager.api.tokens(); const result = await manager.api.estimate(msApiMocks.inputs.quote); expect(result.tag).toBe('right'); if (result.tag === 'right') { expect(result.value.data).toEqual(msApiMocks.results.estimate); } }); it('if aggregatorSelected dexhunter, calls only that aggregator', async () => { const manager = swapManagerMaker(baseConfig); manager.assignSettings({ routingPreference: ['dexhunter'] }); await manager.api.tokens(); const result = await manager.api.estimate(dhApiMocks.inputs.quote); expect(result.tag).toBe('right'); if (result.tag === 'right') { expect(result.value.data).toEqual(dhApiMocks.results.estimate); } }); it('merges results and picks the best swap if aggregatorSelected=auto', async () => { mockDexhunterApi.estimate.mockResolvedValue({ tag: 'right', value: { status: 200, data: dhApiMocks.results.estimate } }); mockMuesliswapApi.estimate.mockResolvedValue({ tag: 'right', value: { status: 200, data: msApiMocks.results.quote } }); const manager = swapManagerMaker(baseConfig); await manager.api.estimate(dhApiMocks.inputs.estimate); // TODO: need to add tokens - maybe should not check all the time for it // expect(result.tag).toBe('right') // expect(result.value.data).toEqual(api.responses.dexhunterEstimate) }); it('should return api error', async () => { mockMuesliswapApi.estimate.mockResolvedValue({ tag: 'left', error: { status: 400, message: 'ms orders error', responseData: {} } }); mockDexhunterApi.estimate.mockResolvedValue({ tag: 'left', error: { status: -3, message: 'Aggregator excluded from call', responseData: {} } }); const manager = swapManagerMaker(baseConfig); manager.assignSettings({ routingPreference: ['dexhunter', 'muesliswap'] }); await manager.api.tokens(); const result = await manager.api.estimate(dhApiMocks.inputs.quote); expect(result.tag).toBe('left'); if (result.tag === 'left') { expect(result.error.message).toBe('ms orders error'); expect(result.error.status).toBe(400); } }); it('should return "invalid" error', async () => { mockMuesliswapApi.estimate.mockResolvedValue({ tag: 'left', error: { status: -3, message: 'Aggregator excluded from call', responseData: {} } }); mockDexhunterApi.estimate.mockResolvedValue({ tag: 'left', error: { status: -3, message: 'Aggregator excluded from call', responseData: {} } }); const manager = swapManagerMaker(baseConfig); manager.assignSettings({ routingPreference: ['dexhunter', 'muesliswap'] }); await manager.api.tokens(); const result = await manager.api.estimate(dhApiMocks.inputs.quote); expect(result.tag).toBe('left'); if (result.tag === 'left') { expect(result.error.message).toBe('Unknown error'); expect(result.error.status).toBe(-3); } }); it('returns invalid when estimates array is empty', async () => { // Mock all APIs to return left responses mockDexhunterApi.estimate.mockResolvedValue({ tag: 'left', error: { status: -3, message: 'Aggregator excluded from call', responseData: {} } }); mockMuesliswapApi.estimate.mockResolvedValue({ tag: 'left', error: { status: -3, message: 'Aggregator excluded from call', responseData: {} } }); const manager = swapManagerMaker(baseConfig); manager.assignSettings({ routingPreference: ['dexhunter', 'muesliswap'] }); await manager.api.tokens(); const result = await manager.api.estimate(dhApiMocks.inputs.quote); expect(result.tag).toBe('left'); if (result.tag === 'left') { expect(result.error.message).toBe('Unknown error'); expect(result.error.status).toBe(-3); } }); }); describe('cancel()', () => { beforeEach(() => { // Set up default mocks for cancel with valid CBOR // Override any existing mocks from outer beforeEach mockDexhunterApi.cancel.mockResolvedValue({ tag: 'right', value: { status: Api.HttpStatusCode.Ok, data: { cbor: 'valid-dexhunter-cbor', additionalCancellationFee: undefined } } }); mockMuesliswapApi.cancel.mockResolvedValue({ tag: 'right', value: { status: Api.HttpStatusCode.Ok, data: { cbor: 'valid-muesliswap-cbor', additionalCancellationFee: undefined } } }); mockMinswapApi.cancel.mockResolvedValue({ tag: 'right', value: { status: Api.HttpStatusCode.Ok, data: { cbor: 'valid-minswap-cbor', additionalCancellationFee: undefined } } }); }); it('delegates to the aggregator specified in the order 1', async () => { const manager = swapManagerMaker(baseConfig); const result = await manager.api.cancel(dhApiMocks.inputs.cancel); expect(mockDexhunterApi.cancel).toHaveBeenCalled(); expect(mockMuesliswapApi.cancel).not.toHaveBeenCalled(); expect(mockMinswapApi.cancel).not.toHaveBeenCalled(); expect(result.tag).toBe('right'); if (result.tag === 'right') { expect(result.value.data.cbor).toBe('valid-dexhunter-cbor'); } }); it('delegates to the aggregator specified in the order 2', async () => { const manager = swapManagerMaker(baseConfig); const result = await manager.api.cancel(msApiMocks.inputs.cancel[0]); expect(mockMuesliswapApi.cancel).toHaveBeenCalled(); expect(mockDexhunterApi.cancel).not.toHaveBeenCalled(); expect(mockMinswapApi.cancel).not.toHaveBeenCalled(); expect(result.tag).toBe('right'); if (result.tag === 'right') { expect(result.value.data.cbor).toBe('valid-muesliswap-cbor'); } }); it('delegates to minswap for minswap orders', async () => { const manager = swapManagerMaker(baseConfig); const minswapCancelRequest = { order: { txHash: 'test-tx-hash', outputIndex: 0, aggregator: Swap.Aggregator.Minswap, protocol: Swap.Protocol.Minswap_v2 } }; const result = await manager.api.cancel(minswapCancelRequest); expect(mockMinswapApi.cancel).toHaveBeenCalled(); expect(mockDexhunterApi.cancel).not.toHaveBeenCalled(); expect(mockMuesliswapApi.cancel).not.toHaveBeenCalled(); expect(result.tag).toBe('right'); if (result.tag === 'right') { expect(result.value.data.cbor).toBe('valid-minswap-cbor'); } }); it('tries other adapters when initial adapter returns empty CBOR', async () => { const manager = swapManagerMaker(baseConfig); // Mock dexhunter to return empty CBOR mockDexhunterApi.cancel.mockResolvedValue({ tag: 'right', value: { status: Api.HttpStatusCode.Ok, data: { cbor: '', additionalCancellationFee: undefined } } }); const result = await manager.api.cancel(dhApiMocks.inputs.cancel); // Should call all adapters expect(mockDexhunterApi.cancel).toHaveBeenCalled(); expect(mockMuesliswapApi.cancel).toHaveBeenCalled(); expect(mockMinswapApi.cancel).toHaveBeenCalled(); // Should return a valid CBOR from one of the other adapters expect(result.tag).toBe('right'); if (result.tag === 'right') { expect(result.value.data.cbor).toBe('valid-muesliswap-cbor'); } }); it('tries other adapters when initial adapter returns left error', async () => { const manager = swapManagerMaker(baseConfig); // Mock dexhunter to return an error mockDexhunterApi.cancel.mockResolvedValue({ tag: 'left', error: { status: 500, message: 'Dexhunter error', responseData: {} } }); const result = await manager.api.cancel(dhApiMocks.inputs.cancel); // Should call all adapters expect(mockDexhunterApi.cancel).toHaveBeenCalled(); expect(mockMuesliswapApi.cancel).toHaveBeenCalled(); expect(mockMinswapApi.cancel).toHaveBeenCalled(); // Should return a valid CBOR from one of the other adapters expect(result.tag).toBe('right'); if (result.tag === 'right') { expect(result.value.data.cbor).toBe('valid-muesliswap-cbor'); } }); it('returns initial response when no valid CBOR is found', async () => { const manager = swapManagerMaker(baseConfig); // Mock all adapters to return empty CBOR mockDexhunterApi.cancel.mockResolvedValue({ tag: 'right', value: { status: Api.HttpStatusCode.Ok, data: { cbor: '', additionalCancellationFee: undefined } } }); mockMuesliswapApi.cancel.mockResolvedValue({ tag: 'right', value: { status: Api.HttpStatusCode.Ok, data: { cbor: '', additionalCancellationFee: undefined } } }); mockMinswapApi.cancel.mockResolvedValue({ tag: 'right', value: { status: Api.HttpStatusCode.Ok, data: { cbor: '', additionalCancellationFee: undefined } } }); const result = await manager.api.cancel(dhApiMocks.inputs.cancel); // Should call all adapters expect(mockDexhunterApi.cancel).toHaveBeenCalled(); expect(mockMuesliswapApi.cancel).toHaveBeenCalled(); expect(mockMinswapApi.cancel).toHaveBeenCalled(); // Should return the initial (dexhunter) response even if it has empty CBOR expect(result.tag).toBe('right'); if (result.tag === 'right') { expect(result.value.data.cbor).toBe(''); } }); }); }); describe('standarizeError', () => { it('should return the same response if it is a Right response', () => { const rightResponse = { tag: 'right', value: { status: 200, data: 'Success' } }; const result = standarizeError(rightResponse); expect(result).toBe(rightResponse); }); it('should standardize insufficient balance errors', () => { const leftResponse = { tag: 'left', error: { status: 400, message: 'Unable to build transaction due to insufficient user balance', responseData: {} } }; const result = standarizeError(leftResponse); if (isLeft(result)) { expect(result.error.message).toBe('Insufficient balance: consider fees and assets blocked by staking.'); } else { fail('Expected result to be a Left type'); } }); it('should standardize positive amounts errors', () => { const leftResponse = { tag: 'left', error: { status: 400, message: 'Buy and sell amounts must be positive', responseData: {} } }; const result = standarizeError(leftResponse); if (isLeft(result)) { expect(result.error.message).toBe('Buy and sell amounts must be positive'); } else { fail('Expected result to be a Left type'); } }); it('should standardize no liquidity errors', () => { const leftResponse = { tag: 'left', error: { status: 400, message: 'No liquidity available for this token pair', responseData: {} } }; const result = standarizeError(leftResponse); if (isLeft(result)) { expect(result.error.message).toBe('This pair is not available in any liquidity pool.'); } else { fail('Expected result to be a Left type'); } }); it('should standardize amount_in_invalid errors', () => { const leftResponse = { tag: 'left', error: { status: 400, message: 'amount_in_invalid', responseData: {} } }; const result = standarizeError(leftResponse); if (isLeft(result)) { expect(result.error.message).toBe('Buy and sell amounts must be positive'); } else { fail('Expected result to be a Left type'); } }); it('should standardize unknown errors with DOCTYPE html', () => { const leftResponse = { tag: 'left', error: { status: 500, message: '<!DOCTYPE html> Some server error', responseData: {} } }; const result = standarizeError(leftResponse); if (isLeft(result)) { expect(result.error.message).toBe('Unknown error'); } else { fail('Expected result to be a Left type'); } }); it('should return the same error message if no standardization applies', () => { const leftResponse = { tag: 'left', error: { status: 400, message: 'Some other error', responseData: {} } }; const result = standarizeError(leftResponse); if (isLeft(result)) { expect(result.error.message).toBe('Some other error'); } else { fail('Expected result to be a Left type'); } }); }); //# sourceMappingURL=manager.test.js.map