/** * Floor price helpers for the red-team agent feedback loop. * * ethPerToken is not a contract view function — it is computed off-chain as * lmTotalEth / adjustedOutstandingSupply, where: * - lmTotalEth = native ETH + WETH held by LiquidityManager * (mirrors ThreePositionStrategy._getEthBalance()) * - adjustedSupply = kraiken.outstandingSupply() minus KRK at * feeDestination and stakingPool * (mirrors LiquidityManager._getOutstandingSupply()) */ import { Interface } from 'ethers'; import { rpcCall } from './rpc.js'; // Base WETH address — stable across Anvil forks of Base Sepolia. const WETH = '0x4200000000000000000000000000000000000006'; const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; const KRK_ABI = [ 'function outstandingSupply() external view returns (uint256)', 'function peripheryContracts() external view returns (address, address)', 'function balanceOf(address account) external view returns (uint256)', ]; /** feeDestination() is the auto-generated getter for the public storage var on LiquidityManager. */ const LM_ABI = ['function feeDestination() external view returns (address)']; const ERC20_ABI = ['function balanceOf(address account) external view returns (uint256)']; const krkIface = new Interface(KRK_ABI); const lmIface = new Interface(LM_ABI); const erc20Iface = new Interface(ERC20_ABI); /** * Read full floor diagnostics for the LiquidityManager / Kraiken pair. * * All reads are issued in at most two parallel rounds to minimise latency: * Round 1: ETH/WETH balances, raw outstanding supply, feeDestination, peripheryContracts * Round 2 (conditional): balanceOf(feeDestination) and/or balanceOf(stakingPool) when non-zero * * outstandingSupply in the return value mirrors LiquidityManager._getOutstandingSupply(): * it starts from kraiken.outstandingSupply() then subtracts KRK held at feeDestination * and stakingPool, since neither can be sold into the floor. * * ethPerToken = (lmNativeEth + lmWeth) / adjustedOutstandingSupply. * Returns ethPerToken = 0n when outstandingSupply is zero (uninitialized pool). * * @param lmAddress - LiquidityManager contract address. * @param krkAddress - Kraiken contract address. */ export async function getFloorState( rpcUrl: string, lmAddress: string, krkAddress: string, ): Promise<{ ethPerToken: bigint; lmEthBalance: bigint; lmWethBalance: bigint; outstandingSupply: bigint; }> { // Round 1: five reads in parallel. const [lmEthHex, lmWethHex, rawSupplyHex, feeDestHex, peripheryHex] = (await Promise.all([ rpcCall(rpcUrl, 'eth_getBalance', [lmAddress, 'latest']), rpcCall(rpcUrl, 'eth_call', [{ to: WETH, data: erc20Iface.encodeFunctionData('balanceOf', [lmAddress]) }, 'latest']), rpcCall(rpcUrl, 'eth_call', [{ to: krkAddress, data: krkIface.encodeFunctionData('outstandingSupply', []) }, 'latest']), rpcCall(rpcUrl, 'eth_call', [{ to: lmAddress, data: lmIface.encodeFunctionData('feeDestination', []) }, 'latest']), rpcCall(rpcUrl, 'eth_call', [{ to: krkAddress, data: krkIface.encodeFunctionData('peripheryContracts', []) }, 'latest']), ])) as [string, string, string, string, string]; const [lmWethRaw] = erc20Iface.decodeFunctionResult('balanceOf', lmWethHex); const [rawSupply] = krkIface.decodeFunctionResult('outstandingSupply', rawSupplyHex); const [feeDestination] = lmIface.decodeFunctionResult('feeDestination', feeDestHex); // peripheryContracts() returns (liquidityManager, stakingPool) — we only need the second. const [, stakingPool] = krkIface.decodeFunctionResult('peripheryContracts', peripheryHex); const lmEthBalance = BigInt(lmEthHex); const lmWethBalance = BigInt(lmWethRaw); // Round 2: subtract excluded KRK balances (matches _getOutstandingSupply logic). const isZero = (addr: string) => addr.toLowerCase() === ZERO_ADDRESS; const excluded: string[] = []; if (!isZero(feeDestination as string)) excluded.push(feeDestination as string); if (!isZero(stakingPool as string)) excluded.push(stakingPool as string); let supply = BigInt(rawSupply); if (excluded.length > 0) { const balHexes = (await Promise.all( excluded.map(addr => rpcCall(rpcUrl, 'eth_call', [{ to: krkAddress, data: krkIface.encodeFunctionData('balanceOf', [addr]) }, 'latest']), ), )) as string[]; for (const hex of balHexes) { const [bal] = krkIface.decodeFunctionResult('balanceOf', hex); supply -= BigInt(bal); } } const lmTotalEth = lmEthBalance + lmWethBalance; // Scale by 1e18 (WAD) before dividing so the result is in wei-per-token // rather than always 0n. Example: 100 ETH / 1M KRK = 1e20 * 1e18 / 1e24 = 1e14 wei/token. const ethPerToken = supply === 0n ? 0n : (lmTotalEth * 10n ** 18n) / supply; return { ethPerToken, lmEthBalance, lmWethBalance, outstandingSupply: supply }; } /** * Compute the current floor price (ethPerToken) from LM state. * * Delegates to getFloorState(); callers that need multiple fields should call * getFloorState() directly to avoid redundant RPC calls. * * @param krkAddress - Kraiken contract address. * @param lmAddress - LiquidityManager contract address. * @returns ethPerToken in wei, 0n when outstanding supply is zero. */ export async function getEthPerToken(rpcUrl: string, krkAddress: string, lmAddress: string): Promise { const { ethPerToken } = await getFloorState(rpcUrl, lmAddress, krkAddress); return ethPerToken; }