reworked stack

This commit is contained in:
johba 2025-10-07 21:57:32 +00:00
parent 6cbb1781ce
commit f7ef56f65f
12 changed files with 853 additions and 458 deletions

View file

@ -193,6 +193,18 @@ prime_chain() {
log "Pre-mining complete" log "Pre-mining complete"
} }
write_deployments_json() {
cat >"$ROOT_DIR/onchain/deployments-local.json" <<EODEPLOYMENTS
{
"contracts": {
"Kraiken": "$KRAIKEN",
"Stake": "$STAKE",
"LiquidityManager": "$LIQUIDITY_MANAGER"
}
}
EODEPLOYMENTS
}
write_ponder_env() { write_ponder_env() {
cat >"$ROOT_DIR/services/ponder/.env.local" <<EOPONDER cat >"$ROOT_DIR/services/ponder/.env.local" <<EOPONDER
PONDER_NETWORK=BASE_SEPOLIA_LOCAL_FORK PONDER_NETWORK=BASE_SEPOLIA_LOCAL_FORK
@ -242,6 +254,7 @@ main() {
grant_recenter_access grant_recenter_access
call_recenter call_recenter
seed_application_state seed_application_state
write_deployments_json
write_ponder_env write_ponder_env
write_txn_bot_env write_txn_bot_env
fund_txn_bot_wallet fund_txn_bot_wallet

View file

@ -1,18 +1,7 @@
{ {
"chainId": 31337,
"network": "local",
"deployer": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
"deploymentDate": "2024-12-07",
"contracts": { "contracts": {
"Kraiken": "0xB58F7a0D856eed18B9f19072dD0843bf03E4eB24", "Kraiken": "0xe527ddac2592faa45884a0b78e4d377a5d3df8cc",
"Stake": "0xa568b723199980B98E1BF765aB2A531C70a5edB3", "Stake": "0x935b78d1862de1ff6504f338752a32e1c0211920",
"Pool": "0x8F02719c2840428b27CD94E2b01e0aE69D796523", "LiquidityManager": "0xa887973a2ec1a3b4c7d50b84306ebcbc21bf2d5a"
"LiquidityManager": "0xbfE20DAb7BefF64237E2162D86F42Bfa228903B5",
"Optimizer": "0x22132dA9e3181850A692d8c36e117BdF30cA911E"
},
"infrastructure": {
"weth": "0x4200000000000000000000000000000000000006",
"factory": "0x4752ba5DBc23f44D87826276BF6Fd6b1C372aD24",
"feeDest": "0xf6a3eef9088A255c32b6aD2025f83E57291D9011"
} }
} }

View file

@ -1,5 +1,9 @@
version: "3.8" version: "3.8"
networks:
harb-network:
driver: bridge
services: services:
anvil: anvil:
image: ghcr.io/foundry-rs/foundry:latest image: ghcr.io/foundry-rs/foundry:latest
@ -11,6 +15,8 @@ services:
ports: ports:
- "127.0.0.1:8545:8545" - "127.0.0.1:8545:8545"
restart: unless-stopped restart: unless-stopped
networks:
- harb-network
healthcheck: healthcheck:
test: ["CMD", "cast", "block-number", "--rpc-url", "http://127.0.0.1:8545"] test: ["CMD", "cast", "block-number", "--rpc-url", "http://127.0.0.1:8545"]
interval: 2s interval: 2s
@ -29,6 +35,8 @@ services:
expose: expose:
- "5432" - "5432"
restart: unless-stopped restart: unless-stopped
networks:
- harb-network
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ponder"] test: ["CMD-SHELL", "pg_isready -U ponder"]
interval: 5s interval: 5s
@ -45,6 +53,8 @@ services:
environment: environment:
- ANVIL_RPC=http://anvil:8545 - ANVIL_RPC=http://anvil:8545
- GIT_BRANCH=${GIT_BRANCH:-} - GIT_BRANCH=${GIT_BRANCH:-}
networks:
- harb-network
restart: "no" restart: "no"
healthcheck: healthcheck:
test: ["CMD", "test", "-f", "/workspace/tmp/podman/contracts.env"] test: ["CMD", "test", "-f", "/workspace/tmp/podman/contracts.env"]
@ -57,11 +67,11 @@ services:
context: . context: .
dockerfile: containers/node-dev.Containerfile dockerfile: containers/node-dev.Containerfile
entrypoint: ["/workspace/containers/ponder-dev-entrypoint.sh"] entrypoint: ["/workspace/containers/ponder-dev-entrypoint.sh"]
user: "0:0"
volumes: volumes:
- .:/workspace:z - .:/workspace:z
- .git:/workspace/.git:ro,z - .git:/workspace/.git:ro,z
- ./kraiken-lib/dist:/workspace/kraiken-lib/dist:ro,z - ./kraiken-lib/dist:/workspace/kraiken-lib/dist:ro,z
- ponder-node-modules:/workspace/services/ponder/node_modules
working_dir: /workspace working_dir: /workspace
environment: environment:
- CHOKIDAR_USEPOLLING=1 - CHOKIDAR_USEPOLLING=1
@ -71,6 +81,8 @@ services:
ports: ports:
- "127.0.0.1:42069:42069" - "127.0.0.1:42069:42069"
restart: unless-stopped restart: unless-stopped
networks:
- harb-network
healthcheck: healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:42069/"] test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:42069/"]
interval: 5s interval: 5s
@ -83,11 +95,11 @@ services:
context: . context: .
dockerfile: containers/node-dev.Containerfile dockerfile: containers/node-dev.Containerfile
entrypoint: ["/workspace/containers/webapp-dev-entrypoint.sh"] entrypoint: ["/workspace/containers/webapp-dev-entrypoint.sh"]
user: "0:0"
volumes: volumes:
- .:/workspace:z - .:/workspace:z
- .git:/workspace/.git:ro,z - .git:/workspace/.git:ro,z
- ./kraiken-lib/dist:/workspace/kraiken-lib/dist:ro,z - ./kraiken-lib/dist:/workspace/kraiken-lib/dist:ro,z
- webapp-node-modules:/workspace/web-app/node_modules
working_dir: /workspace working_dir: /workspace
environment: environment:
- CHOKIDAR_USEPOLLING=1 - CHOKIDAR_USEPOLLING=1
@ -97,8 +109,10 @@ services:
ports: ports:
- "127.0.0.1:5173:5173" - "127.0.0.1:5173:5173"
restart: unless-stopped restart: unless-stopped
networks:
- harb-network
healthcheck: healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:5173/app/"] test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:5173/"]
interval: 5s interval: 5s
retries: 6 retries: 6
start_period: 10s start_period: 10s
@ -108,11 +122,11 @@ services:
context: . context: .
dockerfile: containers/node-dev.Containerfile dockerfile: containers/node-dev.Containerfile
entrypoint: ["/workspace/containers/landing-dev-entrypoint.sh"] entrypoint: ["/workspace/containers/landing-dev-entrypoint.sh"]
user: "0:0"
volumes: volumes:
- .:/workspace:z - .:/workspace:z
- .git:/workspace/.git:ro,z - .git:/workspace/.git:ro,z
- ./kraiken-lib/dist:/workspace/kraiken-lib/dist:ro,z - ./kraiken-lib/dist:/workspace/kraiken-lib/dist:ro,z
- landing-node-modules:/workspace/landing/node_modules
working_dir: /workspace working_dir: /workspace
environment: environment:
- CHOKIDAR_USEPOLLING=1 - CHOKIDAR_USEPOLLING=1
@ -120,6 +134,8 @@ services:
expose: expose:
- "5174" - "5174"
restart: unless-stopped restart: unless-stopped
networks:
- harb-network
healthcheck: healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:5174/"] test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:5174/"]
interval: 5s interval: 5s
@ -131,18 +147,19 @@ services:
context: . context: .
dockerfile: containers/node-dev.Containerfile dockerfile: containers/node-dev.Containerfile
entrypoint: ["/workspace/containers/txn-bot-entrypoint.sh"] entrypoint: ["/workspace/containers/txn-bot-entrypoint.sh"]
user: "0:0"
volumes: volumes:
- .:/workspace:z - .:/workspace:z
- .git:/workspace/.git:ro,z - .git:/workspace/.git:ro,z
- ./kraiken-lib/dist:/workspace/kraiken-lib/dist:ro,z - ./kraiken-lib/dist:/workspace/kraiken-lib/dist:ro,z
- txn-node-modules:/workspace/services/txnBot/node_modules
- kraiken-node-modules:/workspace/kraiken-lib/node_modules
working_dir: /workspace working_dir: /workspace
environment: environment:
- GIT_BRANCH=${GIT_BRANCH:-} - GIT_BRANCH=${GIT_BRANCH:-}
expose: expose:
- "43069" - "43069"
restart: unless-stopped restart: unless-stopped
networks:
- harb-network
healthcheck: healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:43069/status"] test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:43069/status"]
interval: 5s interval: 5s
@ -156,6 +173,8 @@ services:
ports: ports:
- "0.0.0.0:8081:80" - "0.0.0.0:8081:80"
restart: unless-stopped restart: unless-stopped
networks:
- harb-network
healthcheck: healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:80"] test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:80"]
interval: 2s interval: 2s
@ -164,8 +183,3 @@ services:
volumes: volumes:
postgres-data: postgres-data:
webapp-node-modules:
landing-node-modules:
ponder-node-modules:
txn-node-modules:
kraiken-node-modules:

