- no-dilution.spec.ts: pass undefined for opts, screenshotPrefix as 4th arg - swap.ts: add eslint-disable-next-line for eth_getFilterLogs polling delay
246 lines
11 KiB
TypeScript
246 lines
11 KiB
TypeScript
/**
|
|
* 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
|
|
|
|
// 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<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
|
|
}
|
|
// 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<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.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` });
|
|
}
|
|
|
|
/**
|
|
* 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).
|
|
*
|
|
* @returns The WETH delta (wethAfter - wethBefore) received from the swap.
|
|
*/
|
|
export async function sellAllKrk(page: Page, config: SellConfig): Promise<bigint> {
|
|
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);
|
|
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;
|
|
}
|