/** * 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 { rpcCall } from './rpc'; // 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 ───────────────────────────────────────────────────────── /** Read an ERC-20 balanceOf in the Node.js context via direct RPC. */ async function erc20BalanceOf(rpcUrl: string, tokenAddress: string, account: string): Promise { const selector = '0x70a08231'; // balanceOf(address) const data = selector + account.slice(2).padStart(64, '0'); return BigInt((await rpcCall(rpcUrl, 'eth_call', [{ to: tokenAddress, data }, 'latest'])) as string); } /** * 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. * * 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 { for (let i = 0; i < maxAttempts; i++) { const receipt = (await rpcCall(rpcUrl, 'eth_getTransactionReceipt', [txHash])) as Record< string, unknown > | null; if (receipt !== null) { if (receipt.status === '0x0') { throw new Error(`Transaction ${txHash} reverted (status 0x0)`); } return; // status === '0x1' — success } 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 token balances). */ 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 { 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 }); await page.screenshot({ path: 'test-results/holdout-before-buy.png' }); 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 { // Swap completed before the Submitting state could be observed console.log('[swap] Button state not observed (swap may have completed instantly)'); await page.waitForTimeout(2_000); } await page.screenshot({ path: 'test-results/holdout-after-buy.png' }); } /** * 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. * * Logs a warning if the WETH balance does not increase after the swap, which * indicates the pool returned 0 output (possible with amountOutMinimum: 0n on * a partially-drained pool). */ export async function sellAllKrk(page: Page, config: SellConfig): Promise { const krkBalance = await erc20BalanceOf(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 wethBefore = await erc20BalanceOf(config.rpcUrl, WETH, config.accountAddress); 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, { 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, { routerAddr: SWAP_ROUTER, data: swapData, from: config.accountAddress }, ); await waitForReceipt(config.rpcUrl, swapTxHash); console.log('[swap] Swap mined'); const wethAfter = await erc20BalanceOf(config.rpcUrl, WETH, config.accountAddress); if (wethAfter <= wethBefore) { console.warn('[swap] WARNING: WETH balance did not increase after sell — pool may have returned 0 output'); } else { console.log(`[swap] Received ${wethAfter - wethBefore} WETH`); } }