Skip to content

Commit 666f310

Browse files
authored
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
1 parent 9810375 commit 666f310

File tree

5 files changed

+200
-10
lines changed

5 files changed

+200
-10
lines changed

src/earn/prepareTransactions.test.ts

+93-3
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import {
77
prepareSupplyTransactions,
88
prepareWithdrawAndClaimTransactions,
99
} from 'src/earn/prepareTransactions'
10-
import { getDynamicConfigParams } from 'src/statsig'
11-
import { StatsigDynamicConfigs } from 'src/statsig/types'
10+
import { getDynamicConfigParams, getFeatureGate } from 'src/statsig'
11+
import { StatsigDynamicConfigs, StatsigFeatureGates } from 'src/statsig/types'
1212
import { TokenBalance } from 'src/tokens/slice'
1313
import { Network, NetworkId } from 'src/transactions/types'
1414
import { publicClient } from 'src/viem'
@@ -63,10 +63,16 @@ describe('prepareTransactions', () => {
6363
jest.mocked(encodeFunctionData).mockReturnValue('0xencodedData')
6464
jest.mocked(getDynamicConfigParams).mockImplementation(({ configName, defaultValues }) => {
6565
if (configName === StatsigDynamicConfigs.EARN_STABLECOIN_CONFIG) {
66-
return { ...defaultValues, depositGasPadding: 100 }
66+
return { ...defaultValues, depositGasPadding: 100, approveGasPadding: 200 }
6767
}
6868
return defaultValues
6969
})
70+
jest.mocked(getFeatureGate).mockImplementation((featureGate) => {
71+
if (featureGate === StatsigFeatureGates.SUBSIDIZE_STABLECOIN_EARN_GAS_FEES) {
72+
return false
73+
}
74+
throw new Error(`Unexpected feature gate: ${featureGate}`)
75+
})
7076
})
7177

7278
describe('prepareSupplyTransactions', () => {
@@ -146,6 +152,89 @@ describe('prepareTransactions', () => {
146152
feeCurrencies: [mockFeeCurrency],
147153
spendToken: mockToken,
148154
spendTokenAmount: new BigNumber(5),
155+
isGasSubsidized: false,
156+
})
157+
})
158+
it('prepares fees from the cloud function for approve and supply when subsidizing gas fees', async () => {
159+
mockFetch.mockResponseOnce(
160+
JSON.stringify({
161+
status: 'OK',
162+
simulatedTransactions: [
163+
{
164+
status: 'success',
165+
blockNumber: '1',
166+
gasNeeded: 3000,
167+
gasUsed: 2800,
168+
gasPrice: '1',
169+
},
170+
{
171+
status: 'success',
172+
blockNumber: '1',
173+
gasNeeded: 50000,
174+
gasUsed: 49800,
175+
gasPrice: '1',
176+
},
177+
],
178+
})
179+
)
180+
jest.mocked(getFeatureGate).mockImplementation((featureGate) => {
181+
if (featureGate === StatsigFeatureGates.SUBSIDIZE_STABLECOIN_EARN_GAS_FEES) {
182+
return true
183+
}
184+
throw new Error(`Unexpected feature gate: ${featureGate}`)
185+
})
186+
187+
const result = await prepareSupplyTransactions({
188+
amount: '5',
189+
token: mockToken,
190+
walletAddress: '0x1234',
191+
feeCurrencies: [mockFeeCurrency],
192+
poolContractAddress: '0x5678',
193+
})
194+
195+
const expectedTransactions = [
196+
{
197+
from: '0x1234',
198+
to: mockTokenAddress,
199+
data: '0xencodedData',
200+
gas: BigInt(3200),
201+
_estimatedGasUse: BigInt(2800),
202+
},
203+
{
204+
from: '0x1234',
205+
to: '0x5678',
206+
data: '0xencodedData',
207+
gas: BigInt(50100),
208+
_estimatedGasUse: BigInt(49800),
209+
},
210+
]
211+
expect(result).toEqual({
212+
type: 'possible',
213+
feeCurrency: mockFeeCurrency,
214+
transactions: expectedTransactions,
215+
})
216+
expect(publicClient[Network.Arbitrum].readContract).toHaveBeenCalledWith({
217+
address: mockTokenAddress,
218+
abi: erc20.abi,
219+
functionName: 'allowance',
220+
args: ['0x1234', '0x5678'],
221+
})
222+
expect(encodeFunctionData).toHaveBeenNthCalledWith(1, {
223+
abi: erc20.abi,
224+
functionName: 'approve',
225+
args: ['0x5678', BigInt(5e6)],
226+
})
227+
expect(encodeFunctionData).toHaveBeenNthCalledWith(2, {
228+
abi: aavePool,
229+
functionName: 'supply',
230+
args: [mockTokenAddress, BigInt(5e6), '0x1234', 0],
231+
})
232+
expect(prepareTransactions).toHaveBeenCalledWith({
233+
baseTransactions: expectedTransactions,
234+
feeCurrencies: [mockFeeCurrency],
235+
spendToken: mockToken,
236+
spendTokenAmount: new BigNumber(5),
237+
isGasSubsidized: true,
149238
})
150239
})
151240

@@ -204,6 +293,7 @@ describe('prepareTransactions', () => {
204293
feeCurrencies: [mockFeeCurrency],
205294
spendToken: mockToken,
206295
spendTokenAmount: new BigNumber(5),
296+
isGasSubsidized: false,
207297
})
208298
})
209299

src/earn/prepareTransactions.ts

+17-3
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import aaveIncentivesV3Abi from 'src/abis/AaveIncentivesV3'
44
import aavePool from 'src/abis/AavePoolV3'
55
import erc20 from 'src/abis/IERC20'
66
import { RewardsInfo } from 'src/earn/types'
7-
import { getDynamicConfigParams } from 'src/statsig'
7+
import { getDynamicConfigParams, getFeatureGate } from 'src/statsig'
88
import { DynamicConfigs } from 'src/statsig/constants'
9-
import { StatsigDynamicConfigs } from 'src/statsig/types'
9+
import { StatsigDynamicConfigs, StatsigFeatureGates } from 'src/statsig/types'
1010
import { TokenBalance } from 'src/tokens/slice'
1111
import Logger from 'src/utils/Logger'
1212
import { ensureError } from 'src/utils/ensureError'
@@ -122,7 +122,7 @@ export async function prepareSupplyTransactions({
122122
)
123123
}
124124

125-
const { depositGasPadding } = getDynamicConfigParams(
125+
const { depositGasPadding, approveGasPadding } = getDynamicConfigParams(
126126
DynamicConfigs[StatsigDynamicConfigs.EARN_STABLECOIN_CONFIG]
127127
)
128128

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

134+
const isGasSubsidized = getFeatureGate(StatsigFeatureGates.SUBSIDIZE_STABLECOIN_EARN_GAS_FEES)
135+
if (isGasSubsidized && baseTransactions.length > 1) {
136+
// extract fee of the approve transaction and set gas fields
137+
const approveSimulatedTx = simulatedTransactions[0]
138+
if (approveSimulatedTx.status !== 'success') {
139+
throw new Error(
140+
`Failed to simulate approve transaction. response: ${JSON.stringify(simulatedTransactions)}`
141+
)
142+
}
143+
baseTransactions[0].gas = BigInt(approveSimulatedTx.gasNeeded) + BigInt(approveGasPadding)
144+
baseTransactions[0]._estimatedGasUse = BigInt(approveSimulatedTx.gasUsed)
145+
}
146+
134147
return prepareTransactions({
135148
feeCurrencies,
136149
baseTransactions,
137150
spendToken: token,
138151
spendTokenAmount: new BigNumber(amount),
152+
isGasSubsidized,
139153
})
140154
}
141155

src/statsig/constants.ts

+1
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ export const DynamicConfigs = {
143143
providerLogoUrl: '',
144144
providerTermsAndConditionsUrl: '',
145145
depositGasPadding: 0,
146+
approveGasPadding: 0,
146147
moreAavePoolsUrl: '',
147148
},
148149
},

src/viem/prepareTransactions.test.ts

+82-1
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ describe('prepareTransactions module', () => {
178178
})
179179
mocked(estimateGas).mockResolvedValue(BigInt(1_000))
180180

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

