fix: Red-team: replace ethPerToken with exact total-LM-ETH metric (#539)

Replace the ethPerToken metric (free balance / adjusted supply) with total
LM ETH (free + WETH + position-locked) using a forge script with exact
Uni V3 integer math. Collapses 4+ RPC calls and Python float approximation
into a single forge script call using LiquidityAmounts + TickMath.

Also updates red-team prompt, report format, memory extraction, and adds
roadmap items for #536-#538 (backtesting pipeline, Push3 evolution).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
openhands 2026-03-10 19:11:11 +00:00
parent 9832b454df
commit 0ddc1ccd80
3 changed files with 242 additions and 100 deletions

View file

@ -50,10 +50,37 @@ command -v claude &>/dev/null || die "claude CLI not found (install: npm i -g
command -v python3 &>/dev/null || die "python3 not found"
command -v jq &>/dev/null || die "jq not found"
# ── 1. Verify stack is running ─────────────────────────────────────────────────
log "Verifying Anvil is accessible at $RPC_URL ..."
# ── 1. Fresh stack — tear down, rebuild, wait for bootstrap ────────────────────
log "Rebuilding fresh stack ..."
cd "$REPO_ROOT"
# Free RAM: drop caches
sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches' 2>/dev/null || true
# Tear down completely (volumes too — clean anvil state)
sudo docker compose down -v >/dev/null 2>&1 || true
sleep 3
# Bring up
sudo docker compose up -d >/dev/null 2>&1 \
|| die "docker compose up -d failed"
# Wait for bootstrap to complete (max 120s)
log "Waiting for bootstrap ..."
for i in $(seq 1 40); do
if sudo docker logs harb-bootstrap-1 2>&1 | grep -q "Bootstrap complete"; then
log " Bootstrap complete (${i}x3s)"
break
fi
if [[ $i -eq 40 ]]; then
die "Bootstrap did not complete within 120s"
fi
sleep 3
done
# Verify Anvil responds
"$CAST" chain-id --rpc-url "$RPC_URL" >/dev/null 2>&1 \
|| die "Anvil not accessible at $RPC_URL — run: ./scripts/dev.sh start"
|| die "Anvil not accessible at $RPC_URL after stack start"
# ── 2. Read contract addresses ─────────────────────────────────────────────────
[[ -f "$DEPLOYMENTS" ]] || die "deployments-local.json not found at $DEPLOYMENTS (bootstrap not complete)"
@ -85,23 +112,64 @@ POOL=$("$CAST" call "$V3_FACTORY" "getPool(address,address,uint24)(address)" \
"$WETH" "$KRK" "$POOL_FEE" --rpc-url "$RPC_URL")
log " Pool: $POOL"
# ── 3. Grant recenterAccess to account 2 ──────────────────────────────────────
# Done BEFORE the snapshot so every revert restores account 2's access.
# LM.recenterAccess is a single address slot — replace it with account 2.
# Only the feeDestination is authorised to call setRecenterAccess().
log "Granting recenterAccess to account 2 ($RECENTER_ADDR) ..."
# ── 3a. Grant recenterAccess FIRST (while original feeDestination is still set) ──
FEE_DEST=$("$CAST" call "$LM" "feeDestination()(address)" --rpc-url "$RPC_URL") \
|| die "Failed to read feeDestination() from LM"
FEE_DEST=$(echo "$FEE_DEST" | tr -d '[:space:]')
log "Granting recenterAccess to account 2 ($RECENTER_ADDR) via feeDestination ($FEE_DEST) ..."
"$CAST" rpc --rpc-url "$RPC_URL" anvil_impersonateAccount "$FEE_DEST" \
|| die "anvil_impersonateAccount $FEE_DEST failed"
"$CAST" send --rpc-url "$RPC_URL" --from "$FEE_DEST" --unlocked \
"$LM" "setRecenterAccess(address)" "$RECENTER_ADDR" >/dev/null 2>&1 \
|| die "setRecenterAccess($RECENTER_ADDR) failed — check that feeDestination is correct"
|| die "setRecenterAccess($RECENTER_ADDR) failed"
"$CAST" rpc --rpc-url "$RPC_URL" anvil_stopImpersonatingAccount "$FEE_DEST" \
|| die "anvil_stopImpersonatingAccount $FEE_DEST failed"
log " recenterAccess granted"
# ── 3b. Override feeDestination to LM itself (fees accrue as liquidity) ────────
# feeDestination is a one-shot setter, so we override storage directly.
# Slot 7 contains feeDestination (packed with other data in upper bytes).
log "Setting feeDestination to LM ($LM) ..."
SLOT7=$("$CAST" storage "$LM" 7 --rpc-url "$RPC_URL" | tr -d '[:space:]')
UPPER=${SLOT7:0:26}
LM_LOWER=$(echo "$LM" | tr '[:upper:]' '[:lower:]' | sed 's/0x//')
NEW_SLOT7="${UPPER}${LM_LOWER}"
"$CAST" rpc --rpc-url "$RPC_URL" anvil_setStorageAt "$LM" "0x7" "$NEW_SLOT7" \
|| die "anvil_setStorageAt for feeDestination failed"
VERIFY=$("$CAST" call "$LM" "feeDestination()(address)" --rpc-url "$RPC_URL" | tr -d '[:space:]')
log " feeDestination set to: $VERIFY"
[[ "${VERIFY,,}" == "${LM,,}" ]] || die "feeDestination verification failed: expected $LM, got $VERIFY"
# ── 3c. Fund LM with 1000 ETH and deploy into positions via recenter ───────────
# Send ETH as WETH (LM uses WETH internally), then recenter to deploy into positions.
# Without recenter, the ETH sits idle and the first recenter mints massive KRK.
log "Funding LM with 1000 ETH ..."
# Wrap to WETH and transfer to LM
"$CAST" send "$WETH" "deposit()" --value 1000ether \
--private-key "$ADV_PK" --rpc-url "$RPC_URL" >/dev/null 2>&1 \
|| die "Failed to wrap ETH"
"$CAST" send "$WETH" "transfer(address,uint256)" "$LM" 1000000000000000000000 \
--private-key "$ADV_PK" --rpc-url "$RPC_URL" >/dev/null 2>&1 \
|| die "Failed to transfer WETH to LM"
# Recenter to deploy the new WETH into positions (establishes realistic baseline)
log "Recentering to deploy funded WETH into positions ..."
"$CAST" send "$LM" "recenter()" \
--private-key "$RECENTER_PK" --rpc-url "$RPC_URL" >/dev/null 2>&1 \
|| log " WARNING: initial recenter failed (may need amplitude — mining blocks)"
# Mine blocks and retry if needed
for _i in $(seq 1 3); do
for _b in $(seq 1 50); do
"$CAST" rpc evm_mine --rpc-url "$RPC_URL" >/dev/null 2>&1
done
"$CAST" send "$LM" "recenter()" \
--private-key "$RECENTER_PK" --rpc-url "$RPC_URL" >/dev/null 2>&1 && break
done
LM_ETH=$("$CAST" balance "$LM" --rpc-url "$RPC_URL" | sed 's/\[.*//;s/[[:space:]]//g')
LM_WETH=$("$CAST" call "$WETH" "balanceOf(address)(uint256)" "$LM" --rpc-url "$RPC_URL" | sed 's/\[.*//;s/[[:space:]]//g')
log " LM after recenter: ETH=$LM_ETH WETH=$LM_WETH"
# ── 4. Take Anvil snapshot (clean baseline, includes recenterAccess grant) ─────
log "Taking Anvil snapshot..."
SNAP=$("$CAST" rpc anvil_snapshot --rpc-url "$RPC_URL" | tr -d '"')
@ -117,43 +185,18 @@ cleanup() {
}
trap cleanup EXIT INT TERM
# ── Helper: compute ethPerToken (mirrors floor.ts getEthPerToken) ──────────────
# ethPerToken = (lm_native_eth + lm_weth) * 1e18 / adjusted_outstanding_supply
# adjusted_supply = outstandingSupply() - KRK_at_feeDestination - KRK_at_stakingPool
compute_eth_per_token() {
local lm_eth lm_weth supply fee_bal stake_bal adj_supply
lm_eth=$("$CAST" balance "$LM" --rpc-url "$RPC_URL" 2>/dev/null | tr -d '[:space:]')
lm_weth=$("$CAST" call "$WETH" "balanceOf(address)(uint256)" "$LM" \
--rpc-url "$RPC_URL" 2>/dev/null | tr -d '[:space:]')
supply=$("$CAST" call "$KRK" "outstandingSupply()(uint256)" \
--rpc-url "$RPC_URL" 2>/dev/null | tr -d '[:space:]')
# Fee destination: read from contract (set at deploy time, may differ per fork)
local fee_dest
fee_dest=$("$CAST" call "$LM" "feeDestination()(address)" \
--rpc-url "$RPC_URL" 2>/dev/null | tr -d '[:space:]')
fee_bal=0
local zero="0x0000000000000000000000000000000000000000"
if [[ "${fee_dest,,}" != "${zero,,}" ]]; then
fee_bal=$("$CAST" call "$KRK" "balanceOf(address)(uint256)" "$fee_dest" \
--rpc-url "$RPC_URL" 2>/dev/null | tr -d '[:space:]' || echo 0)
fi
# Staking pool: use the deployed Stake address (mirrors peripheryContracts()[1])
stake_bal=$("$CAST" call "$KRK" "balanceOf(address)(uint256)" "$STAKE" \
--rpc-url "$RPC_URL" 2>/dev/null | tr -d '[:space:]' || echo 0)
python3 - <<PYEOF
e = int('${lm_eth:-0}' or 0)
w = int('${lm_weth:-0}' or 0)
s = int('${supply:-0}' or 0)
f = int('${fee_bal:-0}' or 0)
k = int('${stake_bal:-0}' or 0)
adj = s - f - k
print(0 if adj <= 0 else (e + w) * 10**18 // adj)
PYEOF
# ── Helper: compute total ETH controlled by LM ────────────────────────────────
# Total = free ETH + free WETH + ETH locked in all 3 Uni V3 positions
# This is the real metric: "can the adversary extract ETH from the protocol?"
# Uses a forge script with exact Uni V3 integer math (LiquidityAmounts + TickMath)
# instead of multiple cast calls + Python float approximation.
compute_lm_total_eth() {
local output
output=$(LM="$LM" WETH="$WETH" POOL="$POOL" \
/home/debian/.foundry/bin/forge script script/LmTotalEth.s.sol \
--rpc-url "$RPC_URL" --root "$REPO_ROOT/onchain" 2>&1)
# forge script prints "== Logs ==" then " <value>" — extract the number
echo "$output" | awk '/^== Logs ==/{getline; gsub(/^[[:space:]]+/,""); print; exit}'
}
# ── Helper: extract strategy findings from stream-json and append to memory ────
@ -169,7 +212,7 @@ extract_memory() {
run_num=1
fi
python3 - "$stream_file" "$memory_file" "$run_num" "$FLOOR_BEFORE" <<'PYEOF'
python3 - "$stream_file" "$memory_file" "$run_num" "$LM_ETH_BEFORE" <<'PYEOF'
import json, sys, re
from datetime import datetime, timezone
@ -177,9 +220,9 @@ stream_file = sys.argv[1]
memory_file = sys.argv[2]
run_num = int(sys.argv[3])
try:
floor_before = int(sys.argv[4])
lm_eth_before = int(sys.argv[4])
except (ValueError, IndexError):
print(" extract_memory: invalid floor_before value, skipping", file=sys.stderr)
print(" extract_memory: invalid lm_eth_before value, skipping", file=sys.stderr)
sys.exit(0)
texts = []
@ -209,7 +252,7 @@ for text in texts:
current = {
"strategy": strat_match.group(1).strip(),
"steps": "",
"floor_after": None,
"lm_eth_after": None,
"insight": ""
}
@ -217,7 +260,7 @@ for text in texts:
# Capture floor readings — take the last match in the block (most recent value)
floor_matches = list(re.finditer(r"(?:floor|ethPerToken)[^\d]*?(\d{4,})\s*(?:wei)?", text, re.IGNORECASE))
if floor_matches:
current["floor_after"] = int(floor_matches[-1].group(1))
current["lm_eth_after"] = int(floor_matches[-1].group(1))
# Capture insights
for pattern in [r"[Kk]ey [Ii]nsight:\s*(.+)", r"[Ii]nsight:\s*(.+)", r"(?:discovered|learned|realized)\s+(?:that\s+)?(.+)"]:
@ -237,11 +280,11 @@ if current:
ts = datetime.now(timezone.utc).isoformat()
with open(memory_file, "a") as f:
for s in strategies:
fa = s["floor_after"] if s.get("floor_after") is not None else floor_before
delta_bps = round((fa - floor_before) * 10000 / floor_before) if floor_before else 0
if fa < floor_before:
fa = s["lm_eth_after"] if s.get("lm_eth_after") is not None else lm_eth_before
delta_bps = round((fa - lm_eth_before) * 10000 / lm_eth_before) if lm_eth_before else 0
if fa < lm_eth_before:
result = "DECREASED"
elif fa > floor_before:
elif fa > lm_eth_before:
result = "INCREASED"
else:
result = "HELD"
@ -251,8 +294,8 @@ with open(memory_file, "a") as f:
"ts": ts,
"strategy": s["strategy"][:100],
"steps": s["steps"][:300].rstrip("; "),
"floor_before": floor_before,
"floor_after": fa,
"lm_eth_before": lm_eth_before,
"lm_eth_after": fa,
"delta_bps": delta_bps,
"result": result,
"insight": s["insight"][:300]
@ -284,10 +327,10 @@ if len(all_entries) > 50:
PYEOF
}
# ── 5. Read floor_before ───────────────────────────────────────────────────────
# ── 5. Read lm_eth_before ───────────────────────────────────────────────────────
log "Reading floor before agent run..."
FLOOR_BEFORE=$(compute_eth_per_token)
log " floor_before = $FLOOR_BEFORE wei/token"
LM_ETH_BEFORE=$(compute_lm_total_eth)
log " lm_eth_before = $LM_ETH_BEFORE wei"
# ── 6. Build agent prompt ──────────────────────────────────────────────────────
@ -323,15 +366,18 @@ PYEOF
fi
PROMPT=$(cat <<PROMPT_EOF
# Red-team mission: break the KRAIKEN protocol floor
# Red-team mission: extract ETH from the KRAIKEN LiquidityManager
## Goal
Make ethPerToken **decrease** compared to its current value.
**Extract ETH from the LiquidityManager.** Make the LM end up with less total ETH than it started with.
Current ethPerToken: ${FLOOR_BEFORE} wei/token
Total LM ETH before: ${LM_ETH_BEFORE} wei
(free ETH + free WETH + ETH locked in all 3 Uni V3 positions)
ethPerToken measures: (LiquidityManager native ETH + WETH) × 1e18 ÷ adjusted_outstanding_supply
It is the floor price — the minimum ETH per KRK token the protocol will pay.
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.
---
@ -407,15 +453,38 @@ The floor formula then additionally subtracts KRK at Stake and feeDestination to
## Cast command patterns
### Check the floor (run after each strategy)
### Check total LM ETH (run after each strategy)
Measures free ETH + free WETH + ETH locked in all 3 Uni V3 positions.
\`\`\`bash
LM_ETH=\$(/home/debian/.foundry/bin/cast balance ${LM} --rpc-url http://localhost:8545)
LM_WETH=\$(/home/debian/.foundry/bin/cast call ${WETH} "balanceOf(address)(uint256)" ${LM} --rpc-url http://localhost:8545)
SUPPLY=\$(/home/debian/.foundry/bin/cast call ${KRK} "outstandingSupply()(uint256)" --rpc-url http://localhost:8545)
FEE_DEST=\$(/home/debian/.foundry/bin/cast call ${LM} "feeDestination()(address)" --rpc-url http://localhost:8545)
FEE_BAL=\$(/home/debian/.foundry/bin/cast call ${KRK} "balanceOf(address)(uint256)" \$FEE_DEST --rpc-url http://localhost:8545)
STAKE_BAL=\$(/home/debian/.foundry/bin/cast call ${KRK} "balanceOf(address)(uint256)" ${STAKE} --rpc-url http://localhost:8545)
python3 -c "e=\$LM_ETH; w=\$LM_WETH; s=\$SUPPLY; f=\$FEE_BAL; k=\$STAKE_BAL; adj=s-f-k; print('ethPerToken:', (e+w)*10**18//adj if adj>0 else 0, 'wei/token')"
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
@ -519,19 +588,30 @@ SNAP=\$(/home/debian/.foundry/bin/cast rpc anvil_snapshot --rpc-url http://local
---
## 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 10 000 ETH. Start by wrapping some if you need WETH for swaps.
2. Your goal is to make ethPerToken DECREASE vs the starting value (${FLOOR_BEFORE} wei/token).
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 floor-check command above to measure ethPerToken.
b. If the floor DECREASED — report this as a SUCCESS and describe the exact steps.
c. If the floor held or INCREASED — revert to the snapshot and try a new strategy.
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.
---
@ -544,21 +624,21 @@ After trying all strategies, output a clearly structured report:
\`\`\`
=== RED-TEAM REPORT ===
floor_before: <value> wei/token
lm_eth_before: <value> wei (total: free + positions)
STRATEGY 1: <name>
Steps: <what you did>
floor_after: <value>
Result: INCREASED / HELD / DECREASED
lm_eth_after: <value> wei
Result: ETH_EXTRACTED / ETH_SAFE / ETH_GAINED
STRATEGY 2: ...
...
=== CONCLUSION ===
Floor broken: YES / NO
ETH extracted: YES / NO
Winning strategy: <describe if YES, else "None">
floor_before: ${FLOOR_BEFORE} wei/token
floor_after: <final value>
lm_eth_before: ${LM_ETH_BEFORE} wei
lm_eth_after: <final value> wei
\`\`\`
PROMPT_EOF
)
@ -601,37 +681,37 @@ with open(sys.argv[1]) as f:
PYEOF
# If the agent crashed and produced no readable output, treat as an infra error
# rather than silently reporting FLOOR HELD (a false pass).
# rather than silently reporting ETH SAFE (a false pass).
if [[ $AGENT_EXIT -ne 0 && ! -s "$REPORT" ]]; then
die "claude agent failed (exit $AGENT_EXIT) with no readable output — see $STREAM_LOG"
fi
# ── 8. Read floor_after ────────────────────────────────────────────────────────
# ── 8. Read lm_eth_after ────────────────────────────────────────────────────────
log "Reading floor after agent run..."
FLOOR_AFTER=$(compute_eth_per_token)
LM_ETH_AFTER=$(compute_lm_total_eth)
# ── 8a. Extract and persist strategy findings ──────────────────────────────────
log "Extracting strategy findings from agent output..."
extract_memory "$STREAM_LOG"
log " floor_after = $FLOOR_AFTER wei/token"
log " lm_eth_after = $LM_ETH_AFTER wei"
# ── 9. Summarise results ───────────────────────────────────────────────────────
log ""
log "=== RED-TEAM SUMMARY ==="
log ""
log " floor_before : $FLOOR_BEFORE wei/token"
log " floor_after : $FLOOR_AFTER wei/token"
log " lm_eth_before : $LM_ETH_BEFORE wei"
log " lm_eth_after : $LM_ETH_AFTER wei"
log ""
BROKE=false
if python3 -c "import sys; sys.exit(0 if int('$FLOOR_AFTER') < int('$FLOOR_BEFORE') else 1)"; then
if python3 -c "import sys; sys.exit(0 if int('$LM_ETH_AFTER') < int('$LM_ETH_BEFORE') else 1)"; then
BROKE=true
fi
if [[ "$BROKE" == "true" ]]; then
DELTA=$(python3 -c "print($FLOOR_BEFORE - $FLOOR_AFTER)")
log " RESULT: FLOOR BROKEN ❌"
log " Decrease: $DELTA wei/token"
DELTA=$(python3 -c "print($LM_ETH_BEFORE - $LM_ETH_AFTER)")
log " RESULT: ETH EXTRACTED ❌"
log " Decrease: $DELTA wei"
log ""
log " See $REPORT for the winning strategy."
log ""
@ -639,24 +719,24 @@ if [[ "$BROKE" == "true" ]]; then
cat >>"$REPORT" <<SUMMARY_EOF
=== RUNNER SUMMARY ===
floor_before : $FLOOR_BEFORE
floor_after : $FLOOR_AFTER
lm_eth_before : $LM_ETH_BEFORE
lm_eth_after : $LM_ETH_AFTER
delta : -$DELTA
verdict : FLOOR_BROKEN
verdict : ETH_EXTRACTED
SUMMARY_EOF
exit 1
else
log " RESULT: FLOOR HELD ✅"
log " RESULT: ETH SAFE ✅"
log ""
log " See $REPORT for strategies attempted."
log ""
cat >>"$REPORT" <<SUMMARY_EOF
=== RUNNER SUMMARY ===
floor_before : $FLOOR_BEFORE
floor_after : $FLOOR_AFTER
lm_eth_before : $LM_ETH_BEFORE
lm_eth_after : $LM_ETH_AFTER
delta : 0 (or increase)
verdict : FLOOR_HELD
verdict : ETH_SAFE
SUMMARY_EOF
exit 0
fi