diff --git a/src/earn/prepareTransactions.test.ts b/src/earn/prepareTransactions.test.ts new file mode 100644 index 00000000000..dddec0295ac --- /dev/null +++ b/src/earn/prepareTransactions.test.ts @@ -0,0 +1,272 @@ +import BigNumber from 'bignumber.js' +import { FetchMock } from 'jest-fetch-mock/types' +import aavePool from 'src/abis/AavePoolV3' +import erc20 from 'src/abis/IERC20' +import { prepareSupplyTransactions } from 'src/earn/prepareTransactions' +import { TokenBalance } from 'src/tokens/slice' +import { Network, NetworkId } from 'src/transactions/types' +import { publicClient } from 'src/viem' +import { prepareTransactions } from 'src/viem/prepareTransactions' +import { Address, encodeFunctionData } from 'viem' + +const mockFeeCurrency: TokenBalance = { + address: null, + balance: new BigNumber(100), // 10k units, 100.0 decimals + decimals: 2, + priceUsd: null, + lastKnownPriceUsd: null, + tokenId: 'arbitrum-sepolia:native', + symbol: 'FEE1', + name: 'Fee token 1', + networkId: NetworkId['arbitrum-sepolia'], + isNative: true, +} + +const mockTokenAddress: Address = '0x1234567890abcdef1234567890abcdef12345678' + +const mockToken: TokenBalance = { + address: mockTokenAddress, + balance: new BigNumber(10), + decimals: 6, + priceUsd: null, + lastKnownPriceUsd: null, + tokenId: `arbitrum-sepolia:${mockTokenAddress}`, + symbol: 'USDC', + name: 'USD Coin', + networkId: NetworkId['arbitrum-sepolia'], +} + +jest.mock('src/viem/prepareTransactions') +jest.mock('viem', () => ({ + ...jest.requireActual('viem'), + encodeFunctionData: jest.fn(), +})) + +describe('prepareTransactions', () => { + beforeEach(() => { + jest.clearAllMocks() + jest.mocked(prepareTransactions).mockImplementation(async ({ baseTransactions }) => ({ + transactions: baseTransactions, + type: 'possible', + feeCurrency: mockFeeCurrency, + })) + jest.spyOn(publicClient[Network.Arbitrum], 'readContract').mockResolvedValue(BigInt(0)) + jest.mocked(encodeFunctionData).mockReturnValue('0xencodedData') + }) + + describe('prepareSupplyTransactions', () => { + const mockFetch = fetch as FetchMock + beforeEach(() => { + mockFetch.resetMocks() + }) + + it('prepares transactions with approve and supply if not already approved', async () => { + mockFetch.mockResponseOnce( + JSON.stringify({ + status: 'OK', + simulatedTransactions: [ + { + status: 'success', + blockNumber: '1', + gasNeeded: 3000, + gasUsed: 2800, + gasPrice: '1', + }, + { + status: 'success', + blockNumber: '1', + gasNeeded: 50000, + gasUsed: 49800, + gasPrice: '1', + }, + ], + }) + ) + + const result = await prepareSupplyTransactions({ + amount: '5', + token: mockToken, + walletAddress: '0x1234', + feeCurrencies: [mockFeeCurrency], + poolContractAddress: '0x5678', + }) + + const expectedTransactions = [ + { + from: '0x1234', + to: mockTokenAddress, + data: '0xencodedData', + }, + { + from: '0x1234', + to: '0x5678', + data: '0xencodedData', + gas: BigInt(50000), + _estimatedGasUse: BigInt(49800), + }, + ] + expect(result).toEqual({ + type: 'possible', + feeCurrency: mockFeeCurrency, + transactions: expectedTransactions, + }) + expect(publicClient[Network.Arbitrum].readContract).toHaveBeenCalledWith({ + address: mockTokenAddress, + abi: erc20.abi, + functionName: 'allowance', + args: ['0x1234', '0x5678'], + }) + expect(encodeFunctionData).toHaveBeenNthCalledWith(1, { + abi: erc20.abi, + functionName: 'approve', + args: ['0x5678', BigInt(5e6)], + }) + expect(encodeFunctionData).toHaveBeenNthCalledWith(2, { + abi: aavePool, + functionName: 'supply', + args: [mockTokenAddress, BigInt(5e6), '0x1234', 0], + }) + expect(prepareTransactions).toHaveBeenCalledWith({ + baseTransactions: expectedTransactions, + feeCurrencies: [mockFeeCurrency], + spendToken: mockToken, + spendTokenAmount: new BigNumber(5), + }) + }) + + it('prepares transactions with supply if already approved', async () => { + jest.spyOn(publicClient[Network.Arbitrum], 'readContract').mockResolvedValue(BigInt(5e6)) + mockFetch.mockResponseOnce( + JSON.stringify({ + status: 'OK', + simulatedTransactions: [ + { + status: 'success', + blockNumber: '1', + gasNeeded: 50000, + gasUsed: 49800, + gasPrice: '1', + }, + ], + }) + ) + + const result = await prepareSupplyTransactions({ + amount: '5', + token: mockToken, + walletAddress: '0x1234', + feeCurrencies: [mockFeeCurrency], + poolContractAddress: '0x5678', + }) + + const expectedTransactions = [ + { + from: '0x1234', + to: '0x5678', + data: '0xencodedData', + gas: BigInt(50000), + _estimatedGasUse: BigInt(49800), + }, + ] + expect(result).toEqual({ + type: 'possible', + feeCurrency: mockFeeCurrency, + transactions: expectedTransactions, + }) + expect(publicClient[Network.Arbitrum].readContract).toHaveBeenCalledWith({ + address: mockTokenAddress, + abi: erc20.abi, + functionName: 'allowance', + args: ['0x1234', '0x5678'], + }) + expect(encodeFunctionData).toHaveBeenNthCalledWith(1, { + abi: aavePool, + functionName: 'supply', + args: [mockTokenAddress, BigInt(5e6), '0x1234', 0], + }) + expect(prepareTransactions).toHaveBeenCalledWith({ + baseTransactions: expectedTransactions, + feeCurrencies: [mockFeeCurrency], + spendToken: mockToken, + spendTokenAmount: new BigNumber(5), + }) + }) + + it('throws if simulate transactions sends a non 200 response', async () => { + mockFetch.mockResponseOnce( + JSON.stringify({ + status: 'ERROR', + error: 'something went wrong', + }), + { status: 500 } + ) + + await expect( + prepareSupplyTransactions({ + amount: '5', + token: mockToken, + walletAddress: '0x1234', + feeCurrencies: [mockFeeCurrency], + poolContractAddress: '0x5678', + }) + ).rejects.toThrow('Failed to simulate transactions') + }) + + it('throws if supply transaction simulation status is failure', async () => { + mockFetch.mockResponseOnce( + JSON.stringify({ + status: 'OK', + simulatedTransactions: [ + { + status: 'success', + blockNumber: '1', + gasNeeded: 3000, + gasUsed: 2800, + gasPrice: '1', + }, + { + status: 'failure', + }, + ], + }) + ) + + await expect( + prepareSupplyTransactions({ + amount: '5', + token: mockToken, + walletAddress: '0x1234', + feeCurrencies: [mockFeeCurrency], + poolContractAddress: '0x5678', + }) + ).rejects.toThrow('Failed to simulate supply transaction') + }) + + it('throws if simulated transactions length does not match base transactions length', async () => { + mockFetch.mockResponseOnce( + JSON.stringify({ + status: 'OK', + simulatedTransactions: [ + { + status: 'success', + blockNumber: '1', + gasNeeded: 3000, + gasUsed: 2800, + gasPrice: '1', + }, + ], + }) + ) + + await expect( + prepareSupplyTransactions({ + amount: '5', + token: mockToken, + walletAddress: '0x1234', + feeCurrencies: [mockFeeCurrency], + poolContractAddress: '0x5678', + }) + ).rejects.toThrow('Expected 2 simulated transactions, got 1') + }) + }) +}) diff --git a/src/earn/prepareTransactions.ts b/src/earn/prepareTransactions.ts new file mode 100644 index 00000000000..0e22dfefcab --- /dev/null +++ b/src/earn/prepareTransactions.ts @@ -0,0 +1,124 @@ +import BigNumber from 'bignumber.js' +import aavePool from 'src/abis/AavePoolV3' +import erc20 from 'src/abis/IERC20' +import { TokenBalance } from 'src/tokens/slice' +import { fetchWithTimeout } from 'src/utils/fetchWithTimeout' +import { publicClient } from 'src/viem' +import { TransactionRequest, prepareTransactions } from 'src/viem/prepareTransactions' +import networkConfig, { networkIdToNetwork } from 'src/web3/networkConfig' +import { Address, encodeFunctionData, isAddress, parseUnits } from 'viem' + +type SimulatedTransactionResponse = { + status: 'OK' + simulatedTransactions: { + status: 'success' | 'failure' + blockNumber: string + gasNeeded: number + gasUsed: number + gasPrice: string + }[] +} + +export async function prepareSupplyTransactions({ + amount, + token, + walletAddress, + feeCurrencies, + poolContractAddress, +}: { + amount: string + token: TokenBalance + walletAddress: Address + feeCurrencies: TokenBalance[] + poolContractAddress: Address +}) { + const baseTransactions: TransactionRequest[] = [] + + // amount in smallest unit + const amountToSupply = parseUnits(amount, token.decimals) + + if (!token.address || !isAddress(token.address)) { + // should never happen + throw new Error(`Cannot use a token without address. Token id: ${token.tokenId}`) + } + + const approvedAllowanceForSpender = await publicClient[ + networkIdToNetwork[token.networkId] + ].readContract({ + address: token.address, + abi: erc20.abi, + functionName: 'allowance', + args: [walletAddress, poolContractAddress], + }) + + if (approvedAllowanceForSpender < amountToSupply) { + const data = encodeFunctionData({ + abi: erc20.abi, + functionName: 'approve', + args: [poolContractAddress, amountToSupply], + }) + + const approveTx: TransactionRequest = { + from: walletAddress, + to: token.address, + data, + } + baseTransactions.push(approveTx) + } + + const supplyTx: TransactionRequest = { + from: walletAddress, + to: poolContractAddress, + data: encodeFunctionData({ + abi: aavePool, + functionName: 'supply', + args: [token.address, amountToSupply, walletAddress, 0], + }), + } + + baseTransactions.push(supplyTx) + + const response = await fetchWithTimeout(networkConfig.simulateTransactionsUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + transactions: baseTransactions, + networkId: token.networkId, + }), + }) + + if (!response.ok) { + throw new Error( + `Failed to simulate transactions. status ${response.status}, text: ${await response.text()}` + ) + } + + // extract fee of the supply transaction and set gas fields + const { simulatedTransactions }: SimulatedTransactionResponse = await response.json() + + if (simulatedTransactions.length !== baseTransactions.length) { + throw new Error( + `Expected ${baseTransactions.length} simulated transactions, got ${simulatedTransactions.length}, response: ${JSON.stringify(simulatedTransactions)}` + ) + } + + const supplySimulatedTx = simulatedTransactions[simulatedTransactions.length - 1] + + if (supplySimulatedTx.status !== 'success') { + throw new Error( + `Failed to simulate supply transaction. response: ${JSON.stringify(simulatedTransactions)}` + ) + } + + baseTransactions[baseTransactions.length - 1].gas = BigInt(supplySimulatedTx.gasNeeded) + baseTransactions[baseTransactions.length - 1]._estimatedGasUse = BigInt(supplySimulatedTx.gasUsed) + + return prepareTransactions({ + feeCurrencies, + baseTransactions, + spendToken: token, + spendTokenAmount: new BigNumber(amount), + }) +} diff --git a/src/web3/networkConfig.ts b/src/web3/networkConfig.ts index 39c3fea53c1..cde6bc6452b 100644 --- a/src/web3/networkConfig.ts +++ b/src/web3/networkConfig.ts @@ -73,6 +73,7 @@ interface NetworkConfig { getPointsHistoryUrl: string trackPointsEventUrl: string getPointsBalanceUrl: string + simulateTransactionsUrl: string viemChain: { [key in Network]: ViemChain } @@ -272,6 +273,9 @@ const TRACK_POINTS_EVENT_MAINNET = `${CLOUD_FUNCTIONS_MAINNET}/trackPointsEvent` const GET_POINTS_BALANCE_ALFAJORES = `${CLOUD_FUNCTIONS_STAGING}/getPointsBalance` const GET_POINTS_BALANCE_MAINNET = `${CLOUD_FUNCTIONS_MAINNET}/getPointsBalance` +const SIMULATE_TRANSACTIONS_ALFAJORES = `${CLOUD_FUNCTIONS_STAGING}/simulateTransactions` +const SIMULATE_TRANSACTIONS_MAINNET = `${CLOUD_FUNCTIONS_MAINNET}/simulateTransactions` + const networkConfigs: { [testnet: string]: NetworkConfig } = { [Testnets.alfajores]: { networkId: '44787', @@ -333,6 +337,7 @@ const networkConfigs: { [testnet: string]: NetworkConfig } = { getPointsHistoryUrl: GET_POINTS_HISTORY_ALFAJORES, trackPointsEventUrl: TRACK_POINTS_EVENT_ALFAJORES, getPointsBalanceUrl: GET_POINTS_BALANCE_ALFAJORES, + simulateTransactionsUrl: SIMULATE_TRANSACTIONS_ALFAJORES, viemChain: { [Network.Celo]: celoAlfajores, [Network.Ethereum]: ethereumSepolia, @@ -428,6 +433,7 @@ const networkConfigs: { [testnet: string]: NetworkConfig } = { getPointsHistoryUrl: GET_POINTS_HISTORY_MAINNET, trackPointsEventUrl: TRACK_POINTS_EVENT_MAINNET, getPointsBalanceUrl: GET_POINTS_BALANCE_MAINNET, + simulateTransactionsUrl: SIMULATE_TRANSACTIONS_MAINNET, viemChain: { [Network.Celo]: celo, [Network.Ethereum]: ethereum,