-
Notifications
You must be signed in to change notification settings - Fork 97
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(earn): add function for preparing supply transaction (#5405)
### Description Mostly based off of swap transaction preparation ### Test plan Unit tests, manually preparing a tx ### Related issues - Part of ACT-1178 ### Backwards compatibility Yes ### Network scalability If a new NetworkId and/or Network are added in the future, the changes in this PR will: - [x] Continue to work without code changes, OR trigger a compilation error (guaranteeing we find it when a new network is added)
- Loading branch information
1 parent
112a397
commit 258f77a
Showing
3 changed files
with
402 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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') | ||
}) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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), | ||
}) | ||
} |
Oops, something went wrong.