reworked stack
This commit is contained in:
parent
6cbb1781ce
commit
f7ef56f65f
12 changed files with 853 additions and 458 deletions
|
|
@ -193,6 +193,18 @@ prime_chain() {
|
|||
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() {
|
||||
cat >"$ROOT_DIR/services/ponder/.env.local" <<EOPONDER
|
||||
PONDER_NETWORK=BASE_SEPOLIA_LOCAL_FORK
|
||||
|
|
@ -242,6 +254,7 @@ main() {
|
|||
grant_recenter_access
|
||||
call_recenter
|
||||
seed_application_state
|
||||
write_deployments_json
|
||||
write_ponder_env
|
||||
write_txn_bot_env
|
||||
fund_txn_bot_wallet
|
||||
|
|
|
|||
|
|
@ -1,18 +1,7 @@
|
|||
{
|
||||
"chainId": 31337,
|
||||
"network": "local",
|
||||
"deployer": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
|
||||
"deploymentDate": "2024-12-07",
|
||||
"contracts": {
|
||||
"Kraiken": "0xB58F7a0D856eed18B9f19072dD0843bf03E4eB24",
|
||||
"Stake": "0xa568b723199980B98E1BF765aB2A531C70a5edB3",
|
||||
"Pool": "0x8F02719c2840428b27CD94E2b01e0aE69D796523",
|
||||
"LiquidityManager": "0xbfE20DAb7BefF64237E2162D86F42Bfa228903B5",
|
||||
"Optimizer": "0x22132dA9e3181850A692d8c36e117BdF30cA911E"
|
||||
},
|
||||
"infrastructure": {
|
||||
"weth": "0x4200000000000000000000000000000000000006",
|
||||
"factory": "0x4752ba5DBc23f44D87826276BF6Fd6b1C372aD24",
|
||||
"feeDest": "0xf6a3eef9088A255c32b6aD2025f83E57291D9011"
|
||||
"Kraiken": "0xe527ddac2592faa45884a0b78e4d377a5d3df8cc",
|
||||
"Stake": "0x935b78d1862de1ff6504f338752a32e1c0211920",
|
||||
"LiquidityManager": "0xa887973a2ec1a3b4c7d50b84306ebcbc21bf2d5a"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
version: "3.8"
|
||||
|
||||
networks:
|
||||
harb-network:
|
||||
driver: bridge
|
||||
|
||||
services:
|
||||
anvil:
|
||||
image: ghcr.io/foundry-rs/foundry:latest
|
||||
|
|
@ -11,6 +15,8 @@ services:
|
|||
ports:
|
||||
- "127.0.0.1:8545:8545"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- harb-network
|
||||
healthcheck:
|
||||
test: ["CMD", "cast", "block-number", "--rpc-url", "http://127.0.0.1:8545"]
|
||||
interval: 2s
|
||||
|
|
@ -29,6 +35,8 @@ services:
|
|||
expose:
|
||||
- "5432"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- harb-network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ponder"]
|
||||
interval: 5s
|
||||
|
|
@ -45,6 +53,8 @@ services:
|
|||
environment:
|
||||
- ANVIL_RPC=http://anvil:8545
|
||||
- GIT_BRANCH=${GIT_BRANCH:-}
|
||||
networks:
|
||||
- harb-network
|
||||
restart: "no"
|
||||
healthcheck:
|
||||
test: ["CMD", "test", "-f", "/workspace/tmp/podman/contracts.env"]
|
||||
|
|
@ -57,11 +67,11 @@ services:
|
|||
context: .
|
||||
dockerfile: containers/node-dev.Containerfile
|
||||
entrypoint: ["/workspace/containers/ponder-dev-entrypoint.sh"]
|
||||
user: "0:0"
|
||||
volumes:
|
||||
- .:/workspace:z
|
||||
- .git:/workspace/.git:ro,z
|
||||
- ./kraiken-lib/dist:/workspace/kraiken-lib/dist:ro,z
|
||||
- ponder-node-modules:/workspace/services/ponder/node_modules
|
||||
working_dir: /workspace
|
||||
environment:
|
||||
- CHOKIDAR_USEPOLLING=1
|
||||
|
|
@ -71,6 +81,8 @@ services:
|
|||
ports:
|
||||
- "127.0.0.1:42069:42069"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- harb-network
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:42069/"]
|
||||
interval: 5s
|
||||
|
|
@ -83,11 +95,11 @@ services:
|
|||
context: .
|
||||
dockerfile: containers/node-dev.Containerfile
|
||||
entrypoint: ["/workspace/containers/webapp-dev-entrypoint.sh"]
|
||||
user: "0:0"
|
||||
volumes:
|
||||
- .:/workspace:z
|
||||
- .git:/workspace/.git:ro,z
|
||||
- ./kraiken-lib/dist:/workspace/kraiken-lib/dist:ro,z
|
||||
- webapp-node-modules:/workspace/web-app/node_modules
|
||||
working_dir: /workspace
|
||||
environment:
|
||||
- CHOKIDAR_USEPOLLING=1
|
||||
|
|
@ -97,8 +109,10 @@ services:
|
|||
ports:
|
||||
- "127.0.0.1:5173:5173"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- harb-network
|
||||
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
|
||||
retries: 6
|
||||
start_period: 10s
|
||||
|
|
@ -108,11 +122,11 @@ services:
|
|||
context: .
|
||||
dockerfile: containers/node-dev.Containerfile
|
||||
entrypoint: ["/workspace/containers/landing-dev-entrypoint.sh"]
|
||||
user: "0:0"
|
||||
volumes:
|
||||
- .:/workspace:z
|
||||
- .git:/workspace/.git:ro,z
|
||||
- ./kraiken-lib/dist:/workspace/kraiken-lib/dist:ro,z
|
||||
- landing-node-modules:/workspace/landing/node_modules
|
||||
working_dir: /workspace
|
||||
environment:
|
||||
- CHOKIDAR_USEPOLLING=1
|
||||
|
|
@ -120,6 +134,8 @@ services:
|
|||
expose:
|
||||
- "5174"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- harb-network
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:5174/"]
|
||||
interval: 5s
|
||||
|
|
@ -131,18 +147,19 @@ services:
|
|||
context: .
|
||||
dockerfile: containers/node-dev.Containerfile
|
||||
entrypoint: ["/workspace/containers/txn-bot-entrypoint.sh"]
|
||||
user: "0:0"
|
||||
volumes:
|
||||
- .:/workspace:z
|
||||
- .git:/workspace/.git: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
|
||||
environment:
|
||||
- GIT_BRANCH=${GIT_BRANCH:-}
|
||||
expose:
|
||||
- "43069"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- harb-network
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:43069/status"]
|
||||
interval: 5s
|
||||
|
|
@ -156,6 +173,8 @@ services:
|
|||
ports:
|
||||
- "0.0.0.0:8081:80"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- harb-network
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:80"]
|
||||
interval: 2s
|
||||
|
|
@ -164,8 +183,3 @@ services:
|
|||
|
||||
volumes:
|
||||
postgres-data:
|
||||
webapp-node-modules:
|
||||
landing-node-modules:
|
||||
ponder-node-modules:
|
||||
txn-node-modules:
|
||||
kraiken-node-modules:
|
||||
|
|
|
|||
147
scripts/dev.sh
147
scripts/dev.sh
|
|
@ -3,20 +3,82 @@ set -euo pipefail
|
|||
|
||||
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
|
||||
PROJECT_NAME=${COMPOSE_PROJECT_NAME:-$(basename "$PWD")}
|
||||
|
||||
start_stack() {
|
||||
if [[ -f "$PID_FILE" ]]; then
|
||||
local existing_pid
|
||||
existing_pid=$(cat "$PID_FILE")
|
||||
if kill -0 "$existing_pid" 2>/dev/null; then
|
||||
echo "Stopping existing kraiken-lib watcher ($existing_pid)..."
|
||||
kill "$existing_pid" 2>/dev/null || true
|
||||
wait "$existing_pid" 2>/dev/null || true
|
||||
fi
|
||||
cleanup_existing() {
|
||||
# Kill any existing watch scripts
|
||||
pkill -f "watch-kraiken-lib.sh" 2>/dev/null || true
|
||||
pkill -f "inotifywait.*$(pwd)/kraiken-lib" 2>/dev/null || true
|
||||
|
||||
# Remove PID file
|
||||
rm -f "$PID_FILE"
|
||||
|
||||
# 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
|
||||
sleep "$POLL_INTERVAL"
|
||||
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
|
||||
if [[ -n "${GIT_BRANCH:-}" ]]; then
|
||||
|
|
@ -27,84 +89,53 @@ start_stack() {
|
|||
./scripts/build-kraiken-lib.sh
|
||||
|
||||
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)
|
||||
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
|
||||
echo " Waiting for anvil & postgres..."
|
||||
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
|
||||
wait_for_healthy harb_anvil_1 "$ANVIL_TIMEOUT" || exit 1
|
||||
wait_for_healthy harb_postgres_1 "$POSTGRES_TIMEOUT" || exit 1
|
||||
|
||||
# Phase 2: Start bootstrap (depends on anvil & postgres healthy)
|
||||
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
|
||||
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
|
||||
wait_for_exited harb_bootstrap_1 "$BOOTSTRAP_TIMEOUT" || exit 1
|
||||
|
||||
# Phase 3: Start ponder (depends on bootstrap completed)
|
||||
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
|
||||
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
|
||||
wait_for_healthy harb_ponder_1 "$PONDER_TIMEOUT" || exit 1
|
||||
|
||||
# Phase 4: Start frontend services (depend on ponder healthy)
|
||||
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)
|
||||
sleep 5
|
||||
echo " Starting caddy..."
|
||||
podman-compose start caddy >/dev/null 2>&1
|
||||
podman-compose up -d caddy >/dev/null 2>&1
|
||||
|
||||
wait_for_healthy harb_caddy_1 "$CADDY_TIMEOUT" || exit 1
|
||||
|
||||
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 "[ok] Stack started"
|
||||
echo "[ok] Stack started in ${total_time}s"
|
||||
echo " Web App: http://localhost:8081/app/"
|
||||
echo " GraphQL: http://localhost:8081/graphql"
|
||||
}
|
||||
|
||||
stop_stack() {
|
||||
if [[ -f "$PID_FILE" ]]; then
|
||||
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
|
||||
cleanup_existing
|
||||
podman-compose down
|
||||
echo "[ok] Stack stopped"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -244,6 +244,7 @@ type positions {
|
|||
owner: String!
|
||||
share: Float!
|
||||
taxRate: Float!
|
||||
taxRateIndex: Int!
|
||||
kraikenDeposit: BigInt!
|
||||
stakeDeposit: BigInt!
|
||||
taxPaid: BigInt!
|
||||
|
|
@ -303,6 +304,14 @@ input positionsFilter {
|
|||
taxRate_lt: Float
|
||||
taxRate_gte: 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_not: BigInt
|
||||
kraikenDeposit_in: [BigInt]
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import { expect, test, type APIRequestContext } from '@playwright/test';
|
||||
import { Wallet } from 'ethers';
|
||||
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_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';
|
||||
const STACK_GRAPHQL_URL = process.env.STACK_GRAPHQL_URL ?? 'http://localhost:42069/graphql';
|
||||
|
||||
// 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, {
|
||||
|
|
@ -42,17 +45,10 @@ async function fetchPositions(request: APIRequestContext, owner: string) {
|
|||
}
|
||||
|
||||
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 () => {
|
||||
await startStack();
|
||||
await waitForStackReady({
|
||||
rpcUrl: STACK_RPC_URL,
|
||||
webAppUrl: STACK_WEBAPP_URL,
|
||||
graphqlUrl: STACK_GRAPHQL_URL,
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await stopStack();
|
||||
await validateStackHealthy(STACK_CONFIG);
|
||||
});
|
||||
|
||||
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
|
||||
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 test helper...');
|
||||
await page.evaluate(async () => {
|
||||
if (!window.__testHelpers) {
|
||||
throw new Error('Test helpers not available');
|
||||
}
|
||||
await window.__testHelpers.fillStakeForm({
|
||||
amount: 100, // Stake 100 KRK
|
||||
taxRateIndex: 2, // 5% tax rate option
|
||||
});
|
||||
});
|
||||
console.log('[TEST] Filling stake form via accessible controls...');
|
||||
|
||||
// Take screenshot before interaction
|
||||
await page.screenshot({ path: 'test-results/before-stake-fill.png' });
|
||||
|
||||
// Check if input is visible and enabled
|
||||
const stakeAmountInput = page.getByLabel('Staking Amount');
|
||||
console.log('[TEST] Checking if staking amount input is visible...');
|
||||
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...');
|
||||
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 stakeButton.click();
|
||||
|
||||
|
|
@ -190,7 +194,7 @@ test.describe('Acquire & Stake', () => {
|
|||
console.log(`[TEST] Found ${positions.length} position(s)`);
|
||||
|
||||
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);
|
||||
|
||||
console.log(`[TEST] ✅ Staking successful! Created ${activePositions.length} active position(s)`);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
runAllHealthChecks,
|
||||
formatHealthCheckError,
|
||||
type HealthCheckResult,
|
||||
} 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);
|
||||
const __dirname = dirname(__filename);
|
||||
const repoRoot = resolve(__dirname, '..', '..');
|
||||
|
||||
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 });
|
||||
export interface StackConfig {
|
||||
rpcUrl: string;
|
||||
webAppUrl: string;
|
||||
graphqlUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
rpcUrl: string,
|
||||
webAppUrl: string,
|
||||
graphqlUrl: string
|
||||
): Promise<void> {
|
||||
const results = await runAllHealthChecks({ rpcUrl, webAppUrl, graphqlUrl });
|
||||
export function getStackConfig(): StackConfig {
|
||||
return {
|
||||
rpcUrl: process.env.STACK_RPC_URL ?? DEFAULT_RPC_URL,
|
||||
webAppUrl: process.env.STACK_WEBAPP_URL ?? DEFAULT_WEBAPP_URL,
|
||||
graphqlUrl: process.env.STACK_GRAPHQL_URL ?? DEFAULT_GRAPHQL_URL,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
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');
|
||||
console.error(errorMessage);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export async function startStack(): Promise<void> {
|
||||
if (stackStarted) {
|
||||
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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,52 +34,15 @@ npm run build
|
|||
|
||||
## 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)`
|
||||
|
||||
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.
|
||||
Tests should rely on these roles and labels instead of private helpers.
|
||||
|
||||
### E2E Tests
|
||||
|
||||
|
|
|
|||
3
web-app/env.d.ts
vendored
3
web-app/env.d.ts
vendored
|
|
@ -5,9 +5,6 @@ import type { EIP1193Provider } from 'viem';
|
|||
declare global {
|
||||
interface Window {
|
||||
ethereum?: EIP1193Provider;
|
||||
__testHelpers?: {
|
||||
fillStakeForm: (params: { amount: number; taxRateIndex: number }) => Promise<void>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,72 +8,135 @@
|
|||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="subheader2">Token Amount</div>
|
||||
<FSlider :min="minStakeAmount" :max="maxStakeAmount" v-model="stake.stakingAmountNumber"></FSlider>
|
||||
<form class="stake-form" @submit.prevent="handleSubmit" :aria-describedby="formStatusId" novalidate>
|
||||
<div class="form-group">
|
||||
<label :id="sliderLabelId" :for="sliderId" class="subheader2">Token Amount</label>
|
||||
<div class="input-range" :class="{ 'input-range--disabled': isSliderDisabled }">
|
||||
<input
|
||||
:id="sliderId"
|
||||
class="input-range__control"
|
||||
type="range"
|
||||
: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">
|
||||
<template v-slot:details>
|
||||
<div class="balance">Balance: {{ maxStakeAmount.toFixed(2) }} $KRK</div>
|
||||
<div @click="setMaxAmount" class="staking-amount-max">
|
||||
<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>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</FInput>
|
||||
<Icon class="stake-arrow" icon="mdi:chevron-triple-right"></Icon>
|
||||
<FInput label="Owner Slots" class="staking-amount" disabled :modelValue="`${stakeSlots}(${supplyFreeze?.toFixed(4)})`">
|
||||
<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.
|
||||
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">
|
||||
<FSelect :items="adjustTaxRate.taxRates" label="Tax" v-model="taxRateIndex">
|
||||
<template v-slot:info>
|
||||
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.
|
||||
<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>
|
||||
</FSelect>
|
||||
<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>
|
||||
</FInput>
|
||||
<FInput label="Positions Buyout" disabled :modelValue="String(snatchSelection.snatchablePositions.value.length)">
|
||||
<template v-slot:info>
|
||||
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.
|
||||
</IconInfo>
|
||||
</div>
|
||||
<div class="tax-select-wrapper">
|
||||
<select :id="taxSelectId" class="tax-select" v-model.number="taxRateIndex" :aria-describedby="taxHelpId">
|
||||
<option v-for="option in taxOptions" :key="option.index" :value="option.index">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<span class="tax-select__icon" aria-hidden="true">
|
||||
<Icon icon="mdi:chevron-down"></Icon>
|
||||
</span>
|
||||
</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>
|
||||
</FInput>
|
||||
</IconInfo>
|
||||
</div>
|
||||
<p class="form-field__value">{{ positionsBuyoutDisplay }}</p>
|
||||
<p class="field-help">{{ snatchHelpText }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<FButton size="large" disabled block v-if="stake.state === 'NoBalance'">Insufficient Balance</FButton>
|
||||
<FButton size="large" disabled block v-else-if="stake.stakingAmountNumber < minStakeAmount">Stake amount too low</FButton>
|
||||
<FButton
|
||||
size="large"
|
||||
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>
|
||||
</div>
|
||||
|
||||
<section class="stake-summary" :aria-labelledby="stakeSummaryId" aria-live="polite">
|
||||
<h4 class="stake-summary__heading" :id="stakeSummaryId">Stake Summary</h4>
|
||||
<p>{{ stakeSummaryText }}</p>
|
||||
<p>{{ snatchSummaryText }}</p>
|
||||
<p>{{ walletSummaryText }}</p>
|
||||
</section>
|
||||
|
||||
<div class="sr-only" aria-live="assertive">{{ assistiveSummary }}</div>
|
||||
|
||||
<div class="form-status" :id="formStatusId" :role="actionState.tone === 'error' ? 'alert' : 'status'" aria-live="polite">
|
||||
{{ actionState.message }}
|
||||
</div>
|
||||
|
||||
<FButton size="large" block type="submit" :disabled="actionState.disabled" :outlined="actionState.variant === 'outlined'">
|
||||
{{ actionState.label }}
|
||||
</FButton>
|
||||
</form>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -82,9 +145,8 @@
|
|||
<script setup lang="ts">
|
||||
import FButton from '@/components/fcomponents/FButton.vue';
|
||||
import FInput from '@/components/fcomponents/FInput.vue';
|
||||
import FSelect from '@/components/fcomponents/FSelect.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 { bigInt2Number } from '@/utils/helper';
|
||||
import { loadPositions, usePositions, type Position } from '@/composables/usePositions';
|
||||
|
|
@ -95,31 +157,38 @@ import { useSnatchSelection } from '@/composables/useSnatchSelection';
|
|||
import { assetsToShares } from '@/contracts/stake';
|
||||
import { getMinStake } from '@/contracts/harb';
|
||||
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 { useRoute } from 'vue-router';
|
||||
|
||||
const demo = sessionStorage.getItem('demo') === 'true';
|
||||
const route = useRoute();
|
||||
|
||||
const adjustTaxRate = useAdjustTaxRate();
|
||||
|
||||
const StakeMenuOpen = ref(false);
|
||||
const defaultTaxRateIndex = adjustTaxRate.taxRates[0]?.index ?? 0;
|
||||
const taxRateIndex = ref<number>(defaultTaxRateIndex);
|
||||
const loading = ref<boolean>(true);
|
||||
const stakeSnatchLoading = ref<boolean>(false);
|
||||
const stake = useStake();
|
||||
const _claim = useClaim();
|
||||
const wallet = useWallet();
|
||||
const statCollection = useStatCollection();
|
||||
|
||||
const { activePositions: _activePositions } = usePositions();
|
||||
|
||||
const minStake = ref(0n);
|
||||
const stakeSlots = ref();
|
||||
const instance = getCurrentInstance();
|
||||
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);
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
watchEffect(() => {
|
||||
if (!stake.stakingAmount) {
|
||||
supplyFreeze.value = 0;
|
||||
|
|
@ -133,135 +202,389 @@ watchEffect(() => {
|
|||
const stakeableSupplyNumber = bigInt2Number(statCollection.stakeableSupply, 18);
|
||||
minStake.value = await getMinStake();
|
||||
|
||||
if (stakeableSupplyNumber === 0) {
|
||||
supplyFreeze.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
supplyFreeze.value = stakingAmountSharesNumber / stakeableSupplyNumber;
|
||||
}, 500);
|
||||
});
|
||||
|
||||
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(() => {
|
||||
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 minStakeAmount = computed(() => bigInt2Number(minStake.value, 18));
|
||||
|
||||
const maxStakeAmount = computed(() => {
|
||||
if (wallet.balance?.value) {
|
||||
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;
|
||||
}
|
||||
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(
|
||||
minStakeAmount,
|
||||
async newValue => {
|
||||
if (newValue > stake.stakingAmountNumber && stake.stakingAmountNumber === 0) {
|
||||
stake.stakingAmountNumber = minStakeAmount.value;
|
||||
newValue => {
|
||||
if (!Number.isFinite(newValue)) {
|
||||
return;
|
||||
}
|
||||
if (stake.stakingAmountNumber === 0 || stake.stakingAmountNumber < newValue) {
|
||||
stake.stakingAmountNumber = newValue;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(maxStakeAmount, newValue => {
|
||||
if (!Number.isFinite(newValue)) {
|
||||
return;
|
||||
}
|
||||
if (stake.stakingAmountNumber > newValue) {
|
||||
stake.stakingAmountNumber = newValue;
|
||||
}
|
||||
});
|
||||
|
||||
function setMaxAmount() {
|
||||
stake.stakingAmountNumber = maxStakeAmount.value;
|
||||
}
|
||||
|
||||
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));
|
||||
},
|
||||
};
|
||||
const max = sliderMax.value;
|
||||
if (Number.isFinite(max)) {
|
||||
stake.stakingAmountNumber = max;
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<style lang="sass">
|
||||
|
||||
.hold-inner
|
||||
.stake-inner
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 24px
|
||||
.formular
|
||||
|
||||
.stake-form
|
||||
position: relative
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 24px
|
||||
|
||||
.form-group
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 8px
|
||||
|
||||
.input-range
|
||||
position: relative
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 16px
|
||||
margin-top: 8px
|
||||
&__control
|
||||
width: 100%
|
||||
height: 7px
|
||||
border-radius: 12px
|
||||
appearance: none
|
||||
background: linear-gradient(90deg, #7550AE var(--slider-percentage, 0%), #000000 var(--slider-percentage, 0%))
|
||||
outline: none
|
||||
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
|
||||
>*
|
||||
flex: 1 1 auto
|
||||
.row-1
|
||||
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
|
||||
|
|
@ -271,13 +594,79 @@ if (import.meta.env.DEV) {
|
|||
font-size: 14px
|
||||
.staking-amount-max
|
||||
font-weight: 600
|
||||
&:hover, &:active, &:focus
|
||||
background: none
|
||||
border: none
|
||||
color: inherit
|
||||
padding: 0
|
||||
cursor: pointer
|
||||
.row-2
|
||||
justify-content: space-between
|
||||
>*
|
||||
flex: 0 0 30%
|
||||
text-decoration: underline
|
||||
&:focus-visible
|
||||
outline: 2px solid #7550AE
|
||||
outline-offset: 2px
|
||||
|
||||
.stake-arrow
|
||||
align-self: center
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
<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>
|
||||
</button>
|
||||
</template>
|
||||
|
|
@ -15,6 +22,7 @@ interface Props {
|
|||
bgColor?: string;
|
||||
light?: boolean;
|
||||
dark?: boolean;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
}
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
|
@ -22,6 +30,7 @@ import { computed } from 'vue';
|
|||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 'medium',
|
||||
bgColor: '',
|
||||
type: 'button',
|
||||
});
|
||||
|
||||
const classObject = computed(() => ({
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div class="f-input" :class="classObject">
|
||||
<div class="f-input" :class="classObject" v-bind="rootAttrs">
|
||||
<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>
|
||||
<template v-slot:text v-if="slots.info">
|
||||
<slot name="info"></slot>
|
||||
|
|
@ -13,10 +13,11 @@
|
|||
:disabled="props.disabled"
|
||||
:readonly="props.readonly"
|
||||
:type="props.type"
|
||||
:name="name"
|
||||
:id="name"
|
||||
:name="inputName"
|
||||
:id="inputId"
|
||||
@input="updateModelValue"
|
||||
:value="props.modelValue"
|
||||
v-bind="inputAttrs"
|
||||
/>
|
||||
<div class="f-input--suffix" v-if="slots.suffix">
|
||||
<slot name="suffix">test </slot>
|
||||
|
|
@ -30,7 +31,7 @@
|
|||
</template>
|
||||
|
||||
<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 Icon from '@/components/icons/IconInfo.vue';
|
||||
|
|
@ -49,7 +50,52 @@ const slots = useSlots();
|
|||
const inputWrapper = ref();
|
||||
|
||||
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>(), {
|
||||
size: 'normal',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue