From ac914da8875ff6f6f615550af4de2ba3ce5864e3 Mon Sep 17 00:00:00 2001 From: Gergely Lovas <36536513+gergelylovas@users.noreply.github.com> Date: Tue, 26 Nov 2024 21:02:54 +0100 Subject: [PATCH] feat: add response check for Paraswap API (#99) --- jest.config.js | 1 + .../SwapProvider/SwapProvider.test.tsx | 186 +++++++++++++++++- src/contexts/SwapProvider/SwapProvider.tsx | 32 ++- 3 files changed, 215 insertions(+), 4 deletions(-) diff --git a/jest.config.js b/jest.config.js index 3175e676..74d79a80 100644 --- a/jest.config.js +++ b/jest.config.js @@ -15,6 +15,7 @@ module.exports = { '^.+\\.(js|jsx)$': 'babel-jest', }, transformIgnorePatterns: [`/node_modules/(?!micro-eth-signer)`], + modulePathIgnorePatterns: ['/dist/'], globals: { EVM_PROVIDER_INFO_NAME: 'EVM_PROVIDER_INFO_NAME', EVM_PROVIDER_INFO_UUID: 'EVM_PROVIDER_INFO_UUID', diff --git a/src/contexts/SwapProvider/SwapProvider.test.tsx b/src/contexts/SwapProvider/SwapProvider.test.tsx index c501ab5c..b59374e3 100644 --- a/src/contexts/SwapProvider/SwapProvider.test.tsx +++ b/src/contexts/SwapProvider/SwapProvider.test.tsx @@ -77,7 +77,7 @@ jest.mock('react-i18next', () => ({ jest.mock('ethers'); -describe('contexts/SwapProvider', () => { +describe.only('contexts/SwapProvider', () => { const connectionContext = { request: jest.fn(), events: jest.fn(), @@ -108,6 +108,7 @@ describe('contexts/SwapProvider', () => { jest.spyOn(global, 'fetch').mockResolvedValue({ json: async () => ({}), + ok: true, } as any); jest.mocked(useConnectionContext).mockReturnValue(connectionContext); @@ -279,6 +280,7 @@ describe('contexts/SwapProvider', () => { json: () => ({ error: 'Invalid tokens', }), + ok: true, } as any); }); @@ -301,6 +303,7 @@ describe('contexts/SwapProvider', () => { destAmount: 1000, }, }), + ok: true, } as any); const { getRate } = getSwapProvider(); @@ -481,10 +484,23 @@ describe('contexts/SwapProvider', () => { let requestMock; const mockedTx = { + gas: '0x2710', // 10000n data: 'approval-tx-data', }; beforeEach(() => { + jest.spyOn(global, 'fetch').mockResolvedValue({ + json: jest.fn().mockResolvedValue({ + data: 'data', + to: '0xParaswapContractAddress', + from: '0x123', + value: '0x0', + gas: '1223', + chainId: 123, + }), + ok: true, + } as any); + allowanceMock = jest.fn().mockResolvedValue(0); requestMock = jest.fn().mockResolvedValue('0xALLOWANCE_HASH'); @@ -528,7 +544,12 @@ describe('contexts/SwapProvider', () => { json: jest.fn().mockResolvedValue({ data: 'data', to: '0xParaswapContractAddress', + from: '0x123', + value: '0x0', + chainId: 123, + someExtraParam: '123', }), + ok: true, } as any); jest.mocked(Contract).mockReturnValueOnce({ @@ -585,6 +606,158 @@ describe('contexts/SwapProvider', () => { ); }); + it('verifies Paraswap API response for correct parameters', async () => { + const requestMock = jest.fn(); + + jest.spyOn(global, 'fetch').mockResolvedValue({ + json: jest.fn().mockResolvedValue({ + data: '', + to: '', + from: '0x123', + value: '0x0', + chainId: 123, + }), + ok: true, + } as any); + + jest.mocked(Contract).mockReturnValueOnce({ + allowance: jest.fn().mockResolvedValue(Infinity), + } as any); + + jest.mocked(useConnectionContext).mockReturnValue({ + request: requestMock.mockResolvedValue('0xSwapHash'), + events: jest.fn(), + } as any); + + const { swap } = getSwapProvider(); + + const { + destAmount, + destDecimals, + destToken, + srcAmount, + srcDecimals, + srcToken, + priceRoute, + gasLimit, + slippage, + } = getSwapParams(); + + await expect( + swap({ + srcToken, + srcDecimals, + srcAmount, + destToken, + destDecimals, + destAmount, + gasLimit, + priceRoute, + slippage, + }) + ).rejects.toThrow('Data Error: Error: Invalid transaction params'); + }); + + it('handles Paraswap API error responses', async () => { + const requestMock = jest.fn(); + + jest.spyOn(global, 'fetch').mockResolvedValue({ + json: jest + .fn() + .mockResolvedValue({ message: 'Some API error happened' }), + ok: true, + } as any); + + jest.mocked(Contract).mockReturnValueOnce({ + allowance: jest.fn().mockResolvedValue(Infinity), + } as any); + + jest.mocked(useConnectionContext).mockReturnValue({ + request: requestMock.mockResolvedValue('0xSwapHash'), + events: jest.fn(), + } as any); + + const { swap } = getSwapProvider(); + + const { + destAmount, + destDecimals, + destToken, + srcAmount, + srcDecimals, + srcToken, + priceRoute, + gasLimit, + slippage, + } = getSwapParams(); + + await expect( + swap({ + srcToken, + srcDecimals, + srcAmount, + destToken, + destDecimals, + destAmount, + gasLimit, + priceRoute, + slippage, + }) + ).rejects.toThrow('Data Error: Error: Some API error happened'); + }); + + it('handles API HTTP errors', async () => { + const requestMock = jest.fn(); + + jest.spyOn(global, 'fetch').mockResolvedValue({ + json: jest.fn().mockResolvedValue({ + data: 'data', + to: '0xParaswapContractAddress', + from: '0x123', + value: '0x0', + chainId: 123, + }), + ok: false, + } as any); + + jest.mocked(Contract).mockReturnValueOnce({ + allowance: jest.fn().mockResolvedValue(Infinity), + } as any); + + jest.mocked(useConnectionContext).mockReturnValue({ + request: requestMock.mockResolvedValue('0xSwapHash'), + events: jest.fn(), + } as any); + + const { swap } = getSwapProvider(); + + const { + destAmount, + destDecimals, + destToken, + srcAmount, + srcDecimals, + srcToken, + priceRoute, + gasLimit, + slippage, + } = getSwapParams(); + + await expect( + swap({ + srcToken, + srcDecimals, + srcAmount, + destToken, + destDecimals, + destAmount, + gasLimit, + priceRoute, + slippage, + }) + ).rejects.toThrow('Data Error: Error: Invalid transaction params'); + }); + describe('when everything goes right', () => { let allowanceMock; let requestMock; @@ -592,6 +765,17 @@ describe('contexts/SwapProvider', () => { beforeEach(() => { allowanceMock = jest.fn().mockResolvedValue(0); + jest.spyOn(global, 'fetch').mockResolvedValue({ + json: async () => ({ + data: 'data', + to: '0xParaswapContractAddress', + from: '0x123', + value: '0x0', + chainId: 123, + }), + ok: true, + } as any); + requestMock = jest .fn() .mockResolvedValueOnce('0xALLOWANCE_HASH') diff --git a/src/contexts/SwapProvider/SwapProvider.tsx b/src/contexts/SwapProvider/SwapProvider.tsx index 2d371dcf..86bb85d5 100644 --- a/src/contexts/SwapProvider/SwapProvider.tsx +++ b/src/contexts/SwapProvider/SwapProvider.tsx @@ -37,6 +37,8 @@ import { hasParaswapError, DISALLOWED_SWAP_ASSETS, } from './models'; +import Joi from 'joi'; +import { isAPIError } from '@src/pages/Swap/utils'; export const SwapContext = createContext({} as any); @@ -198,6 +200,16 @@ export function SwapContextProvider({ children }: { children: any }) { throw new Error(`Feature (SWAP) is currently unavailable`); } + const responseSchema = Joi.object({ + to: Joi.string().required(), + from: Joi.string().required(), + value: Joi.string().required(), + data: Joi.string().required(), + chainId: Joi.number().required(), + gas: Joi.string().optional(), + gasPrice: Joi.string().optional(), + }).unknown(); + const query = new URLSearchParams(options as Record); const txURL = `${ (paraswap as any).apiURL @@ -226,7 +238,20 @@ export function SwapContextProvider({ children }: { children: any }) { }, body: JSON.stringify(txConfig), }); - return await response.json(); + const transactionParamsOrError: Transaction | APIError = + await response.json(); + const validationResult = responseSchema.validate( + transactionParamsOrError + ); + + if (!response.ok || validationResult.error) { + if (isAPIError(transactionParamsOrError)) { + throw new Error(transactionParamsOrError.message); + } + throw new Error('Invalid transaction params'); + } + + return transactionParamsOrError; }, [featureFlags, paraswap] ); @@ -363,7 +388,8 @@ export function SwapContextProvider({ children }: { children: any }) { params: [ { chainId: ChainId.AVALANCHE_MAINNET_ID.toString(), - gasLimit: String(approveGasLimit || gasLimit), + gas: + '0x' + Number(approveGasLimit || gasLimit).toString(16), data, from: activeAccount.addressC, to: srcTokenAddress, @@ -430,7 +456,7 @@ export function SwapContextProvider({ children }: { children: any }) { params: [ { chainId: ChainId.AVALANCHE_MAINNET_ID.toString(), - gasLimit: String(txBuildData.gas), + gas: '0x' + Number(txBuildData.gas).toString(16), data: txBuildData.data, to: txBuildData.to, from: activeAccount.addressC,