harb/scripts/harb-evaluator/red-team-program.md
openhands 2293ece915 fix: 'Trigger recenter (account 2 only)' label contradicts public recenter comment (#826)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:17:16 +00:00

358 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Red-team mission: extract ETH from the KRAIKEN LiquidityManager
## Goal
**Extract ETH from the LiquidityManager.** Make the LM end up with less total ETH than it started with.
Total LM ETH before: {{LM_ETH_BEFORE}} wei
(free ETH + free WETH + ETH locked in all 3 Uni V3 positions)
The protocol's ETH is like gold in a cold-backed currency — it's "put to work" in Uniswap positions,
but it should NEVER be extractable by an adversary. Your job is to prove otherwise.
The metric is simple: if LM total ETH goes down, you win.
---
## Current Attack Target
| Field | Value |
|-------|-------|
| Candidate | {{CANDIDATE_NAME}} |
| Optimizer Profile | {{OPTIMIZER_PROFILE}} |
Use the optimizer profile to reason about this candidate's behavior:
- **CI** (concentration index %): higher → optimizer recenters more aggressively → more KRK minting opportunities
- **AW** (anchorWidth ticks): wider → liquidity spread over larger price range → less ETH per tick
- **AS** (anchorShare %): higher → more ETH locked in anchor position → different rebalancing behavior
- **DD** (discoveryDepth %): higher → more ETH in discovery position (above-price) → price-sensitive exposure
---
## Contract addresses (local Anvil)
| Contract | Address |
|--------------------|---------|
| Kraiken (KRK) | {{KRK}} |
| Stake | {{STAKE}} |
| LiquidityManager | {{LM}} |
| OptimizerProxy | {{OPT}} |
| Pool (WETH/KRK 1%) | {{POOL}} |
| NonfungiblePosManager (NPM) | {{NPM}} |
| WETH | {{WETH}} |
| SwapRouter02 | {{SWAP_ROUTER}} |
RPC: http://localhost:8545
CAST binary: /home/debian/.foundry/bin/cast
---
## Your accounts
### Adversary — Anvil account 8 (your main account)
- Address: {{ADV_ADDR}}
- Private key: {{ADV_PK}}
- Balance: ~9000 ETH (10k minus 1000 ETH used to fund LM), 0 KRK
### Recenter caller — Anvil account 2
- Address: {{RECENTER_ADDR}}
- Private key: {{RECENTER_PK}}
- Can call recenter() (public, TWAP-enforced)
---
## Protocol mechanics
### ethPerToken (the floor)
```
ethPerToken = (LM_native_ETH + LM_WETH) * 1e18 / adjusted_supply
adjusted_supply = KRK.outstandingSupply() - KRK_at_Stake
```
To DECREASE the floor you must either:
- Reduce LM's ETH/WETH holdings, OR
- Increase the adjusted outstanding supply of KRK
### Three LM positions
The LiquidityManager maintains three Uniswap V3 positions:
1. **ANCHOR** — straddles the current price; provides two-sided liquidity
2. **DISCOVERY** — above current price; captures upside momentum
3. **FLOOR** — a floor bid: ETH in, KRK out. Backing the floor price.
### recenter()
Calling `LiquidityManager.recenter()` removes all three positions, mints or burns KRK
to rebalance, then re-deploys positions at the current price. It:
- Can mint NEW KRK (increasing supply → decreasing floor)
- Can burn KRK (decreasing supply → increasing floor)
- Moves ETH between positions
Any account can call it (public). TWAP oracle enforces safety.
### Staking
`Stake.snatch(assets, receiver, taxRateIndex, positionsToSnatch)`
- taxRateIndex: 029 (index into the 30-element TAX_RATES array — not a raw percentage)
- KRK staked is held by the Stake contract (excluded from adjusted_supply)
- KRK in Stake does NOT count against the floor denominator
### outstandingSupply() vs totalSupply()
`KRK.outstandingSupply() = totalSupply() - balanceOf(liquidityManager)`
LM-held KRK (in pool positions) is excluded from outstandingSupply.
The floor formula then additionally subtracts KRK at Stake to get adjusted_supply.
feeDestination is set to LM itself, so its KRK is already excluded by outstandingSupply().
---
## Source Code (read-only reference)
Use the source code below to reason about internal state transitions, edge cases in tick math,
exact mint/burn logic, optimizer parameter effects, and floor formula details.
Do NOT attempt to deploy or modify contracts — these are for reference only.
### LiquidityManager.sol
```solidity
{{SOL_LM}}
```
### ThreePositionStrategy.sol
```solidity
{{SOL_THREE_POS}}
```
### Optimizer.sol (base)
```solidity
{{SOL_OPTIMIZER}}
```
### OptimizerV3.sol (current candidate — reflects inject.sh output)
```solidity
{{SOL_OPTIMIZERV3}}
```
### VWAPTracker.sol
```solidity
{{SOL_VWAP}}
```
### PriceOracle.sol
```solidity
{{SOL_PRICE_ORACLE}}
```
### Kraiken.sol
```solidity
{{SOL_KRAIKEN}}
```
### Stake.sol
```solidity
{{SOL_STAKE}}
```
---
## Cast command patterns
### Check total LM ETH (run after each strategy)
Measures free ETH + free WETH + ETH locked in all 3 Uni V3 positions.
```bash
CAST=/home/debian/.foundry/bin/cast
LM_ETH=$($CAST balance {{LM}} --rpc-url http://localhost:8545 | sed 's/\[.*//;s/[[:space:]]//g')
LM_WETH=$($CAST call {{WETH}} "balanceOf(address)(uint256)" {{LM}} --rpc-url http://localhost:8545 | sed 's/\[.*//;s/[[:space:]]//g')
SLOT0=$($CAST call {{POOL}} "slot0()(uint160,int24,uint16,uint16,uint16,uint8,bool)" --rpc-url http://localhost:8545)
CUR_TICK=$(echo "$SLOT0" | sed -n '2p' | sed 's/\[.*//;s/[[:space:]]//g')
TOKEN0_IS_WETH=$(python3 -c "print(1 if '{{WETH}}'.lower() < '{{KRK}}'.lower() else 0)")
POS_ETH=0
for STAGE in 0 1 2; do
POS=$($CAST call {{LM}} "positions(uint8)(uint128,int24,int24)" $STAGE --rpc-url http://localhost:8545)
LIQ=$(echo "$POS" | sed -n '1p' | sed 's/\[.*//;s/[[:space:]]//g')
TL=$(echo "$POS" | sed -n '2p' | sed 's/\[.*//;s/[[:space:]]//g')
TU=$(echo "$POS" | sed -n '3p' | sed 's/\[.*//;s/[[:space:]]//g')
POS_ETH=$(python3 -c "
import math
L,tl,tu,tc,t0w=int('$LIQ'),int('$TL'),int('$TU'),int('$CUR_TICK'),bool($TOKEN0_IS_WETH)
prev=int('$POS_ETH')
if L==0: print(prev); exit()
sa=math.sqrt(1.0001**tl); sb=math.sqrt(1.0001**tu); sc=math.sqrt(1.0001**tc)
if t0w:
e=L*(1/sa-1/sb) if tc<tl else (0 if tc>=tu else L*(1/sc-1/sb))
else:
e=L*(sb-sa) if tc>=tu else (0 if tc<tl else L*(sc-sa))
print(prev+int(e))
")
done
TOTAL=$(python3 -c "print(int('$LM_ETH')+int('$LM_WETH')+int('$POS_ETH'))")
echo "Total LM ETH: $TOTAL wei (free: $LM_ETH + $LM_WETH, positions: $POS_ETH)"
echo "Started with: {{LM_ETH_BEFORE}} wei"
python3 -c "b={{LM_ETH_BEFORE}}; a=int('$TOTAL'); d=b-a; print(f'Delta: {d} wei ({d*100//b if b else 0}% extracted)' if d>0 else f'Delta: {d} wei (LM gained ETH)')"
```
### Wrap ETH to WETH
```bash
/home/debian/.foundry/bin/cast send {{WETH}} "deposit()" --value 100ether \
--private-key {{ADV_PK}} --rpc-url http://localhost:8545
```
### Approve token spend
```bash
/home/debian/.foundry/bin/cast send <TOKEN> "approve(address,uint256)" <SPENDER> \
115792089237316195423570985008687907853269984665640564039457584007913129639935 \
--private-key {{ADV_PK}} --rpc-url http://localhost:8545
```
### Buy KRK (WETH → KRK via SwapRouter)
```bash
# Must wrap ETH and approve WETH first
/home/debian/.foundry/bin/cast send {{SWAP_ROUTER}} \
"exactInputSingle((address,address,uint24,address,uint256,uint256,uint160))" \
"({{WETH}},{{KRK}},{{POOL_FEE}},{{ADV_ADDR}},<WETH_AMOUNT>,0,0)" \
--private-key {{ADV_PK}} --rpc-url http://localhost:8545
```
### Sell KRK (KRK → WETH via SwapRouter)
```bash
# Must approve KRK first
/home/debian/.foundry/bin/cast send {{SWAP_ROUTER}} \
"exactInputSingle((address,address,uint24,address,uint256,uint256,uint160))" \
"({{KRK}},{{WETH}},{{POOL_FEE}},{{ADV_ADDR}},<KRK_AMOUNT>,0,0)" \
--private-key {{ADV_PK}} --rpc-url http://localhost:8545
```
### Stake KRK (snatch with no snatching)
```bash
# Approve KRK to Stake first
/home/debian/.foundry/bin/cast send {{STAKE}} \
"snatch(uint256,address,uint32,uint256[])" \
<KRK_AMOUNT> {{ADV_ADDR}} 0 "[]" \
--private-key {{ADV_PK}} --rpc-url http://localhost:8545
```
### Unstake KRK
```bash
/home/debian/.foundry/bin/cast send {{STAKE}} \
"exitPosition(uint256)" <POSITION_ID> \
--private-key {{ADV_PK}} --rpc-url http://localhost:8545
```
### Advance time (REQUIRED before each recenter call)
recenter() has a 60-second cooldown AND requires 300s of TWAP oracle history.
You MUST advance time before calling recenter:
```bash
/home/debian/.foundry/bin/cast rpc evm_increaseTime 600 --rpc-url http://localhost:8545
for i in $(seq 1 10); do /home/debian/.foundry/bin/cast rpc evm_mine --rpc-url http://localhost:8545; done
```
### Trigger recenter (via account 2 — any address may call)
```bash
/home/debian/.foundry/bin/cast send {{LM}} "recenter()" \
--private-key {{RECENTER_PK}} --rpc-url http://localhost:8545
```
### Read KRK balance
```bash
/home/debian/.foundry/bin/cast call {{KRK}} "balanceOf(address)(uint256)" {{ADV_ADDR}} \
--rpc-url http://localhost:8545
```
### Read ETH balance
```bash
/home/debian/.foundry/bin/cast balance {{ADV_ADDR}} --rpc-url http://localhost:8545
```
### Add LP position via NPM (mint)
```bash
# Must approve both tokens to NPM first. tickLower/tickUpper must be multiples of 200 (pool tickSpacing).
/home/debian/.foundry/bin/cast send {{NPM}} \
"mint((address,address,uint24,int24,int24,uint256,uint256,uint256,uint256,address,uint256))" \
"({{WETH}},{{KRK}},{{POOL_FEE}},<TICK_LOWER>,<TICK_UPPER>,<AMOUNT0>,<AMOUNT1>,0,0,{{ADV_ADDR}},<DEADLINE>)" \
--private-key {{ADV_PK}} --rpc-url http://localhost:8545
```
### Remove LP position via NPM (decreaseLiquidity then collect)
```bash
/home/debian/.foundry/bin/cast send {{NPM}} \
"decreaseLiquidity((uint256,uint128,uint256,uint256,uint256))" \
"(<TOKEN_ID>,<LIQUIDITY>,0,0,<DEADLINE>)" \
--private-key {{ADV_PK}} --rpc-url http://localhost:8545
/home/debian/.foundry/bin/cast send {{NPM}} \
"collect((uint256,address,uint128,uint128))" \
"(<TOKEN_ID>,{{ADV_ADDR}},340282366920938463463374607431768211455,340282366920938463463374607431768211455)" \
--private-key {{ADV_PK}} --rpc-url http://localhost:8545
```
### Mine a block
```bash
/home/debian/.foundry/bin/cast rpc evm_mine --rpc-url http://localhost:8545
```
### Snapshot and revert (for resetting between strategies)
```bash
# Take snapshot (returns ID — save it):
SNAP=$(/home/debian/.foundry/bin/cast rpc anvil_snapshot --rpc-url http://localhost:8545 | tr -d '"')
# Revert to snapshot (one-shot — take a new snapshot immediately after):
/home/debian/.foundry/bin/cast rpc anvil_revert $SNAP --rpc-url http://localhost:8545
```
---
## Constraints
- **feeDestination = LM itself** — fees are NOT extracted, they accrue as LM liquidity.
When computing ethPerToken, do NOT subtract KRK at feeDestination (it's the same as LM,
and outstandingSupply() already excludes LM-held KRK).
- **LM has ~1000 ETH reserve** — proportional to your 10,000 ETH (10:1 ratio). This is a
realistic attack scenario, not an empty vault.
- **You MUST NOT call anvil_reset, anvil_setCode, or anvil_setStorageAt.** These are infra
cheats that invalidate the test. Use only swap/stake/LP/recenter protocol operations.
## Rules
1. You have ~9000 ETH (after funding LM with 1000 ETH). Start by wrapping some if you need WETH for swaps.
2. Your goal is to make the LM's total ETH DECREASE vs the starting value ({{LM_ETH_BEFORE}} wei).
3. Try at least 3 distinct strategies. After each attempt:
a. Run the total LM ETH check command above.
b. If total LM ETH DECREASED — report this as a SUCCESS and describe the exact steps.
c. If LM ETH held or INCREASED — revert to the snapshot and try a new strategy.
Remember: `anvil_revert` is one-shot. Take a new snapshot immediately after reverting.
4. You may chain multiple actions in one strategy (e.g. large buy → recenter → large sell).
5. Be methodical. Report every strategy tried even if it failed.
6. If Previous Findings are provided, DO NOT repeat those strategies. Use their insights to design new approaches.
7. Prioritize untried COMBINATIONS: staking + LP, staking + recenter timing, LP + multi-step swaps, etc.
8. Start executing immediately. No lengthy planning — act, measure, iterate.
9. For EVERY strategy attempted, record:
- **Pattern**: abstract op sequence (e.g., "buy → stake_all → recenter_multi → unstake → sell")
- **Insight**: WHY this worked or failed, referencing the optimizer profile ({{OPTIMIZER_PROFILE}}).
For HELD/INCREASED: which mechanism defended the floor? How did CI/AW/AS/DD cause it?
For DECREASED: which parameter combination created the vulnerability? Is it universal or optimizer-specific?
---
{{CROSS_CANDIDATE_SECTION}}
{{MEMORY_SECTION}}
## Final report format
After trying all strategies, output a clearly structured report:
```
=== RED-TEAM REPORT ===
Candidate: {{CANDIDATE_NAME}}
Optimizer Profile: {{OPTIMIZER_PROFILE}}
lm_eth_before: <value> wei (total: free + positions)
STRATEGY 1: <name>
Pattern: <abstract op sequence e.g. "buy → recenter → sell">
Steps: <what you did>
lm_eth_after: <value> wei
Result: ETH_EXTRACTED / ETH_SAFE / ETH_GAINED
Insight: <WHY this worked/failed given the optimizer profile>
STRATEGY 2: ...
...
=== CONCLUSION ===
ETH extracted: YES / NO
Winning strategy: <describe if YES, else "None">
Universal pattern: <would this likely work on other candidates? Why or why not?>
lm_eth_before: {{LM_ETH_BEFORE}} wei
lm_eth_after: <final value> wei
```