diff --git a/onchain/src/Optimizer.sol b/onchain/src/Optimizer.sol index 54db899..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. @@ -141,6 +154,21 @@ 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. + * @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 + pure + returns (uint256 capitalInefficiency, uint256 anchorShare, uint24 anchorWidth, uint256 discoveryDepth) + { + return (0, 3e17, 100, 3e17); + } + // ---- Core computation ---- /** @@ -321,6 +349,18 @@ 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(); + // 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 7382231..20cdf40 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,18 @@ 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). + /// 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 +255,32 @@ 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(); + + // 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); @@ -254,8 +293,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. 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.