Merge pull request 'fix: evaluator: add market simulation and recenter helpers (#455)' (#457) from fix/issue-455 into master

This commit is contained in:
johba 2026-03-05 12:51:29 +01:00
commit 466e0d7767
3 changed files with 221 additions and 1 deletions

View file

@ -0,0 +1,134 @@
/**
* 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'];
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;
}
async function erc20Balance(rpcUrl: string, tokenAddress: string, account: string): Promise<bigint> {
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,
* 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)
// 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', [
{
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);
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');
}
// 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 = await erc20Balance(config.rpcUrl, WETH, config.accountAddress);
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);
provider.destroy();
const wethRecovered = (await erc20Balance(config.rpcUrl, WETH, config.accountAddress)) - wethBefore;
console.log(`[market] Recovered ${wethRecovered} WETH`);
return { krkBought, wethRecovered };
}

View file

@ -0,0 +1,86 @@
/**
* 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)',
'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.
* 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;
}
/**
* Call LiquidityManager.recenter() from an authorized account.
*
* 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
*/
export async function triggerRecenter(config: RecenterConfig): Promise<boolean> {
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}`);
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 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<void> {
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`);
}

View file

@ -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<void> {
export async function waitForReceipt(rpcUrl: string, txHash: string, maxAttempts = 20): Promise<void> {
for (let i = 0; i < maxAttempts; i++) {
const receipt = (await rpcCall(rpcUrl, 'eth_getTransactionReceipt', [txHash])) as Record<
string,