2026-03-02 05:21:24 +00:00
/ * *
* 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' ;
2026-03-02 05:59:21 +00:00
import { rpcCall } from './rpc' ;
2026-03-02 05:21:24 +00:00
// 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 ─────────────────────────────────────────────────────────
2026-03-02 05:59:21 +00:00
/** 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 ) ;
}
2026-03-02 05:21:24 +00:00
/ * *
* 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 .
2026-03-02 05:59:21 +00:00
*
* Throws if the transaction was mined but reverted ( status 0x0 ) so callers
* get a clear failure rather than a confusing downstream balance - assertion error .
2026-03-02 05:21:24 +00:00
* /
async function waitForReceipt ( rpcUrl : string , txHash : string , maxAttempts = 20 ) : Promise < void > {
for ( let i = 0 ; i < maxAttempts ; i ++ ) {
2026-03-02 05:59:21 +00:00
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
}
2026-03-03 20:58:01 +00:00
// 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.
2026-03-02 05:21:24 +00:00
await new Promise ( r = > setTimeout ( r , 500 ) ) ;
}
throw new Error ( ` Transaction ${ txHash } not mined after ${ maxAttempts * 500 } ms ` ) ;
}
// ── Public config type ───────────────────────────────────────────────────────
export interface SellConfig {
2026-03-02 05:59:21 +00:00
/** Anvil JSON-RPC endpoint (used to wait for receipt and query token balances). */
2026-03-02 05:21:24 +00:00
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 } ) ;
2026-03-02 23:13:05 +00:00
const swapInput = page . getByTestId ( 'swap-amount-input' ) ;
2026-03-02 05:21:24 +00:00
await expect ( swapInput ) . toBeVisible ( { timeout : 15_000 } ) ;
await swapInput . fill ( ethAmount ) ;
await page . waitForTimeout ( 500 ) ;
2026-03-02 23:13:05 +00:00
const buyButton = page . getByTestId ( 'swap-buy-button' ) ;
2026-03-02 05:21:24 +00:00
await expect ( buyButton ) . toBeVisible ( { timeout : 5_000 } ) ;
2026-03-02 05:59:21 +00:00
await page . screenshot ( { path : 'test-results/holdout-before-buy.png' } ) ;
2026-03-02 05:21:24 +00:00
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...' ) ;
2026-03-02 23:47:53 +00:00
await expect ( page . getByTestId ( 'swap-buy-button' ) ) . toHaveText ( 'Buy KRK' , { timeout : 60_000 } ) ;
2026-03-02 05:21:24 +00:00
console . log ( '[swap] Swap completed' ) ;
} catch {
2026-03-02 05:59:21 +00:00
// Swap completed before the Submitting state could be observed
2026-03-02 05:21:24 +00:00
console . log ( '[swap] Button state not observed (swap may have completed instantly)' ) ;
2026-03-02 05:59:21 +00:00
await page . waitForTimeout ( 2 _000 ) ;
2026-03-02 05:21:24 +00:00
}
2026-03-02 05:59:21 +00:00
await page . screenshot ( { path : 'test-results/holdout-after-buy.png' } ) ;
2026-03-02 05:21:24 +00:00
}
/ * *
* 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 .
2026-03-02 05:59:21 +00:00
*
* 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 ) .
2026-03-02 05:21:24 +00:00
* /
export async function sellAllKrk ( page : Page , config : SellConfig ) : Promise < void > {
2026-03-02 05:59:21 +00:00
const krkBalance = await erc20BalanceOf ( config . rpcUrl , config . krkAddress , config . accountAddress ) ;
2026-03-02 05:21:24 +00:00
if ( krkBalance === 0 n ) throw new Error ( 'sellAllKrk: KRK balance is 0 — nothing to sell' ) ;
console . log ( ` [swap] Selling ${ krkBalance } KRK... ` ) ;
2026-03-02 05:59:21 +00:00
const wethBefore = await erc20BalanceOf ( config . rpcUrl , WETH , config . accountAddress ) ;
2026-03-02 05:21:24 +00:00
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' ) ;
2026-03-02 05:59:21 +00:00
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 ` ) ;
}
2026-03-02 05:21:24 +00:00
}