Merge pull request 'fix: Fee-income calculation model needs documentation to make delta_bps auditable (#1084)' (#1133) from fix/issue-1084 into master
This commit is contained in:
commit
224edcc6d3
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 `attacks[1]` 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.
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue