Merge pull request 'fix: feat: Push3 evolution — gas limit as fitness pressure (#637)' (#645) from fix/issue-637 into master

This commit is contained in:
johba 2026-03-13 02:39:12 +01:00
commit f10e70171c
3 changed files with 96 additions and 3 deletions

View file

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

View file

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

View file

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