/** * Shared swap helpers for holdout scenarios. * * buyKrk — drives the real get-krk page swap widget (UI path, requires #393 fix). * sellKrk — drives the get-krk page sell widget UI (requires #456 sell tab). * 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 // ERC-20 Transfer event topic (keccak256("Transfer(address,address,uint256)")) const TRANSFER_TOPIC = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'; 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. */ export 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 } // eslint-disable-next-line no-restricted-syntax -- Polling with timeout: no event source for transaction receipt over HTTP RPC (eth_subscribe not available). See AGENTS.md #Engineering Principles. await new Promise(r => setTimeout(r, 500)); } throw new Error(`Transaction ${txHash} not mined after ${maxAttempts * 500}ms`); } // ── Public config type ─────────────────────────────────────────────────────── export interface BuyKrkOptions { /** Anvil JSON-RPC endpoint (used to query KRK balance after swap). */ rpcUrl: string; /** Deployed KRAIKEN (KRK) ERC-20 contract address. */ krkAddress: string; /** EOA address that will receive the KRK tokens. */ accountAddress: string; } 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. * * If opts is provided, creates an eth_newFilter for Transfer events to the account * and polls eth_getFilterLogs until the event arrives, ensuring the swap has been * mined on-chain before returning. Otherwise, just waits for the UI state transition * (caller is responsible for verification). * * @param screenshotPrefix - Optional prefix for screenshot filenames (e.g., 'walletA', 'walletB') */ export async function buyKrk(page: Page, ethAmount: string, opts?: BuyKrkOptions, screenshotPrefix = 'holdout'): 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.getByTestId('swap-amount-input'); await expect(swapInput).toBeVisible({ timeout: 15_000 }); await swapInput.fill(ethAmount); await page.waitForTimeout(500); const buyButton = page.getByTestId('swap-buy-button'); await expect(buyButton).toBeVisible({ timeout: 5_000 }); // Create Transfer event filter BEFORE the swap (if opts provided) let filterId: string | undefined; if (opts) { console.log('[swap] Creating Transfer event filter...'); filterId = (await rpcCall(opts.rpcUrl, 'eth_newFilter', [ { address: opts.krkAddress, topics: [ TRANSFER_TOPIC, null, // any sender '0x' + opts.accountAddress.slice(2).padStart(64, '0'), // to our account ], fromBlock: 'latest', }, ])) as string; console.log(`[swap] Filter created: ${filterId}`); } await page.screenshot({ path: `test-results/${screenshotPrefix}-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 expect(page.getByTestId('swap-buy-button')).toHaveText('Buy KRK', { 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)'); } // If opts provided, wait for the Transfer event to arrive if (opts && filterId) { console.log('[swap] Waiting for Transfer event...'); const deadline = Date.now() + 15_000; let received = false; while (Date.now() < deadline) { const logs = (await rpcCall(opts.rpcUrl, 'eth_getFilterLogs', [filterId])) as unknown[]; if (logs && logs.length > 0) { received = true; console.log(`[swap] Transfer event received (${logs.length} log(s))`); break; } // eslint-disable-next-line no-restricted-syntax -- Polling with timeout: eth_getFilterLogs is HTTP-only polling (not push). See AGENTS.md #Engineering Principles. await new Promise(r => setTimeout(r, 200)); } // Clean up filter await rpcCall(opts.rpcUrl, 'eth_uninstallFilter', [filterId]).catch(() => {}); if (!received) { throw new Error(`No KRK Transfer event received within 15s after buying with ${ethAmount} ETH`); } } await page.screenshot({ path: `test-results/${screenshotPrefix}-after-buy.png` }); } /** * Navigate to the get-krk page, switch to Sell tab, fill KRK amount, click Sell. * Wallet must already be connected. * * Drives the real sell widget UI (requires the #456 sell tab). * * If config is provided, creates an eth_newFilter for WETH Transfer events to the * account before clicking Sell, polls eth_getFilterLogs until the event arrives * (confirming the swap is mined), then returns the WETH balance delta. Otherwise * returns 0n (caller is responsible for verification). * * @param page - Playwright page with injected wallet * @param amount - KRK amount to sell (as string). Use 'max' to click the Max button. * @param screenshotPrefix - Optional prefix for screenshot filenames * @param config - Optional config for on-chain WETH receipt confirmation * @returns WETH received (balance diff) or 0n if config is not provided */ export async function sellKrk( page: Page, amount: string, screenshotPrefix?: string, config?: Pick, ): Promise { console.log(`[swap] Selling ${amount} KRK via get-krk page sell widget...`); await navigateSPA(page, '/app/get-krk'); await expect(page.getByRole('heading', { name: 'Get $KRK Tokens' })).toBeVisible({ timeout: 10_000 }); const sellTab = page.getByTestId('swap-mode-sell'); await expect(sellTab).toBeVisible({ timeout: 10_000 }); await sellTab.click(); const sellInput = page.getByTestId('swap-sell-amount-input'); if (amount === 'max') { const maxButton = page.locator('.max-button'); await expect(maxButton).toBeVisible({ timeout: 5_000 }); await maxButton.click(); // setMax() is async — wait for the composable to populate the input via loadKrkBalance() await expect(sellInput).not.toHaveValue('', { timeout: 10_000 }); console.log('[swap] Clicked Max button'); } else { await expect(sellInput).toBeVisible({ timeout: 5_000 }); await sellInput.fill(amount); console.log(`[swap] Filled sell amount: ${amount}`); } const wethBefore = config ? await erc20BalanceOf(config.rpcUrl, WETH, config.accountAddress) : 0n; // Create WETH Transfer event filter BEFORE the sell (if config provided) let filterId: string | undefined; if (config) { filterId = (await rpcCall(config.rpcUrl, 'eth_newFilter', [ { address: WETH, topics: [ TRANSFER_TOPIC, null, // any sender (pool/router) '0x' + config.accountAddress.slice(2).padStart(64, '0'), // to our account ], fromBlock: 'latest', }, ])) as string; console.log(`[swap] WETH Transfer filter created: ${filterId}`); } if (screenshotPrefix) { await page.screenshot({ path: `test-results/${screenshotPrefix}-before-sell.png` }); } const sellButton = page.getByTestId('swap-sell-button'); await expect(sellButton).toBeVisible({ timeout: 5_000 }); console.log('[swap] Clicking Sell KRK...'); await sellButton.click(); // Button cycles: "Sell KRK" → "Approving…" / "Selling…" → "Sell KRK" try { await sellButton.filter({ hasText: /Approving…|Selling…/i }).waitFor({ state: 'visible', timeout: 5_000 }); console.log('[swap] Sell in progress...'); } catch { // Sell completed before the transient state could be observed console.log('[swap] Button state not observed (sell may have completed instantly)'); } await expect(sellButton).toHaveText('Sell KRK', { timeout: 60_000 }); console.log('[swap] Sell completed (UI idle)'); // Wait for on-chain confirmation via WETH Transfer event if (config && filterId) { console.log('[swap] Waiting for WETH Transfer event...'); const deadline = Date.now() + 15_000; let received = false; while (Date.now() < deadline) { const logs = (await rpcCall(config.rpcUrl, 'eth_getFilterLogs', [filterId])) as unknown[]; if (logs && logs.length > 0) { received = true; console.log(`[swap] WETH Transfer event received (${logs.length} log(s))`); break; } // eslint-disable-next-line no-restricted-syntax -- Polling with timeout: eth_getFilterLogs is HTTP-only polling (not push). See AGENTS.md #Engineering Principles. await new Promise(r => setTimeout(r, 200)); } await rpcCall(config.rpcUrl, 'eth_uninstallFilter', [filterId]).catch(() => {}); if (!received) { throw new Error(`No WETH Transfer event received within 15s after selling ${amount} KRK`); } } if (screenshotPrefix) { await page.screenshot({ path: `test-results/${screenshotPrefix}-after-sell.png` }); } if (!config) return 0n; const wethAfter = await erc20BalanceOf(config.rpcUrl, WETH, config.accountAddress); const wethReceived = wethAfter - wethBefore; if (wethReceived <= 0n) { console.warn('[swap] WARNING: WETH balance did not increase after sell — pool may have returned 0 output'); } else { console.log(`[swap] Received ${wethReceived} WETH`); } return wethReceived; } /** * 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. * * Throws 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). * * @returns The WETH delta (wethAfter - wethBefore) received from the swap. */ 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); const wethReceived = wethAfter - wethBefore; if (wethReceived <= 0n) { throw new Error('sellAllKrk: swap returned 0 WETH — pool may be drained or price impact exceeded balance'); } console.log(`[swap] Received ${wethReceived} WETH`); return wethReceived; }