Merge pull request 'fix: revm evaluator — UUPS bypass, deployedBytecode, graceful attack ops' (#629) from fix/revm-evaluator into master

Reviewed-on: https://codeberg.org/johba/harb/pulls/629
Reviewed-by: review_bot <review_bot@noreply.codeberg.org>
This commit is contained in:
johba 2026-03-12 21:43:53 +01:00
commit c3c262a719
3 changed files with 97 additions and 30 deletions

View file

@ -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

View file

@ -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);

View file

@ -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