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:
parent
b2715b67c0
commit
caedd5c4e6
2 changed files with 165 additions and 1 deletions
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue