2026-03-05 14:37:56 +00:00
/ * *
* Shared staking helpers for holdout scenarios .
*
* stakeKrk — drives the stake page UI ( navigate → fill → submit → wait for Ponder ) .
* unstakeKrk — expands the active position collapse and clicks Unstake .
*
* Both helpers handle the password gate on first visit automatically .
* /
import type { Page } from '@playwright/test' ;
import { expect } from '@playwright/test' ;
import { navigateSPA } from '../../../tests/setup/navigate' ;
import { getStackConfig } from '../../../tests/setup/stack' ;
// ── Internal helpers ──────────────────────────────────────────────────────────
/ * *
* Navigate to / app / stake , handling the auth guard ' s password gate if triggered .
* The guard redirects to / login when localStorage 'authentificated' is absent .
* After a successful login , router . push ( '/' ) causes '/' → '/stake' redirect so
* we end up on the stake page without a second navigateSPA call .
* /
async function navigateToStakePage ( page : Page ) : Promise < void > {
await navigateSPA ( page , '/app/stake' ) ;
2026-03-05 15:09:54 +00:00
// Race between the stake form and the login page to determine which route mounted.
// Point-in-time isVisible() is unreliable here: CSS transitions or async component
// setup can leave the element in the DOM but not yet "visible" right after networkidle.
const isLoginPage = await page
. getByLabel ( 'Password' )
. waitFor ( { state : 'visible' , timeout : 3_000 } )
. then ( ( ) = > true )
. catch ( ( ) = > false ) ;
if ( isLoginPage ) {
2026-03-05 14:37:56 +00:00
console . log ( '[stake] Password prompt detected, entering lobsterDao...' ) ;
2026-03-05 15:09:54 +00:00
await page . getByLabel ( 'Password' ) . fill ( 'lobsterDao' ) ;
2026-03-05 14:37:56 +00:00
await page . getByRole ( 'button' , { name : 'Login' } ) . click ( ) ;
2026-03-06 16:11:24 +00:00
// router.push('/') in LoginView → '/' redirects to '/app/stake' → stake page loads.
// waitForLoadState('networkidle') is avoided here for the same reason as navigate.ts:
// persistent WebSocket connections prevent the network from ever going idle.
// Instead, wait for the URL to settle on the stake page as the readiness signal.
await page . waitForURL ( '**/app/stake**' , { timeout : 20_000 } ) ;
2026-03-05 14:37:56 +00:00
console . log ( '[stake] Authenticated, stake page loading' ) ;
}
}
/ * *
* Poll the Ponder GraphQL API until at least one active position exists for owner .
* Throws if no active position appears within timeoutMs .
2026-03-05 15:09:54 +00:00
*
* owner must be lowercase : the e2e tests and this helper both use address . toLowerCase ( )
* to match Ponder ' s storage format ( verified against 01 - acquire - and - stake . spec . ts ) .
2026-03-05 14:37:56 +00:00
* /
async function waitForActivePosition ( graphqlUrl : string , owner : string , timeoutMs = 30 _000 ) : Promise < void > {
const query = `
query PositionsByOwner ( $owner : String ! ) {
positionss ( where : { owner : $owner } , limit : 5 ) {
items {
id
status
}
}
}
` ;
const deadline = Date . now ( ) + timeoutMs ;
while ( Date . now ( ) < deadline ) {
const resp = await fetch ( graphqlUrl , {
method : 'POST' ,
headers : { 'content-type' : 'application/json' } ,
body : JSON.stringify ( { query , variables : { owner } } ) ,
} ) ;
const payload = ( await resp . json ( ) ) as {
data ? : { positionss ? : { items? : Array < { id : string ; status : string } > } } ;
} ;
const items = payload ? . data ? . positionss ? . items ? ? [ ] ;
const active = items . filter ( p = > p . status === 'Active' ) ;
if ( active . length > 0 ) {
console . log ( ` [stake] Ponder indexed ${ active . length } active position(s) ` ) ;
return ;
}
console . log ( '[stake] Waiting for Ponder to index staking position...' ) ;
// eslint-disable-next-line no-restricted-syntax -- Polling with timeout: Ponder GraphQL is HTTP-only (no push). See AGENTS.md #Engineering Principles.
await new Promise ( r = > setTimeout ( r , 2 _000 ) ) ;
}
throw new Error ( ` No active staking position found in Ponder GraphQL within ${ timeoutMs } ms ` ) ;
}
// ── Exported helpers ──────────────────────────────────────────────────────────
/ * *
* Navigate to the stake page , fill the amount , select tax rate , and submit .
* Wallet must already be connected . App must be password - unlocked .
*
* @param page - Playwright page with injected wallet
* @param amount - KRK amount to stake ( as string , e . g . '1000' )
* @param taxRateIndex - Tax rate option index ( 0 - based ) . Use the highest available for max tax .
* /
export async function stakeKrk ( page : Page , amount : string , taxRateIndex : number ) : Promise < void > {
console . log ( ` [stake] Staking ${ amount } KRK at tax rate index ${ taxRateIndex } ... ` ) ;
await navigateToStakePage ( page ) ;
// The Token Amount slider is the readiness signal used in the e2e test.
const tokenAmountSlider = page . getByRole ( 'slider' , { name : 'Token Amount' } ) ;
await expect ( tokenAmountSlider ) . toBeVisible ( { timeout : 15_000 } ) ;
console . log ( '[stake] Stake form loaded' ) ;
// Fill staking amount.
const stakeAmountInput = page . getByLabel ( 'Staking Amount' ) ;
await expect ( stakeAmountInput ) . toBeVisible ( { timeout : 10_000 } ) ;
await stakeAmountInput . fill ( amount ) ;
console . log ( ` [stake] Filled staking amount: ${ amount } ` ) ;
// Select tax rate.
const taxSelect = page . getByRole ( 'combobox' , { name : 'Tax' } ) ;
await taxSelect . selectOption ( { value : String ( taxRateIndex ) } ) ;
console . log ( ` [stake] Tax rate index ${ taxRateIndex } selected ` ) ;
2026-03-05 15:09:54 +00:00
// Anchored regex avoids matching the "Unstake" buttons on any expanded position cards.
const stakeButton = page . getByRole ( 'main' ) . getByRole ( 'button' , { name : /^(Snatch and )?Stake$/i } ) ;
2026-03-05 14:37:56 +00:00
await expect ( stakeButton ) . toBeVisible ( { timeout : 5_000 } ) ;
console . log ( '[stake] Clicking Stake button...' ) ;
await stakeButton . click ( ) ;
// Wait for transaction: button cycles "Stake" → "Sign Transaction"/"Waiting" → "Stake".
2026-03-05 15:09:54 +00:00
// The intermediate states may be missed if the tx completes instantly (Anvil automine).
2026-03-05 14:37:56 +00:00
try {
await page
. getByRole ( 'button' , { name : /Sign Transaction|Waiting/i } )
. waitFor ( { state : 'visible' , timeout : 5_000 } ) ;
console . log ( '[stake] Transaction initiated, waiting for completion...' ) ;
await page
. getByRole ( 'main' )
2026-03-05 15:09:54 +00:00
. getByRole ( 'button' , { name : /^(Snatch and )?Stake$/i } )
2026-03-05 14:37:56 +00:00
. waitFor ( { state : 'visible' , timeout : 60_000 } ) ;
console . log ( '[stake] Stake transaction completed' ) ;
} catch {
console . log ( '[stake] Transaction state not observed (may have completed instantly)' ) ;
}
// Resolve the connected account address to scope the Ponder query to this wallet.
2026-03-05 15:09:54 +00:00
// Guard against an empty accounts array (locked or disconnected wallet).
2026-03-05 14:37:56 +00:00
const accountAddress = await page . evaluate ( async ( ) = > {
const ethereum = ( window as unknown as { ethereum : { request : ( req : { method : string } ) = > Promise < string [ ] > } } ) . ethereum ;
const accounts = await ethereum . request ( { method : 'eth_accounts' } ) ;
2026-03-05 15:09:54 +00:00
if ( ! accounts || accounts . length === 0 ) {
throw new Error ( '[stake] eth_accounts returned empty array — wallet may be locked' ) ;
}
2026-03-05 14:37:56 +00:00
return accounts [ 0 ] . toLowerCase ( ) ;
} ) ;
// Poll Ponder GraphQL until the new position is indexed (indexing latency up to 30s).
const { graphqlUrl } = getStackConfig ( ) ;
await waitForActivePosition ( graphqlUrl , accountAddress ) ;
console . log ( '[stake] ✅ Staking complete' ) ;
}
/ * *
* Navigate to an active staking position and click Unstake .
* Assumes the wallet has exactly one active position ( simplest case ) .
*
* @param page - Playwright page with injected wallet
* /
export async function unstakeKrk ( page : Page ) : Promise < void > {
console . log ( '[stake] Unstaking active position...' ) ;
await navigateToStakePage ( page ) ;
// Wait for the active position collapse to appear.
const activeCollapse = page . locator ( '.f-collapse-active' ) . first ( ) ;
await expect ( activeCollapse ) . toBeVisible ( { timeout : 15_000 } ) ;
console . log ( '[stake] Active position found' ) ;
// FCollapse starts collapsed (isShow = false). Click the toggle icon to expand.
// The icon carries class .toggle-collapse; clicking it fires openClose() in FCollapse.
const toggleIcon = activeCollapse . locator ( '.toggle-collapse' ) . first ( ) ;
await toggleIcon . click ( ) ;
console . log ( '[stake] Expanded position collapse' ) ;
// The Unstake button appears in the collapsed body when unstake.state === 'Unstakeable'.
const unstakeButton = activeCollapse . getByRole ( 'button' , { name : /^unstake$/i } ) ;
await expect ( unstakeButton ) . toBeVisible ( { timeout : 10_000 } ) ;
console . log ( '[stake] Clicking Unstake...' ) ;
await unstakeButton . click ( ) ;
2026-03-05 15:09:54 +00:00
// Observe the sign/waiting state if visible (may be missed on fast Anvil automine).
2026-03-05 14:37:56 +00:00
try {
await activeCollapse
. getByRole ( 'button' , { name : /Sign Transaction|Waiting/i } )
. waitFor ( { state : 'visible' , timeout : 5_000 } ) ;
2026-03-05 15:09:54 +00:00
console . log ( '[stake] Unstake transaction initiated' ) ;
2026-03-05 14:37:56 +00:00
} catch {
2026-03-05 15:09:54 +00:00
console . log ( '[stake] Transaction sign/waiting state not observed (may have completed instantly)' ) ;
2026-03-05 14:37:56 +00:00
}
2026-03-05 15:09:54 +00:00
// Always wait for the collapse to detach — this verifies the Unstake click actually
// worked and the position was removed, regardless of whether the transient states
// were observed above.
// TODO: add a Ponder waitForPositionGone poll here for holdout scenarios that assert
// off-chain state (e.g. status === 'Closed') immediately after unstaking.
await activeCollapse . waitFor ( { state : 'detached' , timeout : 60_000 } ) ;
console . log ( '[stake] Position removed from UI' ) ;
2026-03-05 14:37:56 +00:00
console . log ( '[stake] ✅ Unstaking complete' ) ;
}