fix: revm evaluator — UUPS bypass, deployedBytecode, graceful attack ops

- 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)
This commit is contained in:
openhands 2026-03-12 19:54:58 +00:00
parent b34e26eb70
commit 87bb5859e2
3 changed files with 97 additions and 30 deletions

View file

@ -2,7 +2,7 @@
src = "src" src = "src"
out = "out" out = "out"
libs = ["lib"] 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_limit = 1_000_000_000
gas_price = 0 gas_price = 0
optimizer = true optimizer = true

View file

@ -221,24 +221,26 @@ contract FitnessEvaluator is Test {
vm.revertTo(baseSnap); vm.revertTo(baseSnap);
baseSnap = vm.snapshot(); baseSnap = vm.snapshot();
// Etch candidate optimizer bytecode and upgrade proxy. // Etch candidate optimizer bytecode onto the implementation address
// Wrapped in try/catch: a malformed candidate (compiler bug, bad transpiler output) // and update the ERC1967 implementation slot directly.
// would otherwise abort the entire batch. On failure, emit fitness=0 and continue; // Skips UUPS upgradeTo() check candidates are standalone contracts
// vm.revertTo(baseSnap) at the top of the next iteration cleans up state. // (OptimizerV3Push3) without UUPSUpgradeable inheritance.
// This is safe because we only care about calculateParams() output.
bytes memory candidateBytecode = vm.parseBytes(bytecodeHex); bytes memory candidateBytecode = vm.parseBytes(bytecodeHex);
vm.etch(IMPL_SLOT, candidateBytecode); if (candidateBytecode.length == 0) {
bool upgradeOk = true; console.log(string.concat('{"candidate_id":"', candidateId, '","fitness":0,"error":"empty_bytecode"}'));
try UUPSUpgradeable(optProxy).upgradeTo(IMPL_SLOT) { }
catch {
upgradeOk = false;
}
if (!upgradeOk) {
console.log(string.concat('{"candidate_id":"', candidateId, '","fitness":0,"error":"upgrade_failed"}'));
continue; 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: 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. // Score: sum lm_eth_total across all attack sequences.
uint256 totalFitness = 0; uint256 totalFitness = 0;
@ -321,7 +323,7 @@ contract FitnessEvaluator is Test {
* d. Wrap 9000 WETH for adversary trades + set approvals. * d. Wrap 9000 WETH for adversary trades + set approvals.
* e. Initial recenter (succeeds immediately: recenterAccess set, no ANCHOR liquidity yet). * 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). // a. Grant recenterAccess (feeDestination call, no ETH needed with gas_price=0).
vm.prank(FEE_DEST); vm.prank(FEE_DEST);
LiquidityManager(payable(lmAddr)).setRecenterAccess(recenterAddr); LiquidityManager(payable(lmAddr)).setRecenterAccess(recenterAddr);
@ -356,9 +358,15 @@ contract FitnessEvaluator is Test {
try ILM(lmAddr).recenter() returns (bool) { try ILM(lmAddr).recenter() returns (bool) {
recentered = true; recentered = true;
break; 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 // Attack execution
@ -394,7 +402,7 @@ contract FitnessEvaluator is Test {
if (_eq(op, "buy")) { if (_eq(op, "buy")) {
uint256 amount = vm.parseUint(vm.parseJsonString(line, ".amount")); uint256 amount = vm.parseUint(vm.parseJsonString(line, ".amount"));
vm.prank(advAddr); vm.prank(advAddr);
ISwapRouter02(SWAP_ROUTER).exactInputSingle( try ISwapRouter02(SWAP_ROUTER).exactInputSingle(
ISwapRouter02.ExactInputSingleParams({ ISwapRouter02.ExactInputSingleParams({
tokenIn: WETH_ADDR, tokenIn: WETH_ADDR,
tokenOut: krkAddr, tokenOut: krkAddr,
@ -404,13 +412,13 @@ contract FitnessEvaluator is Test {
amountOutMinimum: 0, amountOutMinimum: 0,
sqrtPriceLimitX96: 0 sqrtPriceLimitX96: 0
}) })
); ) { } catch { }
} else if (_eq(op, "sell")) { } else if (_eq(op, "sell")) {
string memory amtStr = vm.parseJsonString(line, ".amount"); string memory amtStr = vm.parseJsonString(line, ".amount");
uint256 amount = _eq(amtStr, "all") ? IERC20(krkAddr).balanceOf(advAddr) : vm.parseUint(amtStr); uint256 amount = _eq(amtStr, "all") ? IERC20(krkAddr).balanceOf(advAddr) : vm.parseUint(amtStr);
if (amount == 0) return; if (amount == 0) return;
vm.prank(advAddr); vm.prank(advAddr);
ISwapRouter02(SWAP_ROUTER).exactInputSingle( try ISwapRouter02(SWAP_ROUTER).exactInputSingle(
ISwapRouter02.ExactInputSingleParams({ ISwapRouter02.ExactInputSingleParams({
tokenIn: krkAddr, tokenIn: krkAddr,
tokenOut: WETH_ADDR, tokenOut: WETH_ADDR,
@ -420,7 +428,7 @@ contract FitnessEvaluator is Test {
amountOutMinimum: 0, amountOutMinimum: 0,
sqrtPriceLimitX96: 0 sqrtPriceLimitX96: 0
}) })
); ) { } catch { }
} else if (_eq(op, "recenter")) { } else if (_eq(op, "recenter")) {
vm.prank(recenterAddr); vm.prank(recenterAddr);
try ILM(lmAddr).recenter() { } catch { } try ILM(lmAddr).recenter() { } catch { }
@ -428,16 +436,14 @@ contract FitnessEvaluator is Test {
uint256 amount = vm.parseUint(vm.parseJsonString(line, ".amount")); uint256 amount = vm.parseUint(vm.parseJsonString(line, ".amount"));
uint32 taxRate = uint32(vm.parseJsonUint(line, ".taxRateIndex")); uint32 taxRate = uint32(vm.parseJsonUint(line, ".taxRateIndex"));
vm.prank(advAddr); vm.prank(advAddr);
uint256 posId = IStake(stakeAddr).snatch(amount, advAddr, taxRate, new uint256[](0)); try IStake(stakeAddr).snatch(amount, advAddr, taxRate, new uint256[](0)) returns (uint256 posId) {
_stakedPositionIds.push(posId); _stakedPositionIds.push(posId);
} catch { }
} else if (_eq(op, "unstake")) { } else if (_eq(op, "unstake")) {
uint256 posIndex = vm.parseJsonUint(line, ".positionId"); uint256 posIndex = vm.parseJsonUint(line, ".positionId");
require( if (posIndex < 1 || posIndex > _stakedPositionIds.length) return;
posIndex >= 1 && posIndex <= _stakedPositionIds.length,
"FitnessEvaluator: unstake positionId out of range"
);
vm.prank(advAddr); vm.prank(advAddr);
IStake(stakeAddr).exitPosition(_stakedPositionIds[posIndex - 1]); try IStake(stakeAddr).exitPosition(_stakedPositionIds[posIndex - 1]) { } catch { }
} else if (_eq(op, "mine")) { } else if (_eq(op, "mine")) {
uint256 blocks = vm.parseJsonUint(line, ".blocks"); uint256 blocks = vm.parseJsonUint(line, ".blocks");
vm.roll(block.number + blocks); vm.roll(block.number + blocks);

View file

@ -39,7 +39,10 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
ONCHAIN_DIR="$REPO_ROOT/onchain" ONCHAIN_DIR="$REPO_ROOT/onchain"
TRANSPILER_DIR="$REPO_ROOT/tools/push3-transpiler" TRANSPILER_DIR="$REPO_ROOT/tools/push3-transpiler"
TRANSPILER_OUT="$ONCHAIN_DIR/src/OptimizerV3Push3.sol" 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" DEFAULT_ATTACKS_DIR="$ONCHAIN_DIR/script/backtesting/attacks"
# ============================================================================= # =============================================================================
@ -124,6 +127,64 @@ for PUSH3_FILE in "${PUSH3_FILES[@]}"; do
continue continue
fi 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) # Compile (forge's incremental build skips unchanged files quickly)
FORGE_EC=0 FORGE_EC=0
(cd "$ONCHAIN_DIR" && forge build --silent) >/dev/null 2>&1 || FORGE_EC=$? (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 import json, sys
with open(sys.argv[1]) as f: with open(sys.argv[1]) as f:
d = json.load(f) d = json.load(f)
bytecode = d["bytecode"]["object"] bytecode = d["deployedBytecode"]["object"]
# Ensure 0x prefix # Ensure 0x prefix
if not bytecode.startswith("0x"): if not bytecode.startswith("0x"):
bytecode = "0x" + bytecode bytecode = "0x" + bytecode