From c9c0ce5e95144ef06442d80f3328145fe093fec5 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 13 Mar 2026 00:25:49 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20feat:=20Push3=20evolution=20?= =?UTF-8?q?=E2=80=94=20gas=20limit=20as=20fitness=20pressure=20(#637)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Optimizer.getLiquidityParams() now forwards calculateParams through a staticcall capped at 200 000 gas. Programs that exceed the budget or revert fall back to bear defaults (CI=0, AS=30%, AW=100, DD=0.3e18), so a bloated evolved optimizer can never OOG-revert inside recenter(). - FitnessEvaluator.t.sol measures gas used by calculateParams against fixed representative inputs (50% staked, 5% avg tax) after each bootstrap. A soft penalty of GAS_PENALTY_FACTOR (1e13 wei/gas) is subtracted from total fitness before the JSON score line is emitted. Leaner programs win ties; gas_used is included in the output for observability. At ~15k gas (current seed) the penalty is ~1.5e17 wei; at the 200k hard cap boundary it reaches ~2e18 wei. Co-Authored-By: Claude Sonnet 4.6 --- onchain/src/Optimizer.sol | 28 ++++++++++++++++++++++++- onchain/test/FitnessEvaluator.t.sol | 32 ++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/onchain/src/Optimizer.sol b/onchain/src/Optimizer.sol index 54db899..e849323 100644 --- a/onchain/src/Optimizer.sol +++ b/onchain/src/Optimizer.sol @@ -131,6 +131,13 @@ contract Optimizer is Initializable, UUPSUpgradeable { lastRecenterTimestamp = block.timestamp; } + /// @dev Gas budget forwarded to calculateParams via staticcall. + /// Evolved programs that exceed this are treated as crashes — same outcome + /// as a revert — and getLiquidityParams() returns bear defaults instead. + /// 200 000 gives ~13x headroom over the current seed (~15 k gas) while + /// keeping unbounded growth from ever blocking recenter(). + uint256 internal constant CALCULATE_PARAMS_GAS_LIMIT = 200_000; + // ---- Dyadic rational helpers ---- /** @@ -141,6 +148,18 @@ contract Optimizer is Initializable, UUPSUpgradeable { return OptimizerInput({mantissa: value, shift: 0}); } + /** + * @notice Safe bear-mode defaults returned when calculateParams exceeds its + * gas budget or reverts. Mirrors the catch block in LiquidityManager. + */ + function _bearDefaults() + internal + pure + returns (uint256 capitalInefficiency, uint256 anchorShare, uint24 anchorWidth, uint256 discoveryDepth) + { + return (0, 3e17, 100, 3e17); + } + // ---- Core computation ---- /** @@ -321,6 +340,13 @@ contract Optimizer is Initializable, UUPSUpgradeable { // Slots 6-7: 0 (future) - return calculateParams(inputs); + // Call calculateParams with a fixed gas budget. Evolved programs that grow + // too large hit the cap and fall back to bear defaults — preventing any + // buggy or bloated optimizer from blocking recenter() with an OOG revert. + (bool ok, bytes memory ret) = address(this).staticcall{gas: CALCULATE_PARAMS_GAS_LIMIT}( + abi.encodeCall(this.calculateParams, (inputs)) + ); + if (!ok) return _bearDefaults(); + return abi.decode(ret, (uint256, uint256, uint24, uint256)); } } diff --git a/onchain/test/FitnessEvaluator.t.sol b/onchain/test/FitnessEvaluator.t.sol index 7382231..e726872 100644 --- a/onchain/test/FitnessEvaluator.t.sol +++ b/onchain/test/FitnessEvaluator.t.sol @@ -36,6 +36,7 @@ import "forge-std/Test.sol"; import { Kraiken } from "../src/Kraiken.sol"; import { Stake } from "../src/Stake.sol"; import { Optimizer } from "../src/Optimizer.sol"; +import { OptimizerInput } from "../src/IOptimizer.sol"; import { LiquidityManager } from "../src/LiquidityManager.sol"; import { ERC1967Proxy } from "@openzeppelin/proxy/ERC1967/ERC1967Proxy.sol"; import { UUPSUpgradeable } from "@openzeppelin/proxy/utils/UUPSUpgradeable.sol"; @@ -147,6 +148,13 @@ contract FitnessEvaluator is Test { /// Chosen to be deterministic and not collide with real Base addresses. address internal constant IMPL_SLOT = address(uint160(uint256(keccak256("fitness.impl.slot")))); + /// @dev Soft gas penalty: wei deducted from fitness per gas unit used by calculateParams. + /// Creates selection pressure toward leaner programs while keeping gas as a + /// secondary criterion (ETH retention still dominates). + /// At 15 k gas (current seed): ~1.5e17 wei penalty. + /// At 200 k gas (hard cap boundary): ~2e18 wei penalty. + uint256 internal constant GAS_PENALTY_FACTOR = 1e13; + // ─── Anvil test accounts (deterministic mnemonic) ──────────────────────── /// @dev Account 8 — adversary (10 000 ETH in Anvil; funded via vm.deal here) @@ -242,6 +250,16 @@ contract FitnessEvaluator is Test { continue; } + // Measure gas used by calculateParams with fixed representative inputs. + // Fixed inputs ensure fair, reproducible comparison across all candidates. + // Uses slot 0 = 50% staked, slot 1 = 5% avg tax rate; remaining slots = 0. + OptimizerInput[8] memory sampleInputs; + sampleInputs[0] = OptimizerInput({ mantissa: 5e17, shift: 0 }); + sampleInputs[1] = OptimizerInput({ mantissa: 5e16, shift: 0 }); + uint256 gasBefore = gasleft(); + try Optimizer(optProxy).calculateParams(sampleInputs) returns (uint256, uint256, uint24, uint256) { } catch { } + uint256 gasForCalcParams = gasBefore - gasleft(); + // Score: sum lm_eth_total across all attack sequences. uint256 totalFitness = 0; Vm.DirEntry[] memory entries = vm.readDir(attacksDir); @@ -254,8 +272,20 @@ contract FitnessEvaluator is Test { vm.revertTo(atkSnap); } + // Apply soft gas penalty: fitness = score - (gasUsed * GAS_PENALTY_FACTOR). + // Leaner programs win ties; programs at the hard-cap boundary incur ~2 ETH penalty. + uint256 gasPenalty = gasForCalcParams * GAS_PENALTY_FACTOR; + uint256 adjustedFitness = totalFitness > gasPenalty ? totalFitness - gasPenalty : 0; + // Emit score as a JSON line (parsed by batch-eval.sh). - console.log(string.concat('{"candidate_id":"', candidateId, '","fitness":', _uint2str(totalFitness), "}")); + console.log( + string.concat( + '{"candidate_id":"', candidateId, + '","fitness":', _uint2str(adjustedFitness), + ',"gas_used":', _uint2str(gasForCalcParams), + "}" + ) + ); } // Close manifest files. From 5d369cfab62866542d4fdc81b70d126634036619 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 13 Mar 2026 01:05:37 +0000 Subject: [PATCH 2/2] fix: address review findings for gas-limit fitness pressure (#637) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Optimizer.sol: move CALCULATE_PARAMS_GAS_LIMIT constant to top of contract (after error declaration) to avoid mid-contract placement. Expand natspec with EIP-150 63/64 note: callers need ~203 175 gas to deliver the full 200 000 budget to the inner staticcall. - Optimizer.sol: add ret.length < 128 guard before abi.decode in getLiquidityParams(). Malformed return data (truncated / wrong ABI) from an evolved program now falls back to _bearDefaults() instead of propagating an unhandled revert. The 128-byte minimum is the ABI encoding of (uint256, uint256, uint24, uint256) — four 32-byte slots. - Optimizer.sol: add cross-reference comment to _bearDefaults() noting that its values must stay in sync with LiquidityManager.recenter()'s catch block to prevent silent divergence. - FitnessEvaluator.t.sol: add CALCULATE_PARAMS_GAS_LIMIT mirror constant (must match Optimizer.sol). Disqualify candidates whose measured gas exceeds the production cap with fitness=0 and error="gas_over_limit" — prevents the pipeline from selecting programs that are functionally dead on-chain (would always produce bear defaults in production). - batch-eval.sh: update output format comment to document the gas_used field and over-gas-limit error object added by this feature. Co-Authored-By: Claude Sonnet 4.6 --- onchain/src/Optimizer.sol | 30 ++++++++++++++----- onchain/test/FitnessEvaluator.t.sol | 21 +++++++++++++ .../revm-evaluator/batch-eval.sh | 4 ++- 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/onchain/src/Optimizer.sol b/onchain/src/Optimizer.sol index e849323..f564a9f 100644 --- a/onchain/src/Optimizer.sol +++ b/onchain/src/Optimizer.sol @@ -75,6 +75,19 @@ contract Optimizer is Initializable, UUPSUpgradeable { /// @dev Reverts if the caller is not the admin. error UnauthorizedAccount(address account); + /// @dev Gas budget forwarded to calculateParams via staticcall. + /// Evolved programs that exceed this are treated as crashes — same outcome + /// as a revert — and getLiquidityParams() returns bear defaults instead. + /// 200 000 gives ~13x headroom over the current seed (~15 k gas) while + /// preventing unbounded growth from blocking recenter(). + /// + /// Note (EIP-150 / 63-64 rule): the outer getLiquidityParams() call must + /// arrive with at least ⌈200_000 × 64/63⌉ ≈ 203_175 gas for the inner + /// staticcall to actually receive 200 000. Callers with exactly 200–203 k + /// gas will see a spurious bear-defaults fallback. This is not a practical + /// concern from recenter(), which always has abundant gas. + uint256 internal constant CALCULATE_PARAMS_GAS_LIMIT = 200_000; + /** * @notice Initialize the Optimizer. * @param _kraiken The address of the Kraiken token. @@ -131,13 +144,6 @@ contract Optimizer is Initializable, UUPSUpgradeable { lastRecenterTimestamp = block.timestamp; } - /// @dev Gas budget forwarded to calculateParams via staticcall. - /// Evolved programs that exceed this are treated as crashes — same outcome - /// as a revert — and getLiquidityParams() returns bear defaults instead. - /// 200 000 gives ~13x headroom over the current seed (~15 k gas) while - /// keeping unbounded growth from ever blocking recenter(). - uint256 internal constant CALCULATE_PARAMS_GAS_LIMIT = 200_000; - // ---- Dyadic rational helpers ---- /** @@ -150,7 +156,10 @@ contract Optimizer is Initializable, UUPSUpgradeable { /** * @notice Safe bear-mode defaults returned when calculateParams exceeds its - * gas budget or reverts. Mirrors the catch block in LiquidityManager. + * gas budget or reverts. + * @dev Values must stay in sync with the catch block in LiquidityManager.recenter() + * ({capitalInefficiency:0, anchorShare:3e17, anchorWidth:100, discoveryDepth:3e17}). + * Update both locations together if the safe defaults ever change. */ function _bearDefaults() internal @@ -347,6 +356,11 @@ contract Optimizer is Initializable, UUPSUpgradeable { abi.encodeCall(this.calculateParams, (inputs)) ); if (!ok) return _bearDefaults(); + // ABI encoding of (uint256, uint256, uint24, uint256) is exactly 128 bytes + // (each value padded to 32 bytes). A truncated return — e.g. from a + // malformed evolved program — would cause abi.decode to revert; guard here + // so all failure modes fall back via _bearDefaults(). + if (ret.length < 128) return _bearDefaults(); return abi.decode(ret, (uint256, uint256, uint24, uint256)); } } diff --git a/onchain/test/FitnessEvaluator.t.sol b/onchain/test/FitnessEvaluator.t.sol index e726872..20cdf40 100644 --- a/onchain/test/FitnessEvaluator.t.sol +++ b/onchain/test/FitnessEvaluator.t.sol @@ -148,6 +148,11 @@ contract FitnessEvaluator is Test { /// Chosen to be deterministic and not collide with real Base addresses. address internal constant IMPL_SLOT = address(uint160(uint256(keccak256("fitness.impl.slot")))); + /// @dev Must match Optimizer.CALCULATE_PARAMS_GAS_LIMIT. Candidates that exceed + /// this limit would unconditionally produce bear defaults in production and + /// are disqualified (fitness = 0) rather than scored against their theoretical output. + uint256 internal constant CALCULATE_PARAMS_GAS_LIMIT = 200_000; + /// @dev Soft gas penalty: wei deducted from fitness per gas unit used by calculateParams. /// Creates selection pressure toward leaner programs while keeping gas as a /// secondary criterion (ETH retention still dominates). @@ -260,6 +265,22 @@ contract FitnessEvaluator is Test { try Optimizer(optProxy).calculateParams(sampleInputs) returns (uint256, uint256, uint24, uint256) { } catch { } uint256 gasForCalcParams = gasBefore - gasleft(); + // Hard disqualification: candidates that exceed the production gas cap would + // unconditionally produce bear defaults from getLiquidityParams() in every + // recenter call — equivalent to deploying the bear-defaults optimizer. + // Score them as 0 so the evolution pipeline never selects a program that is + // functionally dead on-chain. + if (gasForCalcParams > CALCULATE_PARAMS_GAS_LIMIT) { + console.log( + string.concat( + '{"candidate_id":"', candidateId, + '","fitness":0,"error":"gas_over_limit","gas_used":', _uint2str(gasForCalcParams), + "}" + ) + ); + continue; + } + // Score: sum lm_eth_total across all attack sequences. uint256 totalFitness = 0; Vm.DirEntry[] memory entries = vm.readDir(attacksDir); diff --git a/tools/push3-evolution/revm-evaluator/batch-eval.sh b/tools/push3-evolution/revm-evaluator/batch-eval.sh index 45b6357..030eb18 100755 --- a/tools/push3-evolution/revm-evaluator/batch-eval.sh +++ b/tools/push3-evolution/revm-evaluator/batch-eval.sh @@ -16,7 +16,9 @@ # # Output (stdout): # One JSON object per candidate: -# {"candidate_id":"gen0_c000","fitness":123456789} +# {"candidate_id":"gen0_c000","fitness":123456789,"gas_used":15432} +# Over-gas-limit candidates emit fitness:0 with "error":"gas_over_limit". +# Downstream parsers use the "fitness" key; extra fields are ignored. # # Exit codes: # 0 Success.