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