// 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; // Base mainnet WETH — https://basescan.org/address/0x4200000000000000000000000000000000000006 address internal constant WETH_ADDR = 0x4200000000000000000000000000000000000006; // Base mainnet SwapRouter02 — https://basescan.org/address/0x2626664c2603336E57B271c5C0b26F421741e481 address internal constant SWAP_ROUTER = 0x2626664c2603336E57B271c5C0b26F421741e481; // Base mainnet NonfungiblePositionManager — https://basescan.org/address/0x03a520b32C04BF3bEEf7BEb72E919cf822Ed34f1 address internal constant NPM_ADDR = 0x03a520b32C04BF3bEEf7BEb72E919cf822Ed34f1; // Base mainnet Uniswap V3 Factory — https://basescan.org/address/0x33128a8fC17869897dcE68Ed026d694621f6FDfD address internal constant V3_FACTORY = 0x33128a8fC17869897dcE68Ed026d694621f6FDfD; /// @dev Intentionally differs from DeployBaseMainnet.sol (0xf6a3...D9011). /// On a Base mainnet fork that address already has contract bytecode, which /// causes LiquidityManager.setFeeDestination() to set feeDestinationLocked=true /// and subsequently revert if called again during snapshot/revert cycles. /// This keccak-derived address is a guaranteed EOA on any live network. address internal constant FEE_DEST = 0x8A9145E1Ea4C4d7FB08cF1011c8ac1F0e10F9383; /// @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 = 500_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 500 k gas (hard cap boundary): ~5e18 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 (recenter() is now permissionless) 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; if (bytes(bytecodeHex).length == 0) { console.log("FitnessEvaluator: bytecodes.txt EOF before ids.txt - pipeline mismatch, stopping"); 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, 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. Fund adversary account and wrap ETH → WETH. * b. Transfer 1000 WETH to LM. * c. Wrap 9000 WETH for adversary trades + set approvals. * d. Initial recenter (callable by anyone: cooldown passes because block.timestamp on a * Base fork is a large value >> 60; TWAP passes because the pool has existing history). */ function _bootstrap() internal returns (bool) { // a. 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(); // d. Initial recenter: no ANCHOR position exists yet so amplitude check is skipped. // Cooldown passes (Base fork timestamp >> 60). TWAP passes (existing pool history). // 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.warp(block.timestamp + 600); } 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) { // Skip comment lines (e.g. "// schema-version: 1" header). if (bytes(line).length >= 2 && bytes(line)[0] == 0x2F && bytes(line)[1] == 0x2F) { line = vm.readLine(attackFile); continue; } _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). try 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 }) ) returns (uint256 mintedTokenId, uint128, uint256, uint256) { _mintedNpmTokenIds.push(mintedTokenId); } catch { } } 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"); if (tokenIndex < 1 || tokenIndex > _mintedNpmTokenIds.length) return; uint256 tokenId = _mintedNpmTokenIds[tokenIndex - 1]; (,,,,,, , uint128 liquidity,,,,) = INonfungiblePositionManager(NPM_ADDR).positions(tokenId); if (liquidity == 0) return; vm.startPrank(advAddr); try INonfungiblePositionManager(NPM_ADDR).decreaseLiquidity( INonfungiblePositionManager.DecreaseLiquidityParams({ tokenId: tokenId, liquidity: liquidity, amount0Min: 0, amount1Min: 0, deadline: block.timestamp + 3600 }) ) { try INonfungiblePositionManager(NPM_ADDR).collect( INonfungiblePositionManager.CollectParams({ tokenId: tokenId, recipient: advAddr, amount0Max: type(uint128).max, amount1Max: type(uint128).max }) ) { } catch { } } catch { } 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); } }