/** * Shared assertion helpers for holdout scenarios. * * These wrap Playwright's `expect` with protocol-specific checks so that * scenario specs stay at the "what" level rather than encoding RPC details. */ import { expect } from '@playwright/test'; import { Interface } from 'ethers'; // ── Internal helpers ───────────────────────────────────────────────────────── async function rpcCall(rpcUrl: string, method: string, params: unknown[]): Promise { const resp = await fetch(rpcUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: Date.now(), method, params }), }); const payload = await resp.json(); if (payload.error) throw new Error(`RPC ${method}: ${payload.error.message}`); return payload.result; } async function getTokenBalance(rpcUrl: string, token: string, address: string): Promise { if (token.toLowerCase() === 'eth') { const result = (await rpcCall(rpcUrl, 'eth_getBalance', [address, 'latest'])) as string; return BigInt(result); } const selector = '0x70a08231'; // balanceOf(address) const data = selector + address.slice(2).padStart(64, '0'); const result = (await rpcCall(rpcUrl, 'eth_call', [{ to: token, data }, 'latest'])) as string; return BigInt(result); } const POOL_ABI = [ 'function slot0() external view returns (uint160 sqrtPriceX96, int24 tick, uint16 observationIndex, uint16 observationCardinality, uint16 observationCardinalityNext, uint8 feeProtocol, bool unlocked)', 'function liquidity() external view returns (uint128)', ]; const poolIface = new Interface(POOL_ABI); // ── Exported helpers ───────────────────────────────────────────────────────── /** * Snapshot the balance of `token` for `address`, run `action`, then assert the * balance increased. * * @param token - An ERC-20 contract address, or the string `'eth'` for native ETH. */ export async function expectBalanceIncrease( rpcUrl: string, token: string, address: string, action: () => Promise, ): Promise { const before = await getTokenBalance(rpcUrl, token, address); await action(); const after = await getTokenBalance(rpcUrl, token, address); expect(after).toBeGreaterThan(before); } /** * Assert that the Uniswap V3 pool at `poolAddress` is initialised and has * non-zero active liquidity at the current tick. * * Used after a sovereign exit to confirm the pool remains functional and the * LiquidityManager's positions are intact. */ export async function expectPoolHasLiquidity(rpcUrl: string, poolAddress: string): Promise { // slot0() — check pool is initialised (sqrtPriceX96 > 0) const slot0Encoded = poolIface.encodeFunctionData('slot0', []); const slot0Hex = (await rpcCall(rpcUrl, 'eth_call', [{ to: poolAddress, data: slot0Encoded }, 'latest'])) as string; const [sqrtPriceX96] = poolIface.decodeFunctionResult('slot0', slot0Hex); expect(sqrtPriceX96).toBeGreaterThan(0n); // liquidity() — active liquidity at the current tick must be non-zero const liquidityEncoded = poolIface.encodeFunctionData('liquidity', []); const liquidityHex = (await rpcCall(rpcUrl, 'eth_call', [ { to: poolAddress, data: liquidityEncoded }, 'latest', ])) as string; const [liquidity] = poolIface.decodeFunctionResult('liquidity', liquidityHex); expect(liquidity).toBeGreaterThan(0n); }