Skip to content

Gas-limit cache keyed by selector alone is unsound for state-dependent calls #70

Description

@Praz314159

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:

  1. Let the caller mark a call non-cacheable, and have adjust_taker/open_taker opt out when perp_delta != 0.
  2. Key on (selector, discriminator) where the discriminator captures the cost-relevant shape (e.g. swap vs no-swap).
  3. Keep max(cached, fresh) semantics and re-estimate on OOG rather than trusting a stale entry.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions