GasLimitCache keys estimates by 4-byte function selector (src/hft/gas.rs, src/client/transactions.rs:291), 1h TTL, 1.2x buffer. That assumes a selector has one stable gas cost. For any function whose cost depends on chain state, it doesn't — and the cache hands the expensive call the cheap call's limit.
Live failure
adjustTaker takes both margin_delta and perp_delta. Two very different calls share the selector:
| call |
swap? |
gas |
margin-only (perp_delta: 0.0) |
no |
~198,258 raw → 237,910 cached |
notional (perp_delta != 0) |
yes, crosses ticks |
needs >235,025 |
On Arbitrum Sepolia, tx 0xfc0a4e2e... reverted ReentrancySentryOOG at 235,025 gas against a 237,910 limit — 98.8%, i.e. OOG at the ceiling. The margin-only call had cached that limit two seconds earlier.
Even setting the shared selector aside, adjustTaker's cost scales with how many initialized ticks the swap crosses. A cached estimate goes stale the moment a maker adds a position — which, in a bot cohort, is constantly.
Why nothing catches it
On a cache hit, transactions.rs:302 preflights with eth_call:
if let Some(limit) = cached_limit {
self.preflight_call(to, calldata, value).await?; // validates logic, NOT gas
return Ok(limit);
}
eth_call runs without the tx's gas limit, so it validates business logic and passes. The caller sees a clean preflight, sends the tx, and it OOGs on-chain. This is the difference between the harmless simulation reverted and the gas-burning transaction reverted.
Suggestions
The cheap fix is to not cache calls that can swap. The honest fix is to stop keying on the selector alone — it's not a sound cache key for state-dependent gas. Options:
- Let the caller mark a call non-cacheable, and have
adjust_taker/open_taker opt out when perp_delta != 0.
- Key on
(selector, discriminator) where the discriminator captures the cost-relevant shape (e.g. swap vs no-swap).
- Keep
max(cached, fresh) semantics and re-estimate on OOG rather than trusting a stale entry.
- At minimum, have
preflight_call on a cache-hit path pass the cached limit as eth_call's gas so the preflight actually tests what gets sent.
Worth noting the buffer isn't the issue — 1.2x on a no-swap estimate can't reach a swap's cost.
Context: StrobeLabs/Legion#137 removes the only caller that provoked this (by combining the two legs into one adjustTaker), but the trap stays armed for the next one.
GasLimitCachekeys estimates by 4-byte function selector (src/hft/gas.rs,src/client/transactions.rs:291), 1h TTL, 1.2x buffer. That assumes a selector has one stable gas cost. For any function whose cost depends on chain state, it doesn't — and the cache hands the expensive call the cheap call's limit.Live failure
adjustTakertakes bothmargin_deltaandperp_delta. Two very different calls share the selector:perp_delta: 0.0)perp_delta != 0)On Arbitrum Sepolia, tx
0xfc0a4e2e...revertedReentrancySentryOOGat 235,025 gas against a 237,910 limit — 98.8%, i.e. OOG at the ceiling. The margin-only call had cached that limit two seconds earlier.Even setting the shared selector aside,
adjustTaker's cost scales with how many initialized ticks the swap crosses. A cached estimate goes stale the moment a maker adds a position — which, in a bot cohort, is constantly.Why nothing catches it
On a cache hit,
transactions.rs:302preflights witheth_call:eth_callruns without the tx's gas limit, so it validates business logic and passes. The caller sees a clean preflight, sends the tx, and it OOGs on-chain. This is the difference between the harmlesssimulation revertedand the gas-burningtransaction reverted.Suggestions
The cheap fix is to not cache calls that can swap. The honest fix is to stop keying on the selector alone — it's not a sound cache key for state-dependent gas. Options:
adjust_taker/open_takeropt out whenperp_delta != 0.(selector, discriminator)where the discriminator captures the cost-relevant shape (e.g. swap vs no-swap).max(cached, fresh)semantics and re-estimate on OOG rather than trusting a stale entry.preflight_callon a cache-hit path pass the cached limit aseth_call's gas so the preflight actually tests what gets sent.Worth noting the buffer isn't the issue — 1.2x on a no-swap estimate can't reach a swap's cost.
Context: StrobeLabs/Legion#137 removes the only caller that provoked this (by combining the two legs into one
adjustTaker), but the trap stays armed for the next one.