183183
const result = await prepareTransactions({
184184
feeCurrencies: mockFeeCurrencies,
@@ -198,6 +198,47 @@ describe('prepareTransactions module', () => {
198198
feeCurrencies: mockFeeCurrencies,
199199
})
200200
})
201+
it("returns a 'possible' result when the balances for feeCurrencies are too low to cover the fee but isGasSubsidized is true", async () => {
202+
mocked(estimateFeesPerGas).mockResolvedValue({
203+
maxFeePerGas: BigInt(100),
204+
maxPriorityFeePerGas: BigInt(2),
205+
baseFeePerGas: BigInt(50),
206+
})
207+
mocked(estimateGas).mockResolvedValue(BigInt(1_000))
208+
209+
// max gas fee is 100 * 1k = 100k units, too high for either fee currency
210+
211+
const result = await prepareTransactions({
212+
feeCurrencies: mockFeeCurrencies,
213+
spendToken: mockSpendToken,
214+
spendTokenAmount: new BigNumber(45_000),
215+
decreasedAmountGasFeeMultiplier: 1,
216+
baseTransactions: [
217+
{
218+
from: '0xfrom' as Address,
219+
to: '0xto' as Address,
220+
data: '0xdata',
221+
},
222+
],
223+
isGasSubsidized: true,
224+
})
225+
expect(result).toStrictEqual({
226+
type: 'possible',
227+
feeCurrency: mockFeeCurrencies[0],
228+
transactions: [
229+
{
230+
from: '0xfrom',
231+
to: '0xto',
232+
data: '0xdata',
233+
234+
gas: BigInt(1000),
235+
maxFeePerGas: BigInt(100),
236+
maxPriorityFeePerGas: BigInt(2),
237+
_baseFeePerGas: BigInt(50),
238+
},
239+
],
240+
})
241+
})
201242
it("returns a 'not-enough-balance-for-gas' result when gas estimation throws error due to insufficient funds", async () => {
202243
mocked(estimateFeesPerGas).mockResolvedValue({
203244
maxFeePerGas: BigInt(100),
@@ -304,6 +345,46 @@ describe('prepareTransactions module', () => {
304345
decreasedSpendAmount: new BigNumber(4.35), // 70.0 balance minus maxGasFee
305346
})
306347
})
348+
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 () => {
349+
mocked(estimateFeesPerGas).mockResolvedValue({
350+
maxFeePerGas: BigInt(1),
351+
maxPriorityFeePerGas: BigInt(2),
352+
baseFeePerGas: BigInt(1),
353+
})
354+
355+
const result = await prepareTransactions({
356+
feeCurrencies: mockFeeCurrencies,
357+
spendToken: mockFeeCurrencies[1],
358+
spendTokenAmount: mockFeeCurrencies[1].balance.shiftedBy(mockFeeCurrencies[1].decimals),
359+
decreasedAmountGasFeeMultiplier: 1.01,
360+
isGasSubsidized: true,
361+
baseTransactions: [
362+
{
363+
from: '0xfrom' as Address,
364+
to: '0xto' as Address,
365+
data: '0xdata',
366+
_estimatedGasUse: BigInt(50),
367+
gas: BigInt(15_000),
368+
},
369+
],
370+
})
371+
expect(result).toStrictEqual({
372+
type: 'possible',
373+
feeCurrency: mockFeeCurrencies[0],
374+
transactions: [
375+
{
376+
_baseFeePerGas: BigInt(1),
377+
_estimatedGasUse: BigInt(50),
378+
from: '0xfrom',
379+
gas: BigInt(15_000),
380+
maxFeePerGas: BigInt(1),
381+
maxPriorityFeePerGas: BigInt(2),
382+
to: '0xto',
383+
data: '0xdata',
384+
},
385+
],
386+
})
387+
})
307388
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 () => {
308389
mocked(estimateFeesPerGas).mockResolvedValue({
309390
maxFeePerGas: BigInt(1),

src/viem/prepareTransactions.ts

+7-3
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ export async function tryEstimateTransactions(
261261
* @param decreasedAmountGasFeeMultiplier
262262
* @param baseTransactions
263263
* @param throwOnSpendTokenAmountExceedsBalance
264+
* @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
264265
*/
265266
export async function prepareTransactions({
266267
feeCurrencies,
@@ -269,13 +270,15 @@ export async function prepareTransactions({
269270
decreasedAmountGasFeeMultiplier = 1,
270271
baseTransactions,
271272
throwOnSpendTokenAmountExceedsBalance = true,
273+
isGasSubsidized = false,
272274
}: {
273275
feeCurrencies: TokenBalance[]
274276
spendToken?: TokenBalance
275277
spendTokenAmount?: BigNumber
276278
decreasedAmountGasFeeMultiplier?: number
277279
baseTransactions: (TransactionRequest & { gas?: bigint })[]
278280
throwOnSpendTokenAmountExceedsBalance?: boolean
281+
isGasSubsidized?: boolean
279282
}): Promise<PreparedTransactionsResult> {
280283
if (!spendToken && spendTokenAmount.isGreaterThan(0)) {
281284
throw new Error(
@@ -299,7 +302,7 @@ export async function prepareTransactions({
299302
estimatedGasFeeInDecimal: BigNumber
300303
}> = []
301304
for (const feeCurrency of feeCurrencies) {
302-
if (feeCurrency.balance.isLessThanOrEqualTo(0)) {
305+
if (feeCurrency.balance.isLessThanOrEqualTo(0) && !isGasSubsidized) {
303306
// No balance, try next fee currency
304307
continue
305308
}
@@ -314,15 +317,16 @@ export async function prepareTransactions({
314317
const estimatedGasFee = getEstimatedGasFee(estimatedTransactions)
315318
const estimatedGasFeeInDecimal = estimatedGasFee?.shiftedBy(-feeDecimals)
316319
gasFees.push({ feeCurrency, maxGasFeeInDecimal, estimatedGasFeeInDecimal })
317-
if (maxGasFeeInDecimal.isGreaterThan(feeCurrency.balance)) {
320+
if (maxGasFeeInDecimal.isGreaterThan(feeCurrency.balance) && !isGasSubsidized) {
318321
// Not enough balance to pay for gas, try next fee currency
319322
continue
320323
}
321324
const spendAmountDecimal = spendTokenAmount.shiftedBy(-(spendToken?.decimals ?? 0))
322325
if (
323326
spendToken &&
324327
spendToken.tokenId === feeCurrency.tokenId &&
325-
spendAmountDecimal.plus(maxGasFeeInDecimal).isGreaterThan(spendToken.balance)
328+
spendAmountDecimal.plus(maxGasFeeInDecimal).isGreaterThan(spendToken.balance) &&
329+
!isGasSubsidized
326330
) {
327331
// Not enough balance to pay for gas, try next fee currency
328332
continue

0 commit comments

Comments
 (0)