diff --git a/docs/PRODUCT-TRUTH.md b/docs/PRODUCT-TRUTH.md index 8476991..cdffa8e 100644 --- a/docs/PRODUCT-TRUTH.md +++ b/docs/PRODUCT-TRUTH.md @@ -52,6 +52,9 @@ when the protocol changes — not the marketing copy. - Staker governance for optimizer upgrades (vote with stake weight) - On-chain training data → new optimizer contracts via Push3 transpiler - Remove admin key in favor of staker voting +- Adversarial backtesting: replay red-team attack sequences against optimizer candidates (#536) +- Push3 optimizer evolution: mutate, score against attacks, select survivors (#537) +- Unified Push3 → deploy pipeline: transpile, compile, UUPS upgrade in one command (#538) ## Fee Destination diff --git a/onchain/script/LmTotalEth.s.sol b/onchain/script/LmTotalEth.s.sol new file mode 100644 index 0000000..1d9ed31 --- /dev/null +++ b/onchain/script/LmTotalEth.s.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.19; + +import "@uniswap-v3-core/interfaces/IUniswapV3Factory.sol"; +import "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol"; +import "forge-std/Script.sol"; + +import "uni-v3-lib/LiquidityAmounts.sol"; +import "uni-v3-lib/TickMath.sol"; + +interface ILM { + function positions(uint8 stage) external view returns (uint128 liquidity, int24 tickLower, int24 tickUpper); +} + +interface IWETH { + function balanceOf(address) external view returns (uint256); +} + +/// @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 +/// Env: LM, WETH, POOL +contract LmTotalEth is Script { + function run() external view { + address lm = vm.envAddress("LM"); + address weth = vm.envAddress("WETH"); + address pool = vm.envAddress("POOL"); + + // Free balances + uint256 freeEth = lm.balance; + uint256 freeWeth = IWETH(weth).balanceOf(lm); + + // Current sqrtPrice from pool + (uint160 sqrtPriceX96,,,,,,) = IUniswapV3Pool(pool).slot0(); + + // Determine which token is WETH (token0 or token1) + bool wethIsToken0 = IUniswapV3Pool(pool).token0() == weth; + + // Sum ETH in all 3 positions: FLOOR=0, ANCHOR=1, DISCOVERY=2 + uint256 positionEth = 0; + for (uint8 stage = 0; stage < 3; stage++) { + (uint128 liquidity, int24 tickLower, int24 tickUpper) = ILM(lm).positions(stage); + if (liquidity == 0) continue; + + uint160 sqrtA = TickMath.getSqrtRatioAtTick(tickLower); + uint160 sqrtB = TickMath.getSqrtRatioAtTick(tickUpper); + + (uint256 amount0, uint256 amount1) = LiquidityAmounts.getAmountsForLiquidity(sqrtPriceX96, sqrtA, sqrtB, liquidity); + + positionEth += wethIsToken0 ? amount0 : amount1; + } + + uint256 total = freeEth + freeWeth + positionEth; + + // Output as plain number for easy bash consumption + console2.log(total); + } +} diff --git a/scripts/harb-evaluator/red-team.sh b/scripts/harb-evaluator/red-team.sh index bcc4b04..da5cb2a 100755 --- a/scripts/harb-evaluator/red-team.sh +++ b/scripts/harb-evaluator/red-team.sh @@ -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 - <&1) + # forge script prints "== Logs ==" then " " — 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 <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=tu else L*(1/sc-1/sb)) +else: + e=L*(sb-sa) if tc>=tu else (0 if tc0 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: wei/token +lm_eth_before: wei (total: free + positions) STRATEGY 1: Steps: - floor_after: - Result: INCREASED / HELD / DECREASED + lm_eth_after: wei + Result: ETH_EXTRACTED / ETH_SAFE / ETH_GAINED STRATEGY 2: ... ... === CONCLUSION === -Floor broken: YES / NO +ETH extracted: YES / NO Winning strategy: -floor_before: ${FLOOR_BEFORE} wei/token -floor_after: +lm_eth_before: ${LM_ETH_BEFORE} wei +lm_eth_after: 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" <>"$REPORT" <