harb/onchain/test/FitnessEvaluator.t.sol
openhands defa1bfb6c fix: fix: Fitness metric should measure ETH only, not token value (#670)
Replace _positionEthValue() with _positionEthOnly() in FitnessEvaluator.t.sol.
The new function returns only the WETH component of each position (amount0 if
token0isWeth, else amount1), ignoring KRK token value entirely. This prevents
evolution from gaming the fitness metric by inflating KRK price through position
placement — the score now reflects actual ETH reserves only.

Also removes the now-unused FullMath import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 09:11:31 +00:00

640 lines
28 KiB
Solidity

// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
/**
* @title FitnessEvaluator
* @notice In-process (revm) batch fitness evaluator for Push3 evolution.
*
* Replaces the Anvil+forge-script pipeline with in-process EVM execution.
* Uses Foundry's native revm backend: vm.snapshot/revertTo are memory operations
* with no JSON-RPC overhead, giving 100-1000x speedup over per-candidate Anvil.
*
* Architecture:
* batch-eval.sh compiles each candidate (Push3→Solidity→bytecode) and writes a
* two-file manifest (ids.txt + bytecodes.txt). This test reads the manifest,
* forks Base mainnet once, deploys the full KRAIKEN stack once, then for each
* candidate:
* 1. snapshot → etch candidate bytecode → UUPS upgrade proxy → bootstrap
* 2. For each attack: snapshot → execute → accumulate lm_eth_total → revert
* 3. Emit JSON score line
* 4. Revert to pre-bootstrap snapshot
*
* Required env vars:
* BASE_RPC_URL Base network RPC endpoint (for fork)
* FITNESS_MANIFEST_DIR Directory containing ids.txt and bytecodes.txt
*
* Optional env vars:
* ATTACKS_DIR Path to *.jsonl attack files (default: script/backtesting/attacks)
*
* Run:
* BASE_RPC_URL=https://mainnet.base.org \
* FITNESS_MANIFEST_DIR=/tmp/manifest \
* forge test --match-contract FitnessEvaluator --match-test testBatchEvaluate -vv
*/
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";
import { IERC20 } from "@openzeppelin/token/ERC20/IERC20.sol";
import { IUniswapV3Factory } from "@uniswap-v3-core/interfaces/IUniswapV3Factory.sol";
import { IUniswapV3Pool } from "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol";
import { LiquidityAmounts } from "@aperture/uni-v3-lib/LiquidityAmounts.sol";
import { TickMath } from "@aperture/uni-v3-lib/TickMath.sol";
import { UniswapHelpers } from "../src/helpers/UniswapHelpers.sol";
import { IWETH9 } from "../src/interfaces/IWETH9.sol";
// ─── External interfaces (mirrors AttackRunner.s.sol) ─────────────────────────
interface ISwapRouter02 {
struct ExactInputSingleParams {
address tokenIn;
address tokenOut;
uint24 fee;
address recipient;
uint256 amountIn;
uint256 amountOutMinimum;
uint160 sqrtPriceLimitX96;
}
function exactInputSingle(ExactInputSingleParams calldata params) external returns (uint256);
}
interface ILM {
function getVWAP() external view returns (uint256);
function positions(uint8 stage) external view returns (uint128 liquidity, int24 tickLower, int24 tickUpper);
function recenter() external returns (bool);
}
interface IStake {
function snatch(uint256 assets, address receiver, uint32 taxRate, uint256[] calldata positionsToSnatch)
external
returns (uint256 positionId);
function exitPosition(uint256 positionId) external;
}
interface INonfungiblePositionManager {
struct MintParams {
address token0;
address token1;
uint24 fee;
int24 tickLower;
int24 tickUpper;
uint256 amount0Desired;
uint256 amount1Desired;
uint256 amount0Min;
uint256 amount1Min;
address recipient;
uint256 deadline;
}
struct DecreaseLiquidityParams {
uint256 tokenId;
uint128 liquidity;
uint256 amount0Min;
uint256 amount1Min;
uint256 deadline;
}
struct CollectParams {
uint256 tokenId;
address recipient;
uint128 amount0Max;
uint128 amount1Max;
}
function mint(MintParams calldata params) external payable returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1);
function positions(uint256 tokenId)
external
view
returns (
uint96 nonce,
address operator,
address token0,
address token1,
uint24 fee,
int24 tickLower,
int24 tickUpper,
uint128 liquidity,
uint256 feeGrowthInside0LastX128,
uint256 feeGrowthInside1LastX128,
uint128 tokensOwed0,
uint128 tokensOwed1
);
function decreaseLiquidity(DecreaseLiquidityParams calldata params) external payable returns (uint256 amount0, uint256 amount1);
function collect(CollectParams calldata params) external payable returns (uint256 amount0, uint256 amount1);
}
// ─── Main test contract ────────────────────────────────────────────────────────
contract FitnessEvaluator is Test {
using UniswapHelpers for IUniswapV3Pool;
// ─── Base network constants ───────────────────────────────────────────────
uint24 internal constant POOL_FEE = 10_000;
address internal constant WETH_ADDR = 0x4200000000000000000000000000000000000006;
address internal constant SWAP_ROUTER = 0x94cC0AaC535CCDB3C01d6787D6413C739ae12bc4;
address internal constant NPM_ADDR = 0x27F971cb582BF9E50F397e4d29a5C7A34f11faA2;
address internal constant V3_FACTORY = 0x4752ba5DBc23f44D87826276BF6Fd6b1C372aD24;
address internal constant FEE_DEST = 0xf6a3eef9088A255c32b6aD2025f83E57291D9011;
/// @dev Fixed address used with vm.etch to inject candidate bytecode.
/// 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)
uint256 internal constant ADV_PK = 0xdbda1821b80551c9d65939329250298aa3472ba22feea921c0cf5d620ea67b97;
/// @dev Account 2 — recenter caller (granted recenterAccess in bootstrap)
uint256 internal constant RECENTER_PK = 0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a;
// ─── Runtime state ────────────────────────────────────────────────────────
address internal lmAddr;
address internal krkAddr;
address internal stakeAddr;
address internal optProxy;
address internal advAddr;
address internal recenterAddr;
IUniswapV3Pool internal pool;
bool internal token0isWeth;
/// @dev Mirrors AttackRunner._stakedPositionIds: position IDs returned by stake ops.
/// vm.snapshot/revertTo reverts this array's storage between attacks.
uint256[] internal _stakedPositionIds;
/// @dev NPM tokenIds returned by mint_lp ops (in insertion order).
/// burn_lp references positions by 1-based index into this array so that
/// attack files are fork-block-independent (tokenIds vary by fork tip).
uint256[] internal _mintedNpmTokenIds;
// ─── Entry point ─────────────────────────────────────────────────────────
/**
* @notice Batch fitness evaluator: score all candidates in the manifest.
*
* Reads FITNESS_MANIFEST_DIR/{ids.txt,bytecodes.txt} line-by-line.
* Outputs one JSON line per candidate to stdout:
* {"candidate_id":"gen0_c000","fitness":1234567890}
*
* Skipped (with a pass) if BASE_RPC_URL is not set, so CI without a Base
* RPC key does not fail the test suite.
*/
function testBatchEvaluate() public {
string memory rpcUrl = vm.envOr("BASE_RPC_URL", string(""));
vm.skip(bytes(rpcUrl).length == 0);
string memory manifestDir = vm.envOr("FITNESS_MANIFEST_DIR", string(""));
require(bytes(manifestDir).length > 0, "FITNESS_MANIFEST_DIR env var required");
string memory attacksDir = vm.envOr("ATTACKS_DIR", string("script/backtesting/attacks"));
// Fork Base mainnet so Uniswap V3, WETH, etc. exist at canonical addresses.
vm.createSelectFork(rpcUrl);
advAddr = vm.addr(ADV_PK);
recenterAddr = vm.addr(RECENTER_PK);
// Deploy the full KRAIKEN stack once on the fork.
_deploy();
// Snapshot after deployment (pre-bootstrap, pre-candidate-specific state).
uint256 baseSnap = vm.snapshot();
// Discover attack files (sorted alphabetically by path).
string memory idsFile = string.concat(manifestDir, "/ids.txt");
string memory bytecodesFile = string.concat(manifestDir, "/bytecodes.txt");
// Process candidates one at a time.
while (true) {
string memory candidateId = vm.readLine(idsFile);
string memory bytecodeHex = vm.readLine(bytecodesFile);
if (bytes(candidateId).length == 0) break;
// Revert to clean post-deploy state for each candidate.
vm.revertTo(baseSnap);
baseSnap = vm.snapshot();
// 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);
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.
if (!_bootstrap()) {
console.log(string.concat('{"candidate_id":"', candidateId, '","fitness":0,"error":"bootstrap_failed"}'));
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);
for (uint256 i = 0; i < entries.length; i++) {
if (entries[i].isDir || !_endsWith(entries[i].path, ".jsonl")) continue;
uint256 atkSnap = vm.snapshot();
uint256 score = _runAttack(entries[i].path);
totalFitness += score;
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(adjustedFitness),
',"gas_used":', _uint2str(gasForCalcParams),
"}"
)
);
}
// Close manifest files.
vm.closeFile(idsFile);
vm.closeFile(bytecodesFile);
}
// ─── Deployment ───────────────────────────────────────────────────────────
/**
* @notice Deploy the full KRAIKEN stack (mirrors DeployLocal.sol).
* @dev All contracts are deployed as address(this) (the test contract),
* which becomes the UUPS admin for the Optimizer proxy.
*/
function _deploy() internal {
// Deploy Kraiken token.
Kraiken kraiken = new Kraiken("Kraiken", "KRK");
krkAddr = address(kraiken);
token0isWeth = WETH_ADDR < krkAddr;
// Deploy Stake.
Stake stake = new Stake(krkAddr, FEE_DEST);
stakeAddr = address(stake);
kraiken.setStakingPool(stakeAddr);
// Get or create Uniswap V3 pool.
IUniswapV3Factory factory = IUniswapV3Factory(V3_FACTORY);
address poolAddr = factory.getPool(WETH_ADDR, krkAddr, POOL_FEE);
if (poolAddr == address(0)) {
poolAddr = factory.createPool(WETH_ADDR, krkAddr, POOL_FEE);
}
pool = IUniswapV3Pool(poolAddr);
// Initialize pool at 1-cent price if not already initialized.
(uint160 sqrtPriceX96,,,,,,) = pool.slot0();
if (sqrtPriceX96 == 0) {
pool.initializePoolFor1Cent(token0isWeth);
}
// Deploy Optimizer implementation + UUPS proxy.
// address(this) (test contract) becomes the UUPS admin via initialize.
Optimizer optimizerImpl = new Optimizer();
bytes memory initData = abi.encodeWithSignature("initialize(address,address)", krkAddr, stakeAddr);
ERC1967Proxy proxy = new ERC1967Proxy(address(optimizerImpl), initData);
optProxy = address(proxy);
// Deploy LiquidityManager.
LiquidityManager lm = new LiquidityManager(V3_FACTORY, WETH_ADDR, krkAddr, optProxy);
lmAddr = address(lm);
// Wire contracts together.
lm.setFeeDestination(FEE_DEST);
kraiken.setLiquidityManager(lmAddr);
}
// ─── Bootstrap ────────────────────────────────────────────────────────────
/**
* @notice Bootstrap LM state for a candidate evaluation (mirrors fitness.sh bootstrap).
*
* Steps (same order as fitness.sh):
* a. Grant recenterAccess to recenterAddr (impersonate feeDestination).
* b. Fund adversary account and wrap ETH → WETH.
* c. Transfer 1000 WETH to LM.
* d. Wrap 9000 WETH for adversary trades + set approvals.
* e. Initial recenter (succeeds immediately: recenterAccess set, no ANCHOR liquidity yet).
*/
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);
// b. Fund adversary with ETH.
vm.deal(advAddr, 10_000 ether);
// c. Wrap 1000 ETH → WETH and send to LM.
vm.startPrank(advAddr);
IWETH9(WETH_ADDR).deposit{ value: 1_000 ether }();
IERC20(WETH_ADDR).transfer(lmAddr, 1_000 ether);
vm.stopPrank();
// d. Wrap remaining 9000 ETH for trade operations + set approvals.
vm.startPrank(advAddr);
IWETH9(WETH_ADDR).deposit{ value: 9_000 ether }();
IERC20(WETH_ADDR).approve(SWAP_ROUTER, type(uint256).max);
IERC20(WETH_ADDR).approve(NPM_ADDR, type(uint256).max);
IERC20(krkAddr).approve(SWAP_ROUTER, type(uint256).max);
IERC20(krkAddr).approve(stakeAddr, type(uint256).max);
IERC20(krkAddr).approve(NPM_ADDR, type(uint256).max);
vm.stopPrank();
// e. Initial recenter: no ANCHOR position exists yet so amplitude check is skipped;
// recenterAccess is set so TWAP stability check is also skipped.
// If all retries fail, revert with a clear message — silent failure would make every
// candidate score identically (all lm_eth_total = free WETH only, no positions).
bool recentered = false;
for (uint256 _attempt = 0; _attempt < 5; _attempt++) {
if (_attempt > 0) vm.roll(block.number + 50);
vm.prank(recenterAddr);
try ILM(lmAddr).recenter() returns (bool) {
recentered = true;
break;
} catch (bytes memory reason) {
console.log(string.concat("recenter attempt ", vm.toString(_attempt), " failed"));
console.logBytes(reason);
}
}
if (!recentered) {
return false;
}
return true;
}
// ─── Attack execution ─────────────────────────────────────────────────────
/**
* @notice Execute one attack sequence and return the final lm_eth_total.
* @param attackFile Path to the *.jsonl attack file.
*/
function _runAttack(string memory attackFile) internal returns (uint256) {
// Reset file read position so each call to _runAttack starts from line 1.
vm.closeFile(attackFile);
// vm.revertTo() reverts all EVM state including test contract storage, so these
// arrays are already empty after revert. Explicit delete is a defensive reset
// for the first attack (no preceding revert) and any future call-path changes.
delete _stakedPositionIds;
delete _mintedNpmTokenIds;
string memory line = vm.readLine(attackFile);
while (bytes(line).length > 0) {
_executeOp(line);
line = vm.readLine(attackFile);
}
return _computeLmEthTotal();
}
/**
* @notice Execute a single attack operation (mirrors AttackRunner._execute).
*/
function _executeOp(string memory line) internal {
string memory op = vm.parseJsonString(line, ".op");
if (_eq(op, "buy")) {
uint256 amount = vm.parseUint(vm.parseJsonString(line, ".amount"));
vm.prank(advAddr);
try ISwapRouter02(SWAP_ROUTER).exactInputSingle(
ISwapRouter02.ExactInputSingleParams({
tokenIn: WETH_ADDR,
tokenOut: krkAddr,
fee: POOL_FEE,
recipient: advAddr,
amountIn: amount,
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);
try ISwapRouter02(SWAP_ROUTER).exactInputSingle(
ISwapRouter02.ExactInputSingleParams({
tokenIn: krkAddr,
tokenOut: WETH_ADDR,
fee: POOL_FEE,
recipient: advAddr,
amountIn: amount,
amountOutMinimum: 0,
sqrtPriceLimitX96: 0
})
) { } catch { }
} else if (_eq(op, "recenter")) {
vm.prank(recenterAddr);
try ILM(lmAddr).recenter() { } catch { }
} else if (_eq(op, "stake")) {
uint256 amount = vm.parseUint(vm.parseJsonString(line, ".amount"));
uint32 taxRate = uint32(vm.parseJsonUint(line, ".taxRateIndex"));
vm.prank(advAddr);
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");
if (posIndex < 1 || posIndex > _stakedPositionIds.length) return;
vm.prank(advAddr);
try IStake(stakeAddr).exitPosition(_stakedPositionIds[posIndex - 1]) { } catch { }
} else if (_eq(op, "mine")) {
uint256 blocks = vm.parseJsonUint(line, ".blocks");
vm.roll(block.number + blocks);
} else if (_eq(op, "mint_lp")) {
int24 tickLower = int24(vm.parseJsonInt(line, ".tickLower"));
int24 tickUpper = int24(vm.parseJsonInt(line, ".tickUpper"));
uint256 amount0 = vm.parseUint(vm.parseJsonString(line, ".amount0"));
uint256 amount1 = vm.parseUint(vm.parseJsonString(line, ".amount1"));
(address t0, address t1) = token0isWeth ? (WETH_ADDR, krkAddr) : (krkAddr, WETH_ADDR);
vm.prank(advAddr);
// Track the returned tokenId so burn_lp can reference it by 1-based index,
// making attack files fork-block-independent (NPM tokenIds depend on fork tip).
(uint256 mintedTokenId,,,) = INonfungiblePositionManager(NPM_ADDR).mint(
INonfungiblePositionManager.MintParams({
token0: t0,
token1: t1,
fee: POOL_FEE,
tickLower: tickLower,
tickUpper: tickUpper,
amount0Desired: amount0,
amount1Desired: amount1,
amount0Min: 0,
amount1Min: 0,
recipient: advAddr,
deadline: block.timestamp + 3600
})
);
_mintedNpmTokenIds.push(mintedTokenId);
} else if (_eq(op, "burn_lp")) {
// .tokenId in the attack file is a 1-based index into _mintedNpmTokenIds
// (positions created by mint_lp ops in this run), not a raw NPM tokenId.
// This mirrors the stake/unstake index pattern and avoids fork-block sensitivity.
uint256 tokenIndex = vm.parseJsonUint(line, ".tokenId");
require(
tokenIndex >= 1 && tokenIndex <= _mintedNpmTokenIds.length,
"FitnessEvaluator: burn_lp tokenId out of range (must be 1-based index of a prior mint_lp op)"
);
uint256 tokenId = _mintedNpmTokenIds[tokenIndex - 1];
(,,,,,, , uint128 liquidity,,,,) = INonfungiblePositionManager(NPM_ADDR).positions(tokenId);
if (liquidity == 0) return;
vm.startPrank(advAddr);
INonfungiblePositionManager(NPM_ADDR).decreaseLiquidity(
INonfungiblePositionManager.DecreaseLiquidityParams({
tokenId: tokenId,
liquidity: liquidity,
amount0Min: 0,
amount1Min: 0,
deadline: block.timestamp + 3600
})
);
INonfungiblePositionManager(NPM_ADDR).collect(
INonfungiblePositionManager.CollectParams({
tokenId: tokenId,
recipient: advAddr,
amount0Max: type(uint128).max,
amount1Max: type(uint128).max
})
);
vm.stopPrank();
}
// Unknown ops are silently ignored (mirrors AttackRunner behaviour).
}
// ─── Score computation ────────────────────────────────────────────────────
/**
* @notice Compute lm_eth_total = free ETH + free WETH + sum(WETH in each position).
* Only counts real ETH reserves — KRK token value is excluded to prevent
* evolution from gaming the metric by inflating token price through positioning.
*/
function _computeLmEthTotal() internal view returns (uint256) {
(uint160 sqrtPriceX96,,,,,,) = pool.slot0();
uint256 lmEthFree = lmAddr.balance;
uint256 lmWethFree = IERC20(WETH_ADDR).balanceOf(lmAddr);
(uint128 fLiq, int24 fLo, int24 fHi) = ILM(lmAddr).positions(0); // FLOOR
(uint128 aLiq, int24 aLo, int24 aHi) = ILM(lmAddr).positions(1); // ANCHOR
(uint128 dLiq, int24 dLo, int24 dHi) = ILM(lmAddr).positions(2); // DISCOVERY
return lmEthFree
+ lmWethFree
+ _positionEthOnly(sqrtPriceX96, fLo, fHi, fLiq)
+ _positionEthOnly(sqrtPriceX96, aLo, aHi, aLiq)
+ _positionEthOnly(sqrtPriceX96, dLo, dHi, dLiq);
}
/**
* @notice WETH-only component of a Uniswap V3 position at the current price.
* Ignores KRK token value entirely — counts only the actual ETH backing.
*/
function _positionEthOnly(
uint160 sqrtPriceX96,
int24 tickLower,
int24 tickUpper,
uint128 liquidity
)
internal
view
returns (uint256)
{
if (liquidity == 0) return 0;
uint160 sqrtRatioAX96 = TickMath.getSqrtRatioAtTick(tickLower);
uint160 sqrtRatioBX96 = TickMath.getSqrtRatioAtTick(tickUpper);
(uint256 amount0, uint256 amount1) =
LiquidityAmounts.getAmountsForLiquidity(sqrtPriceX96, sqrtRatioAX96, sqrtRatioBX96, liquidity);
return token0isWeth ? amount0 : amount1;
}
// ─── Utilities ────────────────────────────────────────────────────────────
function _eq(string memory a, string memory b) internal pure returns (bool) {
return keccak256(bytes(a)) == keccak256(bytes(b));
}
function _endsWith(string memory str, string memory suffix) internal pure returns (bool) {
bytes memory bStr = bytes(str);
bytes memory bSuf = bytes(suffix);
if (bStr.length < bSuf.length) return false;
uint256 offset = bStr.length - bSuf.length;
for (uint256 i = 0; i < bSuf.length; i++) {
if (bStr[offset + i] != bSuf[i]) return false;
}
return true;
}
function _uint2str(uint256 n) internal pure returns (string memory) {
if (n == 0) return "0";
uint256 temp = n;
uint256 digits;
while (temp != 0) {
digits++;
temp /= 10;
}
bytes memory buffer = new bytes(digits);
while (n != 0) {
digits--;
buffer[digits] = bytes1(uint8(48 + (n % 10)));
n /= 10;
}
return string(buffer);
}
}