Merge pull request 'fix: feat: Shared holdout scenario test helpers (#401)' (#405) from fix/issue-401 into master

This commit is contained in:
johba 2026-03-02 07:24:24 +01:00
commit be68018290
5 changed files with 376 additions and 182 deletions

View file

@ -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<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)',
];
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.
*
* 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<void> {
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);
}

View file

@ -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<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;
}

View file

@ -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<bigint> {
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<void> {
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<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 });
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<void> {
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<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');
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`);
}
}

View file

@ -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<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);
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<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');
}

View file

@ -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<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 getKrkBalance(rpcUrl: string, krkAddress: string, account: string): Promise<bigint> {
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<void> {
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<string>,
{ 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<string>,
{ 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);