/** * 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 '/' → '/app/stake' redirect so * we end up on the stake page without a second navigateSPA call. */ async function navigateToStakePage(page: Page): Promise { await navigateSPA(page, '/app/stake'); // 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) { console.log('[stake] Password prompt detected, entering lobsterDao...'); await page.getByLabel('Password').fill('lobsterDao'); await page.getByRole('button', { name: 'Login' }).click(); // 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 }); 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. * * 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). */ async function waitForActivePosition(graphqlUrl: string, owner: string, timeoutMs = 30_000): Promise { 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 { 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`); // Anchored regex avoids matching the "Unstake" buttons on any expanded position cards. const stakeButton = page.getByRole('main').getByRole('button', { name: /^(Snatch and )?Stake$/i }); 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". // The intermediate states may be missed if the tx completes instantly (Anvil automine). 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') .getByRole('button', { name: /^(Snatch and )?Stake$/i }) .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. // Guard against an empty accounts array (locked or disconnected wallet). const accountAddress = await page.evaluate(async () => { const ethereum = (window as unknown as { ethereum: { request: (req: { method: string }) => Promise } }).ethereum; const accounts = await ethereum.request({ method: 'eth_accounts' }); if (!accounts || accounts.length === 0) { throw new Error('[stake] eth_accounts returned empty array — wallet may be locked'); } 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 { 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(); // Observe the sign/waiting state if visible (may be missed on fast Anvil automine). try { await activeCollapse .getByRole('button', { name: /Sign Transaction|Waiting/i }) .waitFor({ state: 'visible', timeout: 5_000 }); console.log('[stake] Unstake transaction initiated'); } catch { console.log('[stake] Transaction sign/waiting state not observed (may have completed instantly)'); } // 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'); console.log('[stake] ✅ Unstaking complete'); }