harb/tests/e2e/02-max-stake-all-tax-rates.spec.ts

330 lines
13 KiB
TypeScript
Raw Normal View History

2025-10-08 15:51:49 +00:00
import { expect, test, type APIRequestContext } from '@playwright/test';
import { Wallet } from 'ethers';
import { createWalletContext } from '../setup/wallet-provider';
import { getStackConfig, validateStackHealthy } from '../setup/stack';
import { navigateSPA } from '../setup/navigate';
2025-10-08 15:51:49 +00:00
import { TAX_RATE_OPTIONS } from '../../kraiken-lib/src/taxRates';
const ACCOUNT_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';
const ACCOUNT_ADDRESS = new Wallet(ACCOUNT_PRIVATE_KEY).address.toLowerCase();
// Get stack configuration from environment (or defaults)
const STACK_CONFIG = getStackConfig();
const STACK_RPC_URL = STACK_CONFIG.rpcUrl;
const STACK_WEBAPP_URL = STACK_CONFIG.webAppUrl;
const STACK_GRAPHQL_URL = STACK_CONFIG.graphqlUrl;
async function fetchPositions(request: APIRequestContext, owner: string) {
const response = await request.post(STACK_GRAPHQL_URL, {
data: {
query: `
query PositionsByOwner($owner: String!) {
positionss(where: { owner: $owner }, limit: 100) {
items {
id
owner
taxRate
stakeDeposit
status
}
}
}
`,
variables: { owner },
},
headers: { 'content-type': 'application/json' },
});
expect(response.ok()).toBeTruthy();
const payload = await response.json();
return (payload?.data?.positionss?.items ?? []) as Array<{
id: string;
owner: string;
taxRate: number;
stakeDeposit: string;
status: string;
}>;
}
async function fetchStats(request: APIRequestContext) {
const response = await request.post(STACK_GRAPHQL_URL, {
data: {
query: `
query Stats {
stats(id: "0x01") {
kraikenTotalSupply
kraikenStakedSupply
percentageStaked
}
}
`,
},
headers: { 'content-type': 'application/json' },
});
expect(response.ok()).toBeTruthy();
const payload = await response.json();
return payload?.data?.stats;
}
test.describe('Max Stake All Tax Rates', () => {
test.beforeAll(async () => {
await validateStackHealthy(STACK_CONFIG);
});
test('fills all tax rates until maxStake is reached', async ({ browser, request }) => {
test.setTimeout(10 * 60 * 1000); // 10 minutes — this test creates 30 staking positions via UI
2025-10-08 15:51:49 +00:00
console.log('[TEST] Creating wallet context...');
const context = await createWalletContext(browser, {
privateKey: ACCOUNT_PRIVATE_KEY,
rpcUrl: STACK_RPC_URL,
});
const page = await context.newPage();
// Log browser console messages
page.on('console', msg => console.log(`[BROWSER] ${msg.type()}: ${msg.text()}`));
page.on('pageerror', error => console.log(`[BROWSER ERROR] ${error.message}`));
try {
console.log('[TEST] Loading app...');
await page.goto(`${STACK_WEBAPP_URL}/app/`, { waitUntil: 'domcontentloaded' });
console.log('[TEST] App loaded, waiting for Vue app to mount...');
// Wait for the Vue app to fully mount by waiting for a key element
// The navbar-title is always present regardless of connection state
const navbarTitle = page.locator('.navbar-title').first();
await expect(navbarTitle).toBeVisible({ timeout: 30_000 });
console.log('[TEST] Vue app mounted, navbar is visible');
// Trigger a resize event to force Vue's useMobile composable to recalculate
// This ensures the app recognizes the desktop screen width set by wallet-provider
await page.evaluate(() => {
window.dispatchEvent(new Event('resize'));
});
await page.waitForTimeout(500);
// Give extra time for wallet connectors to initialize
await page.waitForTimeout(2_000);
// Connect wallet flow:
// The wallet-provider sets screen.width to 1280 to ensure desktop mode.
// We expect the desktop Connect button to be visible.
console.log('[TEST] Looking for Connect button...');
// Desktop Connect button
const connectButton = page.locator('.connect-button--disconnected').first();
let panelOpened = false;
// Wait for the Connect button with a reasonable timeout
if (await connectButton.isVisible({ timeout: 5_000 })) {
console.log('[TEST] Found desktop Connect button, clicking...');
await connectButton.click();
panelOpened = true;
} else {
// Debug: Log current screen.width and navbar-end contents
const screenWidth = await page.evaluate(() => window.screen.width);
const navbarEndHtml = await page.locator('.navbar-end').innerHTML().catch(() => 'not found');
console.log(`[TEST] DEBUG: screen.width = ${screenWidth}`);
console.log(`[TEST] DEBUG: navbar-end HTML = ${navbarEndHtml.substring(0, 500)}`);
console.log('[TEST] Connect button not visible - checking for mobile fallback...');
// Fallback to mobile login icon (SVG in navbar-end when disconnected)
const mobileLoginIcon = page.locator('.navbar-end svg').first();
if (await mobileLoginIcon.isVisible({ timeout: 2_000 })) {
console.log('[TEST] Found mobile login icon, clicking...');
await mobileLoginIcon.click();
panelOpened = true;
} else {
console.log('[TEST] No Connect button or mobile icon visible - wallet may already be connected');
}
}
if (panelOpened) {
await page.waitForTimeout(1_000);
// Look for the injected wallet connector in the slideout panel
console.log('[TEST] Looking for wallet connector in panel...');
const injectedConnector = page.locator('.connectors-element').first();
if (await injectedConnector.isVisible({ timeout: 5_000 })) {
console.log('[TEST] Clicking first wallet connector...');
await injectedConnector.click();
await page.waitForTimeout(2_000);
} else {
console.log('[TEST] WARNING: No wallet connector found in panel');
}
}
2025-10-08 15:51:49 +00:00
// Verify wallet connection
console.log('[TEST] Checking for wallet display...');
const walletDisplay = page.getByText(/0xf39F/i).first();
await expect(walletDisplay).toBeVisible({ timeout: 15_000 });
console.log('[TEST] Wallet connected successfully!');
// Step 1: Mint test ETH
console.log('[TEST] Navigating to cheats page...');
await navigateSPA(page, '/app/cheats');
await expect(page.getByRole('heading', { name: 'Cheat Console' })).toBeVisible({ timeout: 10_000 });
2025-10-08 15:51:49 +00:00
console.log('[TEST] Minting test ETH (10 ETH)...');
await page.getByLabel('RPC URL').fill(STACK_RPC_URL);
await page.getByLabel('Recipient').fill(ACCOUNT_ADDRESS);
const mintButton = page.getByRole('button', { name: 'Mint' });
await expect(mintButton).toBeEnabled();
await mintButton.click();
await page.waitForTimeout(3_000);
// Step 2: Buy a large amount of KRK tokens via the get-krk page
console.log('[TEST] Navigating to get-krk page to buy KRK...');
await navigateSPA(page, '/app/get-krk');
await expect(page.getByRole('heading', { name: 'Get $KRK Tokens' })).toBeVisible({ timeout: 10_000 });
2025-10-08 15:51:49 +00:00
console.log('[TEST] Buying KRK tokens (swapping 5 ETH)...');
const ethToSpendInput = page.getByTestId('swap-amount-input');
await expect(ethToSpendInput).toBeVisible({ timeout: 15_000 });
2025-10-08 15:51:49 +00:00
await ethToSpendInput.fill('5');
const buyButton = page.getByTestId('swap-buy-button');
2025-10-08 15:51:49 +00:00
await expect(buyButton).toBeVisible();
await buyButton.click();
// Wait for swap to complete
console.log('[TEST] Waiting for swap to process...');
try {
await page.getByRole('button', { name: /Submitting/i }).waitFor({ state: 'visible', timeout: 5_000 });
await expect(page.getByTestId('swap-buy-button')).toHaveText('Buy KRK', { timeout: 60_000 });
2025-10-08 15:51:49 +00:00
console.log('[TEST] Swap completed!');
} catch (e) {
console.log('[TEST] Swap may have completed instantly');
}
await page.waitForTimeout(2_000);
// Verify we have KRK tokens
console.log('[TEST] Verifying KRK balance via RPC...');
const balanceResponse = await fetch(STACK_RPC_URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'eth_call',
params: [{
refactor: migrate kraiken-lib to explicit subpath imports (BREAKING CHANGE) (#89) Removes the barrel export pattern in favor of explicit subpath imports for better tree-shaking and clearer dependencies. ## Breaking Changes - Removed `src/helpers.ts` barrel export - Removed `./helpers` from package.json exports - Root `kraiken-lib` import now raises build errors - Consumers MUST use explicit subpaths: - `kraiken-lib/abis` - Contract ABIs - `kraiken-lib/staking` - Staking helpers - `kraiken-lib/snatch` - Snatch selection - `kraiken-lib/ids` - Position ID utilities - `kraiken-lib/subgraph` - Byte conversion utilities - `kraiken-lib/taxRates` - Tax rate constants - `kraiken-lib/version` - Version validation ## Changes - kraiken-lib: - Bumped version to 1.0.0 (breaking change) - Updated src/index.ts to raise build errors - Added backward-compatible ABI aliases (KraikenAbi, StakeAbi) - Updated all test files to use .js extensions and new imports - Updated documentation (README, AGENTS.md) - Consumer updates: - services/ponder: Updated ponder.config.ts to use kraiken-lib/abis - web-app: Updated all imports to use subpaths - composables/usePositions.ts: kraiken-lib/subgraph - contracts/harb.ts: kraiken-lib/abis - contracts/stake.ts: kraiken-lib/abis ## Migration Guide ```typescript // OLD import { getSnatchList } from 'kraiken-lib/helpers'; import { KraikenAbi } from 'kraiken-lib'; // NEW import { getSnatchList } from 'kraiken-lib/snatch'; import { KraikenAbi } from 'kraiken-lib/abis'; ``` Fixes #86 Co-authored-by: openhands <openhands@all-hands.dev> Reviewed-on: https://codeberg.org/johba/harb/pulls/89
2025-11-20 18:54:53 +01:00
to: STACK_CONFIG.contracts.Kraiken, // KRK token from deployments
2025-10-08 15:51:49 +00:00
data: `0x70a08231000000000000000000000000${ACCOUNT_ADDRESS.slice(2)}` // balanceOf(address)
}, 'latest']
})
});
const balanceData = await balanceResponse.json();
refactor: migrate kraiken-lib to explicit subpath imports (BREAKING CHANGE) (#89) Removes the barrel export pattern in favor of explicit subpath imports for better tree-shaking and clearer dependencies. ## Breaking Changes - Removed `src/helpers.ts` barrel export - Removed `./helpers` from package.json exports - Root `kraiken-lib` import now raises build errors - Consumers MUST use explicit subpaths: - `kraiken-lib/abis` - Contract ABIs - `kraiken-lib/staking` - Staking helpers - `kraiken-lib/snatch` - Snatch selection - `kraiken-lib/ids` - Position ID utilities - `kraiken-lib/subgraph` - Byte conversion utilities - `kraiken-lib/taxRates` - Tax rate constants - `kraiken-lib/version` - Version validation ## Changes - kraiken-lib: - Bumped version to 1.0.0 (breaking change) - Updated src/index.ts to raise build errors - Added backward-compatible ABI aliases (KraikenAbi, StakeAbi) - Updated all test files to use .js extensions and new imports - Updated documentation (README, AGENTS.md) - Consumer updates: - services/ponder: Updated ponder.config.ts to use kraiken-lib/abis - web-app: Updated all imports to use subpaths - composables/usePositions.ts: kraiken-lib/subgraph - contracts/harb.ts: kraiken-lib/abis - contracts/stake.ts: kraiken-lib/abis ## Migration Guide ```typescript // OLD import { getSnatchList } from 'kraiken-lib/helpers'; import { KraikenAbi } from 'kraiken-lib'; // NEW import { getSnatchList } from 'kraiken-lib/snatch'; import { KraikenAbi } from 'kraiken-lib/abis'; ``` Fixes #86 Co-authored-by: openhands <openhands@all-hands.dev> Reviewed-on: https://codeberg.org/johba/harb/pulls/89
2025-11-20 18:54:53 +01:00
const balance = BigInt(balanceData.result || '0x0');
2025-10-08 15:51:49 +00:00
console.log(`[TEST] KRK balance: ${balance.toString()} wei`);
expect(balance).toBeGreaterThan(0n);
// Step 3: Navigate to stake page
console.log('[TEST] Navigating to stake page...');
await navigateSPA(page, '/app/stake');
2025-10-08 15:51:49 +00:00
await page.waitForTimeout(2_000);
const tokenAmountSlider = page.getByRole('slider', { name: 'Token Amount' });
await expect(tokenAmountSlider).toBeVisible({ timeout: 15_000 });
// Step 4: Create staking positions for all tax rates
console.log(`[TEST] Creating positions for all ${TAX_RATE_OPTIONS.length} tax rates...`);
let positionCount = 0;
let maxStakeReached = false;
// We'll try to create positions with increasing amounts to fill maxStake faster
const baseStakeAmount = 1000;
for (const taxRateOption of TAX_RATE_OPTIONS) {
if (maxStakeReached) break;
console.log(`[TEST] Creating position with tax rate ${taxRateOption.index} (${taxRateOption.year}% annually)...`);
// Fill stake form
const stakeAmountInput = page.getByLabel('Staking Amount');
await expect(stakeAmountInput).toBeVisible({ timeout: 10_000 });
await stakeAmountInput.fill(baseStakeAmount.toString());
const taxSelect = page.getByRole('combobox', { name: 'Tax' });
await taxSelect.selectOption({ value: taxRateOption.index.toString() });
// Click stake button
const stakeButton = page.getByRole('main').getByRole('button', { name: /Stake|Snatch and Stake/i });
await expect(stakeButton).toBeVisible({ timeout: 5_000 });
// Check if staking is still possible (button might be disabled if maxStake reached)
const isDisabled = await stakeButton.isDisabled();
if (isDisabled) {
console.log('[TEST] Stake button is disabled - likely maxStake reached');
maxStakeReached = true;
break;
}
await stakeButton.click();
// Wait for transaction to process
try {
await page.getByRole('button', { name: /Sign Transaction|Waiting/i }).waitFor({ state: 'visible', timeout: 5_000 });
await page.getByRole('button', { name: /Stake|Snatch and Stake/i }).waitFor({ state: 'visible', timeout: 60_000 });
} catch (e) {
// Transaction completed instantly or failed
// Check if we got an error message
const errorVisible = await page.getByText(/error|failed|exceeded/i).isVisible().catch(() => false);
if (errorVisible) {
console.log('[TEST] Transaction failed - likely maxStake reached');
maxStakeReached = true;
break;
}
}
positionCount++;
await page.waitForTimeout(2_000);
}
console.log(`[TEST] Created ${positionCount} positions`);
// Step 5: Verify positions via GraphQL
console.log('[TEST] Verifying positions via GraphQL...');
const positions = await fetchPositions(request, ACCOUNT_ADDRESS);
console.log(`[TEST] Found ${positions.length} position(s) in GraphQL`);
const activePositions = positions.filter(p => p.status === 'Active');
console.log(`[TEST] ${activePositions.length} active positions`);
expect(activePositions.length).toBeGreaterThan(0);
// Verify we have positions with different tax rates
const uniqueTaxRates = new Set(activePositions.map(p => p.taxRate));
console.log(`[TEST] Unique tax rates used: ${uniqueTaxRates.size}`);
// We should have created positions for most/all tax rates (may have pre-existing positions from other tests)
expect(positionCount).toBeGreaterThan(20);
expect(uniqueTaxRates.size).toBeGreaterThan(20);
// Step 6: Verify maxStake constraint
console.log('[TEST] Verifying maxStake constraint...');
const stats = await fetchStats(request);
console.log(`[TEST] Staked percentage: ${stats?.percentageStaked}`);
if (stats?.percentageStaked) {
const percentageStaked = parseFloat(stats.percentageStaked);
// percentageStaked is a ratio (0-1), maxStake is 20%
expect(percentageStaked).toBeLessThanOrEqual(1.0);
console.log(`[TEST] ✅ MaxStake constraint respected: ${(percentageStaked * 100).toFixed(2)}% staked`);
}
console.log(`[TEST] ✅ Test complete: Created ${activePositions.length} positions across ${uniqueTaxRates.size} different tax rates`);
// Take screenshot of final stake page with all positions
console.log('[TEST] Taking screenshot of stake page...');
await page.screenshot({ path: 'test-results/stake-page-with-all-positions.png', fullPage: true });
console.log('[TEST] Screenshot saved to test-results/stake-page-with-all-positions.png');
} finally {
await context.close();
}
});
});