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:
parent
71218464c0
commit
d0a3bdecdc
4 changed files with 361 additions and 182 deletions
157
scripts/harb-evaluator/helpers/swap.ts
Normal file
157
scripts/harb-evaluator/helpers/swap.ts
Normal 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');
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue