feat: Shared holdout scenario test helpers (#401)

Extract reusable helpers from sovereign-exit/always-leave.spec.ts into
three focused modules under scripts/harb-evaluator/helpers/:

- helpers/wallet.ts: connectWallet, disconnectWallet, getEthBalance,
  getKrkBalance — UI connect/disconnect flow and on-chain balance reads.

- helpers/swap.ts: buyKrk (navigates to the real /app/get-krk page and
  drives the LocalSwapWidget, now that #393 fill() fix is in), sellAllKrk
  (approve + exactInputSingle via window.ethereum, no UI dependency).

- helpers/assertions.ts: expectBalanceIncrease (snapshot/action/assert
  pattern for any token or ETH), expectPoolHasLiquidity (slot0 + liquidity
  sanity check on a Uniswap V3 pool).

always-leave.spec.ts is refactored to use these helpers and to navigate
to /app/get-krk instead of the /app/cheats workaround introduced before
the #393 fix landed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
openhands 2026-03-02 05:21:24 +00:00
parent 71218464c0
commit d0a3bdecdc
4 changed files with 361 additions and 182 deletions

View file

@ -0,0 +1,82 @@
/**
* 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<unknown> {
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<bigint> {
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<void>,
): Promise<void> {
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<void> {
// 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);
}

View file

@ -0,0 +1,157 @@
/**
* Shared swap helpers for holdout scenarios.
*
* buyKrk drives the real get-krk page swap widget (UI path, requires #393 fix).
* sellAllKrk submits approve + exactInputSingle directly via window.ethereum
* (no UI widget the Uniswap router handles the on-chain leg).
*/
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
import { Interface } from 'ethers';
import { navigateSPA } from '../../../tests/setup/navigate';
import { getKrkBalance } from './wallet';
// 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 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)',
];
// ── Internal helpers ─────────────────────────────────────────────────────────
/**
* Poll eth_getTransactionReceipt until the tx is mined or maxAttempts exceeded.
* Anvil with automine resolves almost immediately; the loop guards against
* instances configured with a block interval or high RPC latency.
*/
async function waitForReceipt(rpcUrl: string, txHash: string, maxAttempts = 20): Promise<void> {
for (let i = 0; i < maxAttempts; i++) {
const resp = await fetch(rpcUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: Date.now(),
method: 'eth_getTransactionReceipt',
params: [txHash],
}),
});
const payload = await resp.json();
if (payload.result !== null) return;
await new Promise(r => setTimeout(r, 500));
}
throw new Error(`Transaction ${txHash} not mined after ${maxAttempts * 500}ms`);
}
// ── Public config type ───────────────────────────────────────────────────────
export interface SellConfig {
/** Anvil JSON-RPC endpoint (used to wait for receipt and query KRK balance). */
rpcUrl: string;
/** Deployed KRAIKEN (KRK) ERC-20 contract address. */
krkAddress: string;
/** EOA address that holds the KRK tokens and will send the transactions. */
accountAddress: string;
}
// ── Exported helpers ─────────────────────────────────────────────────────────
/**
* Navigate to the get-krk page, fill the ETH amount, click Buy KRK, and wait for
* the swap widget to return to its idle state ("Buy KRK" button re-enabled).
*
* Uses the real LocalSwapWidget UI path (requires the #393 fill() fix).
* Wallet must already be connected before calling this.
*/
export async function buyKrk(page: Page, ethAmount: string): Promise<void> {
console.log(`[swap] Buying KRK with ${ethAmount} ETH via get-krk page...`);
await navigateSPA(page, '/app/get-krk');
await expect(page.getByRole('heading', { name: 'Get $KRK Tokens' })).toBeVisible({ timeout: 10_000 });
const swapInput = page.getByLabel('ETH to spend');
await expect(swapInput).toBeVisible({ timeout: 15_000 });
await swapInput.fill(ethAmount);
await page.waitForTimeout(500);
const buyButton = page.getByRole('button', { name: 'Buy KRK' });
await expect(buyButton).toBeVisible({ timeout: 5_000 });
console.log('[swap] Clicking Buy KRK...');
await buyButton.click();
// Button cycles: idle "Buy KRK" → "Submitting…" → idle "Buy KRK"
try {
await page.getByRole('button', { name: /Submitting/i }).waitFor({ state: 'visible', timeout: 5_000 });
console.log('[swap] Swap in progress...');
await page.getByRole('button', { name: 'Buy KRK' }).waitFor({ state: 'visible', timeout: 60_000 });
console.log('[swap] Swap completed');
} catch {
console.log('[swap] Button state not observed (swap may have completed instantly)');
}
await page.waitForTimeout(2_000);
}
/**
* Query the current KRK balance, then approve the Uniswap router and swap
* all KRK back to WETH via on-chain transactions submitted through the
* injected window.ethereum provider.
*
* This is the "sovereign exit" path it bypasses the UI swap widget and
* sends transactions directly so the test is not gated on the sell-side UI.
*/
export async function sellAllKrk(page: Page, config: SellConfig): Promise<void> {
const krkBalance = await getKrkBalance(config.rpcUrl, config.krkAddress, config.accountAddress);
if (krkBalance === 0n) throw new Error('sellAllKrk: KRK balance is 0 — nothing to sell');
console.log(`[swap] Selling ${krkBalance} KRK...`);
const erc20Iface = new Interface(ERC20_ABI);
const routerIface = new Interface(ROUTER_ABI);
const approveData = erc20Iface.encodeFunctionData('approve', [
SWAP_ROUTER,
BigInt('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'),
]);
const swapData = routerIface.encodeFunctionData('exactInputSingle', [
{
tokenIn: config.krkAddress,
tokenOut: WETH,
fee: POOL_FEE,
recipient: config.accountAddress,
amountIn: krkBalance,
amountOutMinimum: 0n,
sqrtPriceLimitX96: 0n,
},
]);
// Step 1: approve KRK spend allowance to the Uniswap router
console.log('[swap] Approving KRK to router...');
const approveTxHash = await page.evaluate(
({ krkAddr, data, from }: { krkAddr: string; data: string; from: string }) =>
(window.ethereum as any).request({
method: 'eth_sendTransaction',
params: [{ from, to: krkAddr, data, gas: '0x30000' }],
}) as Promise<string>,
{ krkAddr: config.krkAddress, data: approveData, from: config.accountAddress },
);
await waitForReceipt(config.rpcUrl, approveTxHash);
console.log('[swap] Approve mined');
// Step 2: swap KRK → WETH via the Uniswap V3 router
console.log('[swap] Swapping KRK → WETH (exit)...');
const swapTxHash = await page.evaluate(
({ routerAddr, data, from }: { routerAddr: string; data: string; from: string }) =>
(window.ethereum as any).request({
method: 'eth_sendTransaction',
params: [{ from, to: routerAddr, data, gas: '0x80000' }],
}) as Promise<string>,
{ routerAddr: SWAP_ROUTER, data: swapData, from: config.accountAddress },
);
await waitForReceipt(config.rpcUrl, swapTxHash);
console.log('[swap] Swap mined');
}

