From 666f310d0581fa39c73d12fa1fd93b47acc7abc4 Mon Sep 17 00:00:00 2001 From: Jacob Waterman Date: Wed, 29 May 2024 12:19:29 -0700 Subject: [PATCH] feat(earn): prepare transactions for supply when gas fee is covered (#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 --- src/earn/prepareTransactions.test.ts | 96 +++++++++++++++++++++++++++- src/earn/prepareTransactions.ts | 20 +++++- src/statsig/constants.ts | 1 + src/viem/prepareTransactions.test.ts | 83 +++++++++++++++++++++++- src/viem/prepareTransactions.ts | 10 ++- 5 files changed, 200 insertions(+), 10 deletions(-) diff --git a/src/earn/prepareTransactions.test.ts b/src/earn/prepareTransactions.test.ts index 1faae040529..7ab6abd0d3f 100644 --- a/src/earn/prepareTransactions.test.ts +++ b/src/earn/prepareTransactions.test.ts @@ -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' @@ -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', () => { @@ -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, }) }) @@ -204,6 +293,7 @@ describe('prepareTransactions', () => { feeCurrencies: [mockFeeCurrency], spendToken: mockToken, spendTokenAmount: new BigNumber(5), + isGasSubsidized: false, }) }) diff --git a/src/earn/prepareTransactions.ts b/src/earn/prepareTransactions.ts index b5675c0f233..7990910b919 100644 --- a/src/earn/prepareTransactions.ts +++ b/src/earn/prepareTransactions.ts @@ -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' @@ -122,7 +122,7 @@ export async function prepareSupplyTransactions({ ) } - const { depositGasPadding } = getDynamicConfigParams( + const { depositGasPadding, approveGasPadding } = getDynamicConfigParams( DynamicConfigs[StatsigDynamicConfigs.EARN_STABLECOIN_CONFIG] ) @@ -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, }) } diff --git a/src/statsig/constants.ts b/src/statsig/constants.ts index aef3a0d4fc3..66de15f7d05 100644 --- a/src/statsig/constants.ts +++ b/src/statsig/constants.ts @@ -143,6 +143,7 @@ export const DynamicConfigs = { providerLogoUrl: '', providerTermsAndConditionsUrl: '', depositGasPadding: 0, + approveGasPadding: 0, moreAavePoolsUrl: '', }, }, diff --git a/src/viem/prepareTransactions.test.ts b/src/viem/prepareTransactions.test.ts index 03f64253149..b6580ea96e3 100644 --- a/src/viem/prepareTransactions.test.ts +++ b/src/viem/prepareTransactions.test.ts @@ -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, @@ -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), @@ -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), diff --git a/src/viem/prepareTransactions.ts b/src/viem/prepareTransactions.ts index 744aef8e425..d2be8d783a1 100644 --- a/src/viem/prepareTransactions.ts +++ b/src/viem/prepareTransactions.ts @@ -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, @@ -269,6 +270,7 @@ export async function prepareTransactions({ decreasedAmountGasFeeMultiplier = 1, baseTransactions, throwOnSpendTokenAmountExceedsBalance = true, + isGasSubsidized = false, }: { feeCurrencies: TokenBalance[] spendToken?: TokenBalance @@ -276,6 +278,7 @@ export async function prepareTransactions({ decreasedAmountGasFeeMultiplier?: number baseTransactions: (TransactionRequest & { gas?: bigint })[] throwOnSpendTokenAmountExceedsBalance?: boolean + isGasSubsidized?: boolean }): Promise { if (!spendToken && spendTokenAmount.isGreaterThan(0)) { throw new Error( @@ -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 } @@ -314,7 +317,7 @@ 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 } @@ -322,7 +325,8 @@ export async function prepareTransactions({ 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