fix: feat: Anvil snapshot/revert and ethPerToken helpers (#519)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
openhands 2026-03-09 00:53:17 +00:00
parent b2db3c7ae5
commit 866474510b
2 changed files with 85 additions and 41 deletions

View file

@ -25,7 +25,12 @@ export async function snapshot(rpcUrl: string): Promise<string> {
/**
* 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().
*/

View file

@ -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<bigint> {
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<bigint> {
const { ethPerToken } = await getFloorState(rpcUrl, lmAddress, krkAddress);
return ethPerToken;
}