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"
|
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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
|
||||||
|
|
|
||||||
155
scripts/dev.sh
155
scripts/dev.sh
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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)`);
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
3
web-app/env.d.ts
vendored
|
|
@ -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>;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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(() => ({
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue