11import type { HexString } from "@hyperbridge/sdk"
22import type { ERC7821Call } from "@hyperbridge/sdk"
33import { encodeFunctionData , maxUint256 } from "viem"
4+ import { Mutex } from "async-mutex"
45import type { ChainClientManager } from "@/services/ChainClientManager"
56import type { FillerConfigService } from "@/services/FillerConfigService"
6- import type { FundingVenue , AerodromeOutputFundingConfig , HydratedPool } from "@/funding/types"
7+ import type { FundingPlanResult , FundingVenue , AerodromeOutputFundingConfig , HydratedPool } from "@/funding/types"
78import { AerodromeLiquidityState } from "@/funding/aerodrome/AerodromeLiquidityState"
89import { AERODROME_GAUGE_ABI , AERODROME_ROUTER_ABI } from "@/config/abis/Aerodrome"
910import { ERC20_ABI } from "@/config/abis/ERC20"
@@ -13,8 +14,6 @@ const logger = getLogger("aerodrome-funding")
1314
1415/** Slippage for `amount0Min` / `amount1Min` (99.90 % of quote ≈ 0.10 % slip). */
1516const MIN_AMOUNT_OUT_BPS = 9990
16- /** Gas multiplier (bps, 1e4 = 1×) applied to `callGasLimit` when funding prepends are present (2×). */
17- const FUNDING_GAS_MULTIPLIER_BPS = 20000
1817const MAX_BS_ITER = 48
1918
2019// ============================================================================
@@ -73,6 +72,8 @@ export class AerodromeFundingPlanner implements FundingVenue {
7372 name = "Aerodrome"
7473 /** Long-lived state per chain, keyed by chain identifier. */
7574 private stateByChain = new Map < string , AerodromeLiquidityState > ( )
75+ /** Per-chain mutex serialising planWithdrawalForToken to prevent concurrent state races. */
76+ private mutexByChain = new Map < string , Mutex > ( )
7677
7778 constructor (
7879 private readonly clientManager : ChainClientManager ,
@@ -85,14 +86,10 @@ export class AerodromeFundingPlanner implements FundingVenue {
8586 * Throws on missing/invalid required fields. Address availability
8687 * is checked later in `initialise()` when configService is available.
8788 */
88- static validateConfig (
89- pools : { chain ?: string ; pair ?: string ; gauge ?: string } [ ] ,
90- ) : void {
89+ static validateConfig ( pools : { chain ?: string ; pair ?: string ; gauge ?: string } [ ] ) : void {
9190 for ( const pool of pools ) {
9291 if ( ! pool . chain ?. trim ( ) ) {
93- throw new Error (
94- "Each Aerodrome outputFunding pool must have a non-empty 'chain' (e.g. EVM-8453)" ,
95- )
92+ throw new Error ( "Each Aerodrome outputFunding pool must have a non-empty 'chain' (e.g. EVM-8453)" )
9693 }
9794 if ( ! pool . pair ) {
9895 throw new Error ( "Each Aerodrome pool must include a 'pair' address" )
@@ -123,6 +120,7 @@ export class AerodromeFundingPlanner implements FundingVenue {
123120 const state = new AerodromeLiquidityState ( chain , pools , router , solver , this . clientManager )
124121 await state . hydrate ( )
125122 this . stateByChain . set ( chain , state )
123+ this . mutexByChain . set ( chain , new Mutex ( ) )
126124 }
127125 }
128126
@@ -163,122 +161,124 @@ export class AerodromeFundingPlanner implements FundingVenue {
163161 solver : HexString ,
164162 tokenOutLower : string ,
165163 amountNeeded : bigint ,
166- ) : Promise < { calls : ERC7821Call [ ] ; credited : bigint } > {
167- if ( amountNeeded <= 0n ) return { calls : [ ] , credited : 0n }
168-
169- const state = this . stateByChain . get ( destChain )
170- if ( ! state || ! state . isHydrated ( ) ) return { calls : [ ] , credited : 0n }
171-
172- // Refresh on-chain state for this chain right before planning so
173- // reserves and LP balances are as fresh as possible.
174- await state . refresh ( )
175-
176- const tokenNeed = tokenOutLower . toLowerCase ( )
177- const candidatePools = state . poolsForToken ( tokenNeed )
164+ ) : Promise < FundingPlanResult > {
165+ const noopResult : FundingPlanResult = { calls : [ ] , credited : 0n }
178166
179- for ( const pool of candidatePools ) {
180- const lpAvail = state . remaining ( pool . pair )
181- if ( lpAvail === 0n ) continue
167+ if ( amountNeeded <= 0n ) return noopResult
182168
183- const [ tokenA , tokenB ] = sortTokens ( pool . token0 , pool . token1 )
184-
185- // --- solve LP amount ---
186- const liquidity =
187- this . solveLiquidityForDeficit ( pool , tokenNeed , amountNeeded , lpAvail ) ??
188- ( await this . solveLiquidityForDeficitStable (
189- destChain ,
190- pool ,
169+ const state = this . stateByChain . get ( destChain )
170+ if ( ! state || ! state . isHydrated ( ) ) return noopResult
171+
172+ const mutex = this . mutexByChain . get ( destChain ) !
173+ return mutex . runExclusive ( async ( ) => {
174+ // Refresh on-chain state for this chain right before planning so
175+ // reserves and LP balances are as fresh as possible.
176+ await state . refresh ( )
177+
178+ const tokenNeed = tokenOutLower . toLowerCase ( )
179+ const candidatePools = state . poolsForToken ( tokenNeed )
180+
181+ for ( const pool of candidatePools ) {
182+ const lpAvail = state . remaining ( pool . pair )
183+ if ( lpAvail === 0n ) continue
184+
185+ const [ tokenA , tokenB ] = sortTokens ( pool . token0 , pool . token1 )
186+
187+ // --- solve LP amount ---
188+ const liquidity =
189+ this . solveLiquidityForDeficit ( pool , tokenNeed , amountNeeded , lpAvail ) ??
190+ ( await this . solveLiquidityForDeficitStable (
191+ destChain ,
192+ pool ,
193+ tokenA ,
194+ tokenB ,
195+ tokenNeed ,
196+ amountNeeded ,
197+ lpAvail ,
198+ ) )
199+ if ( liquidity <= 0n ) continue
200+
201+ const cappedL = liquidity > lpAvail ? lpAvail : liquidity
202+
203+ // --- quote expected output ---
204+ const expected = await this . quoteExpectedAmounts ( destChain , pool , tokenA , tokenB , cappedL )
205+ if ( ! expected ) continue
206+
207+ const { amount0, amount1 } = mapRouterAmountsToToken01 (
208+ pool . token0 ,
191209 tokenA ,
192- tokenB ,
193- tokenNeed ,
194- amountNeeded ,
195- lpAvail ,
196- ) )
197- if ( liquidity <= 0n ) continue
198-
199- const cappedL = liquidity > lpAvail ? lpAvail : liquidity
200-
201- // --- quote expected output ---
202- const expected = await this . quoteExpectedAmounts ( destChain , pool , tokenA , tokenB , cappedL )
203- if ( ! expected ) continue
204-
205- const { amount0, amount1 } = mapRouterAmountsToToken01 (
206- pool . token0 ,
207- tokenA ,
208- expected . amountA ,
209- expected . amountB ,
210- )
211- const credit = tokenNeed === pool . token0 . toLowerCase ( ) ? amount0 : amount1
212- if ( credit === 0n ) continue
213-
214- // --- slippage ---
215- // By the time the tx lands, reserves can change (other swaps, liquidity moves, ordering in the block).
216- const bps = BigInt ( this . minAmountOutBps )
217- const amount0Min = ( amount0 * bps ) / 10000n
218- const amount1Min = ( amount1 * bps ) / 10000n
219- const { amountAMin, amountBMin } = mapToken01MinsToRouter ( pool . token0 , tokenA , amount0Min , amount1Min )
220-
221- // --- build ERC-7821 calls ---
222- const deadline = BigInt ( Math . floor ( Date . now ( ) / 1000 ) + 30 * 60 )
223- const calls : ERC7821Call [ ] = [ ]
224-
225- // 1. Gauge withdraw (if needed)
226- if ( pool . gauge ) {
227- // Only withdraw the shortfall beyond what's already in the wallet.
228- const shortfall = cappedL > pool . walletLp ? cappedL - pool . walletLp : 0n
229- const withdrawAmt = shortfall > pool . gaugeLp ? pool . gaugeLp : shortfall
230- if ( withdrawAmt > 0n ) {
231- calls . push ( {
232- target : pool . gauge ,
233- value : 0n ,
234- data : encodeFunctionData ( {
235- abi : AERODROME_GAUGE_ABI ,
236- functionName : "withdraw" ,
237- args : [ withdrawAmt ] ,
238- } ) as HexString ,
239- } )
210+ expected . amountA ,
211+ expected . amountB ,
212+ )
213+ const credit = tokenNeed === pool . token0 . toLowerCase ( ) ? amount0 : amount1
214+ if ( credit === 0n ) continue
215+
216+ // --- slippage ---
217+ // By the time the tx lands, reserves can change (other swaps, liquidity moves, ordering in the block).
218+ const bps = BigInt ( this . minAmountOutBps )
219+ const amount0Min = ( amount0 * bps ) / 10000n
220+ const amount1Min = ( amount1 * bps ) / 10000n
221+ const { amountAMin, amountBMin } = mapToken01MinsToRouter ( pool . token0 , tokenA , amount0Min , amount1Min )
222+
223+ // --- build ERC-7821 calls ---
224+ const deadline = BigInt ( Math . floor ( Date . now ( ) / 1000 ) + 30 * 60 )
225+ const calls : ERC7821Call [ ] = [ ]
226+
227+ // 1. Gauge withdraw (if needed)
228+ if ( pool . gauge ) {
229+ // Only withdraw the shortfall beyond what's already in the wallet.
230+ const shortfall = cappedL > pool . walletLp ? cappedL - pool . walletLp : 0n
231+ const withdrawAmt = shortfall > pool . gaugeLp ? pool . gaugeLp : shortfall
232+ if ( withdrawAmt > 0n ) {
233+ calls . push ( {
234+ target : pool . gauge ,
235+ value : 0n ,
236+ data : encodeFunctionData ( {
237+ abi : AERODROME_GAUGE_ABI ,
238+ functionName : "withdraw" ,
239+ args : [ withdrawAmt ] ,
240+ } ) as HexString ,
241+ } )
242+ }
240243 }
241- }
242244
243- // 2. Approve LP token to router
244- calls . push ( {
245- target : pool . pair ,
246- value : 0n ,
247- data : encodeFunctionData ( {
248- abi : ERC20_ABI ,
249- functionName : "approve" ,
250- args : [ pool . router , maxUint256 ] ,
251- } ) as HexString ,
252- } )
253-
254- // 3. Remove liquidity
255- calls . push ( {
256- target : pool . router ,
257- value : 0n ,
258- data : encodeFunctionData ( {
259- abi : AERODROME_ROUTER_ABI ,
260- functionName : "removeLiquidity" ,
261- args : [ tokenA , tokenB , pool . stable , cappedL , amountAMin , amountBMin , solver , deadline ] ,
262- } ) as HexString ,
263- } )
264-
265- // --- bookkeeping ---
266- state . consume ( pool . pair , cappedL )
267-
268- logger . debug (
269- {
270- pair : pool . pair ,
271- liquidity : cappedL . toString ( ) ,
272- tokenOut : tokenNeed ,
273- credited : credit . toString ( ) ,
274- } ,
275- "Aerodrome funding planned" ,
276- )
245+ // 2. Approve LP token to router
246+ calls . push ( {
247+ target : pool . pair ,
248+ value : 0n ,
249+ data : encodeFunctionData ( {
250+ abi : ERC20_ABI ,
251+ functionName : "approve" ,
252+ args : [ pool . router , maxUint256 ] ,
253+ } ) as HexString ,
254+ } )
255+
256+ // 3. Remove liquidity
257+ calls . push ( {
258+ target : pool . router ,
259+ value : 0n ,
260+ data : encodeFunctionData ( {
261+ abi : AERODROME_ROUTER_ABI ,
262+ functionName : "removeLiquidity" ,
263+ args : [ tokenA , tokenB , pool . stable , cappedL , amountAMin , amountBMin , solver , deadline ] ,
264+ } ) as HexString ,
265+ } )
266+
267+ logger . debug (
268+ {
269+ pair : pool . pair ,
270+ liquidity : cappedL . toString ( ) ,
271+ tokenOut : tokenNeed ,
272+ credited : credit . toString ( ) ,
273+ } ,
274+ "Aerodrome funding planned" ,
275+ )
277276
278- return { calls, credited : credit }
279- }
277+ return { calls, credited : credit }
278+ }
280279
281- return { calls : [ ] , credited : 0n }
280+ return noopResult
281+ } ) // mutex.runExclusive
282282 }
283283
284284 // =========================================================================
0 commit comments