diff --git a/scripts/harb-evaluator/helpers/assertions.ts b/scripts/harb-evaluator/helpers/assertions.ts new file mode 100644 index 0000000..e4a5296 --- /dev/null +++ b/scripts/harb-evaluator/helpers/assertions.ts @@ -0,0 +1,63 @@ +/** + * 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'; +import { rpcCall } from './rpc'; + +// ── Internal helpers ───────────────────────────────────────────────────────── + +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)', +]; +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. + * + * Checks that sqrtPriceX96 > 0, which confirms the pool has been seeded and + * can execute swaps. Active-tick liquidity is intentionally not checked here: + * after a sovereign exit the LiquidityManager's three range positions (Floor, + * Anchor, Discovery) may all sit outside the current tick while the pool + * itself remains functional. + */ +export async function expectPoolHasLiquidity(rpcUrl: string, poolAddress: string): Promise { + 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); +} diff --git a/scripts/harb-evaluator/helpers/rpc.ts b/scripts/harb-evaluator/helpers/rpc.ts new file mode 100644 index 0000000..cb18bac --- /dev/null +++ b/scripts/harb-evaluator/helpers/rpc.ts @@ -0,0 +1,17 @@ +/** + * Shared JSON-RPC utility for holdout helpers. + * + * Exported from one place so wallet.ts, assertions.ts, and future helpers + * share a single implementation rather than embedding the same fetch + + * error-check block in each file. + */ +export 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; +} diff --git a/scripts/harb-evaluator/helpers/swap.ts b/scripts/harb-evaluator/helpers/swap.ts new file mode 100644 index 0000000..7670daa --- /dev/null +++ b/scripts/harb-evaluator/helpers/swap.ts @@ -0,0 +1,183 @@ +/** + * 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`); + } +} diff --git a/scripts/harb-evaluator/helpers/wallet.ts b/scripts/harb-evaluator/helpers/wallet.ts new file mode 100644 index 0000000..ddd1e9d --- /dev/null +++ b/scripts/harb-evaluator/helpers/wallet.ts @@ -0,0 +1,96 @@ +/** + * 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'; +import { rpcCall } from './rpc'; + +// ── Balance readers ────────────────────────────────────────────────────────── + +/** Return the native ETH balance (in wei) of `address`. */ +export async function getEthBalance(rpcUrl: string, address: string): Promise { + 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 { + 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 { + // 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); + + const screenWidth = await page.evaluate(() => window.screen.width); + console.log(`[wallet] screen.width = ${screenWidth}`); + + 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 { + 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'); +} diff --git a/scripts/harb-evaluator/scenarios/sovereign-exit/always-leave.spec.ts b/scripts/harb-evaluator/scenarios/sovereign-exit/always-leave.spec.ts index 389f533..cc10576 100644 --- a/scripts/harb-evaluator/scenarios/sovereign-exit/always-leave.spec.ts +++ b/scripts/harb-evaluator/scenarios/sovereign-exit/always-leave.spec.ts @@ -4,72 +4,23 @@ * Verifies the core protocol invariant: a user can ALWAYS exit their position * by buying KRK through the in-app swap widget and then selling it back. * - * Reuses tests/setup/ infrastructure — no new wallet or navigation helpers. + * Reuses tests/setup/ infrastructure and the shared helpers in + * scripts/harb-evaluator/helpers/ — no inline wallet, swap, or balance logic. * * Account 0 from the Anvil test mnemonic is used (same as e2e tests). * Deploy scripts also use Account 0, but each test run gets a fresh Anvil stack, * so no collision occurs. */ import { expect, test } from '@playwright/test'; -import { Interface, Wallet } from 'ethers'; +import { Wallet } from 'ethers'; import { createWalletContext } from '../../../../tests/setup/wallet-provider'; import { getStackConfig } from '../../../../tests/setup/stack'; -import { navigateSPA } from '../../../../tests/setup/navigate'; +import { connectWallet, getKrkBalance } from '../../helpers/wallet'; +import { buyKrk, sellAllKrk } from '../../helpers/swap'; // Anvil account 0 — same as e2e tests (deploy uses it but state is reset per stack) const PK = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; -const ACCOUNT = new Wallet(PK); -const ACCOUNT_ADDRESS = ACCOUNT.address; - -// Infrastructure addresses that are stable across Anvil forks of Base Sepolia -const SWAP_ROUTER = '0x94cC0AaC535CCDB3C01d6787D6413C739ae12bc4'; -const WETH = '0x4200000000000000000000000000000000000006'; -const POOL_FEE = 10_000; // 1% tier used by KRAIKEN pool - -// ── RPC helpers (Node.js context) ────────────────────────────────────────── - -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 getKrkBalance(rpcUrl: string, krkAddress: string, account: string): Promise { - const selector = '0x70a08231'; // balanceOf(address) - const data = selector + account.slice(2).padStart(64, '0'); - const result = (await rpcCall(rpcUrl, 'eth_call', [{ to: krkAddress, data }, 'latest'])) as string; - return BigInt(result); -} - -/** - * Poll eth_getTransactionReceipt until the tx is mined or maxAttempts exceeded. - * Anvil with automine mines synchronously before returning the tx hash, so this - * resolves almost immediately. The explicit check guards against Anvil instances - * configured with block intervals or high RPC latency. - */ -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]); - if (receipt !== null) return; - await new Promise(r => setTimeout(r, 500)); - } - throw new Error(`Transaction ${txHash} not mined after ${maxAttempts * 500}ms`); -} - -// ── ABI helpers for the sell path ────────────────────────────────────────── - -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)', -]; - -// ── Test ─────────────────────────────────────────────────────────────────── +const ACCOUNT_ADDRESS = new Wallet(PK).address; test('I can always leave', async ({ browser }) => { const config = getStackConfig(); @@ -88,145 +39,29 @@ test('I can always leave', async ({ browser }) => { await page.goto(`${config.webAppUrl}/app/`, { waitUntil: 'domcontentloaded' }); await expect(page.locator('.navbar-title').first()).toBeVisible({ timeout: 30_000 }); - // Force desktop-mode recalculation (wallet-provider sets screen.width = 1280) - await page.evaluate(() => window.dispatchEvent(new Event('resize'))); - await page.waitForTimeout(2_000); - - // ── 2. Connect wallet via the UI (matches e2e/01 pattern) ──────────── + // ── 2. Connect wallet via the UI ───────────────────────────────────── console.log('[TEST] Connecting wallet...'); - let panelOpened = false; - - const connectButton = page.locator('.connect-button--disconnected').first(); - if (await connectButton.isVisible({ timeout: 5_000 })) { - console.log('[TEST] Found desktop Connect button, clicking...'); - await connectButton.click(); - panelOpened = true; - } else { - const screenWidth = await page.evaluate(() => window.screen.width); - console.log(`[TEST] DEBUG: screen.width = ${screenWidth}`); - // Fallback to mobile login icon (dead code — wallet-provider always sets screen.width=1280, copied from e2e/01 for consistency) - const mobileLoginIcon = page.locator('.navbar-end svg').first(); - if (await mobileLoginIcon.isVisible({ timeout: 2_000 })) { - console.log('[TEST] Found mobile login icon, clicking...'); - await mobileLoginIcon.click(); - panelOpened = true; - } else { - console.log('[TEST] No Connect button or mobile icon — wallet may already be connected'); - } - } - - if (panelOpened) { - await page.waitForTimeout(1_000); - console.log('[TEST] Looking for wallet connector in panel...'); - const injectedConnector = page.locator('.connectors-element').first(); - if (await injectedConnector.isVisible({ timeout: 5_000 })) { - console.log('[TEST] Clicking wallet connector...'); - await injectedConnector.click(); - await page.waitForTimeout(2_000); - } else { - console.log('[TEST] WARNING: No wallet connector found in panel'); - } - } - - // Confirm wallet address is displayed in the navbar - const addrPrefix = ACCOUNT_ADDRESS.slice(0, 8); - await expect(page.getByText(new RegExp(addrPrefix, 'i')).first()).toBeVisible({ timeout: 15_000 }); - console.log('[TEST] Wallet connected'); - - // ── 3. Navigate to cheats page (has swap widget, same as e2e tests) ── - console.log('[TEST] Navigating to cheats...'); - await navigateSPA(page, '/app/cheats'); - await expect(page.getByRole('heading', { name: 'Cheat Console' })).toBeVisible({ timeout: 10_000 }); - - // The cheats page has an inline FInput with label 'ETH to spend' - const swapInput = page.getByLabel('ETH to spend'); - await expect(swapInput).toBeVisible({ timeout: 15_000 }); - console.log('[TEST] Swap widget visible'); + await connectWallet(page); + // ── 3. Buy KRK via the get-krk page swap widget ─────────────────────── const krkBefore = await getKrkBalance(config.rpcUrl, config.contracts.Kraiken, ACCOUNT_ADDRESS); console.log(`[TEST] KRK balance before buy: ${krkBefore}`); - // Use fill() which triggers input events for Vue v-model binding - await swapInput.fill('0.1'); - await page.waitForTimeout(500); + await buyKrk(page, '0.1'); - const buyButton = page.getByRole('button', { name: 'Buy' }).last(); - await expect(buyButton).toBeVisible(); - // Debug: screenshot before clicking - await page.screenshot({ path: 'test-results/holdout-before-buy.png' }); - console.log('[TEST] Clicking Buy KRK...'); - await buyButton.click(); - await page.waitForTimeout(3_000); - await page.screenshot({ path: 'test-results/holdout-after-buy.png' }); - - // Wait for the swap to complete (button cycles through "Submitting…" → "Buy") - try { - await page.getByRole('button', { name: /Submitting/i }).waitFor({ state: 'visible', timeout: 5_000 }); - console.log('[TEST] Swap in progress...'); - await page.getByRole('button', { name: 'Buy' }).last().waitFor({ state: 'visible', timeout: 60_000 }); - console.log('[TEST] Swap completed'); - } catch (err) { - console.log(`[TEST] Button state not observed (may be instant): ${err}`); - } - await page.waitForTimeout(2_000); - - // ── 4. Verify KRK was received ──────────────────────────────────── const krkAfterBuy = await getKrkBalance(config.rpcUrl, config.contracts.Kraiken, ACCOUNT_ADDRESS); console.log(`[TEST] KRK balance after buy: ${krkAfterBuy}`); expect(krkAfterBuy).toBeGreaterThan(krkBefore); console.log('[TEST] ✅ KRK received'); - // ── 5. Sell all KRK back (sovereign exit) ─────────────────────────── - // Encode approve + exactInputSingle calldata in Node.js, then send via - // the injected window.ethereum wallet provider (tests/setup/wallet-provider). - console.log('[TEST] Encoding sell transactions...'); - const erc20Iface = new Interface(ERC20_ABI); - const routerIface = new Interface(ROUTER_ABI); + // ── 4. Sell all KRK back (sovereign exit) ──────────────────────────── + await sellAllKrk(page, { + rpcUrl: config.rpcUrl, + krkAddress: config.contracts.Kraiken, + accountAddress: ACCOUNT_ADDRESS, + }); - const approveData = erc20Iface.encodeFunctionData('approve', [ - SWAP_ROUTER, - BigInt('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'), - ]); - - const swapData = routerIface.encodeFunctionData('exactInputSingle', [ - { - tokenIn: config.contracts.Kraiken, - tokenOut: WETH, - fee: POOL_FEE, - recipient: ACCOUNT_ADDRESS, - amountIn: krkAfterBuy, - amountOutMinimum: 0n, - sqrtPriceLimitX96: 0n, - }, - ]); - - // Step 5a: approve KRK to the Uniswap router; wait for on-chain confirmation - console.log('[TEST] 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.contracts.Kraiken, data: approveData, from: ACCOUNT_ADDRESS }, - ); - await waitForReceipt(config.rpcUrl, approveTxHash); - console.log('[TEST] Approve mined'); - - // Step 5b: swap KRK → WETH; wait for on-chain confirmation - console.log('[TEST] 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: ACCOUNT_ADDRESS }, - ); - await waitForReceipt(config.rpcUrl, swapTxHash); - console.log('[TEST] Swap mined'); - - // ── 6. Assert KRK was sold ──────────────────────────────────────── + // ── 5. Assert KRK was sold ──────────────────────────────────────────── const krkAfterSell = await getKrkBalance(config.rpcUrl, config.contracts.Kraiken, ACCOUNT_ADDRESS); console.log(`[TEST] KRK balance after sell: ${krkAfterSell}`); expect(krkAfterSell).toBeLessThan(krkAfterBuy);