Skip to content

Commit 95c635d

Browse files
committed
handle race conditons in funding venues, bps mismatch
1 parent 7a8d9e4 commit 95c635d

7 files changed

Lines changed: 235 additions & 264 deletions

File tree

sdk/packages/simplex/src/funding/aerodrome/AerodromeFundingPlanner.ts

Lines changed: 118 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import type { HexString } from "@hyperbridge/sdk"
22
import type { ERC7821Call } from "@hyperbridge/sdk"
33
import { encodeFunctionData, maxUint256 } from "viem"
4+
import { Mutex } from "async-mutex"
45
import type { ChainClientManager } from "@/services/ChainClientManager"
56
import type { FillerConfigService } from "@/services/FillerConfigService"
6-
import type { FundingVenue, AerodromeOutputFundingConfig, HydratedPool } from "@/funding/types"
7+
import type { FundingPlanResult, FundingVenue, AerodromeOutputFundingConfig, HydratedPool } from "@/funding/types"
78
import { AerodromeLiquidityState } from "@/funding/aerodrome/AerodromeLiquidityState"
89
import { AERODROME_GAUGE_ABI, AERODROME_ROUTER_ABI } from "@/config/abis/Aerodrome"
910
import { 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). */
1516
const 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
1817
const 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
// =========================================================================

sdk/packages/simplex/src/funding/aerodrome/AerodromeLiquidityState.ts

Lines changed: 3 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,7 @@ const logger = getLogger("aerodrome-state")
1414
* refreshes live data (reserves, totalSupply, LP balances). The planner
1515
* reads from this cache instead of hitting the chain per-order.
1616
*
17-
* `consume()` / `restore()` track LP earmarked for pending orders so
18-
* concurrent evaluations don't double-spend.
17+
* Concurrent access is serialised by the planner's per-chain mutex.
1918
*/
2019
export class AerodromeLiquidityState {
2120
/** Keyed by `pair` address (lower-cased). */
@@ -143,6 +142,7 @@ export class AerodromeLiquidityState {
143142
pool.reserve0 = reserves[0]
144143
pool.reserve1 = reserves[1]
145144
pool.totalSupply = totalSupply
145+
146146
pool.walletLp = walletLp
147147
pool.gaugeLp = gaugeLp
148148
pool.remainingLp = walletLp + gaugeLp
@@ -171,35 +171,8 @@ export class AerodromeLiquidityState {
171171
return this.pools.get(pair.toLowerCase())
172172
}
173173

174-
// =========================================================================
175-
// LP accounting (for concurrent order evaluation)
176-
// =========================================================================
177-
178-
/** Remaining LP available for a given pair after prior `consume()` calls. */
174+
/** Remaining LP available for a given pair. */
179175
remaining(pair: HexString): bigint {
180176
return this.pools.get(pair.toLowerCase())?.remainingLp ?? 0n
181177
}
182-
183-
/**
184-
* Earmark LP for a pending order.
185-
* Decrements `remainingLp` so parallel evaluations see an accurate picture.
186-
*/
187-
consume(pair: HexString, amount: bigint): void {
188-
const pool = this.pools.get(pair.toLowerCase())
189-
if (!pool) throw new Error(`Unknown pair ${pair}`)
190-
if (amount > pool.remainingLp) {
191-
throw new Error(`Aerodrome LP underflow for pair ${pair}: need ${amount}, have ${pool.remainingLp}`)
192-
}
193-
pool.remainingLp -= amount
194-
}
195-
196-
/**
197-
* Restore LP if an order evaluation is abandoned (e.g. profitability check
198-
* fails after partial planning). Prevents leaked reservations.
199-
*/
200-
restore(pair: HexString, amount: bigint): void {
201-
const pool = this.pools.get(pair.toLowerCase())
202-
if (!pool) throw new Error(`Unknown pair ${pair}`)
203-
pool.remainingLp += amount
204-
}
205178
}

sdk/packages/simplex/src/funding/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export type {
22
FundingVenue,
3+
FundingPlanResult,
34
AerodromePoolConfig,
45
AerodromeOutputFundingConfig,
56
HydratedPool,

sdk/packages/simplex/src/funding/types.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,21 @@ export interface FundingVenue {
2020
* Plans ERC-7821 calls to withdraw `amountNeeded` of `tokenOutLower`
2121
* from LP positions on `destChain`. Returns the calls and the credited
2222
* amount that will become available after execution.
23+
*
24+
* Access is serialised per chain via a mutex so concurrent evaluations
25+
* do not race on shared liquidity state.
2326
*/
2427
planWithdrawalForToken(
2528
destChain: string,
2629
solver: HexString,
2730
tokenOutLower: string,
2831
amountNeeded: bigint,
29-
): Promise<{ calls: ERC7821Call[]; credited: bigint }>
32+
): Promise<FundingPlanResult>
33+
}
34+
35+
export interface FundingPlanResult {
36+
calls: ERC7821Call[]
37+
credited: bigint
3038
}
3139

3240
// =========================================================================

0 commit comments

Comments
 (0)