fix: Fee-income calculation model needs documentation to make delta_bps auditable (#1084)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
johba 2026-03-23 03:23:23 +00:00
parent b2715b67c0
commit caedd5c4e6
2 changed files with 165 additions and 1 deletions

View file

@ -38,6 +38,149 @@ Every formula follows the same three-step pattern:
--- ---
## Fee-Income Calculation Model
This section documents how `delta_bps` values in red-team and holdout evidence files
are derived, so that recorded values can be independently verified.
### Measurement tool
`delta_bps` is computed from two snapshots of **LM total ETH** taken by
[`onchain/script/LmTotalEth.s.sol`](../onchain/script/LmTotalEth.s.sol):
```
lm_total_eth = lm.balance (free ETH)
+ WETH.balanceOf(lm) (free WETH)
+ Σ positionEthPrincipal(stage) for stage ∈ {FLOOR, ANCHOR, DISCOVERY}
```
Each position's ETH principal is calculated via `LiquidityAmounts.getAmountsForLiquidity`
at the pool's current `sqrtPriceX96`. Only the WETH side of each position is summed;
the KRK side is excluded.
### What is and is not counted
| Counted | Not counted |
|---------|-------------|
| Free native ETH on the LM contract | KRK balance (free or in positions) |
| Free WETH (ERC-20) on the LM contract | Uncollected fees still inside Uni V3 positions |
| ETH-side principal of all 3 positions | KRK fees transferred to `feeDestination` |
**Key consequence:** Uncollected fees accrued inside Uniswap V3 positions are invisible
to `LmTotalEth` until a `recenter()` call executes `pool.burn` + `pool.collect`, which
converts them into free WETH on the LM contract (or transfers them to `feeDestination`).
A `recenter()` between the two snapshots materializes these fees into the measurement.
### `delta_bps` formula
```
delta_bps = (lm_eth_after lm_eth_before) / lm_eth_before × 10_000
```
Where `lm_eth_before` and `lm_eth_after` are `LmTotalEth` readings taken before and
after the attack sequence. Each attack is snapshot-isolated (Anvil snapshot → execute →
measure → revert), so per-attack `delta_bps` values are independent.
### Components that drive `delta_bps`
A round-trip trade (buy KRK with ETH, then sell KRK back for ETH) through the LM's
dominant positions produces a positive `delta_bps` from three sources:
1. **Pool fee income (1% per leg).** The WETH/KRK pool charges a 1% fee (`FEE = 10_000`
in `LiquidityManager.sol`). On a simple round trip this contributes ~2% of volume.
However, fees accrue as uncollected position fees and only become visible after
`recenter()` materializes them. If no recenter occurs between snapshots, fee income
is partially hidden (reflected only indirectly through reduced trade output).
2. **Concentrated-liquidity slippage.** The LM's three-position strategy concentrates
most liquidity in narrow tick ranges. Trades that exceed the depth of a position
range push through progressively thinner liquidity, causing super-linear slippage.
The attacker receives fewer tokens per unit of input on each marginal unit. This
slippage transfers value to the LM's positions as increased ETH principal.
3. **Recenter repositioning gain.** When `recenter()` is called between trade legs:
- All three positions are burned and fees collected.
- New positions are minted at the current price.
- Any accumulated fees (WETH portion) become free WETH and are redeployed as new
position liquidity. KRK fees are sent to `feeDestination`.
- The repositioned liquidity changes the tick ranges the next trade interacts with.
### Why `delta_bps` is non-linear
A naive estimate of `delta_bps ≈ volume × 1% × 2 legs / lm_eth_before × 10_000`
underestimates the actual value for large trades because:
- **Slippage dominates at high volume.** When trade volume approaches or exceeds the
ETH depth of the active positions, the price moves through the entire concentrated
range and into thin or empty ticks. The slippage loss to the attacker (= gain to the
LM) grows super-linearly with volume.
- **Multi-recenter compounding.** Strategies that call `recenter()` between sub-trades
materialize intermediate fees and reposition liquidity at a new price. Subsequent
trades pay fees at the new tick ranges, compounding the total fee capture.
- **KRK fee exclusion.** KRK fees collected during `recenter()` are transferred to
`feeDestination` and excluded from `LmTotalEth`. This means the measurement captures
the ETH-side gain but not the KRK-side gain — `delta_bps` understates total protocol
revenue.
### Fee destination behaviour
When `feeDestination` is `address(0)` or `address(this)` (the LM contract itself),
fees are **not** transferred out — they remain as deployable liquidity on the LM.
In this configuration, materialized WETH fees increase `lm_total_eth` directly. When
`feeDestination` is an external address, WETH fees are transferred out and do **not**
contribute to `lm_total_eth`. The red-team test environment uses `feeDestination =
address(this)` so that fee income is fully reflected in `delta_bps`.
### Worked example
Using attack 2 from `evidence/red-team/2026-03-20.json`:
> **"Buy → Recenter → Sell (800 ETH round trip)"** — `delta_bps: 1179`
**Given:**
- `lm_eth_before` = 999,999,999,999,999,999,998 wei ≈ 1000 ETH
- Trade volume = 800 ETH (buy leg) + equivalent KRK sell leg
- Pool fee rate = 1% per swap
- `feeDestination = address(this)` (fees stay in LM)
**Step-by-step derivation:**
1. **Buy leg (800 ETH → KRK):** The 800 ETH buy pushes the price ~4000 ticks into
the concentrated positions. The pool charges 1% (≈8 ETH in fees accruing to
positions). Because liquidity is concentrated, the price moves far — the attacker
receives significantly fewer KRK than a constant-product AMM would give.
After the buy, position ETH principal increases (price moved up = more ETH value
in range).
2. **Recenter:** Positions are burned, collecting all accrued fees. New positions are
minted at the new (higher) price. The ~8 ETH in WETH fees plus the ETH-side
principal become redeployable liquidity.
3. **Sell leg (KRK → ETH):** The attacker sells all acquired KRK back through the
newly positioned liquidity. Another 1% fee applies. Because the attacker received
fewer KRK than 800 ETH worth (due to buy-leg slippage), the sell leg returns
significantly less than 800 ETH. The price drops back but the LM retains the
slippage differential.
4. **Result:** `lm_eth_after ≈ 1000 + 117.9 ≈ 1117.9 ETH`.
```
delta_bps = (1117.9 1000) / 1000 × 10_000 = 1179 bps
```
The ~117.9 ETH gain comes from: 1% fees on both legs (~16 ETH) **plus** ~102 ETH
in concentrated-liquidity slippage loss by the attacker. The slippage component
dominates because 800 ETH far exceeds the depth of the anchor/discovery positions,
pushing the trade through increasingly thin liquidity.
**Cross-check — why naive formula fails:**
```
naive = 800 × 0.01 × 2 / 1000 × 10_000 = 160 bps (actual: 1179 bps)
```
The naive estimate assumes uniform liquidity (constant slippage = fee rate only).
The 7× difference is entirely due to concentrated-liquidity slippage on a trade that
exceeds position depth.
---
## Schema: `evolution/YYYY-MM-DD.json` ## Schema: `evolution/YYYY-MM-DD.json`
Records one optimizer evolution run. Records one optimizer evolution run.

View file

@ -19,7 +19,28 @@ interface IWETH {
/// @title LmTotalEth /// @title LmTotalEth
/// @notice Read-only script: prints total ETH controlled by LiquidityManager /// @notice Read-only script: prints total ETH controlled by LiquidityManager
/// (free ETH + free WETH + ETH locked in all 3 Uni V3 positions). /// (free ETH + free WETH + ETH locked in all 3 Uni V3 positions).
/// @dev forge script script/LmTotalEth.s.sol --rpc-url $RPC_URL ///
/// @dev **What is counted:**
/// 1. Free native ETH balance of the LM contract
/// 2. Free WETH (ERC-20) balance of the LM contract
/// 3. ETH-side principal of all 3 Uniswap V3 positions (FLOOR, ANCHOR, DISCOVERY),
/// computed via LiquidityAmounts.getAmountsForLiquidity at the current sqrtPrice.
///
/// **What is NOT counted:**
/// - Uncollected trading fees accrued inside positions (these only become visible
/// after a recenter() calls pool.burn + pool.collect and rolls them into free WETH).
/// - KRK held by the LM (either as free balance or as the KRK side of positions).
/// KRK fees collected during recenter() are transferred to feeDestination and
/// excluded from this measurement entirely.
/// - KRK sent to feeDestination is also subtracted from outstandingSupply for floor
/// calculation purposes (see LiquidityManager._getOutstandingSupply).
///
/// **Implication for delta_bps:** Because uncollected fees are invisible, delta_bps
/// measured between two LmTotalEth snapshots reflects position principal changes
/// plus any fees that were materialized by a recenter() between snapshots.
/// See evidence/README.md § "Fee-Income Calculation Model" for the full formula.
///
/// forge script script/LmTotalEth.s.sol --rpc-url $RPC_URL
/// Env: LM, WETH, POOL /// Env: LM, WETH, POOL
contract LmTotalEth is Script { contract LmTotalEth is Script {
function run() external view { function run() external view {