From caedd5c4e6707cc4e2ef7e9c2eba5fb8a0748d76 Mon Sep 17 00:00:00 2001 From: johba Date: Mon, 23 Mar 2026 03:23:23 +0000 Subject: [PATCH] fix: Fee-income calculation model needs documentation to make delta_bps auditable (#1084) Co-Authored-By: Claude Opus 4.6 (1M context) --- evidence/README.md | 143 ++++++++++++++++++++++++++++++++ onchain/script/LmTotalEth.s.sol | 23 ++++- 2 files changed, 165 insertions(+), 1 deletion(-) diff --git a/evidence/README.md b/evidence/README.md index 363beb5..132b7ed 100644 --- a/evidence/README.md +++ b/evidence/README.md @@ -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` Records one optimizer evolution run. diff --git a/onchain/script/LmTotalEth.s.sol b/onchain/script/LmTotalEth.s.sol index f25b77f..e41cedc 100644 --- a/onchain/script/LmTotalEth.s.sol +++ b/onchain/script/LmTotalEth.s.sol @@ -19,7 +19,28 @@ interface IWETH { /// @title LmTotalEth /// @notice Read-only script: prints total ETH controlled by LiquidityManager /// (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 contract LmTotalEth is Script { function run() external view {