Skip to content

Commit

Permalink
feat(earn): prepare transactions for supply when gas fee is covered (#…
Browse files Browse the repository at this point in the history
…5483)

### Description

When gas fees are subsidized:
1. use the simulateTransactions endpoint to estimate gas for the approve
transaction.
1. ignore limitations of not having enough gas when preparing
transactions

### Test plan

Verified that the transaction goes through when using the syndicate RPC
node on mainnet and that the UI works regardless of whether or not the
user has enough gas when the feature flag is on.

### Related issues

https://linear.app/valora/issue/ACT-1193/new-enter-amount-screen
  • Loading branch information
jh2oman authored May 29, 2024
1 parent 9810375 commit 666f310
Show file tree
Hide file tree
Showing 5 changed files with 200 additions and 10 deletions.
96 changes: 93 additions & 3 deletions src/earn/prepareTransactions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import {
prepareSupplyTransactions,
prepareWithdrawAndClaimTransactions,
} from 'src/earn/prepareTransactions'
import { getDynamicConfigParams } from 'src/statsig'
import { StatsigDynamicConfigs } from 'src/statsig/types'
import { getDynamicConfigParams, getFeatureGate } from 'src/statsig'
import { StatsigDynamicConfigs, StatsigFeatureGates } from 'src/statsig/types'
import { TokenBalance } from 'src/tokens/slice'
import { Network, NetworkId } from 'src/transactions/types'
import { publicClient } from 'src/viem'
Expand Down Expand Up @@ -63,10 +63,16 @@ describe('prepareTransactions', () => {
jest.mocked(encodeFunctionData).mockReturnValue('0xencodedData')
jest.mocked(getDynamicConfigParams).mockImplementation(({ configName, defaultValues }) => {
if (configName === StatsigDynamicConfigs.EARN_STABLECOIN_CONFIG) {
return { ...defaultValues, depositGasPadding: 100 }
return { ...defaultValues, depositGasPadding: 100, approveGasPadding: 200 }
}
return defaultValues
})
jest.mocked(getFeatureGate).mockImplementation((featureGate) => {
if (featureGate === StatsigFeatureGates.SUBSIDIZE_STABLECOIN_EARN_GAS_FEES) {
return false
}
throw new Error(`Unexpected feature gate: ${featureGate}`)
})
})

describe('prepareSupplyTransactions', () => {
Expand Down Expand Up @@ -146,6 +152,89 @@ describe('prepareTransactions', () => {
feeCurrencies: [mockFeeCurrency],
spendToken: mockToken,
spendTokenAmount: new BigNumber(5),
isGasSubsidized: false,
})
})
it('prepares fees from the cloud function for approve and supply when subsidizing gas fees', 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',
},
],
})
)
jest.mocked(getFeatureGate).mockImplementation((featureGate) => {
if (featureGate === StatsigFeatureGates.SUBSIDIZE_STABLECOIN_EARN_GAS_FEES) {
return true
}
throw new Error(`Unexpected feature gate: ${featureGate}`)
})

const result = await prepareSupplyTransactions({
amount: '5',
token: mockToken,
walletAddress: '0x1234',
feeCurrencies: [mockFeeCurrency],
poolContractAddress: '0x5678',
})

const expectedTransactions = [
{
from: '0x1234',
to: mockTokenAddress,
data: '0xencodedData',
gas: BigInt(3200),
_estimatedGasUse: BigInt(2800),
},
{
from: '0x1234',
to: '0x5678',
data: '0xencodedData',
gas: BigInt(50100),
_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),
isGasSubsidized: true,
})
})

Expand Down Expand Up @@ -204,6 +293,7 @@ describe('prepareTransactions', () => {
feeCurrencies: [mockFeeCurrency],
spendToken: mockToken,
spendTokenAmount: new BigNumber(5),
isGasSubsidized: false,
})
})

Expand Down
20 changes: 17 additions & 3 deletions src/earn/prepareTransactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import aaveIncentivesV3Abi from 'src/abis/AaveIncentivesV3'
import aavePool from 'src/abis/AavePoolV3'
import erc20 from 'src/abis/IERC20'
import { RewardsInfo } from 'src/earn/types'
import { getDynamicConfigParams } from 'src/statsig'
import { getDynamicConfigParams, getFeatureGate } from 'src/statsig'
import { DynamicConfigs } from 'src/statsig/constants'
import { StatsigDynamicConfigs } from 'src/statsig/types'
import { StatsigDynamicConfigs, StatsigFeatureGates } from 'src/statsig/types'
import { TokenBalance } from 'src/tokens/slice'
import Logger from 'src/utils/Logger'
import { ensureError } from 'src/utils/ensureError'
Expand Down Expand Up @@ -122,7 +122,7 @@ export async function prepareSupplyTransactions({
)
}

const { depositGasPadding } = getDynamicConfigParams(
const { depositGasPadding, approveGasPadding } = getDynamicConfigParams(
DynamicConfigs[StatsigDynamicConfigs.EARN_STABLECOIN_CONFIG]
)

Expand All @@ -131,11 +131,25 @@ export async function prepareSupplyTransactions({
)
baseTransactions[baseTransactions.length - 1]._estimatedGasUse = BigInt(supplySimulatedTx.gasUsed)

const isGasSubsidized = getFeatureGate(StatsigFeatureGates.SUBSIDIZE_STABLECOIN_EARN_GAS_FEES)
if (isGasSubsidized && baseTransactions.length > 1) {
// extract fee of the approve transaction and set gas fields
const approveSimulatedTx = simulatedTransactions[0]
if (approveSimulatedTx.status !== 'success') {
throw new Error(
`Failed to simulate approve transaction. response: ${JSON.stringify(simulatedTransactions)}`
)
}
baseTransactions[0].gas = BigInt(approveSimulatedTx.gasNeeded) + BigInt(approveGasPadding)
baseTransactions[0]._estimatedGasUse = BigInt(approveSimulatedTx.gasUsed)
}

return prepareTransactions({
feeCurrencies,
baseTransactions,
spendToken: token,
spendTokenAmount: new BigNumber(amount),
isGasSubsidized,
})
}

Expand Down
1 change: 1 addition & 0 deletions src/statsig/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export const DynamicConfigs = {
providerLogoUrl: '',
providerTermsAndConditionsUrl: '',
depositGasPadding: 0,
approveGasPadding: 0,
moreAavePoolsUrl: '',
},
},
Expand Down
83 changes: 82 additions & 1 deletion src/viem/prepareTransactions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ describe('prepareTransactions module', () => {
})
mocked(estimateGas).mockResolvedValue(BigInt(1_000))

// max gas fee is 10 * 10k = 100k units, too high for either fee currency
// max gas fee is 100 * 1k = 100k units, too high for either fee currency

