Merge pull request 'fix: Red-team: replace ethPerToken with exact total-LM-ETH metric (#539)' (#540) from fix/issue-539 into master

This commit is contained in:
johba 2026-03-11 08:33:25 +01:00
commit 8c0683cbba
7 changed files with 813 additions and 269 deletions

View file

@ -52,6 +52,9 @@ when the protocol changes — not the marketing copy.
- Staker governance for optimizer upgrades (vote with stake weight)
- On-chain training data → new optimizer contracts via Push3 transpiler
- Remove admin key in favor of staker voting
- Adversarial backtesting: replay red-team attack sequences against optimizer candidates (#536)
- Push3 optimizer evolution: mutate, score against attacks, select survivors (#537)
- Unified Push3 → deploy pipeline: transpile, compile, UUPS upgrade in one command (#538)
## Fee Destination

View file

@ -0,0 +1,59 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
import "@uniswap-v3-core/interfaces/IUniswapV3Factory.sol";
import "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol";
import "forge-std/Script.sol";
import "@aperture/uni-v3-lib/LiquidityAmounts.sol";
import "@aperture/uni-v3-lib/TickMath.sol";
interface ILM {
function positions(uint8 stage) external view returns (uint128 liquidity, int24 tickLower, int24 tickUpper);
}
interface IWETH {
function balanceOf(address) external view returns (uint256);
}
/// @title LmTotalEth
/// @notice Read-only script: prints total ETH controlled by LiquidityManager
/// (free ETH + free WETH + ETH locked in all 3 Uni V3 positions).
/// @dev forge script script/LmTotalEth.s.sol --rpc-url $RPC_URL
/// Env: LM, WETH, POOL
contract LmTotalEth is Script {
function run() external view {
address lm = vm.envAddress("LM");
address weth = vm.envAddress("WETH");
address pool = vm.envAddress("POOL");
// Free balances
uint256 freeEth = lm.balance;
uint256 freeWeth = IWETH(weth).balanceOf(lm);
// Current sqrtPrice from pool
(uint160 sqrtPriceX96,,,,,,) = IUniswapV3Pool(pool).slot0();
// Determine which token is WETH (token0 or token1)
bool wethIsToken0 = IUniswapV3Pool(pool).token0() == weth;
// Sum ETH in all 3 positions: FLOOR=0, ANCHOR=1, DISCOVERY=2
uint256 positionEth = 0;
for (uint8 stage = 0; stage < 3; stage++) {
(uint128 liquidity, int24 tickLower, int24 tickUpper) = ILM(lm).positions(stage);
if (liquidity == 0) continue;
uint160 sqrtA = TickMath.getSqrtRatioAtTick(tickLower);
uint160 sqrtB = TickMath.getSqrtRatioAtTick(tickUpper);
(uint256 amount0, uint256 amount1) = LiquidityAmounts.getAmountsForLiquidity(sqrtPriceX96, sqrtA, sqrtB, liquidity);
positionEth += wethIsToken0 ? amount0 : amount1;
}
uint256 total = freeEth + freeWeth + positionEth;
// Output as plain number for easy bash consumption
console2.log(total);
}
}

View file

@ -0,0 +1,11 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
/**
* @notice Dyadic rational input: mantissa × 2^(-shift).
* For shift == 0 (current usage via _toDyadic), value == mantissa.
*/
struct OptimizerInput {
int256 mantissa;
int256 shift;
}

View file

@ -1,42 +1,77 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
import { Kraiken } from "./Kraiken.sol";
import { Stake } from "./Stake.sol";
import {Kraiken} from "./Kraiken.sol";
import {Stake} from "./Stake.sol";
import {OptimizerInput} from "./IOptimizer.sol";
import { Initializable } from "@openzeppelin/proxy/utils/Initializable.sol";
import { UUPSUpgradeable } from "@openzeppelin/proxy/utils/UUPSUpgradeable.sol";
import {Initializable} from "@openzeppelin/proxy/utils/Initializable.sol";
import {UUPSUpgradeable} from "@openzeppelin/proxy/utils/UUPSUpgradeable.sol";
// ---------------------------------------------------------------------------
// Dyadic rational interface Push3's native number format.
// Represents: mantissa × 2^(-shift).
// _toDyadic wraps an on-chain value with shift=0 (value == mantissa).
// ---------------------------------------------------------------------------
// Minimal interface for VWAPTracker (slot 2 input)
interface IVWAPTracker {
function getAdjustedVWAP(uint256 capitalInefficiency) external view returns (uint256);
}
// Minimal interface for Uniswap V3 pool (slot 3 input)
interface IUniswapV3PoolSlot0 {
function slot0()
external
view
returns (
uint160 sqrtPriceX96,
int24 tick,
uint16 observationIndex,
uint16 observationCardinality,
uint16 observationCardinalityNext,
uint8 feeProtocol,
bool unlocked
);
}
/**
* @title Optimizer
* @notice This contract (formerly Sentimenter) calculates a "sentiment" value and liquidity parameters
* based on the tax rate and the percentage of Kraiken staked.
* @dev It is upgradeable using UUPS. Only the admin (set during initialization) can upgrade.
* @notice Calculates liquidity parameters for the LiquidityManager using an
* 8-slot dyadic rational input interface (Push3's native format).
*
* Key features:
* - Analyzes staking sentiment (% staked, average tax rate)
* - Returns four key parameters for liquidity management:
* 1. capitalInefficiency (0 to 1e18): Capital buffer level
* 2. anchorShare (0 to 1e18): % of non-floor ETH in anchor
* 3. anchorWidth (0 to 100): Anchor position width %
* 4. discoveryDepth (0 to 1e18): Discovery liquidity density (2x-10x)
* - Upgradeable for future algorithm improvements
* @dev Upgradeable (UUPS). The core logic lives in `calculateParams`, which is
* a pure function taking an OptimizerInput[8] array. Future upgrades may
* replace `calculateParams` with a transpiled Push3 program via the
* evolution pipeline (#544, #545, #546).
*
* AnchorWidth Price Ranges:
* The anchor position's price range depends on anchorWidth value:
* - anchorWidth = 10: ±9% range (0.92x to 1.09x current price)
* - anchorWidth = 40: ±33% range (0.75x to 1.34x current price)
* - anchorWidth = 50: ±42% range (0.70x to 1.43x current price)
* - anchorWidth = 80: ±74% range (0.57x to 1.75x current price)
* - anchorWidth = 100: -50% to +100% range (0.50x to 2.00x current price)
* Input slots:
* 0 percentageStaked Stake.getPercentageStaked()
* 1 averageTaxRate Stake.getAverageTaxRate()
* 2 vwapX96 VWAPTracker.getAdjustedVWAP(0) (0 if not configured)
* 3 currentTick pool.slot0() tick (0 if not configured)
* 4 recentVolume swap volume since last recenter (0, future)
* 5 timeSinceLastRecenter block.timestamp - lastRecenterTimestamp (0 if unavailable)
* 6 movingAveragePrice EMA/SMA of recent prices (0, future)
* 7 reserved future use (0)
*
* The formula: anchorSpacing = TICK_SPACING + (34 * anchorWidth * TICK_SPACING / 100)
* creates a non-linear price range due to Uniswap V3's tick-based system
* Four optimizer outputs (0..1e18 fractions unless noted):
* capitalInefficiency capital buffer level
* anchorShare fraction of non-floor ETH in anchor
* anchorWidth anchor position width (tick units, uint24)
* discoveryDepth discovery liquidity density
*/
contract Optimizer is Initializable, UUPSUpgradeable {
Kraiken private kraiken;
Stake private stake;
// ---- Extended data sources for input slots 2-5 ----
// These are optional; unset addresses leave the corresponding slots as 0.
address public vwapTracker; // slot 2 source
address public pool; // slot 3 source
uint256 public lastRecenterTimestamp; // slot 5 source (updated via recordRecenter)
address public recenterRecorder; // authorized to call recordRecenter
/// @dev Reverts if the caller is not the admin.
error UnauthorizedAccount(address account);
@ -63,7 +98,50 @@ contract Optimizer is Initializable, UUPSUpgradeable {
}
}
function _authorizeUpgrade(address newImplementation) internal override onlyAdmin { }
function _authorizeUpgrade(address newImplementation) internal override onlyAdmin {}
// ---- Data-source configuration (admin only) ----
/**
* @notice Configure optional on-chain data sources for input slots 2 and 3.
* @param _vwapTracker VWAPTracker contract address (slot 2); zero = disabled.
* @param _pool Uniswap V3 pool address (slot 3); zero = disabled.
*/
function setDataSources(address _vwapTracker, address _pool) external onlyAdmin {
vwapTracker = _vwapTracker;
pool = _pool;
}
/**
* @notice Set the address authorized to call recordRecenter.
* @param _recorder The LiquidityManager or other authorized address.
*/
function setRecenterRecorder(address _recorder) external onlyAdmin {
recenterRecorder = _recorder;
}
/**
* @notice Record a recenter event for slot 5 (timeSinceLastRecenter).
* @dev Called by the LiquidityManager (or recenterRecorder) after each recenter.
*/
function recordRecenter() external {
if (msg.sender != recenterRecorder && msg.sender != _getAdmin()) {
revert UnauthorizedAccount(msg.sender);
}
lastRecenterTimestamp = block.timestamp;
}
// ---- Dyadic rational helpers ----
/**
* @notice Wrap an integer as a dyadic rational with shift=0.
* value = mantissa × 2^(-0) = mantissa.
*/
function _toDyadic(int256 value) internal pure returns (OptimizerInput memory) {
return OptimizerInput({mantissa: value, shift: 0});
}
// ---- Core computation ----
/**
* @notice Calculates the sentiment based on the average tax rate and the percentage staked.
@ -71,7 +149,11 @@ contract Optimizer is Initializable, UUPSUpgradeable {
* @param percentageStaked The percentage (in 1e18 precision) of the authorized stake that is currently staked.
* @return sentimentValue A value in the range 0 to 1e18 where 1e18 represents the worst sentiment.
*/
function calculateSentiment(uint256 averageTaxRate, uint256 percentageStaked) public pure returns (uint256 sentimentValue) {
function calculateSentiment(uint256 averageTaxRate, uint256 percentageStaked)
public
pure
returns (uint256 sentimentValue)
{
// Ensure percentageStaked doesn't exceed 100%
require(percentageStaked <= 1e18, "Invalid percentage staked");
@ -117,50 +199,21 @@ contract Optimizer is Initializable, UUPSUpgradeable {
* @param percentageStaked The percentage of tokens staked (0 to 1e18)
* @param averageTaxRate The average tax rate across all stakers (0 to 1e18)
* @return anchorWidth The calculated anchor width (10 to 80)
*
* @dev This function implements a staking-based approach to determine anchor width:
*
* Base Strategy:
* - Start with base width of 40% (balanced default)
*
* Staking Adjustment (-20% to +20%):
* - High staking (>70%) indicates bullish confidence narrow anchor for fee optimization
* - Low staking (<30%) indicates bearish/uncertainty wide anchor for safety
* - Inverse relationship: higher staking = lower width adjustment
*
* Tax Rate Adjustment (-10% to +30%):
* - High tax rates signal expected volatility wider anchor to reduce rebalancing
* - Low tax rates signal expected stability narrower anchor for fee collection
* - Direct relationship: higher tax = higher width adjustment
*
* The Harberger tax mechanism acts as a decentralized prediction market where:
* - Tax rates reflect holders' expectations of being "snatched" (volatility)
* - Staking percentage reflects overall market confidence
*
* Final width is clamped between 10 (minimum safe) and 80 (maximum effective)
*/
function _calculateAnchorWidth(uint256 percentageStaked, uint256 averageTaxRate) internal pure returns (uint24) {
// Base width: 40% is our neutral starting point
int256 baseWidth = 40;
// Staking adjustment: -20% to +20% based on staking percentage
// Formula: 20 - (percentageStaked * 40 / 1e18)
// High staking (1e18) -20 adjustment narrower width
// Low staking (0) +20 adjustment wider width
int256 stakingAdjustment = 20 - int256(percentageStaked * 40 / 1e18);
// Tax rate adjustment: -10% to +30% based on average tax rate
// Formula: (averageTaxRate * 40 / 1e18) - 10
// High tax (1e18) +30 adjustment wider width for volatility
// Low tax (0) -10 adjustment narrower width for stability
int256 taxAdjustment = int256(averageTaxRate * 40 / 1e18) - 10;
// Combine all adjustments
int256 totalWidth = baseWidth + stakingAdjustment + taxAdjustment;
// Clamp to safe bounds (10 to 80)
// Below 10%: rebalancing costs exceed benefits
// Above 80%: capital efficiency degrades significantly
if (totalWidth < 10) {
return 10;
}
@ -172,39 +225,98 @@ contract Optimizer is Initializable, UUPSUpgradeable {
}
/**
* @notice Returns liquidity parameters for the liquidity manager.
* @return capitalInefficiency Calculated as (1e18 - sentiment). Capital buffer level (0-1e18)
* @return anchorShare Set equal to the sentiment. % of non-floor ETH in anchor (0-1e18)
* @return anchorWidth Dynamically adjusted based on staking metrics. Anchor position width % (1-100)
* @return discoveryDepth Set equal to the sentiment.
* @notice Pure computation of all four liquidity parameters from 8 dyadic inputs.
*
* @dev AnchorWidth Strategy:
* The anchorWidth parameter controls the price range of the anchor liquidity position.
* - anchorWidth = 50: Price range from 0.70x to 1.43x current price
* - anchorWidth = 100: Price range from 0.50x to 2.00x current price
* @dev This is the transpilation target: future versions of this function will be
* generated from evolved Push3 programs via the transpiler. The current
* implementation uses slots 0 (percentageStaked) and 1 (averageTaxRate);
* slots 2-7 are available to evolved programs that use additional trackers.
*
* We use staking metrics as a decentralized prediction market:
* - High staking % Bullish sentiment Narrower width (30-50%) for fee optimization
* - Low staking % Bearish/uncertain Wider width (60-80%) for defensive positioning
* - High avg tax rate Expects volatility Wider anchor to reduce rebalancing
* - Low avg tax rate Expects stability Narrower anchor for fee collection
* @param inputs 8 dyadic rational slots. For shift == 0 (via _toDyadic), value == mantissa.
* inputs[0].mantissa = percentageStaked (0..1e18)
* inputs[1].mantissa = averageTaxRate (0..1e18)
* inputs[2..7] = extended metrics (ignored by this implementation)
*
* @return capitalInefficiency Capital buffer level (0..1e18). CI=0 is safest.
* @return anchorShare Fraction of non-floor ETH in anchor (0..1e18).
* @return anchorWidth Anchor position width in tick units (uint24).
* @return discoveryDepth Discovery liquidity density (0..1e18).
*/
function getLiquidityParams() external view returns (uint256 capitalInefficiency, uint256 anchorShare, uint24 anchorWidth, uint256 discoveryDepth) {
uint256 percentageStaked = stake.getPercentageStaked();
uint256 averageTaxRate = stake.getAverageTaxRate();
uint256 sentiment = calculateSentiment(averageTaxRate, percentageStaked);
function calculateParams(OptimizerInput[8] memory inputs)
public
pure
returns (uint256 capitalInefficiency, uint256 anchorShare, uint24 anchorWidth, uint256 discoveryDepth)
{
// Extract slots 0 and 1 (shift=0 assumed mantissa IS the value)
uint256 percentageStaked = uint256(inputs[0].mantissa);
uint256 averageTaxRate = uint256(inputs[1].mantissa);
// Ensure sentiment doesn't exceed 1e18 to prevent underflow
// Cap sentiment at 1e18 if it somehow exceeds it
uint256 sentiment = calculateSentiment(averageTaxRate, percentageStaked);
if (sentiment > 1e18) {
sentiment = 1e18;
}
capitalInefficiency = 1e18 - sentiment;
anchorShare = sentiment;
// Calculate dynamic anchorWidth based on staking metrics
anchorWidth = _calculateAnchorWidth(percentageStaked, averageTaxRate);
discoveryDepth = sentiment;
}
/**
* @notice Returns liquidity parameters for the LiquidityManager.
*
* @dev Populates the 8-slot dyadic input array from on-chain sources and
* delegates to calculateParams. Signature is unchanged from prior versions
* so existing LiquidityManager integrations continue working.
*
* Available slots populated here:
* 0 percentageStaked always populated
* 1 averageTaxRate always populated
* 2 vwapX96 populated when vwapTracker != address(0)
* 3 currentTick populated when pool != address(0)
* 4 recentVolume 0 (future tracker)
* 5 timeSinceLastRecenter populated when lastRecenterTimestamp > 0
* 6 movingAveragePrice 0 (future tracker)
* 7 reserved 0
*
* @return capitalInefficiency Capital buffer level (0..1e18)
* @return anchorShare Fraction of non-floor ETH in anchor (0..1e18)
* @return anchorWidth Anchor position width in tick units (uint24)
* @return discoveryDepth Discovery liquidity density (0..1e18)
*/
function getLiquidityParams()
external
view
returns (uint256 capitalInefficiency, uint256 anchorShare, uint24 anchorWidth, uint256 discoveryDepth)
{
OptimizerInput[8] memory inputs;
// Slot 0: percentageStaked
inputs[0] = _toDyadic(int256(stake.getPercentageStaked()));
// Slot 1: averageTaxRate
inputs[1] = _toDyadic(int256(stake.getAverageTaxRate()));
// Slot 2: vwapX96 (optional requires vwapTracker to be configured)
if (vwapTracker != address(0)) {
inputs[2] = _toDyadic(int256(IVWAPTracker(vwapTracker).getAdjustedVWAP(0)));
}
// Slot 3: currentTick (optional requires pool to be configured)
if (pool != address(0)) {
(, int24 currentTick,,,,,) = IUniswapV3PoolSlot0(pool).slot0();
inputs[3] = _toDyadic(int256(currentTick));
}
// Slot 4: recentVolume 0 (future tracker)
// Slot 5: timeSinceLastRecenter (available once recordRecenter has been called)
if (lastRecenterTimestamp > 0) {
inputs[5] = _toDyadic(int256(block.timestamp - lastRecenterTimestamp));
}
// Slots 6-7: 0 (future)
return calculateParams(inputs);
}
}

View file

@ -1,164 +1,296 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
import {OptimizerInput} from "./IOptimizer.sol";
/**
* @title OptimizerV3Push3
* @notice Auto-generated from optimizer_v3.push3 via Push3Solidity transpiler.
* Implements the same isBullMarket logic as OptimizerV3.
* @dev This contract is an equivalence proof, not a deployable upgrade.
* It intentionally exposes only `isBullMarket` and does NOT implement
* the full optimizer interface (e.g. `getLiquidityParams`). Wiring it
* into the proxy upgrade path would require completing that interface first.
* Implements calculateParams with 8 dyadic rational inputs and 4 outputs.
*/
contract OptimizerV3Push3 {
/**
* @notice Determines if the market is in bull configuration.
* @param percentageStaked Percentage of authorized stake in use (0 to 1e18).
* @param averageTaxRate Normalized average tax rate from Stake contract (0 to 1e18).
* @return bull True if bull config, false if bear.
* @notice Compute liquidity parameters from 8 dyadic rational inputs.
* @param inputs 8-slot dyadic rational array: slot 0 = percentageStaked (top of Push3 stack),
* slot 1 = averageTaxRate, slots 2-7 = extended metrics (0 if unavailable).
* @return ci Capital inefficiency (0..1e18).
* @return anchorShare Fraction of non-floor ETH in anchor (0..1e18).
* @return anchorWidth Anchor position width in tick units.
* @return discoveryDepth Discovery liquidity density (0..1e18).
*/
function isBullMarket(uint256 percentageStaked, uint256 averageTaxRate) public pure returns (bool bull) {
require(percentageStaked <= 1e18, "Invalid percentage staked");
require(averageTaxRate <= 1e18, "Invalid tax rate");
uint256 taxrate = uint256(averageTaxRate);
uint256 staked = uint256(((percentageStaked * 100) / 1_000_000_000_000_000_000));
function calculateParams(OptimizerInput[8] memory inputs)
public
pure
returns (uint256 ci, uint256 anchorShare, uint24 anchorWidth, uint256 discoveryDepth)
{
// Validate mantissa for percentageStaked
require(inputs[0].mantissa <= 1e18, "mantissa overflow");
// Validate that shift is 0 (future-only field, not yet supported)
for (uint256 k = 0; k < 8; k++) {
require(inputs[k].shift == 0, "shift not yet supported");
}
// Decode dyadic rational inputs (mantissa * 2^(-shift); shift=0 for current inputs)
uint256 _d0 = uint256(inputs[0].mantissa);
uint256 _d1 = uint256(inputs[1].mantissa);
/* _d2 = uint256(inputs[2].mantissa); */
// Available but not used in current implementation
/* _d3 = uint256(inputs[3].mantissa); */
// Available but not used in current implementation
/* _d4 = uint256(inputs[4].mantissa); */
// Available but not used in current implementation
/* _d5 = uint256(inputs[5].mantissa); */
// Available but not used in current implementation
/* _d6 = uint256(inputs[6].mantissa); */
// Available but not used in current implementation
/* _d7 = uint256(inputs[7].mantissa); */
// Available but not used in current implementation
uint256 rawpct = uint256(_d0);
uint256 taxrate = uint256(_d1);
uint256 staked = uint256(((rawpct * 100) / 1000000000000000000));
bool b33;
if ((staked > 91)) {
uint256 deltas = uint256((100 - staked));
uint256 r28;
if ((taxrate <= 206_185_567_010_309)) {
if ((taxrate <= 206185567010309)) {
r28 = uint256(0);
} else {
uint256 r27;
if ((taxrate <= 412_371_134_020_618)) {
if ((taxrate <= 412371134020618)) {
r27 = uint256(1);
} else {
uint256 r26;
if ((taxrate <= 618_556_701_030_927)) {
if ((taxrate <= 618556701030927)) {
r26 = uint256(2);
} else {
uint256 r25;
if ((taxrate <= 1_030_927_835_051_546)) {
if ((taxrate <= 1030927835051546)) {
r25 = uint256(3);
} else {
uint256 r24;
if ((taxrate <= 1_546_391_752_577_319)) {
if ((taxrate <= 1546391752577319)) {
r24 = uint256(4);
} else {
uint256 r23;
if ((taxrate <= 2_164_948_453_608_247)) {
if ((taxrate <= 2164948453608247)) {
r23 = uint256(5);
} else {
uint256 r22;
if ((taxrate <= 2_783_505_154_639_175)) {
if ((taxrate <= 2783505154639175)) {
r22 = uint256(6);
} else {
uint256 r21;
if ((taxrate <= 3_608_247_422_680_412)) {
if ((taxrate <= 3608247422680412)) {
r21 = uint256(7);
} else {
uint256 r20;
if ((taxrate <= 4_639_175_257_731_958)) {
if ((taxrate <= 4639175257731958)) {
r20 = uint256(8);
} else {
uint256 r19;
if ((taxrate <= 5_670_103_092_783_505)) {
if ((taxrate <= 5670103092783505)) {
r19 = uint256(9);
} else {
uint256 r18;
if ((taxrate <= 7_216_494_845_360_824)) {
if ((taxrate <= 7216494845360824)) {
r18 = uint256(10);
} else {
uint256 r17;
if ((taxrate <= 9_278_350_515_463_917)) {
if ((taxrate <= 9278350515463917)) {
r17 = uint256(11);
} else {
uint256 r16;
if ((taxrate <= 11_855_670_103_092_783)) {
if ((taxrate <= 11855670103092783)) {
r16 = uint256(12);
} else {
uint256 r15;
if ((taxrate <= 15_979_381_443_298_969)) {
if ((taxrate <= 15979381443298969)) {
r15 = uint256(13);
} else {
uint256 r14;
if ((taxrate <= 22_164_948_453_608_247)) {
if ((taxrate <= 22164948453608247)) {
r14 = uint256(14);
} else {
uint256 r13;
if ((taxrate <= 29_381_443_298_969_072)) {
if ((taxrate <= 29381443298969072)) {
r13 = uint256(15);
} else {
uint256 r12;
if ((taxrate <= 38_144_329_896_907_216)) {
if ((taxrate <= 38144329896907216)) {
r12 = uint256(16);
} else {
uint256 r11;
if ((taxrate <= 49_484_536_082_474_226)) {
if ((taxrate <= 49484536082474226)) {
r11 = uint256(17);
} else {
uint256 r10;
if ((taxrate <= 63_917_525_773_195_876)) {
if ((taxrate <= 63917525773195876))
{
r10 = uint256(18);
} else {
uint256 r9;
if ((taxrate <= 83_505_154_639_175_257)) {
if (
(
taxrate
<= 83505154639175257
)
) {
r9 = uint256(19);
} else {
uint256 r8;
if ((taxrate <= 109_278_350_515_463_917)) {
if (
(
taxrate
<=
109278350515463917
)
) {
r8 = uint256(20);
} else {
uint256 r7;
if ((taxrate <= 144_329_896_907_216_494)) {
if (
(
taxrate
<=
144329896907216494
)
) {
r7 = uint256(21);
} else {
uint256 r6;
if ((taxrate <= 185_567_010_309_278_350)) {
if (
(
taxrate
<=
185567010309278350
)
) {
r6 = uint256(22);
} else {
uint256 r5;
if ((taxrate <= 237_113_402_061_855_670)) {
r5 = uint256(23);
if (
(
taxrate
<=
237113402061855670
)
) {
r5 = uint256(
23
);
} else {
uint256 r4;
if ((taxrate <= 309_278_350_515_463_917)) {
r4 = uint256(24);
if (
(
taxrate
<=
309278350515463917
)
) {
r4 =
uint256(
24
);
} else {
uint256 r3;
if ((taxrate <= 402_061_855_670_103_092)) {
r3 = uint256(25);
uint256
r3;
if (
(
taxrate
<=
402061855670103092
)
) {
r3 =
uint256(
25
);
} else {
uint256 r2;
if ((taxrate <= 520_618_556_701_030_927)) {
r2 = uint256(26);
} else {
uint256 r1;
uint256
r2;
if (
(
taxrate
<=
520618556701030927
)
) {
r2
=
uint256(
26
);
}
else
{
uint256
r1;
if (
(taxrate <= 680_412_371_134_020_618)
) {
r1 = uint256(27);
} else {
uint256 r0;
(
taxrate
<=
680412371134020618
)
)
{
r1
=
uint256(
27
);
}
else
{
uint256
r0;
if (
(
taxrate
<= 886_597_938_144_329_896
<=
886597938144329896
)
) {
r0 = uint256(28);
} else {
r0 = uint256(29);
)
{
r0
=
uint256(
28
);
}
r1 = uint256(r0);
else
{
r0
=
uint256(
29
);
}
r1
=
uint256(
r0
);
}
r2 = uint256(r1);
r2
=
uint256(
r1
);
}
r3 = uint256(r2);
r3 =
uint256(
r2
);
}
r4 = uint256(r3);
r4 =
uint256(
r3
);
}
r5 = uint256(r4);
r5 = uint256(
r4
);
}
r6 = uint256(r5);
}
@ -225,6 +357,24 @@ contract OptimizerV3Push3 {
} else {
b33 = false;
}
bull = b33;
uint256 r34;
uint256 r35;
uint256 r36;
uint256 r37;
if (b33) {
r34 = uint256(1000000000000000000);
r35 = uint256(20);
r36 = uint256(1000000000000000000);
r37 = uint256(0);
} else {
r34 = uint256(300000000000000000);
r35 = uint256(100);
r36 = uint256(300000000000000000);
r37 = uint256(0);
}
ci = uint256(r37);
anchorShare = uint256(r36);
anchorWidth = uint24(r35);
discoveryDepth = uint256(r34);
}
}

View file

@ -1,24 +1,75 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
import { OptimizerV3Push3 } from "../src/OptimizerV3Push3.sol";
import {OptimizerV3Push3} from "../src/OptimizerV3Push3.sol";
import {OptimizerInput} from "../src/IOptimizer.sol";
import "forge-std/Test.sol";
/**
* @title OptimizerV3Push3Test
* @notice Verifies the correctness of OptimizerV3Push3 isBullMarket logic.
* @notice Verifies that the transpiled Push3 optimizer produces correct
* bear/bull parameters via the 8-slot dyadic rational interface.
*
* Bear output: CI=0, AS=0.3e18, AW=100, DD=0.3e18
* Bull output: CI=0, AS=1e18, AW=20, DD=1e18
*
* Bull condition: stakedPct > 91 AND penalty < 50
* where penalty = deltaS^3 * effIdx / 20
*/
contract OptimizerV3Push3Test is Test {
OptimizerV3Push3 push3;
uint256[30] TAX_RATES =
[uint256(1), 3, 5, 8, 12, 18, 24, 30, 40, 50, 60, 80, 100, 130, 180, 250, 320, 420, 540, 700, 920, 1200, 1600, 2000, 2600, 3400, 4400, 5700, 7500, 9700];
uint256[30] TAX_RATES = [
uint256(1),
3,
5,
8,
12,
18,
24,
30,
40,
50,
60,
80,
100,
130,
180,
250,
320,
420,
540,
700,
920,
1200,
1600,
2000,
2600,
3400,
4400,
5700,
7500,
9700
];
uint256 constant MAX_TAX = 9700;
// Expected bear/bull outputs
uint256 constant BEAR_CI = 0;
uint256 constant BEAR_ANCHOR_SHARE = 3e17; // 0.3e18
uint24 constant BEAR_ANCHOR_WIDTH = 100;
uint256 constant BEAR_DISCOVERY = 3e17; // 0.3e18
uint256 constant BULL_CI = 0;
uint256 constant BULL_ANCHOR_SHARE = 1e18;
uint24 constant BULL_ANCHOR_WIDTH = 20;
uint256 constant BULL_DISCOVERY = 1e18;
function setUp() public {
push3 = new OptimizerV3Push3();
}
// ---- Helpers ----
function _norm(uint256 taxIdx) internal view returns (uint256) {
return TAX_RATES[taxIdx] * 1e18 / MAX_TAX;
}
@ -27,68 +78,126 @@ contract OptimizerV3Push3Test is Test {
return pct * 1e18 / 100;
}
// ---- Direct correctness tests ----
/// @dev Build an 8-slot input array with only slots 0 and 1 populated.
function _inputs(uint256 percentageStaked, uint256 averageTaxRate)
internal
pure
returns (OptimizerInput[8] memory inp)
{
inp[0] = OptimizerInput({mantissa: int256(percentageStaked), shift: 0});
inp[1] = OptimizerInput({mantissa: int256(averageTaxRate), shift: 0});
// slots 2-7 default to (0, 0)
}
function _assertBear(uint256 ci, uint256 as_, uint24 aw, uint256 dd) internal pure {
assertEq(ci, BEAR_CI, "bear: ci");
assertEq(as_, BEAR_ANCHOR_SHARE, "bear: anchorShare");
assertEq(aw, BEAR_ANCHOR_WIDTH, "bear: anchorWidth");
assertEq(dd, BEAR_DISCOVERY, "bear: discoveryDepth");
}
function _assertBull(uint256 ci, uint256 as_, uint24 aw, uint256 dd) internal pure {
assertEq(ci, BULL_CI, "bull: ci");
assertEq(as_, BULL_ANCHOR_SHARE, "bull: anchorShare");
assertEq(aw, BULL_ANCHOR_WIDTH, "bull: anchorWidth");
assertEq(dd, BULL_DISCOVERY, "bull: discoveryDepth");
}
// ---- Bear cases ----
function testAlwaysBearAt0Percent() public view {
for (uint256 t = 0; t < 30; t++) {
assertFalse(push3.isBullMarket(0, _norm(t)));
(uint256 ci, uint256 as_, uint24 aw, uint256 dd) = push3.calculateParams(_inputs(0, _norm(t)));
_assertBear(ci, as_, aw, dd);
}
}
function testAlwaysBearAt91Percent() public view {
for (uint256 t = 0; t < 30; t++) {
assertFalse(push3.isBullMarket(_pct(91), _norm(t)));
(uint256 ci, uint256 as_, uint24 aw, uint256 dd) = push3.calculateParams(_inputs(_pct(91), _norm(t)));
_assertBear(ci, as_, aw, dd);
}
}
// ---- Bull cases ----
function testBoundary92PercentLowestTax() public view {
// deltaS=8, effIdx=0 penalty=0 < 50 BULL
assertTrue(push3.isBullMarket(_pct(92), _norm(0)));
(uint256 ci, uint256 as_, uint24 aw, uint256 dd) = push3.calculateParams(_inputs(_pct(92), _norm(0)));
_assertBull(ci, as_, aw, dd);
}
function testBoundary92PercentTaxIdx1() public view {
// deltaS=8, effIdx=1 penalty=512*1/20=25 < 50 BULL
assertTrue(push3.isBullMarket(_pct(92), _norm(1)));
(uint256 ci, uint256 as_, uint24 aw, uint256 dd) = push3.calculateParams(_inputs(_pct(92), _norm(1)));
_assertBull(ci, as_, aw, dd);
}
function testBoundary92PercentTaxIdx2() public view {
function testBoundary92PercentTaxIdx2Bear() public view {
// deltaS=8, effIdx=2 penalty=512*2/20=51 >= 50 BEAR
assertFalse(push3.isBullMarket(_pct(92), _norm(2)));
(uint256 ci, uint256 as_, uint24 aw, uint256 dd) = push3.calculateParams(_inputs(_pct(92), _norm(2)));
_assertBear(ci, as_, aw, dd);
}
function testAt95PercentTaxIdx7() public view {
// deltaS=5, effIdx=7 penalty=125*7/20=43 < 50 BULL
assertTrue(push3.isBullMarket(_pct(95), _norm(7)));
(uint256 ci, uint256 as_, uint24 aw, uint256 dd) = push3.calculateParams(_inputs(_pct(95), _norm(7)));
_assertBull(ci, as_, aw, dd);
}
function testAt95PercentTaxIdx8() public view {
function testAt95PercentTaxIdx8Bear() public view {
// deltaS=5, effIdx=8 penalty=125*8/20=50 NOT < 50 BEAR
assertFalse(push3.isBullMarket(_pct(95), _norm(8)));
(uint256 ci, uint256 as_, uint24 aw, uint256 dd) = push3.calculateParams(_inputs(_pct(95), _norm(8)));
_assertBear(ci, as_, aw, dd);
}
function testAt97PercentHighTax() public view {
// deltaS=3, effIdx=29 penalty=27*29/20=39 < 50 BULL
assertTrue(push3.isBullMarket(_pct(97), _norm(29)));
(uint256 ci, uint256 as_, uint24 aw, uint256 dd) = push3.calculateParams(_inputs(_pct(97), _norm(29)));
_assertBull(ci, as_, aw, dd);
}
function testAt100PercentAlwaysBull() public view {
for (uint256 t = 0; t < 30; t++) {
assertTrue(push3.isBullMarket(1e18, _norm(t)));
(uint256 ci, uint256 as_, uint24 aw, uint256 dd) = push3.calculateParams(_inputs(1e18, _norm(t)));
_assertBull(ci, as_, aw, dd);
}
}
function testEffIdxShiftAtBoundary() public view {
// taxIdx=13: effIdx=13, penalty=64*13/20=41 < 50 BULL
assertTrue(push3.isBullMarket(_pct(96), _norm(13)));
{
(uint256 ci, uint256 as_, uint24 aw, uint256 dd) = push3.calculateParams(_inputs(_pct(96), _norm(13)));
_assertBull(ci, as_, aw, dd);
}
// taxIdx=14: effIdx=15 (shift!), penalty=64*15/20=48 < 50 BULL
assertTrue(push3.isBullMarket(_pct(96), _norm(14)));
{
(uint256 ci, uint256 as_, uint24 aw, uint256 dd) = push3.calculateParams(_inputs(_pct(96), _norm(14)));
_assertBull(ci, as_, aw, dd);
}
// taxIdx=15: effIdx=16, penalty=64*16/20=51 >= 50 BEAR
assertFalse(push3.isBullMarket(_pct(96), _norm(15)));
{
(uint256 ci, uint256 as_, uint24 aw, uint256 dd) = push3.calculateParams(_inputs(_pct(96), _norm(15)));
_assertBear(ci, as_, aw, dd);
}
}
function testRevertsAbove100Percent() public {
vm.expectRevert("Invalid percentage staked");
push3.isBullMarket(1e18 + 1, 0);
// ---- Unused slots are ignored ----
function testUnusedSlotsIgnored() public view {
// Populate slots 2-7 with arbitrary values; output should be unchanged.
OptimizerInput[8] memory inp;
inp[0] = OptimizerInput({mantissa: int256(_pct(92)), shift: 0});
inp[1] = OptimizerInput({mantissa: int256(_norm(0)), shift: 0});
inp[2] = OptimizerInput({mantissa: 12345678, shift: 0});
inp[3] = OptimizerInput({mantissa: 9876, shift: 0});
inp[4] = OptimizerInput({mantissa: 1e17, shift: 0});
inp[5] = OptimizerInput({mantissa: 3600, shift: 0});
inp[6] = OptimizerInput({mantissa: 5e17, shift: 0});
inp[7] = OptimizerInput({mantissa: 42, shift: 0});
(uint256 ci, uint256 as_, uint24 aw, uint256 dd) = push3.calculateParams(inp);
_assertBull(ci, as_, aw, dd);
}
// ---- Fuzz ----
@ -96,6 +205,21 @@ contract OptimizerV3Push3Test is Test {
function testFuzzNeverReverts(uint256 percentageStaked, uint256 averageTaxRate) public view {
percentageStaked = bound(percentageStaked, 0, 1e18);
averageTaxRate = bound(averageTaxRate, 0, 1e18);
push3.isBullMarket(percentageStaked, averageTaxRate);
push3.calculateParams(_inputs(percentageStaked, averageTaxRate));
}
function testFuzzOutputsAreAlwaysBearOrBull(uint256 percentageStaked, uint256 averageTaxRate) public view {
percentageStaked = bound(percentageStaked, 0, 1e18);
averageTaxRate = bound(averageTaxRate, 0, 1e18);
(uint256 ci, uint256 as_, uint24 aw, uint256 dd) =
push3.calculateParams(_inputs(percentageStaked, averageTaxRate));
// CI is always 0 in the binary bear/bull model
assertEq(ci, 0, "ci always 0");
// Output is exactly BEAR or BULL
bool isBearOutput = (as_ == BEAR_ANCHOR_SHARE && aw == BEAR_ANCHOR_WIDTH && dd == BEAR_DISCOVERY);
bool isBullOutput = (as_ == BULL_ANCHOR_SHARE && aw == BULL_ANCHOR_WIDTH && dd == BULL_DISCOVERY);
assertTrue(isBearOutput || isBullOutput, "output must be bear or bull");
}
}

View file

@ -50,10 +50,37 @@ command -v claude &>/dev/null || die "claude CLI not found (install: npm i -g
command -v python3 &>/dev/null || die "python3 not found"
command -v jq &>/dev/null || die "jq not found"
# ── 1. Verify stack is running ─────────────────────────────────────────────────
log "Verifying Anvil is accessible at $RPC_URL ..."
# ── 1. Fresh stack — tear down, rebuild, wait for bootstrap ────────────────────
log "Rebuilding fresh stack ..."
cd "$REPO_ROOT"
# Free RAM: drop caches
sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches' 2>/dev/null || true
# Tear down completely (volumes too — clean anvil state)
sudo docker compose down -v >/dev/null 2>&1 || true
sleep 3
# Bring up
sudo docker compose up -d >/dev/null 2>&1 \
|| die "docker compose up -d failed"
# Wait for bootstrap to complete (max 120s)
log "Waiting for bootstrap ..."
for i in $(seq 1 40); do
if sudo docker logs harb-bootstrap-1 2>&1 | grep -q "Bootstrap complete"; then
log " Bootstrap complete (${i}x3s)"
break
fi
if [[ $i -eq 40 ]]; then
die "Bootstrap did not complete within 120s"
fi
sleep 3
done
# Verify Anvil responds
"$CAST" chain-id --rpc-url "$RPC_URL" >/dev/null 2>&1 \
|| die "Anvil not accessible at $RPC_URL — run: ./scripts/dev.sh start"
|| die "Anvil not accessible at $RPC_URL after stack start"
# ── 2. Read contract addresses ─────────────────────────────────────────────────
[[ -f "$DEPLOYMENTS" ]] || die "deployments-local.json not found at $DEPLOYMENTS (bootstrap not complete)"
@ -85,23 +112,67 @@ POOL=$("$CAST" call "$V3_FACTORY" "getPool(address,address,uint24)(address)" \
"$WETH" "$KRK" "$POOL_FEE" --rpc-url "$RPC_URL")
log " Pool: $POOL"
# ── 3. Grant recenterAccess to account 2 ──────────────────────────────────────
# Done BEFORE the snapshot so every revert restores account 2's access.
# LM.recenterAccess is a single address slot — replace it with account 2.
# Only the feeDestination is authorised to call setRecenterAccess().
log "Granting recenterAccess to account 2 ($RECENTER_ADDR) ..."
# ── 3a. Grant recenterAccess FIRST (while original feeDestination is still set) ──
FEE_DEST=$("$CAST" call "$LM" "feeDestination()(address)" --rpc-url "$RPC_URL") \
|| die "Failed to read feeDestination() from LM"
FEE_DEST=$(echo "$FEE_DEST" | tr -d '[:space:]')
log "Granting recenterAccess to account 2 ($RECENTER_ADDR) via feeDestination ($FEE_DEST) ..."
"$CAST" rpc --rpc-url "$RPC_URL" anvil_impersonateAccount "$FEE_DEST" \
|| die "anvil_impersonateAccount $FEE_DEST failed"
"$CAST" send --rpc-url "$RPC_URL" --from "$FEE_DEST" --unlocked \
"$LM" "setRecenterAccess(address)" "$RECENTER_ADDR" >/dev/null 2>&1 \
|| die "setRecenterAccess($RECENTER_ADDR) failed — check that feeDestination is correct"
|| die "setRecenterAccess($RECENTER_ADDR) failed"
"$CAST" rpc --rpc-url "$RPC_URL" anvil_stopImpersonatingAccount "$FEE_DEST" \
|| die "anvil_stopImpersonatingAccount $FEE_DEST failed"
log " recenterAccess granted"
# ── 3b. Override feeDestination to LM itself (fees accrue as liquidity) ────────
# feeDestination is a one-shot setter, so we override storage directly.
# Slot 7 contains feeDestination (address, lower 20 bytes).
# Upper bytes of slot 7 contain other packed state variables.
# NOTE: This is fragile — storage layout changes break this. Consider making
# feeDestination mutable or using Foundry's vm.store with slot lookup.
log "Setting feeDestination to LM ($LM) ..."
SLOT7=$("$CAST" storage "$LM" 7 --rpc-url "$RPC_URL" | tr -d '[:space:]')
UPPER=${SLOT7:0:26}
LM_LOWER=$(echo "$LM" | tr '[:upper:]' '[:lower:]' | sed 's/0x//')
NEW_SLOT7="${UPPER}${LM_LOWER}"
"$CAST" rpc --rpc-url "$RPC_URL" anvil_setStorageAt "$LM" "0x7" "$NEW_SLOT7" \
|| die "anvil_setStorageAt for feeDestination failed"
VERIFY=$("$CAST" call "$LM" "feeDestination()(address)" --rpc-url "$RPC_URL" | tr -d '[:space:]')
log " feeDestination set to: $VERIFY"
[[ "${VERIFY,,}" == "${LM,,}" ]] || die "feeDestination verification failed: expected $LM, got $VERIFY"
# ── 3c. Fund LM with 1000 ETH and deploy into positions via recenter ───────────
# Send ETH as WETH (LM uses WETH internally), then recenter to deploy into positions.
# Without recenter, the ETH sits idle and the first recenter mints massive KRK.
log "Funding LM with 1000 ETH ..."
# Wrap to WETH and transfer to LM
"$CAST" send "$WETH" "deposit()" --value 1000ether \
--private-key "$ADV_PK" --rpc-url "$RPC_URL" >/dev/null 2>&1 \
|| die "Failed to wrap ETH"
"$CAST" send "$WETH" "transfer(address,uint256)" "$LM" 1000000000000000000000 \
--private-key "$ADV_PK" --rpc-url "$RPC_URL" >/dev/null 2>&1 \
|| die "Failed to transfer WETH to LM"
# Recenter to deploy the new WETH into positions (establishes realistic baseline)
log "Recentering to deploy funded WETH into positions ..."
"$CAST" send "$LM" "recenter()" \
--private-key "$RECENTER_PK" --rpc-url "$RPC_URL" >/dev/null 2>&1 \
|| log " WARNING: initial recenter failed (may need amplitude — mining blocks)"
# Mine blocks and retry if needed
for _i in $(seq 1 3); do
for _b in $(seq 1 50); do
"$CAST" rpc evm_mine --rpc-url "$RPC_URL" >/dev/null 2>&1
done
"$CAST" send "$LM" "recenter()" \
--private-key "$RECENTER_PK" --rpc-url "$RPC_URL" >/dev/null 2>&1 && break
done
LM_ETH=$("$CAST" balance "$LM" --rpc-url "$RPC_URL" | sed 's/\[.*//;s/[[:space:]]//g')
LM_WETH=$("$CAST" call "$WETH" "balanceOf(address)(uint256)" "$LM" --rpc-url "$RPC_URL" | sed 's/\[.*//;s/[[:space:]]//g')
log " LM after recenter: ETH=$LM_ETH WETH=$LM_WETH"
# ── 4. Take Anvil snapshot (clean baseline, includes recenterAccess grant) ─────
log "Taking Anvil snapshot..."
SNAP=$("$CAST" rpc anvil_snapshot --rpc-url "$RPC_URL" | tr -d '"')
@ -117,43 +188,20 @@ cleanup() {
}
trap cleanup EXIT INT TERM
# ── Helper: compute ethPerToken (mirrors floor.ts getEthPerToken) ──────────────
# ethPerToken = (lm_native_eth + lm_weth) * 1e18 / adjusted_outstanding_supply
# adjusted_supply = outstandingSupply() - KRK_at_feeDestination - KRK_at_stakingPool
compute_eth_per_token() {
local lm_eth lm_weth supply fee_bal stake_bal adj_supply
lm_eth=$("$CAST" balance "$LM" --rpc-url "$RPC_URL" 2>/dev/null | tr -d '[:space:]')
lm_weth=$("$CAST" call "$WETH" "balanceOf(address)(uint256)" "$LM" \
--rpc-url "$RPC_URL" 2>/dev/null | tr -d '[:space:]')
supply=$("$CAST" call "$KRK" "outstandingSupply()(uint256)" \
--rpc-url "$RPC_URL" 2>/dev/null | tr -d '[:space:]')
# Fee destination: read from contract (set at deploy time, may differ per fork)
local fee_dest
fee_dest=$("$CAST" call "$LM" "feeDestination()(address)" \
--rpc-url "$RPC_URL" 2>/dev/null | tr -d '[:space:]')
fee_bal=0
local zero="0x0000000000000000000000000000000000000000"
if [[ "${fee_dest,,}" != "${zero,,}" ]]; then
fee_bal=$("$CAST" call "$KRK" "balanceOf(address)(uint256)" "$fee_dest" \
--rpc-url "$RPC_URL" 2>/dev/null | tr -d '[:space:]' || echo 0)
fi
# Staking pool: use the deployed Stake address (mirrors peripheryContracts()[1])
stake_bal=$("$CAST" call "$KRK" "balanceOf(address)(uint256)" "$STAKE" \
--rpc-url "$RPC_URL" 2>/dev/null | tr -d '[:space:]' || echo 0)
python3 - <<PYEOF
e = int('${lm_eth:-0}' or 0)
w = int('${lm_weth:-0}' or 0)
s = int('${supply:-0}' or 0)
f = int('${fee_bal:-0}' or 0)
k = int('${stake_bal:-0}' or 0)
adj = s - f - k
print(0 if adj <= 0 else (e + w) * 10**18 // adj)
PYEOF
# ── Helper: compute total ETH controlled by LM ────────────────────────────────
# Total = free ETH + free WETH + ETH locked in all 3 Uni V3 positions
# This is the real metric: "can the adversary extract ETH from the protocol?"
# Uses a forge script with exact Uni V3 integer math (LiquidityAmounts + TickMath)
# instead of multiple cast calls + Python float approximation.
compute_lm_total_eth() {
local output result
output=$(LM="$LM" WETH="$WETH" POOL="$POOL" \
/home/debian/.foundry/bin/forge script script/LmTotalEth.s.sol \
--rpc-url "$RPC_URL" --root "$REPO_ROOT/onchain" --no-color 2>&1)
# forge script prints "== Logs ==" then " <value>" — extract the number
result=$(echo "$output" | awk '/^== Logs ==/{getline; gsub(/^[[:space:]]+/,""); print; exit}')
[[ -n "$result" && "$result" =~ ^[0-9]+$ ]] || die "Failed to read LM total ETH (forge output: $output)"
echo "$result"
}
# ── Helper: extract strategy findings from stream-json and append to memory ────
@ -169,7 +217,7 @@ extract_memory() {
run_num=1
fi
python3 - "$stream_file" "$memory_file" "$run_num" "$FLOOR_BEFORE" <<'PYEOF'
python3 - "$stream_file" "$memory_file" "$run_num" "$LM_ETH_BEFORE" <<'PYEOF'
import json, sys, re
from datetime import datetime, timezone
@ -177,9 +225,9 @@ stream_file = sys.argv[1]
memory_file = sys.argv[2]
run_num = int(sys.argv[3])
try:
floor_before = int(sys.argv[4])
lm_eth_before = int(sys.argv[4])
except (ValueError, IndexError):
print(" extract_memory: invalid floor_before value, skipping", file=sys.stderr)
print(" extract_memory: invalid lm_eth_before value, skipping", file=sys.stderr)
sys.exit(0)
texts = []
@ -209,15 +257,15 @@ for text in texts:
current = {
"strategy": strat_match.group(1).strip(),
"steps": "",
"floor_after": None,
"lm_eth_after": None,
"insight": ""
}
if current:
# Capture floor readings — take the last match in the block (most recent value)
floor_matches = list(re.finditer(r"(?:floor|ethPerToken)[^\d]*?(\d{4,})\s*(?:wei)?", text, re.IGNORECASE))
floor_matches = list(re.finditer(r"(?:floor|ethPerToken|lm.?eth)[^\d]*?(\d{4,})\s*(?:wei)?", text, re.IGNORECASE))
if floor_matches:
current["floor_after"] = int(floor_matches[-1].group(1))
current["lm_eth_after"] = int(floor_matches[-1].group(1))
# Capture insights
for pattern in [r"[Kk]ey [Ii]nsight:\s*(.+)", r"[Ii]nsight:\s*(.+)", r"(?:discovered|learned|realized)\s+(?:that\s+)?(.+)"]:
@ -237,11 +285,11 @@ if current:
ts = datetime.now(timezone.utc).isoformat()
with open(memory_file, "a") as f:
for s in strategies:
fa = s["floor_after"] if s.get("floor_after") is not None else floor_before
delta_bps = round((fa - floor_before) * 10000 / floor_before) if floor_before else 0
if fa < floor_before:
fa = s["lm_eth_after"] if s.get("lm_eth_after") is not None else lm_eth_before
delta_bps = round((fa - lm_eth_before) * 10000 / lm_eth_before) if lm_eth_before else 0
if fa < lm_eth_before:
result = "DECREASED"
elif fa > floor_before:
elif fa > lm_eth_before:
result = "INCREASED"
else:
result = "HELD"
@ -251,8 +299,8 @@ with open(memory_file, "a") as f:
"ts": ts,
"strategy": s["strategy"][:100],
"steps": s["steps"][:300].rstrip("; "),
"floor_before": floor_before,
"floor_after": fa,
"lm_eth_before": lm_eth_before,
"lm_eth_after": fa,
"delta_bps": delta_bps,
"result": result,
"insight": s["insight"][:300]
@ -284,10 +332,10 @@ if len(all_entries) > 50:
PYEOF
}
# ── 5. Read floor_before ───────────────────────────────────────────────────────
# ── 5. Read lm_eth_before ───────────────────────────────────────────────────────
log "Reading floor before agent run..."
FLOOR_BEFORE=$(compute_eth_per_token)
log " floor_before = $FLOOR_BEFORE wei/token"
LM_ETH_BEFORE=$(compute_lm_total_eth)
log " lm_eth_before = $LM_ETH_BEFORE wei"
# ── 6. Build agent prompt ──────────────────────────────────────────────────────
@ -323,15 +371,18 @@ PYEOF
fi
PROMPT=$(cat <<PROMPT_EOF
# Red-team mission: break the KRAIKEN protocol floor
# Red-team mission: extract ETH from the KRAIKEN LiquidityManager
## Goal
Make ethPerToken **decrease** compared to its current value.
**Extract ETH from the LiquidityManager.** Make the LM end up with less total ETH than it started with.
Current ethPerToken: ${FLOOR_BEFORE} wei/token
Total LM ETH before: ${LM_ETH_BEFORE} wei
(free ETH + free WETH + ETH locked in all 3 Uni V3 positions)
ethPerToken measures: (LiquidityManager native ETH + WETH) × 1e18 ÷ adjusted_outstanding_supply
It is the floor price — the minimum ETH per KRK token the protocol will pay.
The protocol's ETH is like gold in a cold-backed currency — it's "put to work" in Uniswap positions,
but it should NEVER be extractable by an adversary. Your job is to prove otherwise.
The metric is simple: if LM total ETH goes down, you win.
---
@ -358,7 +409,7 @@ CAST binary: /home/debian/.foundry/bin/cast
### Adversary — Anvil account 8 (your main account)
- Address: ${ADV_ADDR}
- Private key: ${ADV_PK}
- Balance: 10 000 ETH, 0 KRK
- Balance: ~9000 ETH (10k minus 1000 ETH used to fund LM), 0 KRK
### Recenter caller — Anvil account 2
- Address: ${RECENTER_ADDR}
@ -407,15 +458,38 @@ The floor formula then additionally subtracts KRK at Stake and feeDestination to
## Cast command patterns
### Check the floor (run after each strategy)
### Check total LM ETH (run after each strategy)
Measures free ETH + free WETH + ETH locked in all 3 Uni V3 positions.
\`\`\`bash
LM_ETH=\$(/home/debian/.foundry/bin/cast balance ${LM} --rpc-url http://localhost:8545)
LM_WETH=\$(/home/debian/.foundry/bin/cast call ${WETH} "balanceOf(address)(uint256)" ${LM} --rpc-url http://localhost:8545)
SUPPLY=\$(/home/debian/.foundry/bin/cast call ${KRK} "outstandingSupply()(uint256)" --rpc-url http://localhost:8545)
FEE_DEST=\$(/home/debian/.foundry/bin/cast call ${LM} "feeDestination()(address)" --rpc-url http://localhost:8545)
FEE_BAL=\$(/home/debian/.foundry/bin/cast call ${KRK} "balanceOf(address)(uint256)" \$FEE_DEST --rpc-url http://localhost:8545)
STAKE_BAL=\$(/home/debian/.foundry/bin/cast call ${KRK} "balanceOf(address)(uint256)" ${STAKE} --rpc-url http://localhost:8545)
python3 -c "e=\$LM_ETH; w=\$LM_WETH; s=\$SUPPLY; f=\$FEE_BAL; k=\$STAKE_BAL; adj=s-f-k; print('ethPerToken:', (e+w)*10**18//adj if adj>0 else 0, 'wei/token')"
CAST=/home/debian/.foundry/bin/cast
LM_ETH=\$(\$CAST balance ${LM} --rpc-url http://localhost:8545 | sed 's/\[.*//;s/[[:space:]]//g')
LM_WETH=\$(\$CAST call ${WETH} "balanceOf(address)(uint256)" ${LM} --rpc-url http://localhost:8545 | sed 's/\[.*//;s/[[:space:]]//g')
SLOT0=\$(\$CAST call ${POOL} "slot0()(uint160,int24,uint16,uint16,uint16,uint8,bool)" --rpc-url http://localhost:8545)
CUR_TICK=\$(echo "\$SLOT0" | sed -n '2p' | sed 's/\[.*//;s/[[:space:]]//g')
TOKEN0_IS_WETH=\$(python3 -c "print(1 if '${WETH}'.lower() < '${KRK}'.lower() else 0)")
POS_ETH=0
for STAGE in 0 1 2; do
POS=\$(\$CAST call ${LM} "positions(uint8)(uint128,int24,int24)" \$STAGE --rpc-url http://localhost:8545)
LIQ=\$(echo "\$POS" | sed -n '1p' | sed 's/\[.*//;s/[[:space:]]//g')
TL=\$(echo "\$POS" | sed -n '2p' | sed 's/\[.*//;s/[[:space:]]//g')
TU=\$(echo "\$POS" | sed -n '3p' | sed 's/\[.*//;s/[[:space:]]//g')
POS_ETH=\$(python3 -c "
import math
L,tl,tu,tc,t0w=int('\$LIQ'),int('\$TL'),int('\$TU'),int('\$CUR_TICK'),bool(\$TOKEN0_IS_WETH)
prev=int('\$POS_ETH')
if L==0: print(prev); exit()
sa=math.sqrt(1.0001**tl); sb=math.sqrt(1.0001**tu); sc=math.sqrt(1.0001**tc)
if t0w:
e=L*(1/sa-1/sb) if tc<tl else (0 if tc>=tu else L*(1/sc-1/sb))
else:
e=L*(sb-sa) if tc>=tu else (0 if tc<tl else L*(sc-sa))
print(prev+int(e))
")
done
TOTAL=\$(python3 -c "print(int('\$LM_ETH')+int('\$LM_WETH')+int('\$POS_ETH'))")
echo "Total LM ETH: \$TOTAL wei (free: \$LM_ETH + \$LM_WETH, positions: \$POS_ETH)"
echo "Started with: ${LM_ETH_BEFORE} wei"
python3 -c "b=${LM_ETH_BEFORE}; a=int('\$TOTAL'); d=b-a; print(f'Delta: {d} wei ({d*100//b if b else 0}% extracted)' if d>0 else f'Delta: {d} wei (LM gained ETH)')"
\`\`\`
### Wrap ETH to WETH
@ -519,19 +593,30 @@ SNAP=\$(/home/debian/.foundry/bin/cast rpc anvil_snapshot --rpc-url http://local
---
## Constraints
- **feeDestination = LM itself** — fees are NOT extracted, they accrue as LM liquidity.
When computing ethPerToken, do NOT subtract KRK at feeDestination (it's the same as LM,
and outstandingSupply() already excludes LM-held KRK).
- **LM has ~1000 ETH reserve** — proportional to your 10,000 ETH (10:1 ratio). This is a
realistic attack scenario, not an empty vault.
- **You MUST NOT call anvil_reset, anvil_setCode, or anvil_setStorageAt.** These are infra
cheats that invalidate the test. Use only swap/stake/LP/recenter protocol operations.
## Rules
1. You have 10 000 ETH. Start by wrapping some if you need WETH for swaps.
2. Your goal is to make ethPerToken DECREASE vs the starting value (${FLOOR_BEFORE} wei/token).
1. You have ~9000 ETH (after funding LM with 1000 ETH). Start by wrapping some if you need WETH for swaps.
2. Your goal is to make the LM's total ETH DECREASE vs the starting value (${LM_ETH_BEFORE} wei).
3. Try at least 3 distinct strategies. After each attempt:
a. Run the floor-check command above to measure ethPerToken.
b. If the floor DECREASED — report this as a SUCCESS and describe the exact steps.
c. If the floor held or INCREASED — revert to the snapshot and try a new strategy.
a. Run the total LM ETH check command above.
b. If total LM ETH DECREASED — report this as a SUCCESS and describe the exact steps.
c. If LM ETH held or INCREASED — revert to the snapshot and try a new strategy.
Remember: \`anvil_revert\` is one-shot. Take a new snapshot immediately after reverting.
4. You may chain multiple actions in one strategy (e.g. large buy → recenter → large sell).
5. Be methodical. Report every strategy tried even if it failed.
6. If Previous Findings are provided, DO NOT repeat those strategies. Use their insights to design new approaches.
7. Prioritize untried COMBINATIONS: staking + LP, staking + recenter timing, LP + multi-step swaps, etc.
8. Start executing immediately. No lengthy planning — act, measure, iterate.
---
@ -544,21 +629,21 @@ After trying all strategies, output a clearly structured report:
\`\`\`
=== RED-TEAM REPORT ===
floor_before: <value> wei/token
lm_eth_before: <value> wei (total: free + positions)
STRATEGY 1: <name>
Steps: <what you did>
floor_after: <value>
Result: INCREASED / HELD / DECREASED
lm_eth_after: <value> wei
Result: ETH_EXTRACTED / ETH_SAFE / ETH_GAINED
STRATEGY 2: ...
...
=== CONCLUSION ===
Floor broken: YES / NO
ETH extracted: YES / NO
Winning strategy: <describe if YES, else "None">
floor_before: ${FLOOR_BEFORE} wei/token
floor_after: <final value>
lm_eth_before: ${LM_ETH_BEFORE} wei
lm_eth_after: <final value> wei
\`\`\`
PROMPT_EOF
)
@ -601,37 +686,37 @@ with open(sys.argv[1]) as f:
PYEOF
# If the agent crashed and produced no readable output, treat as an infra error
# rather than silently reporting FLOOR HELD (a false pass).
# rather than silently reporting ETH SAFE (a false pass).
if [[ $AGENT_EXIT -ne 0 && ! -s "$REPORT" ]]; then
die "claude agent failed (exit $AGENT_EXIT) with no readable output — see $STREAM_LOG"
fi
# ── 8. Read floor_after ────────────────────────────────────────────────────────
# ── 8. Read lm_eth_after ────────────────────────────────────────────────────────
log "Reading floor after agent run..."
FLOOR_AFTER=$(compute_eth_per_token)
LM_ETH_AFTER=$(compute_lm_total_eth)
# ── 8a. Extract and persist strategy findings ──────────────────────────────────
log "Extracting strategy findings from agent output..."
extract_memory "$STREAM_LOG"
log " floor_after = $FLOOR_AFTER wei/token"
log " lm_eth_after = $LM_ETH_AFTER wei"
# ── 9. Summarise results ───────────────────────────────────────────────────────
log ""
log "=== RED-TEAM SUMMARY ==="
log ""
log " floor_before : $FLOOR_BEFORE wei/token"
log " floor_after : $FLOOR_AFTER wei/token"
log " lm_eth_before : $LM_ETH_BEFORE wei"
log " lm_eth_after : $LM_ETH_AFTER wei"
log ""
BROKE=false
if python3 -c "import sys; sys.exit(0 if int('$FLOOR_AFTER') < int('$FLOOR_BEFORE') else 1)"; then
if python3 -c "import sys; sys.exit(0 if int('$LM_ETH_AFTER') < int('$LM_ETH_BEFORE') else 1)"; then
BROKE=true
fi
if [[ "$BROKE" == "true" ]]; then
DELTA=$(python3 -c "print($FLOOR_BEFORE - $FLOOR_AFTER)")
log " RESULT: FLOOR BROKEN ❌"
log " Decrease: $DELTA wei/token"
DELTA=$(python3 -c "print($LM_ETH_BEFORE - $LM_ETH_AFTER)")
log " RESULT: ETH EXTRACTED ❌"
log " Decrease: $DELTA wei"
log ""
log " See $REPORT for the winning strategy."
log ""
@ -639,24 +724,24 @@ if [[ "$BROKE" == "true" ]]; then
cat >>"$REPORT" <<SUMMARY_EOF
=== RUNNER SUMMARY ===
floor_before : $FLOOR_BEFORE
floor_after : $FLOOR_AFTER
lm_eth_before : $LM_ETH_BEFORE
lm_eth_after : $LM_ETH_AFTER
delta : -$DELTA
verdict : FLOOR_BROKEN
verdict : ETH_EXTRACTED
SUMMARY_EOF
exit 1
else
log " RESULT: FLOOR HELD ✅"
log " RESULT: ETH SAFE ✅"
log ""
log " See $REPORT for strategies attempted."
log ""
cat >>"$REPORT" <<SUMMARY_EOF
=== RUNNER SUMMARY ===
floor_before : $FLOOR_BEFORE
floor_after : $FLOOR_AFTER
lm_eth_before : $LM_ETH_BEFORE
lm_eth_after : $LM_ETH_AFTER
delta : 0 (or increase)
verdict : FLOOR_HELD
verdict : ETH_SAFE
SUMMARY_EOF
exit 0
fi