From b2db3c7ae5406c8f011c34f1cadb8cc4d129e978 Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 9 Mar 2026 00:12:49 +0000 Subject: [PATCH 1/3] fix: feat: Anvil snapshot/revert and ethPerToken helpers (#519) Co-Authored-By: Claude Sonnet 4.6 --- scripts/harb-evaluator/helpers/anvil.ts | 36 +++++++++++ scripts/harb-evaluator/helpers/floor.ts | 79 +++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 scripts/harb-evaluator/helpers/anvil.ts create mode 100644 scripts/harb-evaluator/helpers/floor.ts diff --git a/scripts/harb-evaluator/helpers/anvil.ts b/scripts/harb-evaluator/helpers/anvil.ts new file mode 100644 index 0000000..a87efb7 --- /dev/null +++ b/scripts/harb-evaluator/helpers/anvil.ts @@ -0,0 +1,36 @@ +/** + * Anvil snapshot/revert helpers for the red-team agent feedback loop. + * + * snapshot() and revert() use Anvil's proprietary RPC methods to save and + * restore chain state, allowing scenarios to run mutating actions and then + * reset the fork cleanly. + * + * mineBlocks is re-exported from recenter.ts so callers can import both + * snapshot helpers and block-mining from a single module. + */ +import { rpcCall } from './rpc.js'; +export { mineBlocks } from './recenter.js'; + +/** + * Take an Anvil chain snapshot. + * + * @returns The snapshot ID (hex string) to pass to revert(). + */ +export async function snapshot(rpcUrl: string): Promise { + const id = (await rpcCall(rpcUrl, 'anvil_snapshot', [])) as string; + console.log(`[anvil] Snapshot taken: ${id}`); + return id; +} + +/** + * Revert the chain to a previously taken snapshot. + * + * Throws if Anvil reports the revert as unsuccessful (e.g. unknown snapshot ID). + * + * @param snapshotId - The hex snapshot ID returned by snapshot(). + */ +export async function revert(rpcUrl: string, snapshotId: string): Promise { + const success = (await rpcCall(rpcUrl, 'anvil_revert', [snapshotId])) as boolean; + if (!success) throw new Error(`[anvil] revert failed for snapshot ${snapshotId}`); + console.log(`[anvil] Reverted to snapshot: ${snapshotId}`); +} diff --git a/scripts/harb-evaluator/helpers/floor.ts b/scripts/harb-evaluator/helpers/floor.ts new file mode 100644 index 0000000..029579a --- /dev/null +++ b/scripts/harb-evaluator/helpers/floor.ts @@ -0,0 +1,79 @@ +/** + * Floor price helpers for the red-team agent feedback loop. + * + * Reads ethPerToken and related floor diagnostics from the Kraiken contract + * and supporting on-chain state via direct JSON-RPC calls. + */ +import { Interface } from 'ethers'; +import { rpcCall } from './rpc.js'; + +// Base WETH address — stable across Anvil forks of Base Sepolia. +const WETH = '0x4200000000000000000000000000000000000006'; + +const KRK_ABI = [ + 'function ethPerToken() external view returns (uint256)', + 'function outstandingSupply() external view returns (uint256)', +]; +const ERC20_ABI = ['function balanceOf(address account) external view returns (uint256)']; + +const krkIface = new Interface(KRK_ABI); +const erc20Iface = new Interface(ERC20_ABI); + +/** + * Read the current floor price from the Kraiken contract. + * + * @param krkAddress - Kraiken contract address. + * @param _lmAddress - LiquidityManager address (reserved for future fallback computation). + * @returns ethPerToken in wei. + */ +export async function getEthPerToken(rpcUrl: string, krkAddress: string, _lmAddress: string): Promise { + const calldata = krkIface.encodeFunctionData('ethPerToken', []); + const result = (await rpcCall(rpcUrl, 'eth_call', [{ to: krkAddress, data: calldata }, 'latest'])) as string; + const [value] = krkIface.decodeFunctionResult('ethPerToken', result); + return BigInt(value); +} + +/** + * Read full floor diagnostics for the LiquidityManager / Kraiken pair. + * + * All four reads are issued in parallel to minimise round-trip latency. + * + * @param lmAddress - LiquidityManager contract address. + * @param krkAddress - Kraiken contract address. + * @returns {ethPerToken} floor price in wei + * @returns {lmEthBalance} native ETH held by the LiquidityManager + * @returns {lmWethBalance} WETH held by the LiquidityManager + * @returns {outstandingSupply} total KRK outstanding supply + */ +export async function getFloorState( + rpcUrl: string, + lmAddress: string, + krkAddress: string, +): Promise<{ + ethPerToken: bigint; + lmEthBalance: bigint; + lmWethBalance: bigint; + outstandingSupply: bigint; +}> { + const ethPerTokenCalldata = krkIface.encodeFunctionData('ethPerToken', []); + const outstandingSupplyCalldata = krkIface.encodeFunctionData('outstandingSupply', []); + const wethBalanceCalldata = erc20Iface.encodeFunctionData('balanceOf', [lmAddress]); + + const [ethPerTokenHex, lmEthBalanceHex, lmWethBalanceHex, outstandingSupplyHex] = (await Promise.all([ + rpcCall(rpcUrl, 'eth_call', [{ to: krkAddress, data: ethPerTokenCalldata }, 'latest']), + rpcCall(rpcUrl, 'eth_getBalance', [lmAddress, 'latest']), + rpcCall(rpcUrl, 'eth_call', [{ to: WETH, data: wethBalanceCalldata }, 'latest']), + rpcCall(rpcUrl, 'eth_call', [{ to: krkAddress, data: outstandingSupplyCalldata }, 'latest']), + ])) as [string, string, string, string]; + + const [ethPerToken] = krkIface.decodeFunctionResult('ethPerToken', ethPerTokenHex); + const [lmWethBalance] = erc20Iface.decodeFunctionResult('balanceOf', lmWethBalanceHex); + const [outstandingSupply] = krkIface.decodeFunctionResult('outstandingSupply', outstandingSupplyHex); + + return { + ethPerToken: BigInt(ethPerToken), + lmEthBalance: BigInt(lmEthBalanceHex), + lmWethBalance: BigInt(lmWethBalance), + outstandingSupply: BigInt(outstandingSupply), + }; +} From 866474510bf95f5e89377d0033de35a5668ba902 Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 9 Mar 2026 00:53:17 +0000 Subject: [PATCH 2/3] fix: feat: Anvil snapshot/revert and ethPerToken helpers (#519) Co-Authored-By: Claude Sonnet 4.6 --- scripts/harb-evaluator/helpers/anvil.ts | 7 +- scripts/harb-evaluator/helpers/floor.ts | 119 ++++++++++++++++-------- 2 files changed, 85 insertions(+), 41 deletions(-) diff --git a/scripts/harb-evaluator/helpers/anvil.ts b/scripts/harb-evaluator/helpers/anvil.ts index a87efb7..add7367 100644 --- a/scripts/harb-evaluator/helpers/anvil.ts +++ b/scripts/harb-evaluator/helpers/anvil.ts @@ -25,7 +25,12 @@ export async function snapshot(rpcUrl: string): Promise { /** * Revert the chain to a previously taken snapshot. * - * Throws if Anvil reports the revert as unsuccessful (e.g. unknown snapshot ID). + * ⚠️ anvil_revert is one-shot: the snapshot is consumed on success and the ID + * becomes invalid afterward. Callers that need to reuse a checkpoint must + * call snapshot() again after each revert. + * + * Throws if Anvil reports the revert as unsuccessful (e.g. unknown or already-used + * snapshot ID). * * @param snapshotId - The hex snapshot ID returned by snapshot(). */ diff --git a/scripts/harb-evaluator/helpers/floor.ts b/scripts/harb-evaluator/helpers/floor.ts index 029579a..86836d6 100644 --- a/scripts/harb-evaluator/helpers/floor.ts +++ b/scripts/harb-evaluator/helpers/floor.ts @@ -1,8 +1,13 @@ /** * Floor price helpers for the red-team agent feedback loop. * - * Reads ethPerToken and related floor diagnostics from the Kraiken contract - * and supporting on-chain state via direct JSON-RPC calls. + * 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'; @@ -10,40 +15,39 @@ 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 ethPerToken() external view returns (uint256)', '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 the current floor price from the Kraiken contract. - * - * @param krkAddress - Kraiken contract address. - * @param _lmAddress - LiquidityManager address (reserved for future fallback computation). - * @returns ethPerToken in wei. - */ -export async function getEthPerToken(rpcUrl: string, krkAddress: string, _lmAddress: string): Promise { - const calldata = krkIface.encodeFunctionData('ethPerToken', []); - const result = (await rpcCall(rpcUrl, 'eth_call', [{ to: krkAddress, data: calldata }, 'latest'])) as string; - const [value] = krkIface.decodeFunctionResult('ethPerToken', result); - return BigInt(value); -} - /** * Read full floor diagnostics for the LiquidityManager / Kraiken pair. * - * All four reads are issued in parallel to minimise round-trip latency. + * 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. - * @returns {ethPerToken} floor price in wei - * @returns {lmEthBalance} native ETH held by the LiquidityManager - * @returns {lmWethBalance} WETH held by the LiquidityManager - * @returns {outstandingSupply} total KRK outstanding supply */ export async function getFloorState( rpcUrl: string, @@ -55,25 +59,60 @@ export async function getFloorState( lmWethBalance: bigint; outstandingSupply: bigint; }> { - const ethPerTokenCalldata = krkIface.encodeFunctionData('ethPerToken', []); - const outstandingSupplyCalldata = krkIface.encodeFunctionData('outstandingSupply', []); - const wethBalanceCalldata = erc20Iface.encodeFunctionData('balanceOf', [lmAddress]); - - const [ethPerTokenHex, lmEthBalanceHex, lmWethBalanceHex, outstandingSupplyHex] = (await Promise.all([ - rpcCall(rpcUrl, 'eth_call', [{ to: krkAddress, data: ethPerTokenCalldata }, 'latest']), + // 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: wethBalanceCalldata }, 'latest']), - rpcCall(rpcUrl, 'eth_call', [{ to: krkAddress, data: outstandingSupplyCalldata }, 'latest']), - ])) as [string, string, string, string]; + 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 [ethPerToken] = krkIface.decodeFunctionResult('ethPerToken', ethPerTokenHex); - const [lmWethBalance] = erc20Iface.decodeFunctionResult('balanceOf', lmWethBalanceHex); - const [outstandingSupply] = krkIface.decodeFunctionResult('outstandingSupply', outstandingSupplyHex); + 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); - return { - ethPerToken: BigInt(ethPerToken), - lmEthBalance: BigInt(lmEthBalanceHex), - lmWethBalance: BigInt(lmWethBalance), - outstandingSupply: BigInt(outstandingSupply), - }; + 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; + const ethPerToken = supply === 0n ? 0n : lmTotalEth / 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; } From e01ef235604af2782880bec80336d1958ed1f08a Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 9 Mar 2026 01:23:36 +0000 Subject: [PATCH 3/3] fix: feat: Anvil snapshot/revert and ethPerToken helpers (#519) Co-Authored-By: Claude Sonnet 4.6 --- scripts/harb-evaluator/helpers/floor.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/harb-evaluator/helpers/floor.ts b/scripts/harb-evaluator/helpers/floor.ts index 86836d6..e2e1c80 100644 --- a/scripts/harb-evaluator/helpers/floor.ts +++ b/scripts/harb-evaluator/helpers/floor.ts @@ -97,7 +97,9 @@ export async function getFloorState( } const lmTotalEth = lmEthBalance + lmWethBalance; - const ethPerToken = supply === 0n ? 0n : lmTotalEth / supply; + // 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 }; }