View file

@ -3,20 +3,82 @@ set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
# Timeout constants (in seconds)
readonly ANVIL_TIMEOUT=30 # Anvil starts fast
readonly POSTGRES_TIMEOUT=20 # Database init is quick
readonly BOOTSTRAP_TIMEOUT=60 # Contract deployment + seeding
readonly PONDER_TIMEOUT=90 # Must index bootstrap events
readonly WEBAPP_TIMEOUT=90 # npm install + Vite startup
readonly CADDY_TIMEOUT=10 # Proxy starts instantly
readonly POLL_INTERVAL=2 # Check health every N seconds
PID_FILE=/tmp/kraiken-watcher.pid PID_FILE=/tmp/kraiken-watcher.pid
PROJECT_NAME=${COMPOSE_PROJECT_NAME:-$(basename "$PWD")} PROJECT_NAME=${COMPOSE_PROJECT_NAME:-$(basename "$PWD")}
start_stack() { cleanup_existing() {
if [[ -f "$PID_FILE" ]]; then # Kill any existing watch scripts
local existing_pid pkill -f "watch-kraiken-lib.sh" 2>/dev/null || true
existing_pid=$(cat "$PID_FILE") pkill -f "inotifywait.*$(pwd)/kraiken-lib" 2>/dev/null || true
if kill -0 "$existing_pid" 2>/dev/null; then
echo "Stopping existing kraiken-lib watcher ($existing_pid)..." # Remove PID file
kill "$existing_pid" 2>/dev/null || true rm -f "$PID_FILE"
wait "$existing_pid" 2>/dev/null || true
# Kill zombie podman processes
pkill -9 -f "podman wait.*harb_" 2>/dev/null || true
# Remove any existing containers (suppress errors if they don't exist)
echo " Cleaning up existing containers..."
podman ps -a --filter "name=harb_" --format "{{.Names}}" 2>/dev/null | \
xargs -r podman rm -f 2>&1 | grep -v "Error.*no container" || true
}
# Wait for container to be healthy (via healthcheck)
wait_for_healthy() {
local container=$1
local timeout_sec=$2
local max_attempts=$((timeout_sec / POLL_INTERVAL))
local start_time=$(date +%s)
for i in $(seq 1 "$max_attempts"); do
if podman healthcheck run "$container" &>/dev/null; then
local elapsed=$(($(date +%s) - start_time))
echo "$container ready (${elapsed}s)"
return 0
fi fi
rm -f "$PID_FILE" sleep "$POLL_INTERVAL"
fi done
echo "ERROR: $container failed to become healthy after ${timeout_sec}s"
return 1
}
# Wait for container to exit (used for bootstrap)
wait_for_exited() {
local container=$1
local timeout_sec=$2
local max_attempts=$((timeout_sec / POLL_INTERVAL))
local start_time=$(date +%s)
for i in $(seq 1 "$max_attempts"); do
local status
status=$(podman inspect "$container" --format='{{.State.Status}}' 2>/dev/null || echo "unknown")
if [[ "$status" == "exited" ]]; then
local elapsed=$(($(date +%s) - start_time))
echo "$container completed (${elapsed}s)"
return 0
fi
sleep "$POLL_INTERVAL"
done
echo "ERROR: $container failed to complete after ${timeout_sec}s"
return 1
}
start_stack() {
local stack_start_time=$(date +%s)
# Clean up any existing processes first
cleanup_existing
# Show branch if set # Show branch if set
if [[ -n "${GIT_BRANCH:-}" ]]; then if [[ -n "${GIT_BRANCH:-}" ]]; then
@ -27,84 +89,53 @@ start_stack() {
./scripts/build-kraiken-lib.sh ./scripts/build-kraiken-lib.sh
echo "Starting stack..." echo "Starting stack..."
# Start services in strict dependency order with explicit create+start
# This avoids podman dependency graph issues
# Create all containers first (without starting)
echo " Creating containers..."
podman-compose up --no-start 2>&1 | grep -v "STEP\|Copying\|Writing\|Getting\|fetch\|Installing\|Executing" || true
# Phase 1: Start base services (no dependencies) # Phase 1: Start base services (no dependencies)
echo " Starting anvil & postgres..." echo " Starting anvil & postgres..."
podman-compose start anvil postgres >/dev/null 2>&1 podman-compose up -d anvil postgres 2>&1 | grep -v "STEP\|Copying\|Writing\|Getting\|fetch\|Installing\|Executing" || true
# Wait for base services to be healthy wait_for_healthy harb_anvil_1 "$ANVIL_TIMEOUT" || exit 1
echo " Waiting for anvil & postgres..." wait_for_healthy harb_postgres_1 "$POSTGRES_TIMEOUT" || exit 1
for i in {1..30}; do
anvil_healthy=$(podman healthcheck run harb_anvil_1 >/dev/null 2>&1 && echo "yes" || echo "no")
postgres_healthy=$(podman healthcheck run harb_postgres_1 >/dev/null 2>&1 && echo "yes" || echo "no")
if [[ "$anvil_healthy" == "yes" ]] && [[ "$postgres_healthy" == "yes" ]]; then
break
fi
sleep 2
done
# Phase 2: Start bootstrap (depends on anvil & postgres healthy) # Phase 2: Start bootstrap (depends on anvil & postgres healthy)
echo " Starting bootstrap..." echo " Starting bootstrap..."
podman-compose start bootstrap >/dev/null 2>&1 podman-compose up -d bootstrap >/dev/null 2>&1
# Wait for bootstrap to complete wait_for_exited harb_bootstrap_1 "$BOOTSTRAP_TIMEOUT" || exit 1
echo " Waiting for bootstrap..."
for i in {1..60}; do
bootstrap_status=$(podman inspect harb_bootstrap_1 --format='{{.State.Status}}')
if [[ "$bootstrap_status" == "exited" ]]; then
break
fi
sleep 2
done
# Phase 3: Start ponder (depends on bootstrap completed) # Phase 3: Start ponder (depends on bootstrap completed)
echo " Starting ponder..." echo " Starting ponder..."
podman-compose start ponder >/dev/null 2>&1 podman-compose up -d ponder >/dev/null 2>&1
# Wait for ponder to be healthy wait_for_healthy harb_ponder_1 "$PONDER_TIMEOUT" || exit 1
echo " Waiting for ponder..."
for i in {1..60}; do
ponder_healthy=$(podman healthcheck run harb_ponder_1 >/dev/null 2>&1 && echo "yes" || echo "no")
if [[ "$ponder_healthy" == "yes" ]]; then
break
fi
sleep 2
done
# Phase 4: Start frontend services (depend on ponder healthy) # Phase 4: Start frontend services (depend on ponder healthy)
echo " Starting webapp, landing, txn-bot..." echo " Starting webapp, landing, txn-bot..."
podman-compose start webapp landing txn-bot >/dev/null 2>&1 podman-compose up -d webapp landing txn-bot >/dev/null 2>&1
wait_for_healthy harb_webapp_1 "$WEBAPP_TIMEOUT" || exit 1
# Phase 5: Start caddy (depends on frontend services) # Phase 5: Start caddy (depends on frontend services)
sleep 5
echo " Starting caddy..." echo " Starting caddy..."
podman-compose start caddy >/dev/null 2>&1 podman-compose up -d caddy >/dev/null 2>&1
echo "Watching for kraiken-lib changes..." wait_for_healthy harb_caddy_1 "$CADDY_TIMEOUT" || exit 1
./scripts/watch-kraiken-lib.sh &
echo $! > "$PID_FILE"
if [[ -z "${SKIP_WATCH:-}" ]]; then
echo "Watching for kraiken-lib changes..."
./scripts/watch-kraiken-lib.sh &
echo $! > "$PID_FILE"
fi
local total_time=$(($(date +%s) - stack_start_time))
echo "" echo ""
echo "[ok] Stack started" echo "[ok] Stack started in ${total_time}s"
echo " Web App: http://localhost:8081/app/" echo " Web App: http://localhost:8081/app/"
echo " GraphQL: http://localhost:8081/graphql" echo " GraphQL: http://localhost:8081/graphql"
} }
stop_stack() { stop_stack() {
if [[ -f "$PID_FILE" ]]; then cleanup_existing
local watcher_pid
watcher_pid=$(cat "$PID_FILE")
if kill "$watcher_pid" 2>/dev/null; then
wait "$watcher_pid" 2>/dev/null || true
fi
rm -f "$PID_FILE"
fi
podman-compose down podman-compose down
echo "[ok] Stack stopped" echo "[ok] Stack stopped"
} }

View file

@ -244,6 +244,7 @@ type positions {
owner: String! owner: String!
share: Float! share: Float!
taxRate: Float! taxRate: Float!
taxRateIndex: Int!
kraikenDeposit: BigInt! kraikenDeposit: BigInt!
stakeDeposit: BigInt! stakeDeposit: BigInt!
taxPaid: BigInt! taxPaid: BigInt!
@ -303,6 +304,14 @@ input positionsFilter {
taxRate_lt: Float taxRate_lt: Float
taxRate_gte: Float taxRate_gte: Float
taxRate_lte: Float taxRate_lte: Float
taxRateIndex: Int
taxRateIndex_not: Int
taxRateIndex_in: [Int]
taxRateIndex_not_in: [Int]
taxRateIndex_gt: Int
taxRateIndex_lt: Int
taxRateIndex_gte: Int
taxRateIndex_lte: Int
kraikenDeposit: BigInt kraikenDeposit: BigInt
kraikenDeposit_not: BigInt kraikenDeposit_not: BigInt
kraikenDeposit_in: [BigInt] kraikenDeposit_in: [BigInt]

View file

@ -1,13 +1,16 @@
import { expect, test, type APIRequestContext } from '@playwright/test'; import { expect, test, type APIRequestContext } from '@playwright/test';
import { Wallet } from 'ethers'; import { Wallet } from 'ethers';
import { createWalletContext } from '../setup/wallet-provider'; import { createWalletContext } from '../setup/wallet-provider';
import { startStack, waitForStackReady, stopStack } from '../setup/stack'; import { getStackConfig, validateStackHealthy } from '../setup/stack';
const ACCOUNT_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; const ACCOUNT_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';
const ACCOUNT_ADDRESS = new Wallet(ACCOUNT_PRIVATE_KEY).address.toLowerCase(); const ACCOUNT_ADDRESS = new Wallet(ACCOUNT_PRIVATE_KEY).address.toLowerCase();
const STACK_RPC_URL = process.env.STACK_RPC_URL ?? 'http://127.0.0.1:8545';
const STACK_WEBAPP_URL = process.env.STACK_WEBAPP_URL ?? 'http://localhost:5173'; // Get stack configuration from environment (or defaults)
const STACK_GRAPHQL_URL = process.env.STACK_GRAPHQL_URL ?? 'http://localhost:42069/graphql'; 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) { async function fetchPositions(request: APIRequestContext, owner: string) {
const response = await request.post(STACK_GRAPHQL_URL, { const response = await request.post(STACK_GRAPHQL_URL, {
@ -42,17 +45,10 @@ async function fetchPositions(request: APIRequestContext, owner: string) {
} }
test.describe('Acquire & Stake', () => { test.describe('Acquire & Stake', () => {
// Validate that a healthy stack exists before running tests
// Tests do NOT start their own stack - stack must be running already
test.beforeAll(async () => { test.beforeAll(async () => {
await startStack(); await validateStackHealthy(STACK_CONFIG);
await waitForStackReady({
rpcUrl: STACK_RPC_URL,
webAppUrl: STACK_WEBAPP_URL,
graphqlUrl: STACK_GRAPHQL_URL,
});
});
test.afterAll(async () => {
await stopStack();
}); });
test('users can swap KRK via UI', async ({ browser, request }) => { test('users can swap KRK via UI', async ({ browser, request }) => {
@ -153,22 +149,30 @@ test.describe('Acquire & Stake', () => {
// Wait for the stake form to be initialized // Wait for the stake form to be initialized
console.log('[TEST] Waiting for stake form to load...'); console.log('[TEST] Waiting for stake form to load...');
await page.waitForSelector('text=Token Amount', { timeout: 15_000 }); const tokenAmountSlider = page.getByRole('slider', { name: 'Token Amount' });
await expect(tokenAmountSlider).toBeVisible({ timeout: 15_000 });
// Use the test helper to fill the stake form console.log('[TEST] Filling stake form via accessible controls...');
console.log('[TEST] Filling stake form via test helper...');
await page.evaluate(async () => { // Take screenshot before interaction
if (!window.__testHelpers) { await page.screenshot({ path: 'test-results/before-stake-fill.png' });
throw new Error('Test helpers not available');
} // Check if input is visible and enabled
await window.__testHelpers.fillStakeForm({ const stakeAmountInput = page.getByLabel('Staking Amount');
amount: 100, // Stake 100 KRK console.log('[TEST] Checking if staking amount input is visible...');
taxRateIndex: 2, // 5% tax rate option await expect(stakeAmountInput).toBeVisible({ timeout: 10_000 });
}); console.log('[TEST] Staking amount input is visible, filling value...');
}); await stakeAmountInput.fill('100');
console.log('[TEST] Filled staking amount!');
const taxSelect = page.getByRole('combobox', { name: 'Tax' });
console.log('[TEST] Selecting tax rate...');
await taxSelect.selectOption({ value: '2' });
console.log('[TEST] Tax rate selected!');
console.log('[TEST] Clicking stake button...'); console.log('[TEST] Clicking stake button...');
const stakeButton = page.getByRole('button', { name: /Stake|Snatch and Stake/i }); // Use the main form's submit button (large/block), not the small position card buttons
const stakeButton = page.getByRole('main').getByRole('button', { name: /Stake|Snatch and Stake/i });
await expect(stakeButton).toBeVisible({ timeout: 5_000 }); await expect(stakeButton).toBeVisible({ timeout: 5_000 });
await stakeButton.click(); await stakeButton.click();
@ -190,7 +194,7 @@ test.describe('Acquire & Stake', () => {
console.log(`[TEST] Found ${positions.length} position(s)`); console.log(`[TEST] Found ${positions.length} position(s)`);
expect(positions.length).toBeGreaterThan(0); expect(positions.length).toBeGreaterThan(0);
const activePositions = positions.filter(p => p.status === 'OPEN'); const activePositions = positions.filter(p => p.status === 'Active');
expect(activePositions.length).toBeGreaterThan(0); expect(activePositions.length).toBeGreaterThan(0);
console.log(`[TEST] ✅ Staking successful! Created ${activePositions.length} active position(s)`); console.log(`[TEST] ✅ Staking successful! Created ${activePositions.length} active position(s)`);

View file

@ -1,118 +1,49 @@
import { exec as execCallback } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import { import {
runAllHealthChecks, runAllHealthChecks,
formatHealthCheckError, formatHealthCheckError,
type HealthCheckResult,
} from './health-checks.js'; } from './health-checks.js';
const exec = promisify(execCallback); const DEFAULT_RPC_URL = 'http://127.0.0.1:8545';
const DEFAULT_WEBAPP_URL = 'http://localhost:8081';
const DEFAULT_GRAPHQL_URL = 'http://localhost:8081/graphql';
const __filename = fileURLToPath(import.meta.url); export interface StackConfig {
const __dirname = dirname(__filename); rpcUrl: string;
const repoRoot = resolve(__dirname, '..', '..'); webAppUrl: string;
graphqlUrl: string;
const DEFAULT_RPC_URL = process.env.STACK_RPC_URL ?? 'http://127.0.0.1:8545';
const DEFAULT_WEBAPP_URL = process.env.STACK_WEBAPP_URL ?? 'http://localhost:5173';
const DEFAULT_GRAPHQL_URL = process.env.STACK_GRAPHQL_URL ?? 'http://localhost:42069/graphql';
let stackStarted = false;
async function cleanupContainers(): Promise<void> {
try {
await run("podman pod ps -q --filter name=harb | xargs -r podman pod rm -f || true");
await run("podman ps -aq --filter name=harb | xargs -r podman rm -f || true");
} catch (error) {
console.warn('[stack] Failed to cleanup containers', error);
}
}
function delay(ms: number): Promise<void> {
return new Promise(resolveDelay => setTimeout(resolveDelay, ms));
}
async function run(command: string): Promise<void> {
await exec(command, { cwd: repoRoot, shell: true });
} }
/** /**
* Run functional health checks that verify services are actually usable, not just running * Get stack configuration from environment variables.
* Tests should NOT start their own stack - they require a pre-existing healthy stack.
*/ */
async function checkStackFunctional( export function getStackConfig(): StackConfig {
rpcUrl: string, return {
webAppUrl: string, rpcUrl: process.env.STACK_RPC_URL ?? DEFAULT_RPC_URL,
graphqlUrl: string webAppUrl: process.env.STACK_WEBAPP_URL ?? DEFAULT_WEBAPP_URL,
): Promise<void> { graphqlUrl: process.env.STACK_GRAPHQL_URL ?? DEFAULT_GRAPHQL_URL,
const results = await runAllHealthChecks({ rpcUrl, webAppUrl, graphqlUrl }); };
}
/**
* Validate that a healthy stack exists and is functional.
* If validation fails, the test run should exit immediately.
*
* Tests do NOT manage stack lifecycle - stack must be started externally via:
* ./scripts/dev.sh start
*/
export async function validateStackHealthy(config: StackConfig): Promise<void> {
const results = await runAllHealthChecks(config);
const failures = results.filter(r => !r.success); const failures = results.filter(r => !r.success);
if (failures.length > 0) { if (failures.length > 0) {
throw new Error(formatHealthCheckError(results)); const errorMessage = formatHealthCheckError(results);
} console.error('\n❌ Stack health validation failed');
} console.error('Tests require a pre-existing healthy stack.');
console.error('Start the stack with: ./scripts/dev.sh start\n');
export async function startStack(): Promise<void> { console.error(errorMessage);
if (stackStarted) { process.exit(1);
return;
}
await cleanupContainers();
await run('nohup ./scripts/dev.sh start > ./tests/.stack.log 2>&1 &');
stackStarted = true;
}
export async function waitForStackReady(options: {
rpcUrl?: string;
webAppUrl?: string;
graphqlUrl?: string;
timeoutMs?: number;
pollIntervalMs?: number;
} = {}): Promise<void> {
const rpcUrl = options.rpcUrl ?? DEFAULT_RPC_URL;
const webAppUrl = options.webAppUrl ?? DEFAULT_WEBAPP_URL;
const graphqlUrl = options.graphqlUrl ?? DEFAULT_GRAPHQL_URL;
const timeoutMs = options.timeoutMs ?? 180_000;
const pollIntervalMs = options.pollIntervalMs ?? 2_000;
const start = Date.now();
const errors = new Map<string, string>();
while (Date.now() - start < timeoutMs) {
try {
await checkStackFunctional(rpcUrl, webAppUrl, graphqlUrl);
console.log('✅ All stack health checks passed');
return;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
errors.set('lastError', message);
await delay(pollIntervalMs);
}
}
const logPath = resolve(repoRoot, 'tests', '.stack.log');
let logTail = '';
try {
const contents = await readFile(logPath, 'utf-8');
logTail = contents.split('\n').slice(-40).join('\n');
} catch (readError) {
logTail = `Unable to read stack log: ${readError}`;
}
throw new Error(`Stack failed to become ready within ${timeoutMs}ms\nLast error: ${errors.get('lastError')}\nLog tail:\n${logTail}`);
}
export async function stopStack(): Promise<void> {
if (!stackStarted) {
return;
}
try {
await run('./scripts/dev.sh stop');
} finally {
stackStarted = false;
} }
console.log('✅ Stack health validation passed');
} }

View file

@ -34,52 +34,15 @@ npm run build
## Testing ## Testing
### Test Helpers ### Accessibility Hooks
The application exposes test helpers on `window.__testHelpers` in development mode to facilitate E2E testing. The staking form now exposes semantic controls that Playwright can exercise directly:
#### Available Helpers - Slider: `page.getByRole('slider', { name: 'Token Amount' })`
- Amount input: `page.getByLabel('Staking Amount')`
- Tax selector: `page.getByLabel('Tax')`
##### `fillStakeForm(params)` Tests should rely on these roles and labels instead of private helpers.
Programmatically fills the staking form without requiring fragile UI selectors.
**Parameters:**
- `amount` (number): Amount of KRK tokens to stake (must be >= minimum stake)
- `taxRateIndex` (number): Index of the tax rate option (must match one of the configured options)
**Example:**
```typescript
// In Playwright test
await page.evaluate(async () => {
await window.__testHelpers.fillStakeForm({
amount: 100,
taxRateIndex: 2,
});
});
// Then click the stake button
const stakeButton = page.getByRole('button', { name: /Stake|Snatch and Stake/i });
await stakeButton.click();
```
**Validation:**
- Throws if amount is below minimum stake
- Throws if amount exceeds wallet balance
- Throws if `taxRateIndex` does not match an available option
**TypeScript Support:**
Type declarations are available in `env.d.ts`:
```typescript
interface Window {
__testHelpers?: {
fillStakeForm: (params: { amount: number; taxRateIndex: number }) => Promise<void>;
};
}
```
**Security:**
Test helpers are only available when `import.meta.env.DEV === true` and are automatically stripped from production builds.
### E2E Tests ### E2E Tests

3
web-app/env.d.ts vendored
View file

@ -5,9 +5,6 @@ import type { EIP1193Provider } from 'viem';
declare global { declare global {
interface Window { interface Window {
ethereum?: EIP1193Provider; ethereum?: EIP1193Provider;
__testHelpers?: {
fillStakeForm: (params: { amount: number; taxRateIndex: number }) => Promise<void>;
};
} }
} }

View file

@ -8,72 +8,135 @@
</template> </template>
<template v-else> <template v-else>
<div class="subheader2">Token Amount</div> <form class="stake-form" @submit.prevent="handleSubmit" :aria-describedby="formStatusId" novalidate>
<FSlider :min="minStakeAmount" :max="maxStakeAmount" v-model="stake.stakingAmountNumber"></FSlider> <div class="form-group">
<div class="formular"> <label :id="sliderLabelId" :for="sliderId" class="subheader2">Token Amount</label>
<div class="row row-1"> <div class="input-range" :class="{ 'input-range--disabled': isSliderDisabled }">
<FInput label="Staking Amount" class="staking-amount" v-model="stake.stakingAmountNumber"> <input
<template v-slot:details> :id="sliderId"
<div class="balance">Balance: {{ maxStakeAmount.toFixed(2) }} $KRK</div> class="input-range__control"
<div @click="setMaxAmount" class="staking-amount-max"> type="range"
<b>Max</b> :min="sliderMin"
:max="sliderMax"
:step="sliderStep"
:aria-labelledby="sliderLabelId"
:aria-describedby="sliderHelpId"
:aria-valuemin="sliderMin"
:aria-valuemax="sliderMax"
:aria-valuenow="currentStakeAmount"
:aria-valuetext="stakeAmountAriaText"
:disabled="isSliderDisabled"
v-model.number="stake.stakingAmountNumber"
:style="{ '--slider-percentage': sliderPercentage + '%' }"
/>
<output class="input-range__value" :for="sliderId">{{ formattedStakeAmount }}</output>
</div>
<p :id="sliderHelpId" class="input-range__help">{{ sliderDescription }}</p>
<div class="sr-only" aria-live="polite">{{ sliderAnnouncement }}</div>
</div>
<div class="formular">
<div class="row row-1">
<FInput
label="Staking Amount"
class="staking-amount"
v-model="stake.stakingAmountNumber"
type="number"
inputmode="decimal"
:aria-describedby="stakeAmountDescriptionId"
>
<template #details>
<div class="balance" :id="stakeAmountDescriptionId">Balance: {{ formattedBalance }} $KRK</div>
<button type="button" @click="setMaxAmount" class="staking-amount-max">
<b>Max</b>
</button>
</template>
</FInput>
<Icon class="stake-arrow" icon="mdi:chevron-triple-right" aria-hidden="true"></Icon>
<FInput
label="Owner Slots"
class="staking-amount"
readonly
:modelValue="`${stakeSlots} (${supplyFreeze?.toFixed(4) ?? '0.0000'})`"
aria-live="polite"
>
<template #info>
Slots correspond to a percentage of ownership in the protocol.<br /><br />1,000 Slots = 1% Ownership<br /><br />When you
unstake you get the exact percentage of the current $KRK total supply. When the total supply increased since you staked
you get more tokens back than before.
</template>
</FInput>
</div>
<div class="row row-2">
<div class="form-field tax-field">
<div class="field-label">
<label :for="taxSelectId">Tax</label>
<IconInfo size="20px">
<template #text>
The yearly tax you have to pay to keep your slots open. The tax is paid when unstaking or manually in the dashboard.
If someone pays a higher tax they can buy you out.
</template>
</IconInfo>
</div> </div>
</template> <div class="tax-select-wrapper">
</FInput> <select :id="taxSelectId" class="tax-select" v-model.number="taxRateIndex" :aria-describedby="taxHelpId">
<Icon class="stake-arrow" icon="mdi:chevron-triple-right"></Icon> <option v-for="option in taxOptions" :key="option.index" :value="option.index">
<FInput label="Owner Slots" class="staking-amount" disabled :modelValue="`${stakeSlots}(${supplyFreeze?.toFixed(4)})`"> {{ option.label }}
<template #info> </option>
Slots correspond to a percentage of ownership in the protocol.<br /><br />1,000 Slots = 1% Ownership<br /><br />When you </select>
unstake you get the exact percentage of the current $KRK total supply. When the total supply increased since you staked you <span class="tax-select__icon" aria-hidden="true">
get more tokens back than before. <Icon icon="mdi:chevron-down"></Icon>
</template> </span>
</FInput> </div>
<p :id="taxHelpId" class="field-help">{{ taxRateDescription }}</p>
<div class="sr-only" aria-live="polite">{{ taxRateAnnouncement }}</div>
</div>
<div class="form-field summary-field" :aria-labelledby="floorTaxLabelId" aria-live="polite">
<div class="field-label" :id="floorTaxLabelId">
<span>Floor Tax</span>
<IconInfo size="20px">
<template #text> This is the current minimum tax you have to pay to claim owner slots from other owners. </template>
</IconInfo>
</div>
<p class="form-field__value">{{ floorTaxDisplay }}</p>
<p class="field-help">{{ floorTaxHelpText }}</p>
</div>
<div class="form-field summary-field" :aria-labelledby="snatchLabelId" aria-live="polite">
<div class="field-label" :id="snatchLabelId">
<span>Positions Buyout</span>
<IconInfo size="20px">
<template #text>
This shows you the number of staking positions you buy out from current owners by paying a higher tax. If you get
bought out yourself you receive the current market value of your position including your profits.
</template>
</IconInfo>
</div>
<p class="form-field__value">{{ positionsBuyoutDisplay }}</p>
<p class="field-help">{{ snatchHelpText }}</p>
</div>
</div>
</div> </div>
<div class="row row-2">
<FSelect :items="adjustTaxRate.taxRates" label="Tax" v-model="taxRateIndex"> <section class="stake-summary" :aria-labelledby="stakeSummaryId" aria-live="polite">
<template v-slot:info> <h4 class="stake-summary__heading" :id="stakeSummaryId">Stake Summary</h4>
The yearly tax you have to pay to keep your slots open. The tax is paid when unstaking or manually in the dashboard. If <p>{{ stakeSummaryText }}</p>
someone pays a higher tax they can buy you out. <p>{{ snatchSummaryText }}</p>
</template> <p>{{ walletSummaryText }}</p>
</FSelect> </section>
<FInput label="Floor Tax" disabled :modelValue="String(snatchSelection.floorTax)">
<template v-slot:info> This is the current minimum tax you have to pay to claim owner slots from other owners. </template> <div class="sr-only" aria-live="assertive">{{ assistiveSummary }}</div>
</FInput>
<FInput label="Positions Buyout" disabled :modelValue="String(snatchSelection.snatchablePositions.value.length)"> <div class="form-status" :id="formStatusId" :role="actionState.tone === 'error' ? 'alert' : 'status'" aria-live="polite">
<template v-slot:info> {{ actionState.message }}
This shows you the numbers of staking positions you buy out from current owners by paying a higher tax. If you get bought
out yourself by new owners you get paid out the current market value of your position incl. your profits.
</template>
</FInput>
</div> </div>
</div>
<FButton size="large" disabled block v-if="stake.state === 'NoBalance'">Insufficient Balance</FButton> <FButton size="large" block type="submit" :disabled="actionState.disabled" :outlined="actionState.variant === 'outlined'">
<FButton size="large" disabled block v-else-if="stake.stakingAmountNumber < minStakeAmount">Stake amount too low</FButton> {{ actionState.label }}
<FButton </FButton>
size="large" </form>
disabled
block
v-else-if="
!snatchSelection.openPositionsAvailable && stake.state === 'StakeAble' && snatchSelection.snatchablePositions.value.length === 0
"
>taxRate too low to snatch</FButton
>
<FButton
size="large"
block
v-else-if="stake.state === 'StakeAble' && snatchSelection.snatchablePositions.value.length === 0"
@click="stakeSnatch"
>Stake</FButton
>
<FButton
size="large"
block
v-else-if="stake.state === 'StakeAble' && snatchSelection.snatchablePositions.value.length > 0"
@click="stakeSnatch"
>Snatch and Stake</FButton
>
<FButton size="large" outlined block v-else-if="stake.state === 'SignTransaction'">Sign Transaction ...</FButton>
<FButton size="large" outlined block v-else-if="stake.state === 'Waiting'">Waiting ...</FButton>
</template> </template>
</div> </div>
</div> </div>
@ -82,9 +145,8 @@
<script setup lang="ts"> <script setup lang="ts">
import FButton from '@/components/fcomponents/FButton.vue'; import FButton from '@/components/fcomponents/FButton.vue';
import FInput from '@/components/fcomponents/FInput.vue'; import FInput from '@/components/fcomponents/FInput.vue';
import FSelect from '@/components/fcomponents/FSelect.vue';
import FLoader from '@/components/fcomponents/FLoader.vue'; import FLoader from '@/components/fcomponents/FLoader.vue';
import FSlider from '@/components/fcomponents/FSlider.vue'; import IconInfo from '@/components/icons/IconInfo.vue';
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import { bigInt2Number } from '@/utils/helper'; import { bigInt2Number } from '@/utils/helper';
import { loadPositions, usePositions, type Position } from '@/composables/usePositions'; import { loadPositions, usePositions, type Position } from '@/composables/usePositions';
@ -95,31 +157,38 @@ import { useSnatchSelection } from '@/composables/useSnatchSelection';
import { assetsToShares } from '@/contracts/stake'; import { assetsToShares } from '@/contracts/stake';
import { getMinStake } from '@/contracts/harb'; import { getMinStake } from '@/contracts/harb';
import { useWallet } from '@/composables/useWallet'; import { useWallet } from '@/composables/useWallet';
import { ref, onMounted, watch, computed, watchEffect } from 'vue'; import { ref, onMounted, watch, computed, watchEffect, getCurrentInstance } from 'vue';
import { useStatCollection, loadStats } from '@/composables/useStatCollection'; import { useStatCollection, loadStats } from '@/composables/useStatCollection';
import { useRoute } from 'vue-router';
const demo = sessionStorage.getItem('demo') === 'true'; const demo = sessionStorage.getItem('demo') === 'true';
const route = useRoute();
const adjustTaxRate = useAdjustTaxRate(); const adjustTaxRate = useAdjustTaxRate();
const StakeMenuOpen = ref(false);
const defaultTaxRateIndex = adjustTaxRate.taxRates[0]?.index ?? 0; const defaultTaxRateIndex = adjustTaxRate.taxRates[0]?.index ?? 0;
const taxRateIndex = ref<number>(defaultTaxRateIndex); const taxRateIndex = ref<number>(defaultTaxRateIndex);
const loading = ref<boolean>(true);
const stakeSnatchLoading = ref<boolean>(false);
const stake = useStake(); const stake = useStake();
const _claim = useClaim(); const _claim = useClaim();
const wallet = useWallet(); const wallet = useWallet();
const statCollection = useStatCollection(); const statCollection = useStatCollection();
const { activePositions: _activePositions } = usePositions(); const { activePositions: _activePositions } = usePositions();
const minStake = ref(0n); const instance = getCurrentInstance();
const stakeSlots = ref(); const uid = instance?.uid ?? Math.floor(Math.random() * 10000);
const sliderId = `stake-slider-${uid}`;
const sliderLabelId = `${sliderId}-label`;
const sliderHelpId = `${sliderId}-help`;
const stakeAmountDescriptionId = `stake-amount-description-${uid}`;
const taxSelectId = `stake-tax-select-${uid}`;
const taxHelpId = `${taxSelectId}-help`;
const floorTaxLabelId = `stake-floor-tax-${uid}`;
const snatchLabelId = `stake-snatch-${uid}`;
const stakeSummaryId = `stake-summary-${uid}`;
const formStatusId = `stake-status-${uid}`;
const minStake = ref<bigint>(0n);
const stakeSlots = ref<string>('0.00');
const supplyFreeze = ref<number>(0); const supplyFreeze = ref<number>(0);
let debounceTimer: ReturnType<typeof setTimeout>; let debounceTimer: ReturnType<typeof setTimeout>;
watchEffect(() => { watchEffect(() => {
if (!stake.stakingAmount) { if (!stake.stakingAmount) {
supplyFreeze.value = 0; supplyFreeze.value = 0;
@ -133,151 +202,471 @@ watchEffect(() => {
const stakeableSupplyNumber = bigInt2Number(statCollection.stakeableSupply, 18); const stakeableSupplyNumber = bigInt2Number(statCollection.stakeableSupply, 18);
minStake.value = await getMinStake(); minStake.value = await getMinStake();
if (stakeableSupplyNumber === 0) {
supplyFreeze.value = 0;
return;
}
supplyFreeze.value = stakingAmountSharesNumber / stakeableSupplyNumber; supplyFreeze.value = stakingAmountSharesNumber / stakeableSupplyNumber;
}, 500); }, 500);
}); });
watchEffect(() => { watchEffect(() => {
stakeSlots.value = (supplyFreeze.value * 1000)?.toFixed(2); const slots = supplyFreeze.value * 1000;
stakeSlots.value = Number.isFinite(slots) ? slots.toFixed(2) : '0.00';
}); });
const _tokenIssuance = computed(() => { const minStakeAmount = computed(() => bigInt2Number(minStake.value, 18));
if (statCollection.kraikenTotalSupply === 0n) {
return 0n;
}
return (statCollection.nettoToken7d / statCollection.kraikenTotalSupply) * 100n;
});
async function stakeSnatch() {
if (snatchSelection.snatchablePositions.value.length === 0) {
await stake.snatch(stake.stakingAmount, taxRateIndex.value);
} else {
const snatchAblePositionsIds = snatchSelection.snatchablePositions.value.map((p: Position) => p.positionId);
await stake.snatch(stake.stakingAmount, taxRateIndex.value, snatchAblePositionsIds);
}
stakeSnatchLoading.value = true;
await new Promise(resolve => setTimeout(resolve, 10000));
await loadPositions();
await loadStats();
stakeSnatchLoading.value = false;
}
watch(
route,
async to => {
if (to.hash === '#stake') {
StakeMenuOpen.value = true;
}
},
{ flush: 'pre', immediate: true, deep: true }
);
onMounted(async () => {
try {
minStake.value = await getMinStake();
stake.stakingAmountNumber = minStakeAmount.value;
} finally {
loading.value = false;
}
});
const minStakeAmount = computed(() => {
return bigInt2Number(minStake.value, 18);
});
const maxStakeAmount = computed(() => { const maxStakeAmount = computed(() => {
if (wallet.balance?.value) { if (wallet.balance?.value) {
return bigInt2Number(wallet.balance.value, 18); return bigInt2Number(wallet.balance.value, 18);
} else { }
return 0;
});
const sliderMin = computed(() => {
const value = Number(minStakeAmount.value || 0);
return Number.isFinite(value) && value >= 0 ? value : 0;
});
const sliderMax = computed(() => {
const value = Number(maxStakeAmount.value || 0);
if (!Number.isFinite(value) || value <= sliderMin.value) {
return sliderMin.value;
}
return value;
});
const isSliderDisabled = computed(() => sliderMax.value <= sliderMin.value);
const sliderStep = computed(() => {
if (isSliderDisabled.value) {
return 0.01;
}
const range = sliderMax.value - sliderMin.value;
const step = range / 100;
if (!Number.isFinite(step) || step <= 0) {
return 0.01;
}
return Number(step.toFixed(4));
});
const currentStakeAmount = computed(() => {
const value = Number(stake.stakingAmountNumber);
if (!Number.isFinite(value)) {
return sliderMin.value;
}
return Math.min(Math.max(value, sliderMin.value), sliderMax.value);
});
const sliderPercentage = computed(() => {
const range = sliderMax.value - sliderMin.value;
if (range <= 0) {
return 0; return 0;
} }
const percent = ((currentStakeAmount.value - sliderMin.value) / range) * 100;
return Math.min(100, Math.max(0, Number(percent.toFixed(2))));
});
watch([sliderMin, sliderMax], ([min, max]) => {
const value = Number(stake.stakingAmountNumber);
if (!Number.isFinite(value)) {
stake.stakingAmountNumber = min;
return;
}
if (value < min) {
stake.stakingAmountNumber = min;
} else if (value > max) {
stake.stakingAmountNumber = max;
}
});
function formatNumber(value: number, maximumFractionDigits = 2) {
if (!Number.isFinite(value)) {
return '0';
}
return value.toLocaleString('en-US', {
minimumFractionDigits: 0,
maximumFractionDigits,
});
}
const formattedStakeAmount = computed(() => `${formatNumber(currentStakeAmount.value, 4)} $KRK`);
const formattedBalance = computed(() => formatNumber(maxStakeAmount.value, 4));
const stakeAmountAriaText = computed(() => `Stake ${formatNumber(currentStakeAmount.value, 4)} Kraiken tokens`);
const sliderDescription = computed(() => {
if (isSliderDisabled.value) {
return 'Set a stake amount once you have tokens available in your wallet.';
}
return `Use arrow keys or type a value between ${formatNumber(sliderMin.value, 4)} and ${formatNumber(sliderMax.value, 4)} $KRK.`;
});
const sliderAnnouncement = computed(() => `Stake amount set to ${formatNumber(currentStakeAmount.value, 4)} $KRK.`);
const taxOptions = computed(() =>
adjustTaxRate.taxRates.map(option => ({
index: option.index,
year: option.year,
daily: option.daily,
label: `${option.index} · ${formatNumber(option.year, 2)}% yearly (${option.daily.toFixed(4)}% daily)`,
}))
);
const selectedTaxOption = computed(() => adjustTaxRate.taxRates.find(option => option.index === taxRateIndex.value) ?? null);
const taxRateDescription = computed(() => {
if (!selectedTaxOption.value) {
return 'Select a tax rate to determine your yearly obligation.';
}
return `Tax rate index ${selectedTaxOption.value.index} with ${formatNumber(selectedTaxOption.value.year, 2)}% yearly (${selectedTaxOption.value.daily.toFixed(4)}% daily).`;
});
const taxRateAnnouncement = computed(() => {
if (!selectedTaxOption.value) {
return 'No tax rate selected.';
}
return `Selected tax rate index ${selectedTaxOption.value.index}. You will pay ${formatNumber(selectedTaxOption.value.year, 2)} percent yearly.`;
});
const snatchSelection = useSnatchSelection(demo, taxRateIndex);
const floorTaxDisplay = computed(() => `${formatNumber(snatchSelection.floorTax.value ?? 0, 2)} %`);
const floorTaxHelpText = 'Your tax needs to exceed this value to displace an existing position.';
const snatchPositionsCount = computed(() => snatchSelection.snatchablePositions.value.length);
const positionsBuyoutDisplay = computed(() => formatNumber(snatchPositionsCount.value, 0));
const snatchHelpText = 'Increasing your tax may buy out existing slots. This count updates automatically as you adjust inputs.';
const stakeSummaryText = computed(() => {
const amount = formatNumber(currentStakeAmount.value, 4);
const taxText = selectedTaxOption.value
? `${formatNumber(selectedTaxOption.value.year, 2)}% yearly tax (index ${selectedTaxOption.value.index})`
: 'no tax rate selected';
return `Staking ${amount} $KRK with ${taxText}.`;
});
const snatchSummaryText = computed(() => {
if (snatchPositionsCount.value > 0) {
const count = snatchPositionsCount.value;
const positions = count === 1 ? 'position' : 'positions';
return `You will snatch ${count} ${positions} at a floor tax of ${floorTaxDisplay.value}.`;
}
if (!snatchSelection.openPositionsAvailable.value) {
return 'No open positions are available at this tax rate. Increase the tax to claim slots from others.';
}
return 'No existing positions will be snatched.';
});
const walletSummaryText = computed(() => {
const address = wallet.account.address;
if (!address) {
return 'Receiver wallet unavailable. Connect a wallet to continue.';
}
return `Receiver wallet: ${address}.`;
});
const assistiveSummary = computed(() => `${stakeSummaryText.value} ${snatchSummaryText.value} ${walletSummaryText.value}`);
const isStakeAmountTooLow = computed(() => currentStakeAmount.value < minStakeAmount.value);
const hasBalance = computed(() => maxStakeAmount.value > 0);
const actionState = computed(() => {
if (!hasBalance.value || stake.state === 'NoBalance') {
return {
label: 'Insufficient Balance',
disabled: true,
variant: 'primary',
message: 'You need more $KRK to cover the minimum stake amount.',
tone: 'error' as const,
};
}
if (isStakeAmountTooLow.value) {
return {
label: 'Stake Amount Too Low',
disabled: true,
variant: 'primary',
message: `Minimum stake is ${formatNumber(minStakeAmount.value, 4)} $KRK.`,
tone: 'error' as const,
};
}
if (!snatchSelection.openPositionsAvailable.value && stake.state === 'StakeAble' && snatchPositionsCount.value === 0) {
return {
label: 'Tax Rate Too Low',
disabled: true,
variant: 'primary',
message: 'Increase your tax rate to open staking slots.',
tone: 'error' as const,
};
}
if (stake.state === 'StakeAble' && snatchPositionsCount.value === 0) {
return {
label: 'Stake',
disabled: false,
variant: 'primary',
message: 'Ready to stake without snatching other positions.',
tone: 'status' as const,
};
}
if (stake.state === 'StakeAble' && snatchPositionsCount.value > 0) {
return {
label: 'Snatch and Stake',
disabled: false,
variant: 'primary',
message: `Ready to snatch ${snatchPositionsCount.value} position${snatchPositionsCount.value === 1 ? '' : 's'} while staking.`,
tone: 'status' as const,
};
}
if (stake.state === 'SignTransaction') {
return {
label: 'Sign Transaction ...',
disabled: true,
variant: 'outlined',
message: 'Check your wallet to sign the staking transaction.',
tone: 'status' as const,
};
}
if (stake.state === 'Waiting') {
return {
label: 'Waiting ...',
disabled: true,
variant: 'outlined',
message: 'Waiting for the transaction to confirm on-chain.',
tone: 'status' as const,
};
}
return {
label: 'Stake',
disabled: true,
variant: 'primary',
message: '',
tone: 'status' as const,
};
}); });
watch( watch(
minStakeAmount, minStakeAmount,
async newValue => { newValue => {
if (newValue > stake.stakingAmountNumber && stake.stakingAmountNumber === 0) { if (!Number.isFinite(newValue)) {
stake.stakingAmountNumber = minStakeAmount.value; return;
}
if (stake.stakingAmountNumber === 0 || stake.stakingAmountNumber < newValue) {
stake.stakingAmountNumber = newValue;
} }
}, },
{ immediate: true } { immediate: true }
); );
watch(maxStakeAmount, newValue => {
if (!Number.isFinite(newValue)) {
return;
}
if (stake.stakingAmountNumber > newValue) {
stake.stakingAmountNumber = newValue;
}
});
function setMaxAmount() { function setMaxAmount() {
stake.stakingAmountNumber = maxStakeAmount.value; const max = sliderMax.value;
} if (Number.isFinite(max)) {
stake.stakingAmountNumber = max;
const snatchSelection = useSnatchSelection(demo, taxRateIndex);
// Test helper - only available in dev mode
if (import.meta.env.DEV) {
if (typeof window !== 'undefined') {
window.__testHelpers = {
fillStakeForm: async (params: { amount: number; taxRateIndex: number }) => {
// Validate inputs
const minStakeNum = bigInt2Number(minStake.value, 18);
if (params.amount < minStakeNum) {
throw new Error(`Stake amount ${params.amount} is below minimum ${minStakeNum}`);
}
const maxStakeNum = maxStakeAmount.value;
if (params.amount > maxStakeNum) {
throw new Error(`Stake amount ${params.amount} exceeds balance ${maxStakeNum}`);
}
const options = adjustTaxRate.taxRates;
const selectedOption = options[params.taxRateIndex];
if (!selectedOption) {
throw new Error(`Tax rate index ${params.taxRateIndex} is invalid`);
}
// Fill the form
stake.stakingAmountNumber = params.amount;
taxRateIndex.value = params.taxRateIndex;
// Wait for reactive updates
await new Promise(resolve => setTimeout(resolve, 100));
},
};
} }
} }
async function stakeSnatch() {
if (snatchPositionsCount.value === 0) {
await stake.snatch(stake.stakingAmount, taxRateIndex.value);
} else {
const snatchAblePositionsIds = snatchSelection.snatchablePositions.value.map((p: Position) => p.positionId);
await stake.snatch(stake.stakingAmount, taxRateIndex.value, snatchAblePositionsIds);
}
await loadPositions();
await loadStats();
}
async function handleSubmit() {
if (actionState.value.disabled) {
return;
}
await stakeSnatch();
}
onMounted(async () => {
minStake.value = await getMinStake();
stake.stakingAmountNumber = minStakeAmount.value;
});
</script> </script>
<style lang="sass"> <style lang="sass">
.hold-inner .hold-inner
.stake-inner .stake-inner
display: flex display: flex
flex-direction: column flex-direction: column
gap: 24px gap: 24px
.formular
display: flex .stake-form
flex-direction: column position: relative
gap: 8px display: flex
.row flex-direction: column
>* gap: 24px
flex: 1 1 auto
.row-1 .form-group
gap: 12px display: flex
>:nth-child(2) flex-direction: column
flex: 0 0 auto gap: 8px
.staking-amount
.f-input--details .input-range
display: flex position: relative
gap: 8px display: flex
justify-content: flex-end align-items: center
color: #9A9898 gap: 16px
font-size: 14px margin-top: 8px
.staking-amount-max &__control
font-weight: 600 width: 100%
&:hover, &:active, &:focus height: 7px
cursor: pointer border-radius: 12px
.row-2 appearance: none
justify-content: space-between background: linear-gradient(90deg, #7550AE var(--slider-percentage, 0%), #000000 var(--slider-percentage, 0%))
>* outline: none
flex: 0 0 30% accent-color: #7550AE
&__control::-webkit-slider-thumb
appearance: none
width: 24px
height: 24px
border-radius: 50%
background-color: #7550AE
cursor: pointer
&__control::-moz-range-thumb
width: 24px
height: 24px
border-radius: 50%
background-color: #7550AE
cursor: pointer
&__value
min-width: 120px
text-align: right
font-weight: 600
&--disabled
opacity: .6
.input-range__help
font-size: 14px
color: #9A9898
.sr-only
position: absolute
width: 1px
height: 1px
padding: 0
margin: -1px
overflow: hidden
clip: rect(0, 0, 0, 0)
border: 0
.formular
display: flex
flex-direction: column
gap: 16px
.row
display: flex
gap: 12px
flex-wrap: wrap
>*
flex: 1 1 30%
.row-1
align-items: flex-start
>:nth-child(2)
flex: 0 0 auto
.staking-amount
.f-input--details
display: flex
gap: 8px
justify-content: flex-end
color: #9A9898
font-size: 14px
.staking-amount-max
font-weight: 600
background: none
border: none
color: inherit
padding: 0
cursor: pointer
text-decoration: underline
&:focus-visible
outline: 2px solid #7550AE
outline-offset: 2px
.stake-arrow .stake-arrow
align-self: center align-self: center
font-size: 30px font-size: 30px
.form-field
display: flex
flex-direction: column
gap: 8px
.field-label
display: inline-flex
align-items: center
gap: 8px
font-weight: 600
.tax-select-wrapper
position: relative
display: flex
align-items: center
.tax-select
width: 100%
padding: 12px
border-radius: 12px
border: 1px solid black
background-color: #2D2D2D
color: #FFFFFF
appearance: none
font-size: 16px
.tax-select__icon
position: absolute
right: 12px
pointer-events: none
color: #FFFFFF
.field-help
font-size: 14px
color: #9A9898
.summary-field
.form-field__value
font-size: 18px
font-weight: 600
.stake-summary
display: flex
flex-direction: column
gap: 8px
padding: 16px
border-radius: 12px
background-color: rgba(117, 80, 174, 0.12)
.stake-summary__heading
margin: 0
font-size: 16px
.form-status
min-height: 24px
font-size: 14px
@media (max-width: 767px)
.formular .row
flex-direction: column
>*
flex: 1 1 auto
</style> </style>

View file

@ -1,5 +1,12 @@
<template> <template>
<button class="f-btn" :class="classObject" :style="styleObject"> <button
class="f-btn"
:class="classObject"
:style="styleObject"
:type="props.type"
:disabled="props.disabled"
:aria-disabled="props.disabled ? 'true' : undefined"
>
<slot></slot> <slot></slot>
</button> </button>
</template> </template>
@ -15,6 +22,7 @@ interface Props {
bgColor?: string; bgColor?: string;
light?: boolean; light?: boolean;
dark?: boolean; dark?: boolean;
type?: 'button' | 'submit' | 'reset';
} }
import { computed } from 'vue'; import { computed } from 'vue';
@ -22,6 +30,7 @@ import { computed } from 'vue';
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
size: 'medium', size: 'medium',
bgColor: '', bgColor: '',
type: 'button',
}); });
const classObject = computed(() => ({ const classObject = computed(() => ({

View file

@ -1,7 +1,7 @@
<template> <template>
<div class="f-input" :class="classObject"> <div class="f-input" :class="classObject" v-bind="rootAttrs">
<div class="f-input-label subheader2"> <div class="f-input-label subheader2">
<label v-if="props.label" :for="name">{{ props.label }}</label> <label v-if="props.label" :for="inputId">{{ props.label }}</label>
<Icon> <Icon>
<template v-slot:text v-if="slots.info"> <template v-slot:text v-if="slots.info">
<slot name="info"></slot> <slot name="info"></slot>
@ -13,10 +13,11 @@
:disabled="props.disabled" :disabled="props.disabled"
:readonly="props.readonly" :readonly="props.readonly"
:type="props.type" :type="props.type"
:name="name" :name="inputName"
:id="name" :id="inputId"
@input="updateModelValue" @input="updateModelValue"
:value="props.modelValue" :value="props.modelValue"
v-bind="inputAttrs"
/> />
<div class="f-input--suffix" v-if="slots.suffix"> <div class="f-input--suffix" v-if="slots.suffix">
<slot name="suffix">test </slot> <slot name="suffix">test </slot>
@ -30,7 +31,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, getCurrentInstance, useSlots, ref } from 'vue'; import { computed, getCurrentInstance, useSlots, ref, useAttrs } from 'vue';
import useClickOutside from '@/composables/useClickOutside'; import useClickOutside from '@/composables/useClickOutside';
import Icon from '@/components/icons/IconInfo.vue'; import Icon from '@/components/icons/IconInfo.vue';
@ -49,7 +50,52 @@ const slots = useSlots();
const inputWrapper = ref(); const inputWrapper = ref();
const instance = getCurrentInstance(); const instance = getCurrentInstance();
const name = `f-input-${instance!.uid}`; defineOptions({ inheritAttrs: false });
const attrs = useAttrs();
const generatedId = `f-input-${instance!.uid}`;
const inputId = computed(() => {
const attrId = (attrs as Record<string, unknown>).id;
return typeof attrId === 'string' && attrId.length > 0 ? attrId : generatedId;
});
const inputName = computed(() => {
const attrName = (attrs as Record<string, unknown>).name;
if (typeof attrName === 'string' && attrName.length > 0) {
return attrName;
}
return inputId.value;
});
const rootAttrs = computed(() => {
const attrRecord = attrs as Record<string, unknown>;
const root: Record<string, unknown> = {};
if (typeof attrRecord.class === 'string') {
root.class = attrRecord.class;
}
if (attrRecord.style) {
root.style = attrRecord.style;
}
for (const [key, value] of Object.entries(attrRecord)) {
if (key.startsWith('data-')) {
root[key] = value;
}
}
return root;
});
const inputAttrs = computed(() => {
const attrRecord = attrs as Record<string, unknown>;
const input: Record<string, unknown> = {};
for (const [key, value] of Object.entries(attrRecord)) {
if (key === 'class' || key === 'style' || key.startsWith('data-') || key === 'id' || key === 'name') {
continue;
}
input[key] = value;
}
return input;
});
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
size: 'normal', size: 'normal',