View file

@ -0,0 +1,105 @@
/**
* Shared wallet helpers for holdout scenarios.
*
* Functions here operate in the Playwright Node.js context (not the browser).
* UI interactions use Playwright locators; on-chain reads use direct RPC calls
* so that tests do not depend on the app's wallet state for balance queries.
*/
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
// ── RPC helpers ─────────────────────────────────────────────────────────────
async function rpcCall(rpcUrl: string, method: string, params: unknown[]): Promise<unknown> {
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;
}
// ── Balance readers ──────────────────────────────────────────────────────────
/** Return the native ETH balance (in wei) of `address`. */
export async function getEthBalance(rpcUrl: string, address: string): Promise<bigint> {
const result = (await rpcCall(rpcUrl, 'eth_getBalance', [address, 'latest'])) as string;
return BigInt(result);
}
/** Return the ERC-20 KRK balance (in wei) of `address` on the given token contract. */
export async function getKrkBalance(rpcUrl: string, krkAddress: string, address: string): Promise<bigint> {
const selector = '0x70a08231'; // balanceOf(address)
const data = selector + address.slice(2).padStart(64, '0');
const result = (await rpcCall(rpcUrl, 'eth_call', [{ to: krkAddress, data }, 'latest'])) as string;
return BigInt(result);
}
// ── UI helpers ───────────────────────────────────────────────────────────────
/**
* Connect the test wallet via the desktop UI flow.
*
* Expects the page to already be on the web app with the navbar visible.
* The injected wallet provider must already be set up by createWalletContext.
* Verifies connection by waiting for the connected button to appear in the navbar.
*/
export async function connectWallet(page: Page): Promise<void> {
// Trigger resize so Vue's useMobile composable re-evaluates with screen.width=1280.
await page.evaluate(() => window.dispatchEvent(new Event('resize')));
await page.waitForTimeout(2_000);
let panelOpened = false;
const connectButton = page.locator('.connect-button--disconnected').first();
if (await connectButton.isVisible({ timeout: 5_000 })) {
console.log('[wallet] Found desktop Connect button, clicking...');
await connectButton.click();
panelOpened = true;
} else {
// Fallback: mobile login icon. Dead code when screen.width=1280 but kept for safety.
const mobileLoginIcon = page.locator('.navbar-end svg').first();
if (await mobileLoginIcon.isVisible({ timeout: 2_000 })) {
console.log('[wallet] Found mobile login icon, clicking...');
await mobileLoginIcon.click();
panelOpened = true;
}
}
if (panelOpened) {
await page.waitForTimeout(1_000);
const injectedConnector = page.locator('.connectors-element').first();
if (await injectedConnector.isVisible({ timeout: 5_000 })) {
console.log('[wallet] Clicking wallet connector...');
await injectedConnector.click();
await page.waitForTimeout(2_000);
} else {
console.log('[wallet] WARNING: No wallet connector found in panel');
}
}
// The navbar shows .connect-button--connected once wagmi reports status=connected.
await expect(page.locator('.connect-button--connected').first()).toBeVisible({ timeout: 15_000 });
console.log('[wallet] Wallet connected');
}
/**
* Disconnect the wallet by opening the connected panel and clicking the logout icon.
*
* Verifies disconnection by waiting for the Connect button to reappear.
*/
export async function disconnectWallet(page: Page): Promise<void> {
const connectedButton = page.locator('.connect-button--connected').first();
await expect(connectedButton).toBeVisible({ timeout: 5_000 });
await connectedButton.click();
// Panel opens showing .connected-header-logout (img alt="Logout").
const logoutIcon = page.locator('.connected-header-logout').first();
await expect(logoutIcon).toBeVisible({ timeout: 5_000 });
await logoutIcon.click();
await expect(page.locator('.connect-button--disconnected').first()).toBeVisible({ timeout: 10_000 });
console.log('[wallet] Wallet disconnected');
}