2026-03-02 05:21:24 +00:00
/ * *
* Shared swap helpers for holdout scenarios .
*
2026-03-05 13:13:04 +00:00
* 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 ) .
2026-03-02 05:21:24 +00:00
* 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
2026-03-03 22:20:02 +01:00
// ERC-20 Transfer event topic (keccak256("Transfer(address,address,uint256)"))
const TRANSFER_TOPIC = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef' ;
2026-03-02 05:21:24 +00:00
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
* /
2026-03-05 10:52:28 +00:00
export async function waitForReceipt ( rpcUrl : string , txHash : string , maxAttempts = 20 ) : Promise < void > {
2026-03-02 05:21:24 +00:00
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 ───────────────────────────────────────────────────────
2026-03-03 22:20:02 +01:00
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 ;
}
2026-03-02 05:21:24 +00:00
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 .
2026-03-03 22:20:02 +01:00
*
* 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 ) .
2026-03-03 21:01:38 +00:00
*
* @param screenshotPrefix - Optional prefix for screenshot filenames ( e . g . , 'walletA' , 'walletB' )
2026-03-02 05:21:24 +00:00
* /
2026-03-03 21:01:38 +00:00
export async function buyKrk ( page : Page , ethAmount : string , opts? : BuyKrkOptions , screenshotPrefix = 'holdout' ) : Promise < void > {
2026-03-02 05:21:24 +00:00
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
2026-03-03 22:20:02 +01:00
// 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 } ` ) ;
}
2026-03-03 21:01:38 +00:00
await page . screenshot ( { path : ` test-results/ ${ screenshotPrefix } -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-03 22:20:02 +01:00
}
// 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 ;
}
2026-03-05 05:53:19 +00:00
// eslint-disable-next-line no-restricted-syntax -- Polling with timeout: eth_getFilterLogs is HTTP-only polling (not push). See AGENTS.md #Engineering Principles.
2026-03-03 22:20:02 +01:00
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 ` ) ;
}
2026-03-02 05:21:24 +00:00
}
2026-03-02 05:59:21 +00:00
2026-03-03 21:01:38 +00:00
await page . screenshot ( { path : ` test-results/ ${ screenshotPrefix } -after-buy.png ` } ) ;
2026-03-02 05:21:24 +00:00
}
2026-03-05 13:13:04 +00:00
/ * *
* 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 ) .
*
2026-03-05 13:58:14 +00:00
* 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 0 n ( caller is responsible for verification ) .
2026-03-05 13:13:04 +00:00
*
* @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
2026-03-05 13:58:14 +00:00
* @param config - Optional config for on - chain WETH receipt confirmation
2026-03-05 13:13:04 +00:00
* @returns WETH received ( balance diff ) or 0 n if config is not provided
* /
export async function sellKrk (
page : Page ,
amount : string ,
screenshotPrefix? : string ,
2026-03-05 13:58:14 +00:00
config? : Pick < SellConfig , 'rpcUrl' | 'accountAddress' > ,
2026-03-05 13:13:04 +00:00
) : Promise < bigint > {
console . log ( ` [swap] Selling ${ amount } KRK via get-krk page sell widget... ` ) ;
await navigateSPA ( page , '/app/get-krk' ) ;
2026-03-05 13:58:14 +00:00
await expect ( page . getByRole ( 'heading' , { name : 'Get $KRK Tokens' } ) ) . toBeVisible ( { timeout : 10_000 } ) ;
2026-03-05 13:13:04 +00:00
const sellTab = page . getByTestId ( 'swap-mode-sell' ) ;
await expect ( sellTab ) . toBeVisible ( { timeout : 10_000 } ) ;
await sellTab . click ( ) ;
2026-03-05 13:58:14 +00:00
const sellInput = page . getByTestId ( 'swap-sell-amount-input' ) ;
2026-03-05 13:13:04 +00:00
if ( amount === 'max' ) {
const maxButton = page . locator ( '.max-button' ) ;
await expect ( maxButton ) . toBeVisible ( { timeout : 5_000 } ) ;
await maxButton . click ( ) ;
2026-03-05 13:58:14 +00:00
// setMax() is async — wait for the composable to populate the input via loadKrkBalance()
await expect ( sellInput ) . not . toHaveValue ( '' , { timeout : 10_000 } ) ;
2026-03-05 13:13:04 +00:00
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 ) : 0 n ;
2026-03-05 13:58:14 +00:00
// 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 } ` ) ;
}
2026-03-05 13:13:04 +00:00
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 } ) ;
2026-03-05 13:58:14 +00:00
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 ` ) ;
}
}
2026-03-05 13:13:04 +00:00
if ( screenshotPrefix ) {
await page . screenshot ( { path : ` test-results/ ${ screenshotPrefix } -after-sell.png ` } ) ;
}
if ( ! config ) return 0 n ;
const wethAfter = await erc20BalanceOf ( config . rpcUrl , WETH , config . accountAddress ) ;
const wethReceived = wethAfter - wethBefore ;
if ( wethReceived <= 0 n ) {
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 ;
}
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
*
2026-03-06 01:51:23 +00:00
* Throws if the WETH balance does not increase after the swap , which
2026-03-02 05:59:21 +00:00
* indicates the pool returned 0 output ( possible with amountOutMinimum : 0n on
* a partially - drained pool ) .
2026-03-03 19:45:46 +00:00
*
* @returns The WETH delta ( wethAfter - wethBefore ) received from the swap .
2026-03-02 05:21:24 +00:00
* /
2026-03-03 19:45:46 +00:00
export async function sellAllKrk ( page : Page , config : SellConfig ) : Promise < bigint > {
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 ) ;
2026-03-03 19:45:46 +00:00
const wethReceived = wethAfter - wethBefore ;
if ( wethReceived <= 0 n ) {
2026-03-06 01:51:23 +00:00
throw new Error ( 'sellAllKrk: swap returned 0 WETH — pool may be drained or price impact exceeded balance' ) ;
2026-03-02 05:59:21 +00:00
}
2026-03-06 01:51:23 +00:00
console . log ( ` [swap] Received ${ wethReceived } WETH ` ) ;
2026-03-03 19:45:46 +00:00
return wethReceived ;
2026-03-02 05:21:24 +00:00
}