From 1973ccf25b4621c1ba83ac1ffded73cab4f9d63a Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 5 Mar 2026 10:52:28 +0000 Subject: [PATCH 1/2] fix: evaluator: add market simulation and recenter helpers (#455) - Export waitForReceipt from swap.ts so market.ts and recenter.ts can reuse it - Add market.ts with roundTripSwap: direct-RPC buy+sell round-trip using ethers Wallet - Add recenter.ts with triggerRecenter (calls LiquidityManager.recenter()) and mineBlocks (anvil_mine) Co-Authored-By: Claude Sonnet 4.6 --- scripts/harb-evaluator/helpers/market.ts | 140 +++++++++++++++++++++ scripts/harb-evaluator/helpers/recenter.ts | 63 ++++++++++ scripts/harb-evaluator/helpers/swap.ts | 2 +- 3 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 scripts/harb-evaluator/helpers/market.ts create mode 100644 scripts/harb-evaluator/helpers/recenter.ts diff --git a/scripts/harb-evaluator/helpers/market.ts b/scripts/harb-evaluator/helpers/market.ts new file mode 100644 index 0000000..dc7d1d0 --- /dev/null +++ b/scripts/harb-evaluator/helpers/market.ts @@ -0,0 +1,140 @@ +/** + * Direct-RPC market simulation helper. + * + * Executes swaps via ethers Wallet (not browser/Playwright) so tests can + * simulate market activity from non-user accounts without a UI. + */ +import { Interface, JsonRpcProvider, Wallet } from 'ethers'; +import { waitForReceipt } from './swap.js'; +import { rpcCall } from './rpc.js'; + +// Infrastructure addresses stable across Anvil forks of Base Sepolia +const SWAP_ROUTER = '0x94cC0AaC535CCDB3C01d6787D6413C739ae12bc4'; +const WETH = '0x4200000000000000000000000000000000000006'; +const POOL_FEE = 10_000; // 1% tier used by the KRAIKEN pool + +const WETH_ABI = ['function deposit() payable', 'function withdraw(uint256 wad)']; +const ERC20_ABI = ['function approve(address spender, uint256 amount) returns (bool)']; +const ROUTER_ABI = [ + 'function exactInputSingle((address tokenIn, address tokenOut, uint24 fee, address recipient, uint256 amountIn, uint256 amountOutMinimum, uint160 sqrtPriceLimitX96) params) payable returns (uint256 amountOut)', +]; + +export interface MarketSwapConfig { + rpcUrl: string; + /** Anvil account private key for signing */ + privateKey: string; + /** Account address */ + accountAddress: string; + /** KRK token address */ + krkAddress: string; +} + +/** + * Execute a round-trip swap: buy KRK with `ethAmount` ETH, then sell ALL KRK back. + * Uses direct RPC transactions (not browser/UI). Net effect on price ≈ 0, + * but generates trading volume through the pool. + * + * Steps: + * 1. Wrap ETH to WETH: call WETH.deposit{value: ethAmount}() + * 2. Approve WETH to SwapRouter02 + * 3. exactInputSingle: WETH → KRK (buy) + * 4. Approve KRK to SwapRouter02 + * 5. exactInputSingle: KRK → WETH (sell all) + * + * @returns Object with { krkBought: bigint, wethRecovered: bigint } + */ +export async function roundTripSwap( + config: MarketSwapConfig, + ethAmount: bigint, +): Promise<{ krkBought: bigint; wethRecovered: bigint }> { + const provider = new JsonRpcProvider(config.rpcUrl); + const wallet = new Wallet(config.privateKey, provider); + + const wethIface = new Interface(WETH_ABI); + const erc20Iface = new Interface(ERC20_ABI); + const routerIface = new Interface(ROUTER_ABI); + + const maxApproval = BigInt('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'); + + // Step 1: Wrap ETH → WETH + console.log(`[market] Wrapping ${ethAmount} wei ETH to WETH...`); + const depositData = wethIface.encodeFunctionData('deposit', []); + const depositTx = await wallet.sendTransaction({ to: WETH, data: depositData, value: ethAmount }); + await waitForReceipt(config.rpcUrl, depositTx.hash); + console.log('[market] ETH wrapped to WETH'); + + // Step 2: Approve WETH to router + console.log('[market] Approving WETH to router...'); + const approveWethData = erc20Iface.encodeFunctionData('approve', [SWAP_ROUTER, maxApproval]); + const approveWethTx = await wallet.sendTransaction({ to: WETH, data: approveWethData }); + await waitForReceipt(config.rpcUrl, approveWethTx.hash); + console.log('[market] WETH approved'); + + // Step 3: Buy KRK (WETH → KRK) + console.log('[market] Buying KRK (WETH → KRK)...'); + const buyData = routerIface.encodeFunctionData('exactInputSingle', [ + { + tokenIn: WETH, + tokenOut: config.krkAddress, + fee: POOL_FEE, + recipient: config.accountAddress, + amountIn: ethAmount, + amountOutMinimum: 0n, + sqrtPriceLimitX96: 0n, + }, + ]); + const buyTx = await wallet.sendTransaction({ to: SWAP_ROUTER, data: buyData }); + await waitForReceipt(config.rpcUrl, buyTx.hash); + + // Read KRK balance to know how much was bought + const balanceOfSelector = '0x70a08231'; + const balanceData = balanceOfSelector + config.accountAddress.slice(2).padStart(64, '0'); + const krkBought = BigInt( + (await rpcCall(config.rpcUrl, 'eth_call', [{ to: config.krkAddress, data: balanceData }, 'latest'])) as string, + ); + console.log(`[market] Bought ${krkBought} KRK`); + + if (krkBought === 0n) { + throw new Error('[market] roundTripSwap: bought 0 KRK — pool may be empty or price limit hit'); + } + + // Step 4: Approve KRK to router + console.log('[market] Approving KRK to router...'); + const approveKrkData = erc20Iface.encodeFunctionData('approve', [SWAP_ROUTER, maxApproval]); + const approveKrkTx = await wallet.sendTransaction({ to: config.krkAddress, data: approveKrkData }); + await waitForReceipt(config.rpcUrl, approveKrkTx.hash); + console.log('[market] KRK approved'); + + // Step 5: Sell all KRK (KRK → WETH) + console.log(`[market] Selling ${krkBought} KRK back to WETH...`); + const wethBefore = BigInt( + (await rpcCall(config.rpcUrl, 'eth_call', [ + { to: WETH, data: balanceOfSelector + config.accountAddress.slice(2).padStart(64, '0') }, + 'latest', + ])) as string, + ); + const sellData = routerIface.encodeFunctionData('exactInputSingle', [ + { + tokenIn: config.krkAddress, + tokenOut: WETH, + fee: POOL_FEE, + recipient: config.accountAddress, + amountIn: krkBought, + amountOutMinimum: 0n, + sqrtPriceLimitX96: 0n, + }, + ]); + const sellTx = await wallet.sendTransaction({ to: SWAP_ROUTER, data: sellData }); + await waitForReceipt(config.rpcUrl, sellTx.hash); + + const wethAfter = BigInt( + (await rpcCall(config.rpcUrl, 'eth_call', [ + { to: WETH, data: balanceOfSelector + config.accountAddress.slice(2).padStart(64, '0') }, + 'latest', + ])) as string, + ); + const wethRecovered = wethAfter - wethBefore; + console.log(`[market] Recovered ${wethRecovered} WETH`); + + return { krkBought, wethRecovered }; +} diff --git a/scripts/harb-evaluator/helpers/recenter.ts b/scripts/harb-evaluator/helpers/recenter.ts new file mode 100644 index 0000000..6832e3b --- /dev/null +++ b/scripts/harb-evaluator/helpers/recenter.ts @@ -0,0 +1,63 @@ +/** + * LiquidityManager recenter helper and Anvil block-mining utility. + * + * Pure Node.js — no browser/Playwright dependency. + */ +import { Interface, JsonRpcProvider, Wallet } from 'ethers'; +import { rpcCall } from './rpc.js'; +import { waitForReceipt } from './swap.js'; + +const RECENTER_ABI = ['function recenter() external returns (bool isUp)']; + +export interface RecenterConfig { + rpcUrl: string; + /** Address of the LiquidityManager contract */ + lmAddress: string; + /** Private key of an account with recenter access (deployer or txnBot) */ + privateKey: string; + /** Address corresponding to privateKey */ + accountAddress: string; +} + +/** + * Call LiquidityManager.recenter() from an authorized account. + * + * In the local Anvil stack, recenterAccess is granted to: + * - Deployer (account 0): PK 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 + * - TxnBot (account 2): derived from mnemonic index 2 + * + * @returns isUp - true if price moved up + */ +export async function triggerRecenter(config: RecenterConfig): Promise { + const provider = new JsonRpcProvider(config.rpcUrl); + const wallet = new Wallet(config.privateKey, provider); + + const iface = new Interface(RECENTER_ABI); + const data = iface.encodeFunctionData('recenter', []); + + console.log('[recenter] Calling LiquidityManager.recenter()...'); + const tx = await wallet.sendTransaction({ to: config.lmAddress, data }); + await waitForReceipt(config.rpcUrl, tx.hash); + console.log(`[recenter] recenter() mined: ${tx.hash}`); + + // Decode return value via eth_call (receipt doesn't expose return data) + const returnData = (await rpcCall(config.rpcUrl, 'eth_call', [ + { from: config.accountAddress, to: config.lmAddress, data }, + 'latest', + ])) as string; + const [isUp] = iface.decodeFunctionResult('recenter', returnData); + console.log(`[recenter] isUp = ${isUp}`); + + return Boolean(isUp); +} + +/** + * Mine `blocks` empty blocks on Anvil to advance time. + * Useful to get past MIN_RECENTER_INTERVAL (if set). + * + * Uses the anvil_mine RPC method. + */ +export async function mineBlocks(rpcUrl: string, blocks: number): Promise { + await rpcCall(rpcUrl, 'anvil_mine', ['0x' + blocks.toString(16)]); + console.log(`[recenter] Mined ${blocks} blocks`); +} diff --git a/scripts/harb-evaluator/helpers/swap.ts b/scripts/harb-evaluator/helpers/swap.ts index 93793fe..84f40d6 100644 --- a/scripts/harb-evaluator/helpers/swap.ts +++ b/scripts/harb-evaluator/helpers/swap.ts @@ -41,7 +41,7 @@ async function erc20BalanceOf(rpcUrl: string, tokenAddress: string, account: str * Throws if the transaction was mined but reverted (status 0x0) so callers * get a clear failure rather than a confusing downstream balance-assertion error. */ -async function waitForReceipt(rpcUrl: string, txHash: string, maxAttempts = 20): Promise { +export async function waitForReceipt(rpcUrl: string, txHash: string, maxAttempts = 20): Promise { for (let i = 0; i < maxAttempts; i++) { const receipt = (await rpcCall(rpcUrl, 'eth_getTransactionReceipt', [txHash])) as Record< string, From 9cda5beb4a9450336bae0734eff99e38c9a2e0bb Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 5 Mar 2026 11:27:31 +0000 Subject: [PATCH 2/2] fix: address review findings in market and recenter helpers - recenter.ts: parse isUp from Recentered event logs instead of a follow-up eth_call that would decode wrong post-recenter state - recenter.ts: remove hardcoded private key from comment; add blocks>0 guard in mineBlocks; call provider.destroy() to prevent leaked intervals - market.ts: snapshot KRK balance before buy to compute krkBought as delta instead of cumulative total; call provider.destroy() on exit; remove unused withdraw entry from WETH_ABI Co-Authored-By: Claude Sonnet 4.6 --- scripts/harb-evaluator/helpers/market.ts | 34 +++++++--------- scripts/harb-evaluator/helpers/recenter.ts | 47 ++++++++++++++++------ 2 files changed, 49 insertions(+), 32 deletions(-) diff --git a/scripts/harb-evaluator/helpers/market.ts b/scripts/harb-evaluator/helpers/market.ts index dc7d1d0..cab4df5 100644 --- a/scripts/harb-evaluator/helpers/market.ts +++ b/scripts/harb-evaluator/helpers/market.ts @@ -13,7 +13,7 @@ const SWAP_ROUTER = '0x94cC0AaC535CCDB3C01d6787D6413C739ae12bc4'; const WETH = '0x4200000000000000000000000000000000000006'; const POOL_FEE = 10_000; // 1% tier used by the KRAIKEN pool -const WETH_ABI = ['function deposit() payable', 'function withdraw(uint256 wad)']; +const WETH_ABI = ['function deposit() payable']; const ERC20_ABI = ['function approve(address spender, uint256 amount) returns (bool)']; const ROUTER_ABI = [ 'function exactInputSingle((address tokenIn, address tokenOut, uint24 fee, address recipient, uint256 amountIn, uint256 amountOutMinimum, uint160 sqrtPriceLimitX96) params) payable returns (uint256 amountOut)', @@ -29,6 +29,11 @@ export interface MarketSwapConfig { krkAddress: string; } +async function erc20Balance(rpcUrl: string, tokenAddress: string, account: string): Promise { + const data = '0x70a08231' + account.slice(2).padStart(64, '0'); + return BigInt((await rpcCall(rpcUrl, 'eth_call', [{ to: tokenAddress, data }, 'latest'])) as string); +} + /** * Execute a round-trip swap: buy KRK with `ethAmount` ETH, then sell ALL KRK back. * Uses direct RPC transactions (not browser/UI). Net effect on price ≈ 0, @@ -71,6 +76,8 @@ export async function roundTripSwap( console.log('[market] WETH approved'); // Step 3: Buy KRK (WETH → KRK) + // Snapshot balance before so krkBought is the delta, not the cumulative total. + const krkBefore = await erc20Balance(config.rpcUrl, config.krkAddress, config.accountAddress); console.log('[market] Buying KRK (WETH → KRK)...'); const buyData = routerIface.encodeFunctionData('exactInputSingle', [ { @@ -86,15 +93,11 @@ export async function roundTripSwap( const buyTx = await wallet.sendTransaction({ to: SWAP_ROUTER, data: buyData }); await waitForReceipt(config.rpcUrl, buyTx.hash); - // Read KRK balance to know how much was bought - const balanceOfSelector = '0x70a08231'; - const balanceData = balanceOfSelector + config.accountAddress.slice(2).padStart(64, '0'); - const krkBought = BigInt( - (await rpcCall(config.rpcUrl, 'eth_call', [{ to: config.krkAddress, data: balanceData }, 'latest'])) as string, - ); + const krkBought = (await erc20Balance(config.rpcUrl, config.krkAddress, config.accountAddress)) - krkBefore; console.log(`[market] Bought ${krkBought} KRK`); if (krkBought === 0n) { + provider.destroy(); throw new Error('[market] roundTripSwap: bought 0 KRK — pool may be empty or price limit hit'); } @@ -107,12 +110,7 @@ export async function roundTripSwap( // Step 5: Sell all KRK (KRK → WETH) console.log(`[market] Selling ${krkBought} KRK back to WETH...`); - const wethBefore = BigInt( - (await rpcCall(config.rpcUrl, 'eth_call', [ - { to: WETH, data: balanceOfSelector + config.accountAddress.slice(2).padStart(64, '0') }, - 'latest', - ])) as string, - ); + const wethBefore = await erc20Balance(config.rpcUrl, WETH, config.accountAddress); const sellData = routerIface.encodeFunctionData('exactInputSingle', [ { tokenIn: config.krkAddress, @@ -127,13 +125,9 @@ export async function roundTripSwap( const sellTx = await wallet.sendTransaction({ to: SWAP_ROUTER, data: sellData }); await waitForReceipt(config.rpcUrl, sellTx.hash); - const wethAfter = BigInt( - (await rpcCall(config.rpcUrl, 'eth_call', [ - { to: WETH, data: balanceOfSelector + config.accountAddress.slice(2).padStart(64, '0') }, - 'latest', - ])) as string, - ); - const wethRecovered = wethAfter - wethBefore; + provider.destroy(); + + const wethRecovered = (await erc20Balance(config.rpcUrl, WETH, config.accountAddress)) - wethBefore; console.log(`[market] Recovered ${wethRecovered} WETH`); return { krkBought, wethRecovered }; diff --git a/scripts/harb-evaluator/helpers/recenter.ts b/scripts/harb-evaluator/helpers/recenter.ts index 6832e3b..8f7cbf1 100644 --- a/scripts/harb-evaluator/helpers/recenter.ts +++ b/scripts/harb-evaluator/helpers/recenter.ts @@ -7,13 +7,21 @@ import { Interface, JsonRpcProvider, Wallet } from 'ethers'; import { rpcCall } from './rpc.js'; import { waitForReceipt } from './swap.js'; -const RECENTER_ABI = ['function recenter() external returns (bool isUp)']; +const RECENTER_ABI = [ + 'function recenter() external returns (bool isUp)', + 'event Recentered(int24 indexed currentTick, bool indexed isUp)', +]; export interface RecenterConfig { rpcUrl: string; /** Address of the LiquidityManager contract */ lmAddress: string; - /** Private key of an account with recenter access (deployer or txnBot) */ + /** + * Private key of an account with recenter access. + * In the local Anvil stack, recenterAccess is granted to the deployer + * (Anvil account index 0) and the txnBot (Anvil account index 2). + * See scripts/bootstrap-common.sh for how access is granted at deploy time. + */ privateKey: string; /** Address corresponding to privateKey */ accountAddress: string; @@ -22,9 +30,9 @@ export interface RecenterConfig { /** * Call LiquidityManager.recenter() from an authorized account. * - * In the local Anvil stack, recenterAccess is granted to: - * - Deployer (account 0): PK 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 - * - TxnBot (account 2): derived from mnemonic index 2 + * Reads isUp from the Recentered(int24 currentTick, bool isUp) event emitted + * by the mined transaction — not from a follow-up eth_call, which would + * simulate a second recenter on already-updated state and return the wrong value. * * @returns isUp - true if price moved up */ @@ -40,15 +48,29 @@ export async function triggerRecenter(config: RecenterConfig): Promise await waitForReceipt(config.rpcUrl, tx.hash); console.log(`[recenter] recenter() mined: ${tx.hash}`); - // Decode return value via eth_call (receipt doesn't expose return data) - const returnData = (await rpcCall(config.rpcUrl, 'eth_call', [ - { from: config.accountAddress, to: config.lmAddress, data }, - 'latest', - ])) as string; - const [isUp] = iface.decodeFunctionResult('recenter', returnData); + provider.destroy(); + + // Parse isUp from the Recentered event in the receipt logs. + // A follow-up eth_call would simulate on post-recenter state and give the wrong result. + const receipt = (await rpcCall(config.rpcUrl, 'eth_getTransactionReceipt', [tx.hash])) as { + logs: Array<{ address: string; topics: string[]; data: string }>; + }; + + const recenterEventTopic = iface.getEvent('Recentered')!.topicHash; + const log = receipt.logs.find( + l => + l.address.toLowerCase() === config.lmAddress.toLowerCase() && + l.topics[0] === recenterEventTopic, + ); + if (!log) { + throw new Error('[recenter] Recentered event not found in receipt — did the tx revert silently?'); + } + + const parsed = iface.parseLog({ topics: log.topics, data: log.data }); + const isUp = Boolean(parsed!.args.isUp); console.log(`[recenter] isUp = ${isUp}`); - return Boolean(isUp); + return isUp; } /** @@ -58,6 +80,7 @@ export async function triggerRecenter(config: RecenterConfig): Promise * Uses the anvil_mine RPC method. */ export async function mineBlocks(rpcUrl: string, blocks: number): Promise { + if (blocks <= 0) throw new Error(`mineBlocks: blocks must be > 0, got ${blocks}`); await rpcCall(rpcUrl, 'anvil_mine', ['0x' + blocks.toString(16)]); console.log(`[recenter] Mined ${blocks} blocks`); }