From 87bb5859e245210e563d751c7fb564434cc5b24b Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 12 Mar 2026 19:54:58 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20revm=20evaluator=20=E2=80=94=20UUPS=20by?= =?UTF-8?q?pass,=20deployedBytecode,=20graceful=20attack=20ops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Skip UUPS upgradeTo: etch + vm.store ERC1967 implementation slot directly (OptimizerV3Push3 is standalone, no UUPS inheritance needed for evolution) - Use deployedBytecode (runtime) instead of bytecode (creation) for vm.etch - Inject transpiled body into OptimizerV3.sol (has getLiquidityParams via Optimizer) instead of using standalone OptimizerV3Push3.sol - Wrap buy/sell/stake/unstake in try/catch — attack ops should not abort the batch - Add /tmp read to fs_permissions for batch-eval manifest files - Bootstrap recenter returns bool instead of reverting (soft-fail per candidate) --- onchain/foundry.toml | 2 +- onchain/test/FitnessEvaluator.t.sol | 60 +++++++++-------- .../revm-evaluator/batch-eval.sh | 65 ++++++++++++++++++- 3 files changed, 97 insertions(+), 30 deletions(-) diff --git a/onchain/foundry.toml b/onchain/foundry.toml index 0383672..7b6cc1f 100644 --- a/onchain/foundry.toml +++ b/onchain/foundry.toml @@ -2,7 +2,7 @@ src = "src" out = "out" libs = ["lib"] -fs_permissions = [{ access = "read-write", path = "./"}] +fs_permissions = [{ access = "read-write", path = "./"}, { access = "read", path = "/tmp"}] gas_limit = 1_000_000_000 gas_price = 0 optimizer = true diff --git a/onchain/test/FitnessEvaluator.t.sol b/onchain/test/FitnessEvaluator.t.sol index 5eb2699..7382231 100644 --- a/onchain/test/FitnessEvaluator.t.sol +++ b/onchain/test/FitnessEvaluator.t.sol @@ -221,24 +221,26 @@ contract FitnessEvaluator is Test { vm.revertTo(baseSnap); baseSnap = vm.snapshot(); - // Etch candidate optimizer bytecode and upgrade proxy. - // Wrapped in try/catch: a malformed candidate (compiler bug, bad transpiler output) - // would otherwise abort the entire batch. On failure, emit fitness=0 and continue; - // vm.revertTo(baseSnap) at the top of the next iteration cleans up state. + // Etch candidate optimizer bytecode onto the implementation address + // and update the ERC1967 implementation slot directly. + // Skips UUPS upgradeTo() check — candidates are standalone contracts + // (OptimizerV3Push3) without UUPSUpgradeable inheritance. + // This is safe because we only care about calculateParams() output. bytes memory candidateBytecode = vm.parseBytes(bytecodeHex); - vm.etch(IMPL_SLOT, candidateBytecode); - bool upgradeOk = true; - try UUPSUpgradeable(optProxy).upgradeTo(IMPL_SLOT) { } - catch { - upgradeOk = false; - } - if (!upgradeOk) { - console.log(string.concat('{"candidate_id":"', candidateId, '","fitness":0,"error":"upgrade_failed"}')); + if (candidateBytecode.length == 0) { + console.log(string.concat('{"candidate_id":"', candidateId, '","fitness":0,"error":"empty_bytecode"}')); continue; } + vm.etch(IMPL_SLOT, candidateBytecode); + // ERC1967 implementation slot = keccak256("eip1967.proxy.implementation") - 1 + bytes32 ERC1967_IMPL = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + vm.store(optProxy, ERC1967_IMPL, bytes32(uint256(uint160(IMPL_SLOT)))); // Bootstrap: fund LM, set recenterAccess, initial recenter. - _bootstrap(); + if (!_bootstrap()) { + console.log(string.concat('{"candidate_id":"', candidateId, '","fitness":0,"error":"bootstrap_failed"}')); + continue; + } // Score: sum lm_eth_total across all attack sequences. uint256 totalFitness = 0; @@ -321,7 +323,7 @@ contract FitnessEvaluator is Test { * d. Wrap 9000 WETH for adversary trades + set approvals. * e. Initial recenter (succeeds immediately: recenterAccess set, no ANCHOR liquidity yet). */ - function _bootstrap() internal { + function _bootstrap() internal returns (bool) { // a. Grant recenterAccess (feeDestination call, no ETH needed with gas_price=0). vm.prank(FEE_DEST); LiquidityManager(payable(lmAddr)).setRecenterAccess(recenterAddr); @@ -356,9 +358,15 @@ contract FitnessEvaluator is Test { try ILM(lmAddr).recenter() returns (bool) { recentered = true; break; - } catch { } + } catch (bytes memory reason) { + console.log(string.concat("recenter attempt ", vm.toString(_attempt), " failed")); + console.logBytes(reason); + } } - require(recentered, "FitnessEvaluator: bootstrap recenter failed after 5 attempts"); + if (!recentered) { + return false; + } + return true; } // ─── Attack execution ───────────────────────────────────────────────────── @@ -394,7 +402,7 @@ contract FitnessEvaluator is Test { if (_eq(op, "buy")) { uint256 amount = vm.parseUint(vm.parseJsonString(line, ".amount")); vm.prank(advAddr); - ISwapRouter02(SWAP_ROUTER).exactInputSingle( + try ISwapRouter02(SWAP_ROUTER).exactInputSingle( ISwapRouter02.ExactInputSingleParams({ tokenIn: WETH_ADDR, tokenOut: krkAddr, @@ -404,13 +412,13 @@ contract FitnessEvaluator is Test { amountOutMinimum: 0, sqrtPriceLimitX96: 0 }) - ); + ) { } catch { } } else if (_eq(op, "sell")) { string memory amtStr = vm.parseJsonString(line, ".amount"); uint256 amount = _eq(amtStr, "all") ? IERC20(krkAddr).balanceOf(advAddr) : vm.parseUint(amtStr); if (amount == 0) return; vm.prank(advAddr); - ISwapRouter02(SWAP_ROUTER).exactInputSingle( + try ISwapRouter02(SWAP_ROUTER).exactInputSingle( ISwapRouter02.ExactInputSingleParams({ tokenIn: krkAddr, tokenOut: WETH_ADDR, @@ -420,7 +428,7 @@ contract FitnessEvaluator is Test { amountOutMinimum: 0, sqrtPriceLimitX96: 0 }) - ); + ) { } catch { } } else if (_eq(op, "recenter")) { vm.prank(recenterAddr); try ILM(lmAddr).recenter() { } catch { } @@ -428,16 +436,14 @@ contract FitnessEvaluator is Test { uint256 amount = vm.parseUint(vm.parseJsonString(line, ".amount")); uint32 taxRate = uint32(vm.parseJsonUint(line, ".taxRateIndex")); vm.prank(advAddr); - uint256 posId = IStake(stakeAddr).snatch(amount, advAddr, taxRate, new uint256[](0)); - _stakedPositionIds.push(posId); + try IStake(stakeAddr).snatch(amount, advAddr, taxRate, new uint256[](0)) returns (uint256 posId) { + _stakedPositionIds.push(posId); + } catch { } } else if (_eq(op, "unstake")) { uint256 posIndex = vm.parseJsonUint(line, ".positionId"); - require( - posIndex >= 1 && posIndex <= _stakedPositionIds.length, - "FitnessEvaluator: unstake positionId out of range" - ); + if (posIndex < 1 || posIndex > _stakedPositionIds.length) return; vm.prank(advAddr); - IStake(stakeAddr).exitPosition(_stakedPositionIds[posIndex - 1]); + try IStake(stakeAddr).exitPosition(_stakedPositionIds[posIndex - 1]) { } catch { } } else if (_eq(op, "mine")) { uint256 blocks = vm.parseJsonUint(line, ".blocks"); vm.roll(block.number + blocks); diff --git a/tools/push3-evolution/revm-evaluator/batch-eval.sh b/tools/push3-evolution/revm-evaluator/batch-eval.sh index 0bec75b..45b6357 100755 --- a/tools/push3-evolution/revm-evaluator/batch-eval.sh +++ b/tools/push3-evolution/revm-evaluator/batch-eval.sh @@ -39,7 +39,10 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" ONCHAIN_DIR="$REPO_ROOT/onchain" TRANSPILER_DIR="$REPO_ROOT/tools/push3-transpiler" TRANSPILER_OUT="$ONCHAIN_DIR/src/OptimizerV3Push3.sol" -ARTIFACT_PATH="$ONCHAIN_DIR/out/OptimizerV3Push3.sol/OptimizerV3Push3.json" +# Use OptimizerV3 (inherits Optimizer → UUPS compatible, has getLiquidityParams) +# instead of standalone OptimizerV3Push3 which lacks UUPS hooks. +OPTIMIZERV3_SOL="$ONCHAIN_DIR/src/OptimizerV3.sol" +ARTIFACT_PATH="$ONCHAIN_DIR/out/OptimizerV3.sol/OptimizerV3.json" DEFAULT_ATTACKS_DIR="$ONCHAIN_DIR/script/backtesting/attacks" # ============================================================================= @@ -124,6 +127,64 @@ for PUSH3_FILE in "${PUSH3_FILES[@]}"; do continue fi + # Inject transpiled calculateParams body into OptimizerV3.sol (UUPS-compatible). + # Extract function body from OptimizerV3Push3.sol and replace content between + # BEGIN/END markers in OptimizerV3.sol. + python3 - "$TRANSPILER_OUT" "$OPTIMIZERV3_SOL" <<'PYEOF' || { +import sys + +push3_path = sys.argv[1] +v3_path = sys.argv[2] + +# Extract function body from OptimizerV3Push3.sol +# Find "function calculateParams" then extract everything between the opening { and +# the matching closing } at the same indent level (4 spaces / function level) +with open(push3_path) as f: + push3 = f.read() + +# Find body start: line after "function calculateParams...{" +fn_start = push3.find("function calculateParams") +if fn_start == -1: + sys.exit("calculateParams not found in OptimizerV3Push3") +brace_start = push3.find("{", fn_start) +body_start = push3.index("\n", brace_start) + 1 + +# Find body end: the closing " }" of the function (4-space indent, before contract close) +# Walk backwards from end to find the function-level closing brace +lines = push3[body_start:].split("\n") +body_lines = [] +for line in lines: + if line.strip() == "}" and (line.startswith(" }") or line == "}"): + # This is the function-closing brace + break + body_lines.append(line) + +body = "\n".join(body_lines) + +# Now inject into OptimizerV3.sol between markers +with open(v3_path) as f: + v3 = f.read() + +begin_marker = "// ── BEGIN TRANSPILER OUTPUT" +end_marker = "// ── END TRANSPILER OUTPUT" +begin_idx = v3.find(begin_marker) +end_idx = v3.find(end_marker) +if begin_idx == -1 or end_idx == -1: + sys.exit("markers not found in OptimizerV3.sol") + +begin_line_end = v3.index("\n", begin_idx) + 1 +# Keep the end marker line intact +with open(v3_path, "w") as f: + f.write(v3[:begin_line_end]) + f.write(body + "\n") + f.write(v3[end_idx:]) + +PYEOF + log "WARNING: failed to inject calculateParams into OptimizerV3.sol for $CANDIDATE_ID — skipping" + FAILED_IDS="$FAILED_IDS $CANDIDATE_ID" + continue + } + # Compile (forge's incremental build skips unchanged files quickly) FORGE_EC=0 (cd "$ONCHAIN_DIR" && forge build --silent) >/dev/null 2>&1 || FORGE_EC=$? @@ -139,7 +200,7 @@ for PUSH3_FILE in "${PUSH3_FILES[@]}"; do import json, sys with open(sys.argv[1]) as f: d = json.load(f) -bytecode = d["bytecode"]["object"] +bytecode = d["deployedBytecode"]["object"] # Ensure 0x prefix if not bytecode.startswith("0x"): bytecode = "0x" + bytecode