const result = await prepareTransactions({
feeCurrencies: mockFeeCurrencies,
Expand All @@ -198,6 +198,47 @@ describe('prepareTransactions module', () => {
feeCurrencies: mockFeeCurrencies,
})
})
it("returns a 'possible' result when the balances for feeCurrencies are too low to cover the fee but isGasSubsidized is true", async () => {
mocked(estimateFeesPerGas).mockResolvedValue({
maxFeePerGas: BigInt(100),
maxPriorityFeePerGas: BigInt(2),
baseFeePerGas: BigInt(50),
})
mocked(estimateGas).mockResolvedValue(BigInt(1_000))

// max gas fee is 100 * 1k = 100k units, too high for either fee currency

const result = await prepareTransactions({
feeCurrencies: mockFeeCurrencies,
spendToken: mockSpendToken,
spendTokenAmount: new BigNumber(45_000),
decreasedAmountGasFeeMultiplier: 1,
baseTransactions: [
{
from: '0xfrom' as Address,
to: '0xto' as Address,
data: '0xdata',
},
],
isGasSubsidized: true,
})
expect(result).toStrictEqual({
type: 'possible',
feeCurrency: mockFeeCurrencies[0],
transactions: [
{
from: '0xfrom',
to: '0xto',
data: '0xdata',

gas: BigInt(1000),
maxFeePerGas: BigInt(100),
maxPriorityFeePerGas: BigInt(2),
_baseFeePerGas: BigInt(50),
},
],
})
})
it("returns a 'not-enough-balance-for-gas' result when gas estimation throws error due to insufficient funds", async () => {
mocked(estimateFeesPerGas).mockResolvedValue({
maxFeePerGas: BigInt(100),
Expand Down Expand Up @@ -304,6 +345,46 @@ describe('prepareTransactions module', () => {
decreasedSpendAmount: new BigNumber(4.35), // 70.0 balance minus maxGasFee
})
})
it("returns a 'possible' result when spending the exact max amount of a feeCurrency, and no other feeCurrency has enough balance to pay for the fee and isGasSubsidized is true", async () => {
mocked(estimateFeesPerGas).mockResolvedValue({
maxFeePerGas: BigInt(1),
maxPriorityFeePerGas: BigInt(2),
baseFeePerGas: BigInt(1),
})

const result = await prepareTransactions({
feeCurrencies: mockFeeCurrencies,
spendToken: mockFeeCurrencies[1],
spendTokenAmount: mockFeeCurrencies[1].balance.shiftedBy(mockFeeCurrencies[1].decimals),
decreasedAmountGasFeeMultiplier: 1.01,
isGasSubsidized: true,
baseTransactions: [
{
from: '0xfrom' as Address,
to: '0xto' as Address,
data: '0xdata',
_estimatedGasUse: BigInt(50),
gas: BigInt(15_000),
},
],
})
expect(result).toStrictEqual({
type: 'possible',
feeCurrency: mockFeeCurrencies[0],
transactions: [
{
_baseFeePerGas: BigInt(1),
_estimatedGasUse: BigInt(50),
from: '0xfrom',
gas: BigInt(15_000),
maxFeePerGas: BigInt(1),
maxPriorityFeePerGas: BigInt(2),
to: '0xto',
data: '0xdata',
},
],
})
})
it("returns a 'need-decrease-spend-amount-for-gas' result when spending close to the max amount of a feeCurrency, and no other feeCurrency has enough balance to pay for the fee", async () => {
mocked(estimateFeesPerGas).mockResolvedValue({
maxFeePerGas: BigInt(1),
Expand Down
10 changes: 7 additions & 3 deletions src/viem/prepareTransactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ export async function tryEstimateTransactions(
* @param decreasedAmountGasFeeMultiplier
* @param baseTransactions
* @param throwOnSpendTokenAmountExceedsBalance
* @param isGasSubsidized - This flag should only be set to true if all of the baseTransactions already have gas estimates, aka the 'gas' and '_estimatedGasUse' fields have been manually set
*/
export async function prepareTransactions({
feeCurrencies,
Expand All @@ -269,13 +270,15 @@ export async function prepareTransactions({
decreasedAmountGasFeeMultiplier = 1,
baseTransactions,
throwOnSpendTokenAmountExceedsBalance = true,
isGasSubsidized = false,
}: {
feeCurrencies: TokenBalance[]
spendToken?: TokenBalance
spendTokenAmount?: BigNumber
decreasedAmountGasFeeMultiplier?: number
baseTransactions: (TransactionRequest & { gas?: bigint })[]
throwOnSpendTokenAmountExceedsBalance?: boolean
isGasSubsidized?: boolean
}): Promise<PreparedTransactionsResult> {
if (!spendToken && spendTokenAmount.isGreaterThan(0)) {
throw new Error(
Expand All @@ -299,7 +302,7 @@ export async function prepareTransactions({
estimatedGasFeeInDecimal: BigNumber
}> = []
for (const feeCurrency of feeCurrencies) {
if (feeCurrency.balance.isLessThanOrEqualTo(0)) {
if (feeCurrency.balance.isLessThanOrEqualTo(0) && !isGasSubsidized) {
// No balance, try next fee currency
continue
}
Expand All @@ -314,15 +317,16 @@ export async function prepareTransactions({
const estimatedGasFee = getEstimatedGasFee(estimatedTransactions)
const estimatedGasFeeInDecimal = estimatedGasFee?.shiftedBy(-feeDecimals)
gasFees.push({ feeCurrency, maxGasFeeInDecimal, estimatedGasFeeInDecimal })
if (maxGasFeeInDecimal.isGreaterThan(feeCurrency.balance)) {
if (maxGasFeeInDecimal.isGreaterThan(feeCurrency.balance) && !isGasSubsidized) {
// Not enough balance to pay for gas, try next fee currency
continue
}
const spendAmountDecimal = spendTokenAmount.shiftedBy(-(spendToken?.decimals ?? 0))
if (
spendToken &&
spendToken.tokenId === feeCurrency.tokenId &&
spendAmountDecimal.plus(maxGasFeeInDecimal).isGreaterThan(spendToken.balance)
spendAmountDecimal.plus(maxGasFeeInDecimal).isGreaterThan(spendToken.balance) &&
!isGasSubsidized
) {
// Not enough balance to pay for gas, try next fee currency
continue
Expand Down

0 comments on commit 666f310

Please sign in to comment.