Skip to content

Commit

Permalink
feat(earn): add function for preparing supply transaction (valora-inc…
Browse files Browse the repository at this point in the history
…#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
satish-ravi authored and shottah committed May 15, 2024
1 parent 1fa0e08 commit d274e8e
Show file tree
Hide file tree
Showing 3 changed files with 402 additions and 0 deletions.
272 changes: 272 additions & 0 deletions src/earn/prepareTransactions.test.ts
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')
})
})
})
124 changes: 124 additions & 0 deletions src/earn/prepareTransactions.ts
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),
})
}
Loading

0 comments on commit d274e8e

Please sign in to comment.