new version

This commit is contained in:
ZOMBIIIIIII
2026-04-14 20:22:51 +03:00
parent 37146f7375
commit 89cb6174b7
144 changed files with 1710 additions and 17258 deletions

View File

@@ -1,8 +1,12 @@
node_modules
.next
dist
node_modules/
dist/
.next/
.env
.env.local
*.log
.git
.turbo
.turbo/
.git/
.gitea/
coverage/
.DS_Store
docs/

View File

@@ -1,24 +1,76 @@
# PostgreSQL
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/cryptowallet_devphase3
# Для локального dev: DB_HOST=localhost
# Для Docker Compose: DB_HOST переопределяется на 'postgres' в docker-compose.yml
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=cryptowallet_devphase3
DB_NAME=cryptowallet_v2
# Database Pool
DATABASE_POOL_SIZE=10
DATABASE_MAX_OVERFLOW=20
DATABASE_POOL_TIMEOUT=30
DATABASE_POOL_RECYCLE=3600
DATABASE_ECHO=false
# Vault (AppRole auth)
VAULT_ADDR=
VAULT_ROLE_ID=
VAULT_SECRET_ID=
VAULT_MOUNT_POINT=dev-secrets
VAULT_SECRET_PATH=database
VAULT_JWT_KID_PATH=jwt/kid
VAULT_JWT_KIDS_PREFIX=jwt/kids
# CSRF
CSRF_COOKIE_SECURE=false
CSRF_COOKIE_HTTPONLY=true
CSRF_COOKIE_SAMESITE=Lax
CSRF_COOKIE_PATH=/
CSRF_COOKIE_DOMAIN=
# JWT
JWT_ALGORITHM=RS256
JWT_ACCESS_TTL_SECONDS=900
JWT_REFRESH_TTL_SECONDS=2592000
JWT_ISSUER=auth-service
JWT_AUDIENCE=wallet-service
# Docs
DOCS_USERNAME=admin
DOCS_PASSWORD=admin
# Redis
REDIS_HOST=keydb
REDIS_PORT=6379
REDIS_PASSWORD=keydb
REDIS_DB=0
# RabbitMQ
RABBIT_EMAIL_CODE_QUEUE=email.verification_code
RABBIT_PUBLISH_PERSIST=true
RABBIT_CONNECT_TIMEOUT=5
# Logging
LOG_LEVEL=INFO
LOG_FORMAT=JSON
# CORS
CORS_ORIGINS=http://localhost:3000,http://localhost:8000
CORS_ALLOW_CREDENTIALS=true
# Rate Limiting
RATE_LIMIT_REQUESTS=60
RATE_LIMIT_WINDOW=60
# Server
API_PORT=3001
FRONTEND_URL=http://localhost:3000
RELAY_API_KEY=
# BITOK auth service
BITOK_JWKS_URL=http://localhost:8000/.well-known/jwks.json
BITOK_ISSUER=auth-service
BITOK_AUDIENCE=wallet-service
# TRON
TRON_API_KEY=
# RabbitMQ
RABBITMQ_URL=amqp://guest:guest@localhost:5672/
# Rate Limiting
RATE_LIMIT_LOGIN_MAX=5
RATE_LIMIT_LOGIN_WINDOW_MS=900000
# Jupiter (Solana DEX aggregator)
JUPITER_API_KEY=

11
.gitignore vendored
View File

@@ -1,11 +0,0 @@
node_modules/
dist/
.next/
.env
.env.local
*.log
.turbo/
coverage/
.DS_Store
vault/data/
vault/init-keys.json

View File

@@ -1,22 +0,0 @@
__pycache__/
*.pyc
*.pyo
*.pyd
.pytest_cache/
.mypy_cache/
.ruff_cache/
.cache/
.venv/
venv/
.env
.git/
.gitignore
dist/
build/
*.egg-info/
.DS_Store

141
BITOK/.gitignore vendored
View File

@@ -1,141 +0,0 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
*.pyd
*.dll
# Distribution / packaging
.Python
build/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache/
.pytest_cache/
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
# Type checkers / linters
.mypy_cache/
.dmypy.json
dmypy.json
.pyre/
.pytype/
.ruff_cache/
# Jupyter Notebook
.ipynb_checkpoints/
# Environments
.env
.env.*
.venv/
venv/
ENV/
env/
env.bak/
venv.bak/
# Poetry
poetry.lock
# Pipenv
Pipfile.lock
# Hatch
.hatch/
# pyenv
.python-version
# Logs
*.log
logs/
# Local databases
*.sqlite3
*.db
# Secrets / credentials
secrets.json
credentials.json
*.pem
*.key
*.crt
# OS generated files
.DS_Store
Thumbs.db
Desktop.ini
# PyCharm / IntelliJ IDEA
.idea/
*.iml
out/
# VS Code (optional)
.vscode/
# Temporary files
*.tmp
*.temp
*.swp
*.swo
*~
# Sphinx docs
docs/_build/
# mkdocs
site/
# celery
celerybeat-schedule
celerybeat.pid
# mypy compiled cache
.mypy_cache/
# pyinstaller
*.manifest
*.spec
# pytest debug
pytestdebug.log
# Local config overrides
config.local.py
settings.local.py
# Vault / local dev secrets
.env.vault
vault.token

83
Dockerfile Normal file
View File

@@ -0,0 +1,83 @@
# syntax=docker/dockerfile:1.7
# ─────────────────────────────────────────────────────────────────────────────
# Production Dockerfile for CryptoWallet API
# Multi-stage: deps → build → prod-deps → runtime
# Runtime: non-root user, tini (signal handling), wget (healthcheck)
# ─────────────────────────────────────────────────────────────────────────────
ARG NODE_VERSION=20.19-alpine
ARG PNPM_VERSION=10.28.2
# ─────────────────────────────────────────────────────────────────────────────
# Base: node + pnpm
# ─────────────────────────────────────────────────────────────────────────────
FROM node:${NODE_VERSION} AS base
ARG PNPM_VERSION
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
WORKDIR /app
ENV NODE_ENV=production \
CI=true \
HUSKY=0
# ─────────────────────────────────────────────────────────────────────────────
# deps: install ALL deps (incl. dev) for TypeScript build
# ─────────────────────────────────────────────────────────────────────────────
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/api/package.json apps/api/
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile --prod=false
# ─────────────────────────────────────────────────────────────────────────────
# build: compile TypeScript → dist/
# ─────────────────────────────────────────────────────────────────────────────
FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/api ./apps/api
RUN cd apps/api && pnpm build
# ─────────────────────────────────────────────────────────────────────────────
# prod-deps: only production dependencies (smaller image)
# ─────────────────────────────────────────────────────────────────────────────
FROM base AS prod-deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/api/package.json apps/api/
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile --prod
# ─────────────────────────────────────────────────────────────────────────────
# runtime: minimal production image
# ─────────────────────────────────────────────────────────────────────────────
FROM node:${NODE_VERSION} AS runtime
# tini for proper PID 1 / signal handling
# wget for healthcheck
RUN apk add --no-cache tini wget && \
addgroup -S -g 1001 app && \
adduser -S -u 1001 -G app app
WORKDIR /app/apps/api
# Copy production dependencies
COPY --from=prod-deps --chown=app:app /app/node_modules /app/node_modules
COPY --from=prod-deps --chown=app:app /app/apps/api/node_modules ./node_modules
# Copy compiled code + static assets
COPY --from=build --chown=app:app /app/apps/api/dist ./dist
COPY --from=build --chown=app:app /app/apps/api/swagger.json ./swagger.json
COPY --from=build --chown=app:app /app/apps/api/package.json ./package.json
USER app
ENV NODE_ENV=production \
API_PORT=3001
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://localhost:3001/api/health || exit 1
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/index.js"]

130
README.md Normal file
View File

@@ -0,0 +1,130 @@
# CryptoWallet API — Production Deploy Bundle
Самодостаточная папка для деплоя на Linux-сервер. Содержит всё нужное для сборки и запуска продакшн-версии API.
## Состав
```
deployserver/
├── Dockerfile # Multi-stage production build
├── docker-compose.yml # PostgreSQL + API
├── .env.example # Шаблон переменных окружения
├── .dockerignore
├── start.sh # Автоматический deploy скрипт
├── apps/api/ # Исходник API
│ ├── src/
│ ├── package.json
│ ├── tsconfig.json
│ ├── swagger.json
│ └── .eslintrc.json
├── package.json # Монорепо root
├── pnpm-workspace.yaml
└── pnpm-lock.yaml
```
## Требования
- Ubuntu 20.04+ / Debian 11+ / любой Linux с Docker 24+
- Docker Compose plugin (`docker compose` команда)
- Исходящий HTTPS на Vault (`corp.vault.elcsa.ru:443`)
## Быстрый старт
```bash
# 1. Скопировать папку на сервер (или git clone и cd deployserver)
scp -r deployserver user@server:/opt/cryptowallet
ssh user@server
cd /opt/cryptowallet
# 2. Установить Docker (если нет)
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER
newgrp docker
# 3. Настроить .env
cp .env.example .env
nano .env # заполнить VAULT_ROLE_ID, VAULT_SECRET_ID
# 4. Запустить
chmod +x start.sh
./start.sh
# 5. Открыть порт наружу
sudo ufw allow 22/tcp
sudo ufw allow 3001/tcp
sudo ufw enable
```
## Проверка
```bash
curl http://localhost:3001/api/health
# → {"success":true,"data":{"status":"ok"}}
curl http://<server-ip>:3001/api/health # извне
```
Swagger UI: `http://<server-ip>:3001/api/docs`
## Порты
| Порт | Назначение | Открыть наружу? |
|------|-----------|-----------------|
| 3001 | API HTTP | ✅ да (`ufw allow 3001`) |
| 5432 | PostgreSQL | ❌ нет (только docker network) |
| 443 (out) | Vault | исходящий, обычно открыт |
## Управление
```bash
docker compose logs -f api # смотреть логи
docker compose restart api # рестарт
docker compose down # остановить
docker compose down -v # + удалить БД (ОСТОРОЖНО)
docker compose ps # статус
docker compose exec postgres psql -U postgres cryptowallet_v2 # подключиться к БД
```
## Обновление
```bash
# Скопировать новую версию deployserver/
docker compose build --pull api
docker compose up -d
```
Миграции применятся автоматически при старте API.
## Безопасность Dockerfile
- **Non-root user** (uid 1001) — контейнер не работает от root
- **tini** как PID 1 — корректная обработка `SIGTERM` / `SIGKILL`
- **Multi-stage build** — в финальный образ попадают только production deps + компилированный dist
- **Alpine base** — минимальный образ (~150 MB)
- **Healthcheck** — Docker рестартит контейнер если API упал
- **Log rotation** — max 5×20MB логов, не забьёт диск
## Troubleshooting
**`Vault AppRole login failed`**
- Проверь VAULT_ROLE_ID / VAULT_SECRET_ID в .env
- Проверь доступ: `curl -v https://corp.vault.elcsa.ru/v1/sys/health`
**API рестартуется в цикле**
- `docker compose logs api` — смотри ошибку
- Скорее всего БД не поднялась: `docker compose logs postgres`
**Port 3001 занят**
- `sudo lsof -i :3001`
- Измени порт: в `docker-compose.yml` `"3002:3001"` и в `ufw allow 3002`
**Нет места на диске**
- `docker system prune -a` — удалит старые образы
- `docker compose logs --tail=0 --no-log-prefix > /dev/null` — логи ротейтятся автоматически
## Автозапуск при reboot
Restart policy `unless-stopped` уже настроен. Убедись что Docker стартует:
```bash
sudo systemctl enable docker
```

20
apps/api/.eslintrc.json Normal file
View File

@@ -0,0 +1,20 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module"
},
"rules": {
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
"@typescript-eslint/no-namespace": "off",
"no-console": "off"
},
"ignorePatterns": ["dist/", "node_modules/"]
}

View File

@@ -1,47 +0,0 @@
# Build stage
FROM node:20-alpine AS builder
RUN apk add --no-cache python3 make g++
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
WORKDIR /app
# Copy workspace config
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml turbo.json ./
COPY apps/api/package.json apps/api/
COPY packages/shared/package.json packages/shared/
# Enable hoisting so tsc can find all deps
RUN echo "node-linker=hoisted" > .npmrc
RUN pnpm install --frozen-lockfile
# Copy source
COPY apps/api/ apps/api/
COPY packages/shared/ packages/shared/
# Build api (node_modules are hoisted, tsc available at root)
RUN cd apps/api && ../../node_modules/.bin/tsc \
&& rm -f dist/db/migrations/*.d.ts dist/db/migrations/*.d.ts.map dist/db/migrations/*.js.map
# Runtime stage
FROM node:20-alpine
RUN apk add --no-cache curl
WORKDIR /app
# Copy built output (includes compiled migrations + knexfile in dist/db/)
COPY --from=builder /app/apps/api/dist ./dist
COPY --from=builder /app/apps/api/package.json ./
# Copy node_modules (runtime deps including bcrypt native)
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/apps/api/node_modules ./apps_node_modules
# Entrypoint
COPY apps/api/docker-entrypoint.sh ./docker-entrypoint.sh
RUN chmod +x ./docker-entrypoint.sh
EXPOSE 3001
ENTRYPOINT ["./docker-entrypoint.sh"]

View File

@@ -1,15 +0,0 @@
#!/bin/sh
set -e
# If Vault init-keys exist, extract root token
if [ -f /vault/file/init-keys.json ]; then
export VAULT_TOKEN=$(tr -d ' \n' < /vault/file/init-keys.json | grep -o '"root_token":"[^"]*"' | cut -d'"' -f4)
echo "[API] Vault token loaded from init-keys.json"
fi
# Run migrations
node node_modules/knex/bin/cli.js migrate:latest --knexfile dist/db/knexfile.js
echo "[API] Migrations complete"
# Start server
exec node dist/index.js

View File

@@ -8,39 +8,33 @@
"start": "node dist/index.js",
"migrate": "node --require ts-node/register node_modules/knex/bin/cli.js migrate:latest --knexfile src/db/knexfile.ts",
"migrate:rollback": "node --require ts-node/register node_modules/knex/bin/cli.js migrate:rollback --knexfile src/db/knexfile.ts",
"db:fresh": "pnpm db:reset && pnpm migrate",
"db:reset": "node --require ts-node/register src/db/reset-db.ts",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"lint": "eslint src/ --ext .ts"
},
"dependencies": {
"@cryptowallet/shared": "workspace:*",
"amqplib": "^1.0.3",
"bcrypt": "^5.1.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.0",
"ethers": "5.7.2",
"express": "^4.21.0",
"express-rate-limit": "^7.4.0",
"helmet": "^8.0.0",
"jose": "^6.2.2",
"jsonwebtoken": "^9.0.0",
"knex": "^3.1.0",
"pg": "^8.13.0",
"swagger-ui-express": "^5.0.1",
"ulidx": "^2.4.1",
"uuid": "^11.0.0",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/amqplib": "^0.10.8",
"@types/bcrypt": "^5.0.2",
"@types/cookie-parser": "^1.4.7",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/express-serve-static-core": "^5.1.1",
"@types/jsonwebtoken": "^9.0.0",
"@types/node": "^20.0.0",
"@types/uuid": "^10.0.0",
"@types/swagger-ui-express": "^4.1.8",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"eslint": "^8.57.1",
"ts-node": "^10.9.0",
"ts-node-dev": "^2.0.0",
"typescript": "^5.6.0"

View File

@@ -2,11 +2,13 @@ import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import swaggerUi from 'swagger-ui-express';
import { env } from './config/env';
import { swaggerSpec } from './config/swagger';
import { traceMiddleware } from './middleware/trace';
import { authMiddleware } from './middleware/auth';
import { errorHandler } from './middleware/error-handler';
import walletSetupRoutes from './routes/wallet-setup.routes';
import walletRoutes from './routes/wallet.routes';
import vaultRoutes from './routes/vault.routes';
import relayProxyRoutes from './routes/relay-proxy.routes';
import tronProxyRoutes from './routes/tron-proxy.routes';
import solSwapProxyRoutes from './routes/sol-swap-proxy.routes';
@@ -20,20 +22,26 @@ app.use(helmet());
app.use(cors({ origin: env.frontendUrl, credentials: true }));
app.use(express.json());
app.use(cookieParser());
app.use(traceMiddleware);
// ── PUBLIC endpoints (no auth) ────────────────────────────────────────────────
app.get('/api/health', (_req, res) => {
res.json({ success: true, data: { status: 'ok' } });
});
app.use('/api/wallet', walletSetupRoutes);
app.use('/api/wallets', walletRoutes);
app.use('/api/vault', vaultRoutes);
app.use('/api/relay', relayProxyRoutes);
app.use('/api/tron', tronProxyRoutes);
app.use('/api/sol/swap', solSwapProxyRoutes);
app.use('/api/tron/swap', tronSwapProxyRoutes);
app.use('/api/btc', btcProxyRoutes);
app.use('/api/bsc/swap', bscSwapProxyRoutes);
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
app.get('/api/docs/swagger.json', (_req, res) => {
res.json(swaggerSpec);
});
// ── PROTECTED endpoints (JWT required) ────────────────────────────────────────
app.use('/api/wallets', authMiddleware, walletRoutes);
app.use('/api/relay', authMiddleware, relayProxyRoutes);
app.use('/api/tron', authMiddleware, tronProxyRoutes);
app.use('/api/sol/swap', authMiddleware, solSwapProxyRoutes);
app.use('/api/tron/swap', authMiddleware, tronSwapProxyRoutes);
app.use('/api/btc', authMiddleware, btcProxyRoutes);
app.use('/api/bsc/swap', authMiddleware, bscSwapProxyRoutes);
app.use(errorHandler);

View File

@@ -1,55 +1,158 @@
import dotenv from 'dotenv';
import path from 'path';
import { fetchVaultSecrets } from './vault';
import { vaultAppRoleLogin, fetchVaultKV2 } from './vault';
import { logger } from '../lib/logger';
dotenv.config({ path: path.resolve(__dirname, '../../../../.env') });
const p = process.env;
export let env = {
db: {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432'),
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'postgres',
name: process.env.DB_NAME || 'cryptowallet',
host: p.DB_HOST || 'localhost',
port: parseInt(p.DB_PORT || '5432'),
user: p.DB_USER || 'postgres',
password: p.DB_PASSWORD || 'postgres',
name: p.DB_NAME || 'cryptowallet_v2',
poolSize: parseInt(p.DATABASE_POOL_SIZE || '10'),
maxOverflow: parseInt(p.DATABASE_MAX_OVERFLOW || '20'),
poolTimeout: parseInt(p.DATABASE_POOL_TIMEOUT || '30'),
poolRecycle: parseInt(p.DATABASE_POOL_RECYCLE || '3600'),
echo: p.DATABASE_ECHO === 'true',
},
port: parseInt(process.env.API_PORT || '3001'),
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:3000',
relayApiKey: process.env.RELAY_API_KEY || null,
tronApiKey: process.env.TRON_API_KEY || null,
jupiterApiKey: process.env.JUPITER_API_KEY || null,
jupiterReferralAccount: process.env.JUPITER_REFERRAL_ACCOUNT || null,
jupiterFeeBps: parseInt(process.env.JUPITER_FEE_BPS || '70'), // 0.7%
// BITOK auth service
bitokJwksUrl: process.env.BITOK_JWKS_URL || 'http://localhost:8000/.well-known/jwks.json',
bitokIssuer: process.env.BITOK_ISSUER || 'auth-service',
bitokAudience: process.env.BITOK_AUDIENCE || 'wallet-service',
// RabbitMQ
rabbitmqUrl: process.env.RABBITMQ_URL || 'amqp://guest:guest@localhost:5672/',
rabbitmqExchange: process.env.RABBITMQ_EXCHANGE || 'bitok.events',
rabbitmqWalletQueue: process.env.RABBITMQ_WALLET_QUEUE || 'wallet.user_events',
jwt: {
algorithm: p.JWT_ALGORITHM || 'RS256',
issuer: p.JWT_ISSUER || 'auth-service',
audience: p.JWT_AUDIENCE || 'bitforce',
accessTtl: parseInt(p.JWT_ACCESS_TTL_SECONDS || '900'),
refreshTtl: parseInt(p.JWT_REFRESH_TTL_SECONDS || '2592000'),
},
vault: {
addr: p.VAULT_ADDR || '',
roleId: p.VAULT_ROLE_ID || '',
secretId: p.VAULT_SECRET_ID || '',
mount: p.VAULT_MOUNT_POINT || 'dev-secrets',
secretPath: p.VAULT_SECRET_PATH || 'database',
jwtKidPath: p.VAULT_JWT_KID_PATH || 'jwt/kid',
jwtKidsPrefix: p.VAULT_JWT_KIDS_PREFIX || 'jwt/kids',
},
csrf: {
cookieSecure: p.CSRF_COOKIE_SECURE === 'true',
cookieHttpOnly: p.CSRF_COOKIE_HTTPONLY !== 'false',
cookieSameSite: p.CSRF_COOKIE_SAMESITE || 'Lax',
cookiePath: p.CSRF_COOKIE_PATH || '/',
cookieDomain: p.CSRF_COOKIE_DOMAIN || '',
},
docs: {
username: p.DOCS_USERNAME || 'admin',
password: p.DOCS_PASSWORD || 'admin',
},
redis: {
host: p.REDIS_HOST || 'keydb',
port: parseInt(p.REDIS_PORT || '6379'),
password: p.REDIS_PASSWORD || 'keydb',
db: parseInt(p.REDIS_DB || '0'),
},
rabbit: {
emailCodeQueue: p.RABBIT_EMAIL_CODE_QUEUE || 'email.verification_code',
publishPersist: p.RABBIT_PUBLISH_PERSIST !== 'false',
connectTimeout: parseInt(p.RABBIT_CONNECT_TIMEOUT || '5'),
},
log: {
level: p.LOG_LEVEL || 'INFO',
format: p.LOG_FORMAT || 'JSON',
},
cors: {
origins: (p.CORS_ORIGINS || 'http://localhost:3000').split(','),
allowCredentials: p.CORS_ALLOW_CREDENTIALS !== 'false',
},
rateLimit: {
requests: parseInt(p.RATE_LIMIT_REQUESTS || '60'),
window: parseInt(p.RATE_LIMIT_WINDOW || '60'),
},
port: parseInt(p.API_PORT || '3001'),
frontendUrl: p.FRONTEND_URL || 'http://localhost:3000',
relayApiKey: p.RELAY_API_KEY || null,
tronApiKey: p.TRON_API_KEY || null,
jupiterApiKey: p.JUPITER_API_KEY || null,
jupiterReferralAccount: p.JUPITER_REFERRAL_ACCOUNT || null,
jupiterFeeBps: parseInt(p.JUPITER_FEE_BPS || '70'),
};
export async function initEnv(): Promise<void> {
const secrets = await fetchVaultSecrets();
let vaultToken: string | null = null;
if (secrets) {
console.log('[ENV] Loaded secrets from Vault');
env = {
...env,
db: {
host: secrets.db_host,
port: parseInt(secrets.db_port),
user: secrets.db_user,
password: secrets.db_password,
name: secrets.db_name,
},
relayApiKey: secrets.relay_api_key || null,
tronApiKey: secrets.tron_api_key || env.tronApiKey,
jupiterApiKey: secrets.jupiter_api_key || env.jupiterApiKey,
};
} else {
console.log('[ENV] Vault not available, using env vars');
}
export function getVaultToken(): string | null {
return vaultToken;
}
export async function initEnv(): Promise<void> {
const { addr, roleId, secretId, mount, secretPath } = env.vault;
if (!addr || !roleId || !secretId) {
logger.info('Vault not configured, using .env');
return;
}
const token = await vaultAppRoleLogin(addr, roleId, secretId);
if (!token) {
logger.warn('Vault AppRole login failed, using .env fallback');
return;
}
vaultToken = token;
logger.info('Vault AppRole login successful');
const secrets = await fetchVaultKV2(addr, token, mount, secretPath);
if (!secrets) {
logger.warn('Failed to read DB secrets from Vault');
return;
}
logger.info('Loaded DB secrets from Vault');
const s = (key: string) => secrets[key];
const si = (key: string, fallback: number) => {
const v = secrets[key];
return v ? parseInt(v) : fallback;
};
env = {
...env,
db: {
host: s('DB_HOST') || env.db.host,
port: si('DB_PORT', env.db.port),
user: s('DB_USER') || env.db.user,
password: s('DB_PASSWORD') || env.db.password,
name: s('DB_NAME') || env.db.name,
poolSize: si('DATABASE_POOL_SIZE', env.db.poolSize),
maxOverflow: si('DATABASE_MAX_OVERFLOW', env.db.maxOverflow),
poolTimeout: si('DATABASE_POOL_TIMEOUT', env.db.poolTimeout),
poolRecycle: si('DATABASE_POOL_RECYCLE', env.db.poolRecycle),
echo: secrets['DATABASE_ECHO'] === 'true',
},
jwt: {
...env.jwt,
issuer: s('JWT_ISSUER') || env.jwt.issuer,
audience: s('JWT_AUDIENCE') || env.jwt.audience,
accessTtl: si('JWT_ACCESS_TTL_SECONDS', env.jwt.accessTtl),
refreshTtl: si('JWT_REFRESH_TTL_SECONDS', env.jwt.refreshTtl),
},
redis: {
host: s('REDIS_HOST') || env.redis.host,
port: si('REDIS_PORT', env.redis.port),
password: s('REDIS_PASSWORD') || env.redis.password,
db: si('REDIS_DB', env.redis.db),
},
cors: {
origins: s('CORS_ORIGINS') ? s('CORS_ORIGINS')!.split(',') : env.cors.origins,
allowCredentials: secrets['CORS_ALLOW_CREDENTIALS'] !== 'false',
},
rateLimit: {
requests: si('RATE_LIMIT_REQUESTS', env.rateLimit.requests),
window: si('RATE_LIMIT_WINDOW', env.rateLimit.window),
},
relayApiKey: s('RELAY_API_KEY') || env.relayApiKey,
tronApiKey: s('TRON_API_KEY') || env.tronApiKey,
jupiterApiKey: s('JUPITER_API_KEY') || env.jupiterApiKey,
};
}

View File

@@ -0,0 +1,5 @@
import fs from 'fs';
import path from 'path';
const swaggerPath = path.resolve(__dirname, '../../swagger.json');
export const swaggerSpec = JSON.parse(fs.readFileSync(swaggerPath, 'utf-8'));

View File

@@ -1,29 +1,42 @@
interface VaultSecrets {
db_host: string;
db_port: string;
db_user: string;
db_password: string;
db_name: string;
relay_api_key: string;
tron_api_key: string;
jupiter_api_key: string;
}
export async function fetchVaultSecrets(): Promise<VaultSecrets | null> {
const vaultAddr = process.env.VAULT_ADDR;
const vaultToken = process.env.VAULT_TOKEN;
if (!vaultAddr || !vaultToken) return null;
export async function vaultAppRoleLogin(
addr: string,
roleId: string,
secretId: string,
): Promise<string | null> {
try {
const res = await fetch(`${vaultAddr}/v1/kv/data/cryptowallet`, {
headers: { 'X-Vault-Token': vaultToken },
const res = await fetch(`${addr}/v1/auth/approle/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role_id: roleId, secret_id: secretId }),
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return null;
const body = (await res.json()) as { data: { data: VaultSecrets } };
return body.data.data;
const body = (await res.json()) as { auth?: { client_token?: string } };
return body?.auth?.client_token ?? null;
} catch {
return null;
}
}
export async function fetchVaultKV2(
addr: string,
token: string,
mount: string,
path: string,
): Promise<Record<string, string> | null> {
try {
const url = `${addr}/v1/${mount}/data/${path}`;
const res = await fetch(url, {
headers: { 'X-Vault-Token': token },
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return null;
const body = (await res.json()) as { data?: { data?: Record<string, string> } };
return body?.data?.data ?? null;
} catch {
return null;
}

View File

@@ -1,23 +0,0 @@
import { Request, Response } from 'express';
import { UserModel } from '../models/user.model';
export const VaultController = {
async getVault(req: Request, res: Response) {
try {
const user = await UserModel.findByBitokUserId(req.user!.bitokUserId);
if (!user) {
res.status(404).json({ success: false, error: 'User not found' });
return;
}
res.json({
success: true,
data: {
encryptedVault: user.encrypted_vault,
vaultSalt: user.vault_salt,
},
});
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
},
};

View File

@@ -1,118 +0,0 @@
import { Request, Response } from 'express';
import { UserModel } from '../models/user.model';
import { WalletModel } from '../models/wallet.model';
import { db } from '../config/database';
import { generateUlid } from '../utils/ulid';
export const WalletSetupController = {
async setup(req: Request, res: Response) {
try {
const { bitokUserId, email } = req.user!;
const { encryptedVault, vaultSalt, wallets } = req.body;
// Check if user already exists
const existing = await UserModel.findByBitokUserId(bitokUserId);
if (existing) {
res.status(409).json({ success: false, error: 'Wallet already set up for this user' });
return;
}
const result = await db.transaction(async (trx) => {
const [user] = await trx('users')
.insert({
id: generateUlid(),
bitok_user_id: bitokUserId,
email: email || null,
encrypted_vault: encryptedVault,
vault_salt: vaultSalt,
})
.returning('*');
const walletRows = await trx('wallets')
.insert(
wallets.map((w: { chain: string; address: string; derivationPath: string }) => ({
id: generateUlid(),
user_id: user.id,
chain: w.chain,
address: w.address,
derivation_path: w.derivationPath,
}))
)
.returning('*');
return { user, wallets: walletRows };
});
res.status(201).json({
success: true,
data: {
user: {
id: result.user.id,
bitokUserId: result.user.bitok_user_id,
email: result.user.email,
},
wallets: result.wallets.map((w: any) => ({
chain: w.chain,
address: w.address,
derivationPath: w.derivation_path,
})),
},
});
} catch (err: any) {
console.error('[WalletSetup] Error:', err.message);
res.status(500).json({ success: false, error: 'Failed to set up wallet' });
}
},
async confirmMnemonic(req: Request, res: Response) {
try {
const { bitokUserId } = req.user!;
const user = await UserModel.findByBitokUserId(bitokUserId);
if (!user) {
res.status(404).json({ success: false, error: 'Wallet not found' });
return;
}
await UserModel.setMnemonicShown(user.id);
res.json({ success: true, data: { mnemonicShown: true } });
} catch (err: any) {
console.error('[ConfirmMnemonic] Error:', err.message);
res.status(500).json({ success: false, error: 'Failed to confirm mnemonic' });
}
},
async unlock(req: Request, res: Response) {
try {
const { bitokUserId } = req.user!;
const user = await UserModel.findByBitokUserId(bitokUserId);
if (!user) {
res.status(404).json({ success: false, error: 'Wallet not found' });
return;
}
if (user.deleted) {
res.status(403).json({ success: false, error: 'Account has been deleted' });
return;
}
const wallets = await WalletModel.findByUserId(user.id);
res.json({
success: true,
data: {
encryptedVault: user.encrypted_vault,
vaultSalt: user.vault_salt,
wallets: wallets.map((w) => ({
chain: w.chain,
address: w.address,
derivationPath: w.derivation_path,
})),
mnemonicShown: user.mnemonic_shown,
},
});
} catch (err: any) {
console.error('[WalletUnlock] Error:', err.message);
res.status(500).json({ success: false, error: 'Failed to unlock wallet' });
}
},
};

View File

@@ -1,17 +1,10 @@
import { Request, Response } from 'express';
import { UserModel } from '../models/user.model';
import { WalletModel } from '../models/wallet.model';
export const WalletController = {
async getWallets(req: Request, res: Response) {
try {
const user = await UserModel.findByBitokUserId(req.user!.bitokUserId);
if (!user) {
res.status(404).json({ success: false, error: 'User not found' });
return;
}
const wallets = await WalletModel.findByUserId(user.id);
const wallets = await WalletModel.findByUserId(req.auth!.userId);
res.json({
success: true,
data: wallets.map((w) => ({

View File

@@ -12,7 +12,7 @@ const config: Knex.Config = {
port: parseInt(process.env.DB_PORT || '5432'),
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'postgres',
database: process.env.DB_NAME || 'cryptowallet',
database: process.env.DB_NAME || 'cryptowallet_v2',
},
migrations: {
directory: path.resolve(__dirname, 'migrations'),

View File

@@ -3,12 +3,21 @@ import type { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('users', (t) => {
t.string('id', 26).primary();
t.string('username', 64).notNullable().unique();
t.text('password_hash').notNullable();
t.text('pin_hash').notNullable();
t.text('encrypted_vault').notNullable();
t.string('vault_salt', 128).notNullable();
t.boolean('mnemonic_shown').notNullable().defaultTo(false);
t.string('email', 255).notNullable().unique();
t.string('password_hash', 255).notNullable();
t.string('last_name', 128).nullable();
t.string('first_name', 128).nullable();
t.string('middle_name', 128).nullable();
t.date('birth_date').nullable();
t.string('crypto_wallet', 255).nullable();
t.string('phone', 16).nullable();
t.string('bik', 9).nullable();
t.string('account_number', 20).nullable();
t.string('card_number', 19).nullable();
t.string('inn', 12).nullable();
t.boolean('kyc_verified').notNullable().defaultTo(false);
t.timestamp('kyc_verified_at', { useTz: true }).nullable();
t.boolean('is_deleted').notNullable().defaultTo(false);
t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
t.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
});

View File

@@ -3,16 +3,22 @@ import type { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('sessions', (t) => {
t.string('id', 26).primary();
t.string('sid', 26).notNullable().unique();
t.string('user_id', 26).notNullable().references('id').inTable('users').onDelete('CASCADE');
t.text('refresh_token_hash').notNullable();
t.string('user_agent').nullable();
t.specificType('ip_address', 'inet').nullable();
t.timestamp('expires_at', { useTz: true }).notNullable();
t.string('device_id', 26).nullable();
t.string('user_agent', 500).nullable();
t.string('first_ip', 64).nullable();
t.string('last_ip', 64).nullable();
t.timestamp('last_seen_at', { useTz: true }).nullable();
t.timestamp('revoked_at', { useTz: true }).nullable();
t.string('refresh_jti_hash', 255).nullable();
t.timestamp('refresh_expires_at', { useTz: true }).nullable();
t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
t.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
});
await knex.schema.raw('CREATE INDEX idx_sessions_user_id ON sessions(user_id)');
await knex.schema.raw('CREATE INDEX idx_sessions_expires ON sessions(expires_at)');
await knex.schema.raw('CREATE INDEX idx_sessions_sid ON sessions(sid)');
}
export async function down(knex: Knex): Promise<void> {

View File

@@ -1,18 +0,0 @@
import type { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('login_attempts', (t) => {
t.string('id', 26).primary();
t.string('username', 64).notNullable();
t.specificType('ip_address', 'inet').notNullable();
t.boolean('success').notNullable().defaultTo(false);
t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
});
await knex.schema.raw('CREATE INDEX idx_login_attempts_username_created ON login_attempts(username, created_at)');
await knex.schema.raw('CREATE INDEX idx_login_attempts_ip_created ON login_attempts(ip_address, created_at)');
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists('login_attempts');
}

View File

@@ -1,33 +0,0 @@
import type { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable('users', (t) => {
t.dropColumn('username');
t.dropColumn('password_hash');
t.dropColumn('pin_hash');
t.string('bitok_user_id', 26).notNullable().unique();
t.string('email', 255).nullable();
t.boolean('kyc_verified').notNullable().defaultTo(false);
t.string('kyc_level', 20).nullable();
t.boolean('deleted').notNullable().defaultTo(false);
t.index(['bitok_user_id'], 'idx_users_bitok_user_id');
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable('users', (t) => {
t.dropIndex(['bitok_user_id'], 'idx_users_bitok_user_id');
t.dropColumn('bitok_user_id');
t.dropColumn('email');
t.dropColumn('kyc_verified');
t.dropColumn('kyc_level');
t.dropColumn('deleted');
t.string('username', 64).notNullable().unique();
t.text('password_hash').notNullable();
t.text('pin_hash').notNullable();
});
}

View File

@@ -1,35 +0,0 @@
import type { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists('sessions');
await knex.schema.dropTableIfExists('login_attempts');
await knex.schema.createTable('processed_events', (t) => {
t.string('event_id', 26).primary();
t.string('event_type', 64).notNullable();
t.string('payload_hash', 64).notNullable();
t.timestamp('processed_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists('processed_events');
await knex.schema.createTable('sessions', (t) => {
t.string('id', 26).primary();
t.string('user_id', 26).notNullable().references('id').inTable('users').onDelete('CASCADE');
t.text('refresh_token_hash').notNullable();
t.string('user_agent').nullable();
t.specificType('ip_address', 'inet').nullable();
t.timestamp('expires_at', { useTz: true }).notNullable();
t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
});
await knex.schema.createTable('login_attempts', (t) => {
t.string('id', 26).primary();
t.string('username', 64).notNullable();
t.specificType('ip_address', 'inet').notNullable();
t.boolean('success').notNullable().defaultTo(false);
t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
});
}

View File

@@ -1,49 +0,0 @@
import dotenv from 'dotenv';
import path from 'path';
import knex from 'knex';
// Load .env from repo root (works when running from apps/api)
dotenv.config({ path: path.resolve(__dirname, '../../../../.env') });
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
const dbName = process.env.DB_NAME || 'cryptowallet_devphase3';
if (!/^[a-zA-Z0-9_]+$/.test(dbName)) {
console.error('[DB Reset] Invalid DB_NAME');
process.exit(1);
}
const baseConnection = {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432'),
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'postgres',
};
async function reset() {
const admin = knex({
client: 'pg',
connection: { ...baseConnection, database: 'postgres' },
});
try {
await admin.raw(
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = ? AND pid <> pg_backend_pid()`,
[dbName]
);
} catch {
// Ignore if no connections
}
const safeName = dbName.replace(/"/g, '""');
await admin.raw(`DROP DATABASE IF EXISTS "${safeName}"`);
await admin.raw(`CREATE DATABASE "${safeName}"`);
await admin.destroy();
console.log('[DB Reset] Database dropped and recreated:', dbName);
}
reset().catch((err: unknown) => {
console.error('[DB Reset] Failed:', err instanceof Error ? err.message : String(err));
if (err instanceof Error && err.stack) console.error(err.stack);
process.exit(1);
});

View File

@@ -1,59 +0,0 @@
import amqplib, { type Channel, type ChannelModel } from 'amqplib';
import { env } from '../config/env';
let connectionModel: ChannelModel | null = null;
let channel: Channel | null = null;
const DLX_EXCHANGE = `${env.rabbitmqExchange}.dlx`;
const DLQ_NAME = `${env.rabbitmqWalletQueue}.dlq`;
export async function createRabbitConnection(): Promise<Channel> {
connectionModel = await amqplib.connect(env.rabbitmqUrl);
connectionModel.on('error', (err) => {
console.error('[RabbitMQ] Connection error:', err.message);
});
connectionModel.on('close', () => {
console.warn('[RabbitMQ] Connection closed. Reconnecting in 5s...');
setTimeout(() => createRabbitConnection().catch(console.error), 5000);
});
channel = await connectionModel.createChannel();
await channel.prefetch(1);
// Declare main exchange
await channel.assertExchange(env.rabbitmqExchange, 'topic', { durable: true });
// Declare DLX and DLQ
await channel.assertExchange(DLX_EXCHANGE, 'topic', { durable: true });
await channel.assertQueue(DLQ_NAME, { durable: true });
await channel.bindQueue(DLQ_NAME, DLX_EXCHANGE, '#');
// Declare main queue with DLX
await channel.assertQueue(env.rabbitmqWalletQueue, {
durable: true,
arguments: {
'x-dead-letter-exchange': DLX_EXCHANGE,
},
});
// Bind routing keys
await channel.bindQueue(env.rabbitmqWalletQueue, env.rabbitmqExchange, 'user.kyc_verified');
await channel.bindQueue(env.rabbitmqWalletQueue, env.rabbitmqExchange, 'user.deleted');
console.log('[RabbitMQ] Connected and queues declared');
return channel;
}
export async function closeRabbitConnection(): Promise<void> {
try {
if (channel) await channel.close();
if (connectionModel) await connectionModel.close();
} catch {
// ignore close errors
}
channel = null;
connectionModel = null;
}

View File

@@ -1,121 +0,0 @@
import type { Channel, ConsumeMessage } from 'amqplib';
import crypto from 'crypto';
import { db } from '../config/database';
import { env } from '../config/env';
import { handleKycVerified } from './handlers/kyc-verified.handler';
import { handleUserDeleted } from './handlers/deleted.handler';
const MAX_RETRIES = 3;
interface BitokEvent {
event_id: string;
event_type: string;
payload: Record<string, unknown>;
occurred_at: string;
schema_version: number;
}
function isValidEvent(msg: unknown): msg is BitokEvent {
if (!msg || typeof msg !== 'object') return false;
const e = msg as Record<string, unknown>;
return (
typeof e.event_id === 'string' &&
typeof e.event_type === 'string' &&
typeof e.payload === 'object' &&
e.payload !== null &&
typeof e.occurred_at === 'string'
);
}
function getRetryCount(msg: ConsumeMessage): number {
const xDeath = msg.properties.headers?.['x-death'] as Array<{ count: number }> | undefined;
if (!xDeath || xDeath.length === 0) return 0;
return xDeath[0].count ?? 0;
}
function hashPayload(payload: Record<string, unknown>): string {
return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
}
async function isAlreadyProcessed(eventId: string): Promise<boolean> {
const row = await db('processed_events').where({ event_id: eventId }).first();
return !!row;
}
async function markProcessed(eventId: string, eventType: string, payloadHash: string): Promise<void> {
await db('processed_events').insert({
event_id: eventId,
event_type: eventType,
payload_hash: payloadHash,
});
}
export async function startConsumer(channel: Channel): Promise<void> {
console.log('[Consumer] Listening on queue:', env.rabbitmqWalletQueue);
await channel.consume(env.rabbitmqWalletQueue, async (msg) => {
if (!msg) return;
let parsed: unknown;
try {
parsed = JSON.parse(msg.content.toString());
} catch {
console.error('[Consumer] Invalid JSON, nacking without requeue');
channel.nack(msg, false, false);
return;
}
if (!isValidEvent(parsed)) {
console.error('[Consumer] Schema validation failed, nacking without requeue');
channel.nack(msg, false, false);
return;
}
const event = parsed;
// Idempotency check
try {
if (await isAlreadyProcessed(event.event_id)) {
console.log(`[Consumer] Event ${event.event_id} already processed, acking`);
channel.ack(msg);
return;
}
} catch (err) {
console.error('[Consumer] DB error checking idempotency, nacking with requeue');
channel.nack(msg, false, true);
return;
}
// Check retry count
const retries = getRetryCount(msg);
if (retries >= MAX_RETRIES) {
console.error(`[Consumer] Event ${event.event_id} exceeded max retries (${MAX_RETRIES}), sending to DLQ`);
channel.nack(msg, false, false);
return;
}
try {
switch (event.event_type) {
case 'user.kyc_verified':
await handleKycVerified(event.payload);
break;
case 'user.deleted':
await handleUserDeleted(event.payload);
break;
default:
console.warn(`[Consumer] Unknown event type: ${event.event_type}, acking`);
channel.ack(msg);
return;
}
const payloadHash = hashPayload(event.payload);
await markProcessed(event.event_id, event.event_type, payloadHash);
channel.ack(msg);
console.log(`[Consumer] Processed event: ${event.event_id} (${event.event_type})`);
} catch (err: any) {
console.error(`[Consumer] Handler error for ${event.event_id}:`, err.message);
// DB/handler error -- requeue for retry
channel.nack(msg, false, true);
}
});
}

View File

@@ -1,17 +0,0 @@
import { UserModel } from '../../models/user.model';
interface UserDeletedPayload {
bitok_user_id: string;
reason: string;
}
export async function handleUserDeleted(payload: Record<string, unknown>): Promise<void> {
const data = payload as unknown as UserDeletedPayload;
if (!data.bitok_user_id) {
throw new Error('Invalid user.deleted payload: missing bitok_user_id');
}
await UserModel.softDelete(data.bitok_user_id);
console.log(`[UserDeleted] Soft-deleted user ${data.bitok_user_id} reason=${data.reason}`);
}

View File

@@ -1,18 +0,0 @@
import { UserModel } from '../../models/user.model';
interface KycVerifiedPayload {
bitok_user_id: string;
kyc_verified: boolean;
kyc_level: string;
}
export async function handleKycVerified(payload: Record<string, unknown>): Promise<void> {
const data = payload as unknown as KycVerifiedPayload;
if (!data.bitok_user_id || typeof data.kyc_verified !== 'boolean') {
throw new Error('Invalid kyc_verified payload');
}
await UserModel.updateKyc(data.bitok_user_id, data.kyc_verified, data.kyc_level || null);
console.log(`[KYC] Updated KYC for user ${data.bitok_user_id}: verified=${data.kyc_verified}, level=${data.kyc_level}`);
}

View File

@@ -1,23 +1,43 @@
import knex from 'knex';
import knexConfig from './db/knexfile';
import app from './app';
import { env, initEnv } from './config/env';
import { createRabbitConnection } from './events/connection';
import { startConsumer } from './events/consumer';
import { env, initEnv, getVaultToken } from './config/env';
import { loadJwtKeysFromVault } from './services/jwt.service';
import { logger } from './lib/logger';
async function main() {
logger.info(`Wallet service instance started with id ${logger.instanceId}`);
await initEnv();
// Start RabbitMQ consumer
try {
const channel = await createRabbitConnection();
await startConsumer(channel);
console.log('[API] RabbitMQ consumer started');
} catch (err: any) {
console.warn('[API] RabbitMQ not available, events will not be consumed:', err.message);
// Load JWT public keys from Vault if available
const vaultToken = getVaultToken();
if (vaultToken && env.vault.addr) {
await loadJwtKeysFromVault(
env.vault.addr,
vaultToken,
env.vault.mount,
env.vault.jwtKidPath,
env.vault.jwtKidsPrefix,
);
} else {
logger.warn('JWT keys not loaded: Vault not available');
}
const db = knex(knexConfig);
logger.info('Running migrations...');
await db.migrate.latest();
logger.info('Migrations complete');
await db.destroy();
app.listen(env.port, () => {
console.log(`[API] Server running on port ${env.port}`);
logger.info(`Server running on port ${env.port}`);
});
}
main().catch(console.error);
main().catch((err) => {
logger.error(`Failed to start: ${err.message}`);
process.exit(1);
});

View File

@@ -0,0 +1,38 @@
import { generateUlid } from '../utils/ulid';
import { getTraceId } from './trace-store';
const instanceId = generateUlid();
function getCallerInfo(): { file: string; line: number } {
const stack = new Error().stack;
if (!stack) return { file: 'unknown', line: 0 };
const lines = stack.split('\n');
// Skip: Error, logger method, actual caller
const callerLine = lines[3] || '';
const match = callerLine.match(/\((.+):(\d+):\d+\)/) || callerLine.match(/at (.+):(\d+):\d+/);
if (match) return { file: match[1], line: parseInt(match[2]) };
return { file: 'unknown', line: 0 };
}
function log(level: string, message: string): void {
const caller = getCallerInfo();
const entry = {
timestamp: new Date().toISOString(),
level,
instance_id: instanceId,
file: caller.file,
line: caller.line,
trace_id: getTraceId(),
message,
};
process.stdout.write(JSON.stringify(entry) + '\n');
}
export const logger = {
instanceId,
info: (msg: string) => log('INFO', msg),
warn: (msg: string) => log('WARN', msg),
error: (msg: string) => log('ERROR', msg),
debug: (msg: string) => log('DEBUG', msg),
};

View File

@@ -0,0 +1,7 @@
import { AsyncLocalStorage } from 'node:async_hooks';
export const traceStore = new AsyncLocalStorage<string>();
export function getTraceId(): string {
return traceStore.getStore() || 'N/A';
}

View File

@@ -0,0 +1,41 @@
import { Request, Response, NextFunction } from 'express';
import { verifyAccessToken, AuthContext } from '../services/jwt.service';
import { logger } from '../lib/logger';
declare global {
namespace Express {
interface Request {
auth?: AuthContext;
}
}
}
function extractToken(req: Request): string | null {
const cookie = req.cookies?.access_token;
if (cookie) return cookie;
const auth = req.headers.authorization;
if (auth) {
const [scheme, token] = auth.split(' ');
if (scheme?.toLowerCase() === 'bearer' && token) return token;
}
return null;
}
export async function authMiddleware(req: Request, res: Response, next: NextFunction): Promise<void> {
const token = extractToken(req);
if (!token) {
res.status(401).json({ success: false, error: 'Not authenticated' });
return;
}
try {
req.auth = await verifyAccessToken(token);
next();
} catch (err: any) {
logger.warn(`Auth failed: ${err.message}`);
res.status(err.status || 401).json({ success: false, error: err.message || 'Invalid token' });
}
}

View File

@@ -1,60 +0,0 @@
import { Request, Response, NextFunction } from 'express';
import { jwtVerify, decodeProtectedHeader } from 'jose';
import { getSigningKey } from '../services/jwks.service';
import { env } from '../config/env';
declare global {
namespace Express {
interface Request {
user?: { bitokUserId: string; email?: string };
}
}
}
export async function bitokAuth(req: Request, res: Response, next: NextFunction): Promise<void> {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
res.status(401).json({ success: false, error: 'No token provided' });
return;
}
try {
const token = header.slice(7);
// Decode header to get kid
const protectedHeader = decodeProtectedHeader(token);
if (protectedHeader.alg !== 'RS256') {
res.status(401).json({ success: false, error: 'Invalid token algorithm' });
return;
}
if (!protectedHeader.kid) {
res.status(401).json({ success: false, error: 'Token missing kid' });
return;
}
// Get the signing key for this kid
const key = await getSigningKey(protectedHeader.kid);
// Verify the token
const { payload } = await jwtVerify(token, key, {
issuer: env.bitokIssuer,
audience: env.bitokAudience,
algorithms: ['RS256'],
});
if (!payload.sub) {
res.status(401).json({ success: false, error: 'Token missing subject' });
return;
}
req.user = {
bitokUserId: payload.sub,
email: payload.email as string | undefined,
};
next();
} catch {
res.status(401).json({ success: false, error: 'Invalid or expired token' });
}
}

View File

@@ -1,6 +1,7 @@
import { Request, Response, NextFunction } from 'express';
import { logger } from '../lib/logger';
export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction): void {
console.error('[ERROR]', err.message);
logger.error(err.message);
res.status(500).json({ success: false, error: 'Internal server error' });
}

View File

@@ -1,25 +0,0 @@
import rateLimit from 'express-rate-limit';
export const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 20,
message: { success: false, error: 'Too many login attempts, try again later' },
standardHeaders: true,
legacyHeaders: false,
});
export const registerLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 3,
message: { success: false, error: 'Too many registration attempts, try again later' },
standardHeaders: true,
legacyHeaders: false,
});
export const seedPhraseLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 3,
message: { success: false, error: 'Too many attempts. Try again in 15 minutes.' },
standardHeaders: true,
legacyHeaders: false,
});

View File

@@ -0,0 +1,15 @@
import { Request, Response, NextFunction } from 'express';
import { generateUlid } from '../utils/ulid';
import { traceStore } from '../lib/trace-store';
export function traceMiddleware(req: Request, res: Response, next: NextFunction): void {
const traceId = req.headers['x-trace-id'] as string
|| req.headers['x-request-id'] as string
|| generateUlid();
res.setHeader('X-Trace-ID', traceId);
traceStore.run(traceId, () => {
next();
});
}

View File

@@ -0,0 +1,66 @@
import { db } from '../config/database';
import { generateUlid } from '../utils/ulid';
export interface SessionRow {
id: string;
sid: string;
user_id: string;
device_id: string | null;
user_agent: string | null;
first_ip: string | null;
last_ip: string | null;
last_seen_at: Date | null;
revoked_at: Date | null;
refresh_jti_hash: string | null;
refresh_expires_at: Date | null;
created_at: Date;
updated_at: Date;
}
export const SessionModel = {
async findBySid(sid: string): Promise<SessionRow | undefined> {
return db('sessions').where({ sid }).whereNull('revoked_at').first();
},
async findByUserId(userId: string): Promise<SessionRow[]> {
return db('sessions').where({ user_id: userId }).whereNull('revoked_at');
},
async create(data: {
sid: string;
user_id: string;
device_id?: string;
user_agent?: string;
first_ip?: string;
refresh_jti_hash?: string;
refresh_expires_at?: Date;
}): Promise<SessionRow> {
const [session] = await db('sessions')
.insert({
id: generateUlid(),
...data,
last_ip: data.first_ip || null,
})
.returning('*');
return session;
},
async revoke(sid: string): Promise<void> {
await db('sessions')
.where({ sid })
.update({ revoked_at: db.fn.now(), updated_at: db.fn.now() });
},
async revokeAllForUser(userId: string): Promise<void> {
await db('sessions')
.where({ user_id: userId })
.whereNull('revoked_at')
.update({ revoked_at: db.fn.now(), updated_at: db.fn.now() });
},
async updateLastSeen(sid: string, ip: string): Promise<void> {
await db('sessions')
.where({ sid })
.update({ last_seen_at: db.fn.now(), last_ip: ip, updated_at: db.fn.now() });
},
};

View File

@@ -3,71 +3,47 @@ import { generateUlid } from '../utils/ulid';
export interface UserRow {
id: string;
bitok_user_id: string;
email: string | null;
encrypted_vault: string;
vault_salt: string;
mnemonic_shown: boolean;
email: string;
password_hash: string;
last_name: string | null;
first_name: string | null;
middle_name: string | null;
birth_date: string | null;
crypto_wallet: string | null;
phone: string | null;
bik: string | null;
account_number: string | null;
card_number: string | null;
inn: string | null;
kyc_verified: boolean;
kyc_level: string | null;
deleted: boolean;
kyc_verified_at: Date | null;
is_deleted: boolean;
created_at: Date;
updated_at: Date;
}
export const UserModel = {
async findByEmail(email: string): Promise<UserRow | undefined> {
return db('users').where({ email, is_deleted: false }).first();
},
async findById(id: string): Promise<UserRow | undefined> {
return db('users').where({ id }).first();
return db('users').where({ id, is_deleted: false }).first();
},
async findByBitokUserId(bitokUserId: string): Promise<UserRow | undefined> {
return db('users').where({ bitok_user_id: bitokUserId }).first();
},
async createFromBitok(data: {
bitokUserId: string;
email?: string | null;
encryptedVault: string;
vaultSalt: string;
async create(data: {
email: string;
password_hash: string;
}): Promise<UserRow> {
const [user] = await db('users')
.insert({
id: generateUlid(),
bitok_user_id: data.bitokUserId,
email: data.email || null,
encrypted_vault: data.encryptedVault,
vault_salt: data.vaultSalt,
})
.returning('*');
const [user] = await db('users').insert({ id: generateUlid(), ...data }).returning('*');
return user;
},
async setMnemonicShown(id: string): Promise<void> {
await db('users').where({ id }).update({ mnemonic_shown: true, updated_at: db.fn.now() });
},
async updateVault(id: string, encrypted_vault: string, vault_salt: string): Promise<void> {
await db('users')
async update(id: string, data: Partial<Omit<UserRow, 'id' | 'created_at'>>): Promise<UserRow | undefined> {
const [user] = await db('users')
.where({ id })
.update({ encrypted_vault, vault_salt, updated_at: db.fn.now() });
},
async updateKyc(bitokUserId: string, kycVerified: boolean, kycLevel: string | null): Promise<void> {
await db('users')
.where({ bitok_user_id: bitokUserId })
.update({
kyc_verified: kycVerified,
kyc_level: kycLevel,
updated_at: db.fn.now(),
});
},
async softDelete(bitokUserId: string): Promise<void> {
await db('users')
.where({ bitok_user_id: bitokUserId })
.update({
deleted: true,
updated_at: db.fn.now(),
});
.update({ ...data, updated_at: db.fn.now() })
.returning('*');
return user;
},
};

View File

@@ -1,9 +0,0 @@
import { Router } from 'express';
import { VaultController } from '../controllers/vault.controller';
import { bitokAuth } from '../middleware/bitok-auth';
const router = Router();
router.get('/', bitokAuth, VaultController.getVault);
export default router;

View File

@@ -1,25 +0,0 @@
import { Router } from 'express';
import { z } from 'zod';
import { WalletSetupController } from '../controllers/wallet-setup.controller';
import { validate } from '../middleware/validate';
import { bitokAuth } from '../middleware/bitok-auth';
const setupSchema = z.object({
encryptedVault: z.string().min(1),
vaultSalt: z.string().min(1),
wallets: z.array(
z.object({
chain: z.enum(['ETH', 'BTC', 'SOL', 'TRX', 'BSC']),
address: z.string().min(1),
derivationPath: z.string().min(1),
})
).min(4).max(5),
});
const router = Router();
router.post('/setup', bitokAuth, validate(setupSchema), WalletSetupController.setup);
router.get('/unlock', bitokAuth, WalletSetupController.unlock);
router.post('/confirm-mnemonic', bitokAuth, WalletSetupController.confirmMnemonic);
export default router;

View File

@@ -1,9 +1,8 @@
import { Router } from 'express';
import { WalletController } from '../controllers/wallet.controller';
import { bitokAuth } from '../middleware/bitok-auth';
const router = Router();
router.get('/', bitokAuth, WalletController.getWallets);
router.get('/', WalletController.getWallets);
export default router;

View File

@@ -1,46 +0,0 @@
import { importJWK, type JWK, type CryptoKey } from 'jose';
import { env } from '../config/env';
interface CachedKey {
key: CryptoKey | Uint8Array;
fetchedAt: number;
}
const KEY_TTL_MS = 60 * 60 * 1000; // 1 hour
const keyCache = new Map<string, CachedKey>();
async function fetchJwks(): Promise<{ keys: JWK[] }> {
const res = await fetch(env.bitokJwksUrl);
if (!res.ok) {
throw new Error(`Failed to fetch JWKS: ${res.status} ${res.statusText}`);
}
return res.json() as Promise<{ keys: JWK[] }>;
}
async function refreshKeys(): Promise<void> {
const jwks = await fetchJwks();
for (const jwk of jwks.keys) {
if (!jwk.kid) continue;
const key = await importJWK(jwk, 'RS256');
keyCache.set(jwk.kid, { key, fetchedAt: Date.now() });
}
}
export async function getSigningKey(kid: string): Promise<CryptoKey | Uint8Array> {
const cached = keyCache.get(kid);
if (cached && Date.now() - cached.fetchedAt < KEY_TTL_MS) {
return cached.key;
}
// Unknown kid or expired -- force refresh
await refreshKeys();
const refreshed = keyCache.get(kid);
if (!refreshed) {
throw new Error(`No key found for kid: ${kid}`);
}
return refreshed.key;
}

View File

@@ -0,0 +1,127 @@
import * as jose from 'jose';
import { env } from '../config/env';
import { logger } from '../lib/logger';
export interface AccessTokenPayload {
sub: string;
type: string;
sid: string;
iat: number;
nbf: number;
exp: number;
iss?: string;
aud?: string;
}
export interface AuthContext {
userId: string;
sid: string;
token: AccessTokenPayload;
}
const keyMap = new Map<string, Awaited<ReturnType<typeof jose.importSPKI>>>();
export async function loadJwtKeysFromVault(
vaultAddr: string,
vaultToken: string,
mount: string,
kidPath: string,
kidsPrefix: string,
): Promise<void> {
const { fetchVaultKV2 } = await import('../config/vault');
const kidData = await fetchVaultKV2(vaultAddr, vaultToken, mount, kidPath);
if (!kidData) {
logger.warn('Failed to read JWT kid config from Vault');
return;
}
const kids: string[] = [];
if (kidData.active) kids.push(kidData.active);
if (kidData.previous && kidData.previous !== kidData.active) kids.push(kidData.previous);
if (kids.length === 0) {
logger.warn('No active/previous kids found in Vault');
return;
}
for (const kid of kids) {
const kidSecret = await fetchVaultKV2(vaultAddr, vaultToken, mount, `${kidsPrefix}/${kid}`);
if (!kidSecret?.public_key) {
logger.warn(`No public_key found for kid=${kid}`);
continue;
}
try {
const key = await jose.importSPKI(kidSecret.public_key, env.jwt.algorithm);
keyMap.set(kid, key);
logger.info(`Loaded JWT public key for kid=${kid}`);
} catch (err: any) {
logger.error(`Failed to import public key for kid=${kid}: ${err.message}`);
}
}
logger.info(`JWT key store loaded: ${keyMap.size} key(s)`);
}
export async function verifyAccessToken(token: string): Promise<AuthContext> {
let payload: jose.JWTPayload;
try {
const header = jose.decodeProtectedHeader(token);
const kid = header.kid;
if (!kid) {
throw Object.assign(new Error('Missing kid in token header'), { status: 401 });
}
const key = keyMap.get(kid);
if (!key) {
logger.warn(`Unknown kid=${kid}`);
throw Object.assign(new Error('Unknown token kid'), { status: 401 });
}
if (header.alg !== env.jwt.algorithm) {
throw Object.assign(new Error('Invalid token algorithm'), { status: 401 });
}
const verifyOptions: jose.JWTVerifyOptions = {
algorithms: [env.jwt.algorithm],
clockTolerance: 10,
};
if (env.jwt.issuer) verifyOptions.issuer = env.jwt.issuer;
if (env.jwt.audience) verifyOptions.audience = env.jwt.audience;
const result = await jose.jwtVerify(token, key, verifyOptions);
payload = result.payload;
} catch (err: any) {
if (err.status === 401) throw err;
if (err.code === 'ERR_JWT_EXPIRED') {
throw Object.assign(new Error('Token expired'), { status: 401 });
}
throw Object.assign(new Error('Invalid token'), { status: 401 });
}
if (payload.type !== 'access') {
throw Object.assign(new Error('Invalid token type'), { status: 401 });
}
if (!payload.sub || !payload.sid) {
throw Object.assign(new Error('Missing token claims'), { status: 401 });
}
return {
userId: payload.sub,
sid: payload.sid as string,
token: {
sub: payload.sub,
type: payload.type as string,
sid: payload.sid as string,
iat: payload.iat!,
nbf: payload.nbf!,
exp: payload.exp!,
iss: payload.iss,
aud: typeof payload.aud === 'string' ? payload.aud : undefined,
},
};
}

101
apps/api/swagger.json Normal file
View File

@@ -0,0 +1,101 @@
{
"openapi": "3.0.0",
"info": {
"title": "CryptoWallet API",
"version": "2.0.0",
"description": "Multi-chain cryptocurrency wallet API with blockchain proxy services"
},
"servers": [
{ "url": "/api", "description": "API" }
],
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
},
"schemas": {
"Error": {
"type": "object",
"properties": {
"success": { "type": "boolean", "example": false },
"error": { "type": "string" }
}
},
"Wallet": {
"type": "object",
"properties": {
"chain": { "type": "string", "enum": ["ETH", "BTC", "SOL", "TRX", "BSC"] },
"address": { "type": "string" },
"derivationPath": { "type": "string" }
}
},
"HealthResponse": {
"type": "object",
"properties": {
"success": { "type": "boolean", "example": true },
"data": {
"type": "object",
"properties": {
"status": { "type": "string", "example": "ok" }
}
}
}
}
}
},
"paths": {
"/health": {
"get": {
"summary": "Health check",
"tags": ["System"],
"responses": {
"200": {
"description": "Service is healthy",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HealthResponse" }
}
}
}
}
}
},
"/wallets": {
"get": {
"summary": "Get user wallets",
"tags": ["Wallets"],
"security": [{ "bearerAuth": [] }],
"responses": {
"200": {
"description": "List of wallets",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": { "type": "boolean", "example": true },
"data": {
"type": "array",
"items": { "$ref": "#/components/schemas/Wallet" }
}
}
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Error" }
}
}
}
}
}
}
}
}

41
apps/web/.gitignore vendored
View File

@@ -1,41 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -1,34 +0,0 @@
# Build stage
FROM node:20-alpine AS builder
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
WORKDIR /app
# Copy everything (filtered by .dockerignore)
COPY . .
# Install deps with hoisting
RUN echo "node-linker=hoisted" > .npmrc
RUN pnpm install --frozen-lockfile
# Build web
ENV NEXT_PUBLIC_API_URL=http://localhost:3001
RUN cd apps/web && ../../node_modules/.bin/next build
# Runtime stage
FROM node:20-alpine
WORKDIR /app
# Copy standalone output
COPY --from=builder /app/apps/web/.next/standalone ./
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=builder /app/apps/web/public ./apps/web/public
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
CMD ["node", "apps/web/server.js"]

View File

@@ -1,36 +0,0 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -1,28 +0,0 @@
import path from 'path';
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'standalone',
turbopack: {
// Keep Turbopack pinned to the monorepo root so dev HMR
// does not mis-detect `apps/web` as a standalone project.
root: path.resolve(__dirname, '../..'),
},
webpack(config) {
// Enable WebAssembly for tiny-secp256k1 (used by ecpair/bitcoinjs-lib)
config.experiments = {
...config.experiments,
asyncWebAssembly: true,
};
// Prevent webpack from changing the output of WASM imports
config.module.rules.push({
test: /\.wasm$/,
type: 'webassembly/async',
});
return config;
},
};
export default nextConfig;

View File

@@ -1,38 +0,0 @@
{
"name": "web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --webpack",
"build": "next build",
"start": "next start"
},
"dependencies": {
"@scure/bip32": "^2.0.1",
"@scure/bip39": "^2.0.1",
"@solana/web3.js": "^1.98.4",
"@uniswap/router-sdk": "^2.7.1",
"@uniswap/sdk-core": "^7.12.1",
"@uniswap/universal-router-sdk": "^4.34.0",
"@uniswap/v3-sdk": "^3.29.1",
"@uniswap/v4-sdk": "^1.29.1",
"@yudiel/react-qr-scanner": "^2.5.1",
"bip32": "^5.0.1",
"bitcoinjs-lib": "^7.0.1",
"ecpair": "^3.0.1",
"ed25519-hd-key": "^1.3.0",
"ethers": "5.7.2",
"next": "16.1.6",
"qrcode.react": "^4.2.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"tiny-secp256k1": "^2.2.4",
"zustand": "^5.0.11"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"typescript": "^5"
}
}

1770
apps/web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +0,0 @@
ignoredBuiltDependencies:
- sharp
- unrs-resolver

View File

@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

View File

@@ -1,330 +0,0 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import {
BRIDGE_CHAINS,
BRIDGE_CHAIN_OPTIONS,
getDestinationChainOptions,
getTokenOptions,
getDefaultToken,
type BridgeChainKey,
} from '@/lib/bridge/constants';
import { useBridge } from '@/hooks/useBridge';
import { useGasPrice } from '@/hooks/useGasPrice';
import { useGasSettings, type GasMode } from '@/hooks/useGasSettings';
import { useAuthStore } from '@/store/auth-store';
const GAS_MODE_LABELS: Record<GasMode, string> = {
slow: 'Slow',
normal: 'Normal',
fast: 'Fast',
custom: 'Custom',
};
const GAS_MODES: GasMode[] = ['slow', 'normal', 'fast', 'custom'];
export default function BridgePage() {
const router = useRouter();
const user = useAuthStore((state) => state.user);
const { data: gasPriceData, loading: gasLoading } = useGasPrice();
const gas = useGasSettings(gasPriceData);
const {
status, quote, bridgeStatus, requestId, txHashes, error,
sourceChain, setSourceChain, sourceWallet,
fetchQuote, submitBridge, resetBridge,
} = useBridge();
const [sourceToken, setSourceToken] = useState(() => getDefaultToken('ETH'));
const [destChain, setDestChain] = useState<BridgeChainKey>('SOL');
const [destToken, setDestToken] = useState(() => getDefaultToken('SOL'));
const [amount, setAmount] = useState('');
const [confirmed, setConfirmed] = useState(false);
useEffect(() => {
if (!user) router.push('/login');
}, [router, user]);
const destChainOptions = useMemo(() => getDestinationChainOptions(sourceChain), [sourceChain]);
const sourceTokenOptions = useMemo(() => getTokenOptions(sourceChain), [sourceChain]);
const destTokenOptions = useMemo(() => getTokenOptions(destChain), [destChain]);
const handleSourceChainChange = (newChain: BridgeChainKey) => {
setSourceChain(newChain);
setSourceToken(getDefaultToken(newChain));
// If dest chain is same as new source, switch dest
const newDestOptions = getDestinationChainOptions(newChain);
if (!newDestOptions.includes(destChain)) {
setDestChain(newDestOptions[0]);
setDestToken(getDefaultToken(newDestOptions[0]));
}
handleReset();
};
const handleDestChainChange = (newChain: BridgeChainKey) => {
setDestChain(newChain);
setDestToken(getDefaultToken(newChain));
handleReset();
};
if (!user) return null;
const canQuote =
Number(amount) > 0 &&
status !== 'quoting' &&
status !== 'executing' &&
status !== 'monitoring';
const canBridge = !!quote && confirmed && status !== 'executing' && status !== 'monitoring';
const isEvmSource = sourceChain === 'ETH' || sourceChain === 'BSC';
const showGasControls = sourceChain === 'ETH'; // BSC uses fixed gas price
const handleQuote = async () => {
setConfirmed(false);
await fetchQuote({ sourceChain, sourceToken, destChain, destToken, amount });
};
const handleBridge = async () => {
await submitBridge(
{ sourceChain, sourceToken, destChain, destToken, amount },
isEvmSource ? gas.effectiveMaxFee : null,
isEvmSource ? gas.effectivePriorityFee : null,
);
};
const handleReset = () => {
setConfirmed(false);
resetBridge();
};
const tierGwei = (mode: GasMode): string => {
if (mode === 'custom') return '';
if (!gasPriceData) return '...';
const v = gasPriceData[mode].maxFeePerGas;
if (v >= 1) return v.toFixed(2);
const s = v.toFixed(4);
return s.replace(/0+$/, '').replace(/\.$/, '');
};
const sourceExplorerBase = BRIDGE_CHAINS[sourceChain].explorerTxBaseUrl;
const destExplorerBase = BRIDGE_CHAINS[destChain].explorerTxBaseUrl;
return (
<div style={{ maxWidth: 720, margin: '50px auto', padding: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<h1>Bridge</h1>
<Link href="/dashboard" style={navButtonStyle}>
Back to Dashboard
</Link>
</div>
<div style={{ border: '1px solid #ccc', padding: 16 }}>
{/* Source Chain */}
<div style={fieldGroupStyle}>
<label>Source Chain</label>
<select
value={sourceChain}
onChange={(e) => handleSourceChainChange(e.target.value as BridgeChainKey)}
style={inputStyle}
>
{BRIDGE_CHAIN_OPTIONS.map((key) => (
<option key={key} value={key}>{BRIDGE_CHAINS[key].label}</option>
))}
</select>
</div>
{/* Source Token */}
<div style={fieldGroupStyle}>
<label>Source Token</label>
<select
value={sourceToken}
onChange={(e) => { setSourceToken(e.target.value); handleReset(); }}
style={inputStyle}
>
{sourceTokenOptions.map((sym) => (
<option key={sym} value={sym}>{sym}</option>
))}
</select>
</div>
{/* Destination Chain */}
<div style={fieldGroupStyle}>
<label>Destination Chain</label>
<select
value={destChain}
onChange={(e) => handleDestChainChange(e.target.value as BridgeChainKey)}
style={inputStyle}
>
{destChainOptions.map((key) => (
<option key={key} value={key}>{BRIDGE_CHAINS[key].label}</option>
))}
</select>
</div>
{/* Destination Token */}
<div style={fieldGroupStyle}>
<label>Destination Token</label>
<select
value={destToken}
onChange={(e) => { setDestToken(e.target.value); handleReset(); }}
style={inputStyle}
>
{destTokenOptions.map((sym) => (
<option key={sym} value={sym}>{sym}</option>
))}
</select>
</div>
{/* Amount */}
<div style={fieldGroupStyle}>
<label>Amount</label>
<input
value={amount}
onChange={(e) => { setAmount(e.target.value); handleReset(); }}
type="number"
min="0"
step="any"
style={inputStyle}
/>
</div>
{/* Gas Speed — only for ETH source */}
{showGasControls ? (
<div style={fieldGroupStyle}>
<label>Gas Speed {gasLoading ? '(loading...)' : ''}</label>
<div style={{ display: 'flex', gap: 6 }}>
{GAS_MODES.map((mode) => (
<button
key={mode}
onClick={() => gas.setGasMode(mode)}
style={{
flex: 1,
padding: '6px 4px',
border: gas.gasMode === mode ? '2px solid #333' : '1px solid #ccc',
borderRadius: 4,
background: gas.gasMode === mode ? '#f0f0f0' : '#fff',
cursor: 'pointer',
fontSize: 13,
}}
>
<div>{GAS_MODE_LABELS[mode]}</div>
{mode !== 'custom' && (
<div style={{ fontSize: 11, color: '#666', marginTop: 2 }}>
{tierGwei(mode)} gwei
</div>
)}
</button>
))}
</div>
{gas.gasMode === 'custom' && (
<input
value={gas.customGwei}
onChange={(e) => gas.setCustomGwei(e.target.value)}
type="number"
min="0"
step="0.01"
placeholder="Enter gwei"
style={{ ...inputStyle, marginTop: 6 }}
/>
)}
<p style={{ marginTop: 4, fontSize: 13, color: '#666' }}>
Effective: {gas.displayGwei}
</p>
</div>
) : sourceChain === 'BSC' ? (
<div style={fieldGroupStyle}>
<label>Fee</label>
<p style={{ fontSize: 13, color: '#666' }}>Fixed: <strong>0.055 gwei</strong> (BSC)</p>
</div>
) : (
<div style={fieldGroupStyle}>
<label>Fee</label>
<p style={{ fontSize: 13, color: '#666' }}>Auto (managed by Relay)</p>
</div>
)}
<button onClick={() => void handleQuote()} disabled={!canQuote} style={{ padding: '8px 16px' }}>
{status === 'quoting' ? 'Getting Quote...' : 'Get Quote'}
</button>
</div>
{/* Quote Review */}
{quote && (
<div style={{ border: '1px solid #ccc', padding: 16, marginTop: 20 }}>
<h2 style={{ marginBottom: 12 }}>Review</h2>
<p>Expected output: <strong>{quote.outputAmountFormatted} {quote.outputSymbol}</strong></p>
<p>Minimum output: <strong>{quote.minimumAmountFormatted}</strong></p>
<p>Estimated fee: <strong>{quote.feeSummary}</strong></p>
<p>Estimated time: <strong>{quote.timeEstimateSeconds ? `${quote.timeEstimateSeconds}s` : 'Unavailable'}</strong></p>
{showGasControls && (
<p>Gas: <strong>{gas.displayGwei} ({GAS_MODE_LABELS[gas.gasMode]})</strong></p>
)}
{sourceChain === 'BSC' && (
<p>Gas: <strong>0.055 gwei</strong> (BSC fixed)</p>
)}
<label style={{ display: 'flex', gap: 8, marginTop: 16, alignItems: 'flex-start' }}>
<input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} />
<span>I confirm the bridge amount, fee and destination shown above.</span>
</label>
<button onClick={() => void handleBridge()} disabled={!canBridge} style={{ padding: '8px 16px', marginTop: 16 }}>
{status === 'executing' ? 'Executing...' : status === 'monitoring' ? 'Monitoring...' : 'Bridge'}
</button>
</div>
)}
{/* Status */}
{(requestId || txHashes.length > 0 || bridgeStatus) && (
<div style={{ border: '1px solid #ccc', padding: 16, marginTop: 20 }}>
<h2 style={{ marginBottom: 12 }}>Status</h2>
{requestId && <p>Request ID: <strong>{requestId}</strong></p>}
{bridgeStatus && <p>Relay status: <strong>{bridgeStatus.status}</strong></p>}
{txHashes.map((hash) => (
<p key={hash}>
Origin tx:{' '}
<a href={`${sourceExplorerBase}${hash}`} target="_blank" rel="noreferrer">
{hash}
</a>
</p>
))}
{(bridgeStatus?.txHashes ?? []).map((hash) => (
<p key={hash}>
Destination tx:{' '}
<a href={`${destExplorerBase}${hash}`} target="_blank" rel="noreferrer">
{hash}
</a>
</p>
))}
</div>
)}
{(error || status === 'error') && (
<p style={{ color: 'red', marginTop: 16 }}>
{error ?? 'Bridge failed'}
</p>
)}
</div>
);
}
const fieldGroupStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 6,
marginBottom: 12,
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: 8,
};
const navButtonStyle: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '6px 12px',
border: '1px solid #ccc',
borderRadius: 4,
};

View File

@@ -1,166 +0,0 @@
'use client';
import { useEffect } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useBalances } from '@/hooks/useBalances';
import type { ChainBalance } from '@/lib/balances/types';
import { useAuthStore } from '@/store/auth-store';
export default function DashboardPage() {
const router = useRouter();
const { user, wallets, logout } = useAuthStore();
const { portfolio, loading, refreshing, error, refresh } = useBalances();
useEffect(() => {
if (!user) {
router.push('/login');
}
}, [user, router]);
if (!user) return null;
return (
<div style={{ maxWidth: 820, margin: '50px auto', padding: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16 }}>
<h1>Dashboard</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<span>{user.email}</span>
<Link href="/send" style={navButtonStyle}>
Send
</Link>
<Link href="/receive" style={navButtonStyle}>
Receive
</Link>
<Link href="/swap" style={navButtonStyle}>
Swap
</Link>
<Link href="/bridge" style={navButtonStyle}>
Bridge
</Link>
<Link href="/settings" style={navButtonStyle}>
Settings
</Link>
<button onClick={() => void refresh()} style={navButtonStyle} disabled={loading || refreshing}>
{refreshing ? 'Refreshing...' : 'Refresh'}
</button>
<button onClick={logout} style={{ padding: '6px 12px' }}>
Logout
</button>
</div>
</div>
<div style={{ border: '1px solid #ccc', padding: 16, marginTop: 20, marginBottom: 20 }}>
<p style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: 1, marginBottom: 8 }}>
Total Portfolio USD
</p>
<h2 style={{ marginBottom: 8 }}>{formatUsd(portfolio?.totalUsd ?? null)}</h2>
<p style={{ color: '#666', fontSize: 14 }}>
{portfolio?.updatedAt
? `Updated ${new Date(portfolio.updatedAt).toLocaleTimeString()}`
: loading
? 'Loading balances...'
: 'Balances will appear after the first refresh.'}
</p>
</div>
{error && (
<p style={{ color: 'red', marginBottom: 12 }}>
{error}
</p>
)}
{portfolio?.priceError && (
<p style={{ color: '#b45309', marginBottom: 12 }}>
USD pricing is partially unavailable: {portfolio.priceError}
</p>
)}
<h2>Your Wallets</h2>
{wallets.map((w) => {
const chainBalance = getChainBalance(w.chain, portfolio?.chains);
return (
<div key={w.chain} style={{ border: '1px solid #ccc', padding: 16, marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, alignItems: 'center' }}>
<h3>{w.chain}</h3>
<span style={{ fontWeight: 600 }}>{formatUsd(chainBalance?.totalUsd ?? null)}</span>
</div>
<p style={{ wordBreak: 'break-all' }}>
<strong>Address:</strong> {w.address}
</p>
{chainBalance?.error && chainBalance.error !== '__transient__' && (
<p style={{ color: 'red', marginTop: 8 }}>
{chainBalance.error}
</p>
)}
<div style={{ marginTop: 12 }}>
{chainBalance?.tokens.length ? (
chainBalance.tokens.map((token) => (
<div
key={`${w.chain}-${token.symbol}`}
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr 1fr',
gap: 12,
padding: '8px 0',
borderTop: '1px solid #eee',
}}
>
<span>{token.symbol}</span>
<span>{formatTokenAmount(token.balanceFormatted)}</span>
<span style={{ textAlign: 'right' }}>{formatUsd(token.valueUsd)}</span>
</div>
))
) : (
<p style={{ color: '#666', marginTop: 8 }}>
{loading ? 'Loading balances...' : 'No balances loaded yet.'}
</p>
)}
</div>
</div>
);
})}
</div>
);
}
function getChainBalance(chain: string, chains?: ChainBalance[]): ChainBalance | undefined {
return chains?.find((item) => item.chain === chain);
}
function formatUsd(value: number | null): string {
if (typeof value !== 'number') {
return 'Unavailable';
}
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 2,
}).format(value);
}
function formatTokenAmount(value: string): string {
const numericValue = Number(value);
if (!Number.isFinite(numericValue)) {
return value;
}
return new Intl.NumberFormat('en-US', {
minimumFractionDigits: 0,
maximumFractionDigits: 6,
}).format(numericValue);
}
const navButtonStyle: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '6px 12px',
border: '1px solid #ccc',
borderRadius: 4,
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -1,42 +0,0 @@
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
a {
color: inherit;
text-decoration: none;
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
}

View File

@@ -1,17 +0,0 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Crypto Wallet",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}

View File

@@ -1,125 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/store/auth-store';
type Step = 'email' | 'code';
export default function LoginPage() {
const router = useRouter();
const { loginStart, loginComplete, loading, error, clearError } = useAuthStore();
const [step, setStep] = useState<Step>('email');
const [email, setEmail] = useState('');
const [code, setCode] = useState('');
const [password, setPassword] = useState('');
const handleEmailSubmit = async (e: React.FormEvent) => {
e.preventDefault();
clearError();
await loginStart(email);
const state = useAuthStore.getState();
if (!state.error) {
setStep('code');
}
};
const handleCodeSubmit = async (e: React.FormEvent) => {
e.preventDefault();
clearError();
await loginComplete(email, password, code);
const state = useAuthStore.getState();
if (state.user) {
if (!state.mnemonicShown) {
router.push('/mnemonic');
} else {
router.push('/dashboard');
}
}
};
return (
<div style={{ maxWidth: 400, margin: '100px auto', padding: 20 }}>
<h1>Login</h1>
{step === 'email' && (
<form onSubmit={handleEmailSubmit}>
<div style={{ marginBottom: 12 }}>
<label>Email</label><br />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
style={{ width: '100%', padding: 8 }}
placeholder="you@example.com"
/>
</div>
{error && <p style={{ color: 'red' }}>{error}</p>}
<button
type="submit"
disabled={loading}
style={{ padding: '8px 24px', background: '#007bff', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
>
{loading ? 'Sending code...' : 'Send verification code'}
</button>
</form>
)}
{step === 'code' && (
<form onSubmit={handleCodeSubmit}>
<p style={{ color: '#666', fontSize: 13, marginBottom: 16 }}>
A verification code was sent to <strong>{email}</strong>.
</p>
<div style={{ marginBottom: 12 }}>
<label>Verification code</label><br />
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
required
minLength={6}
maxLength={6}
style={{ width: '100%', padding: 8, letterSpacing: 4, fontSize: 18, textAlign: 'center' }}
placeholder="000000"
inputMode="numeric"
autoFocus
/>
</div>
<div style={{ marginBottom: 12 }}>
<label>Password</label><br />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
style={{ width: '100%', padding: 8 }}
/>
</div>
{error && <p style={{ color: 'red' }}>{error}</p>}
<button
type="submit"
disabled={loading}
style={{ padding: '8px 24px', background: '#007bff', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
>
{loading ? 'Logging in...' : 'Login'}
</button>
<button
type="button"
onClick={() => { setStep('email'); setCode(''); setPassword(''); clearError(); }}
style={{ marginLeft: 8, padding: '8px 16px', background: '#fff', border: '1px solid #ccc', borderRadius: 4, cursor: 'pointer' }}
>
Back
</button>
</form>
)}
<p style={{ marginTop: 16 }}>
No account? <a href="/register">Register</a>
</p>
</div>
);
}

View File

@@ -1,148 +0,0 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/store/auth-store';
export default function MnemonicPage() {
const router = useRouter();
const { mnemonic, wallets, confirmMnemonic } = useAuthStore();
const [step, setStep] = useState<'show' | 'verify' | 'keys'>('show');
const [answers, setAnswers] = useState<Record<number, string>>({});
const [verifyError, setVerifyError] = useState('');
const words = useMemo(() => mnemonic?.split(' ') || [], [mnemonic]);
useEffect(() => {
if (!mnemonic) {
router.push('/dashboard');
}
}, [mnemonic, router]);
const quizIndices = useMemo(() => {
if (words.length < 3) return [];
const indices: number[] = [];
while (indices.length < 3) {
const idx = Math.floor(Math.random() * words.length);
if (!indices.includes(idx)) indices.push(idx);
}
return indices.sort((a, b) => a - b);
}, [words.length]);
if (!mnemonic) return null;
const handleVerify = () => {
setVerifyError('');
for (const idx of quizIndices) {
if (answers[idx]?.trim().toLowerCase() !== words[idx]) {
setVerifyError(`Wrong word for position #${idx + 1}. Try again.`);
return;
}
}
setStep('keys');
};
const handleConfirm = async () => {
await confirmMnemonic();
router.push('/dashboard');
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setTimeout(() => {
try {
void navigator.clipboard.writeText('');
} catch {
// Document may have lost focus; ignore
}
}, 60000);
} catch {
// Fallback for older browsers or denied permission
}
};
if (step === 'show') {
return (
<div style={{ maxWidth: 600, margin: '50px auto', padding: 20 }}>
<h1>Save Your Mnemonic Phrase</h1>
<p style={{ color: 'red', fontWeight: 'bold' }}>
Write these words down and store them safely. You will NOT be able to see them again!
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8, margin: '20px 0' }}>
{words.map((word, i) => (
<div key={i} style={{ padding: 8, border: '1px solid #ccc' }}>
<strong>{i + 1}.</strong> {word}
</div>
))}
</div>
<button onClick={() => copyToClipboard(mnemonic)} style={{ padding: '8px 16px', marginRight: 8 }}>
Copy Mnemonic
</button>
<button onClick={() => setStep('verify')} style={{ padding: '8px 16px' }}>
Next: Verify
</button>
</div>
);
}
if (step === 'verify') {
return (
<div style={{ maxWidth: 400, margin: '50px auto', padding: 20 }}>
<h1>Verify Mnemonic</h1>
<p>Enter the following words from your mnemonic to confirm you saved it.</p>
{quizIndices.map((idx) => (
<div key={idx} style={{ marginBottom: 12 }}>
<label>Word #{idx + 1}</label><br />
<input
type="text"
value={answers[idx] || ''}
onChange={(e) => setAnswers({ ...answers, [idx]: e.target.value })}
style={{ width: '100%', padding: 8 }}
/>
</div>
))}
{verifyError && <p style={{ color: 'red' }}>{verifyError}</p>}
<button onClick={() => setStep('show')} style={{ padding: '8px 16px', marginRight: 8 }}>
Back
</button>
<button onClick={handleVerify} style={{ padding: '8px 16px' }}>
Verify
</button>
</div>
);
}
return (
<div style={{ maxWidth: 600, margin: '50px auto', padding: 20 }}>
<h1>Your Private Keys</h1>
<p style={{ color: 'red', fontWeight: 'bold' }}>
Save these private keys. They will NOT be shown again!
</p>
{wallets.map((w) => (
<div key={w.chain} style={{ border: '1px solid #ccc', padding: 12, marginBottom: 12 }}>
<h3>{w.chain}</h3>
<p><strong>Address:</strong> {w.address}</p>
<p style={{ wordBreak: 'break-all' }}>
<strong>Private Key:</strong> {w.privateKey}
</p>
<button onClick={() => copyToClipboard(w.privateKey)} style={{ padding: '4px 12px' }}>
Copy Key
</button>
</div>
))}
<div style={{ marginTop: 20 }}>
<label>
<input type="checkbox" id="confirm-checkbox" />
{' '}I have saved all my private keys
</label>
</div>
<button
onClick={handleConfirm}
style={{ padding: '8px 24px', marginTop: 12 }}
>
Continue to Dashboard
</button>
</div>
);
}

View File

@@ -1,141 +0,0 @@
.page {
--background: #fafafa;
--foreground: #fff;
--text-primary: #000;
--text-secondary: #666;
--button-primary-hover: #383838;
--button-secondary-hover: #f2f2f2;
--button-secondary-border: #ebebeb;
display: flex;
min-height: 100vh;
align-items: center;
justify-content: center;
font-family: var(--font-geist-sans);
background-color: var(--background);
}
.main {
display: flex;
min-height: 100vh;
width: 100%;
max-width: 800px;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
background-color: var(--foreground);
padding: 120px 60px;
}
.intro {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
gap: 24px;
}
.intro h1 {
max-width: 320px;
font-size: 40px;
font-weight: 600;
line-height: 48px;
letter-spacing: -2.4px;
text-wrap: balance;
color: var(--text-primary);
}
.intro p {
max-width: 440px;
font-size: 18px;
line-height: 32px;
text-wrap: balance;
color: var(--text-secondary);
}
.intro a {
font-weight: 500;
color: var(--text-primary);
}
.ctas {
display: flex;
flex-direction: row;
width: 100%;
max-width: 440px;
gap: 16px;
font-size: 14px;
}
.ctas a {
display: flex;
justify-content: center;
align-items: center;
height: 40px;
padding: 0 16px;
border-radius: 128px;
border: 1px solid transparent;
transition: 0.2s;
cursor: pointer;
width: fit-content;
font-weight: 500;
}
a.primary {
background: var(--text-primary);
color: var(--background);
gap: 8px;
}
a.secondary {
border-color: var(--button-secondary-border);
}
/* Enable hover only on non-touch devices */
@media (hover: hover) and (pointer: fine) {
a.primary:hover {
background: var(--button-primary-hover);
border-color: transparent;
}
a.secondary:hover {
background: var(--button-secondary-hover);
border-color: transparent;
}
}
@media (max-width: 600px) {
.main {
padding: 48px 24px;
}
.intro {
gap: 16px;
}
.intro h1 {
font-size: 32px;
line-height: 40px;
letter-spacing: -1.92px;
}
}
@media (prefers-color-scheme: dark) {
.logo {
filter: invert();
}
.page {
--background: #000;
--foreground: #000;
--text-primary: #ededed;
--text-secondary: #999;
--button-primary-hover: #ccc;
--button-secondary-hover: #1a1a1a;
--button-secondary-border: #1a1a1a;
}
}

View File

@@ -1,5 +0,0 @@
import { redirect } from 'next/navigation';
export default function Home() {
redirect('/login');
}

View File

@@ -1,228 +0,0 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { QRCodeSVG } from 'qrcode.react';
import { useAuthStore } from '@/store/auth-store';
import {
SEND_CHAIN_OPTIONS,
SEND_CHAINS,
getTokenOptions,
getDefaultToken,
type SendChain,
} from '@/lib/send/constants';
import { generateReceiveUri } from '@/lib/qr/generate';
export default function ReceivePage() {
const router = useRouter();
const user = useAuthStore((state) => state.user);
const wallets = useAuthStore((state) => state.wallets);
const [chain, setChain] = useState<SendChain>('ETH');
const [token, setToken] = useState<string>(getDefaultToken('ETH'));
const [amount, setAmount] = useState('');
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!user) router.push('/login');
}, [user, router]);
// Reset token when chain changes
useEffect(() => {
setToken(getDefaultToken(chain));
setAmount('');
}, [chain]);
const wallet = useMemo(
() => wallets.find((w) => w.chain === SEND_CHAINS[chain].walletChain),
[wallets, chain],
);
const address = wallet?.address ?? '';
// Ensure token is valid for the current chain (guards against stale state during chain switch)
const effectiveToken = useMemo(() => {
const options = getTokenOptions(chain);
return options.includes(token) ? token : getDefaultToken(chain);
}, [chain, token]);
const qrUri = useMemo(() => {
if (!address) return '';
return generateReceiveUri({
chain,
token: effectiveToken,
address,
amount: amount.trim() || undefined,
});
}, [chain, effectiveToken, address, amount]);
const handleCopy = async () => {
if (!address) return;
try {
await navigator.clipboard.writeText(address);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback
const textArea = document.createElement('textarea');
textArea.value = address;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
if (!user) return null;
const tokenOptions = getTokenOptions(chain);
return (
<div style={{ maxWidth: 520, margin: '50px auto', padding: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<h1>Receive</h1>
<div style={{ display: 'flex', gap: 8 }}>
<Link href="/send" style={navButtonStyle}>Send</Link>
<Link href="/dashboard" style={navButtonStyle}>Dashboard</Link>
</div>
</div>
{/* Chain selector */}
<div style={{ marginBottom: 16 }}>
<label style={labelStyle}>Network</label>
<select
value={chain}
onChange={(e) => setChain(e.target.value as SendChain)}
style={selectStyle}
>
{SEND_CHAIN_OPTIONS.map((c) => (
<option key={c} value={c}>{SEND_CHAINS[c].label}</option>
))}
</select>
</div>
{/* Token selector */}
<div style={{ marginBottom: 16 }}>
<label style={labelStyle}>Token</label>
<select
value={token}
onChange={(e) => setToken(e.target.value)}
style={selectStyle}
>
{tokenOptions.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
{/* Amount (optional) */}
<div style={{ marginBottom: 24 }}>
<label style={labelStyle}>Amount (optional)</label>
<input
type="text"
inputMode="decimal"
placeholder="0.00"
value={amount}
onChange={(e) => {
const v = e.target.value;
if (/^\d*\.?\d*$/.test(v)) setAmount(v);
}}
style={inputStyle}
/>
</div>
{/* QR Code */}
{address && (
<div style={{ textAlign: 'center', marginBottom: 24 }}>
<div style={{
display: 'inline-block',
padding: 16,
background: '#fff',
borderRadius: 12,
border: '1px solid #ddd',
}}>
<QRCodeSVG value={qrUri} size={256} level="M" />
</div>
</div>
)}
{/* Address display */}
<div style={{ marginBottom: 16 }}>
<label style={labelStyle}>Your {SEND_CHAINS[chain].label} Address</label>
<div style={{
padding: '10px 12px',
border: '1px solid #ccc',
borderRadius: 4,
wordBreak: 'break-all',
fontSize: 13,
fontFamily: 'monospace',
background: '#f9f9f9',
}}>
{address || 'No wallet found for this chain'}
</div>
</div>
{/* Copy button */}
<button
onClick={handleCopy}
disabled={!address}
style={{
width: '100%',
padding: '10px 16px',
border: '1px solid #ccc',
borderRadius: 4,
cursor: address ? 'pointer' : 'not-allowed',
background: copied ? '#d4edda' : '#fff',
fontSize: 14,
}}
>
{copied ? 'Copied!' : 'Copy Address'}
</button>
{/* URI preview */}
{qrUri && (
<div style={{ marginTop: 16, fontSize: 11, color: '#888', wordBreak: 'break-all' }}>
<strong>QR URI:</strong> {qrUri}
</div>
)}
</div>
);
}
const labelStyle: React.CSSProperties = {
display: 'block',
marginBottom: 4,
fontSize: 13,
fontWeight: 600,
};
const selectStyle: React.CSSProperties = {
width: '100%',
padding: '8px 12px',
border: '1px solid #ccc',
borderRadius: 4,
fontSize: 14,
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '8px 12px',
border: '1px solid #ccc',
borderRadius: 4,
fontSize: 14,
boxSizing: 'border-box',
};
const navButtonStyle: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '6px 12px',
border: '1px solid #ccc',
borderRadius: 4,
textDecoration: 'none',
color: 'inherit',
};

View File

@@ -1,144 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/store/auth-store';
type Step = 'email' | 'code';
export default function RegisterPage() {
const router = useRouter();
const { registerStart, registerComplete, loading, error, clearError } = useAuthStore();
const [step, setStep] = useState<Step>('email');
const [email, setEmail] = useState('');
const [code, setCode] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [localError, setLocalError] = useState<string | null>(null);
const handleEmailSubmit = async (e: React.FormEvent) => {
e.preventDefault();
clearError();
setLocalError(null);
await registerStart(email);
const state = useAuthStore.getState();
if (!state.error) {
setStep('code');
}
};
const handleCodeSubmit = async (e: React.FormEvent) => {
e.preventDefault();
clearError();
setLocalError(null);
if (password !== confirmPassword) {
setLocalError('Passwords do not match');
return;
}
await registerComplete(email, password, code);
const state = useAuthStore.getState();
if (state.user) {
router.push('/mnemonic');
}
};
const displayError = localError || error;
return (
<div style={{ maxWidth: 400, margin: '100px auto', padding: 20 }}>
<h1>Register</h1>
{step === 'email' && (
<form onSubmit={handleEmailSubmit}>
<div style={{ marginBottom: 12 }}>
<label>Email</label><br />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
style={{ width: '100%', padding: 8 }}
placeholder="you@example.com"
/>
</div>
{displayError && <p style={{ color: 'red' }}>{displayError}</p>}
<button
type="submit"
disabled={loading}
style={{ padding: '8px 24px', background: '#007bff', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
>
{loading ? 'Sending code...' : 'Send verification code'}
</button>
</form>
)}
{step === 'code' && (
<form onSubmit={handleCodeSubmit}>
<p style={{ color: '#666', fontSize: 13, marginBottom: 16 }}>
A verification code was sent to <strong>{email}</strong>.
</p>
<div style={{ marginBottom: 12 }}>
<label>Verification code</label><br />
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
required
minLength={6}
maxLength={6}
style={{ width: '100%', padding: 8, letterSpacing: 4, fontSize: 18, textAlign: 'center' }}
placeholder="000000"
inputMode="numeric"
autoFocus
/>
</div>
<div style={{ marginBottom: 12 }}>
<label>Password (min 8 characters)</label><br />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
style={{ width: '100%', padding: 8 }}
/>
</div>
<div style={{ marginBottom: 12 }}>
<label>Confirm password</label><br />
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
style={{ width: '100%', padding: 8 }}
/>
</div>
{displayError && <p style={{ color: 'red' }}>{displayError}</p>}
<button
type="submit"
disabled={loading}
style={{ padding: '8px 24px', background: '#007bff', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
>
{loading ? 'Creating account...' : 'Register'}
</button>
<button
type="button"
onClick={() => { setStep('email'); setCode(''); setPassword(''); setConfirmPassword(''); clearError(); setLocalError(null); }}
style={{ marginLeft: 8, padding: '8px 16px', background: '#fff', border: '1px solid #ccc', borderRadius: 4, cursor: 'pointer' }}
>
Back
</button>
</form>
)}
<p style={{ marginTop: 16 }}>
Already have an account? <a href="/login">Login</a>
</p>
</div>
);
}

View File

@@ -1,564 +0,0 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Scanner } from '@yudiel/react-qr-scanner';
import { useAuthStore } from '@/store/auth-store';
import { useBalances } from '@/hooks/useBalances';
import { useGasPrice } from '@/hooks/useGasPrice';
import { useGasSettings, type GasMode } from '@/hooks/useGasSettings';
import {
SEND_CHAIN_OPTIONS,
SEND_CHAINS,
getTokenOptions,
getDefaultToken,
type SendChain,
} from '@/lib/send/constants';
import { validateAddress } from '@/lib/send/validate';
import { parseQrUri } from '@/lib/qr/parse';
import { executeSend, type SendResult } from '@/lib/send/execute';
import type { ChainBalance } from '@/lib/balances/types';
const GAS_MODE_LABELS: Record<GasMode, string> = {
slow: 'Slow',
normal: 'Normal',
fast: 'Fast',
custom: 'Custom',
};
const GAS_MODES: GasMode[] = ['slow', 'normal', 'fast', 'custom'];
type SendStatus = 'idle' | 'review' | 'sending' | 'success' | 'error';
export default function SendPage() {
const router = useRouter();
const user = useAuthStore((state) => state.user);
const wallets = useAuthStore((state) => state.wallets);
const { portfolio } = useBalances();
const { data: gasPriceData } = useGasPrice();
const gas = useGasSettings(gasPriceData);
const [chain, setChain] = useState<SendChain>('ETH');
const [token, setToken] = useState<string>(getDefaultToken('ETH'));
const [recipient, setRecipient] = useState('');
const [amount, setAmount] = useState('');
const [confirmed, setConfirmed] = useState(false);
const [status, setStatus] = useState<SendStatus>('idle');
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<SendResult | null>(null);
const [scannerOpen, setScannerOpen] = useState(false);
useEffect(() => {
if (!user) router.push('/login');
}, [user, router]);
// Reset token on chain change
useEffect(() => {
setToken(getDefaultToken(chain));
setRecipient('');
setAmount('');
setConfirmed(false);
setStatus('idle');
setError(null);
setResult(null);
}, [chain]);
const wallet = useMemo(
() => wallets.find((w) => w.chain === SEND_CHAINS[chain].walletChain),
[wallets, chain],
);
const fromAddress = wallet?.address ?? '';
// Get available balance for the selected token
const availableBalance = useMemo(() => {
if (!portfolio?.chains) return null;
const chainBalance: ChainBalance | undefined = portfolio.chains.find(
(c) => c.chain === SEND_CHAINS[chain].walletChain,
);
if (!chainBalance) return null;
const tokenBalance = chainBalance.tokens.find((t) => t.symbol === token);
return tokenBalance?.balanceFormatted ?? null;
}, [portfolio, chain, token]);
// Validate address
const addressValidation = useMemo(() => {
if (!recipient.trim()) return null;
return validateAddress(chain, recipient);
}, [chain, recipient]);
// Handle QR scan
const handleScan = useCallback((results: Array<{ rawValue: string }>) => {
if (!results.length) return;
const raw = results[0].rawValue;
if (!raw) return;
const parsed = parseQrUri(raw);
setScannerOpen(false);
if (parsed.chain) {
setChain(parsed.chain);
// Wait for chain useEffect, then set token/recipient/amount
setTimeout(() => {
if (parsed.token) setToken(parsed.token);
if (parsed.address) setRecipient(parsed.address);
if (parsed.amount) setAmount(parsed.amount);
}, 50);
} else if (parsed.address) {
setRecipient(parsed.address);
if (parsed.amount) setAmount(parsed.amount);
}
}, []);
const handleReview = () => {
setError(null);
if (!recipient.trim()) {
setError('Recipient address is required');
return;
}
const validation = validateAddress(chain, recipient);
if (!validation.valid) {
setError(validation.error || 'Invalid address');
return;
}
if (!amount || Number(amount) <= 0) {
setError('Enter a valid amount');
return;
}
setStatus('review');
};
const handleSend = async () => {
if (!wallet) {
setError('Wallet not found for this chain');
return;
}
setStatus('sending');
setError(null);
try {
const sendResult = await executeSend({
chain,
token,
toAddress: recipient.trim(),
amount,
privateKey: wallet.privateKey,
fromAddress,
maxFeeGwei: chain === 'ETH' ? gas.effectiveMaxFee : null,
priorityFeeGwei: chain === 'ETH' ? gas.effectivePriorityFee : null,
});
setResult(sendResult);
setStatus('success');
} catch (err: any) {
setError(err.message || 'Transaction failed');
setStatus('error');
}
};
const handleReset = () => {
setRecipient('');
setAmount('');
setConfirmed(false);
setStatus('idle');
setError(null);
setResult(null);
};
const handleMax = () => {
if (availableBalance) {
setAmount(availableBalance);
}
};
if (!user) return null;
const tokenOptions = getTokenOptions(chain);
return (
<div style={{ maxWidth: 520, margin: '50px auto', padding: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<h1>Send</h1>
<div style={{ display: 'flex', gap: 8 }}>
<Link href="/receive" style={navButtonStyle}>Receive</Link>
<Link href="/dashboard" style={navButtonStyle}>Dashboard</Link>
</div>
</div>
{/* QR Scanner Modal */}
{scannerOpen && (
<div style={overlayStyle}>
<div style={modalStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h3 style={{ margin: 0 }}>Scan QR Code</h3>
<button onClick={() => setScannerOpen(false)} style={{ padding: '4px 8px', cursor: 'pointer' }}>
Close
</button>
</div>
<div style={{ width: '100%', maxWidth: 400 }}>
<Scanner
onScan={handleScan}
onError={(err) => {
console.error('QR scanner error:', err);
setScannerOpen(false);
}}
formats={['qr_code']}
styles={{ container: { width: '100%' } }}
/>
</div>
<p style={{ fontSize: 12, color: '#888', marginTop: 8, textAlign: 'center' }}>
Point your camera at a QR code to auto-fill send details
</p>
</div>
</div>
)}
{/* Success state */}
{status === 'success' && result && (
<div style={{ border: '2px solid #28a745', borderRadius: 8, padding: 20, marginBottom: 20 }}>
<h3 style={{ color: '#28a745', marginTop: 0 }}>Transaction Sent!</h3>
<p style={{ wordBreak: 'break-all', fontSize: 13, fontFamily: 'monospace' }}>
<strong>TX Hash:</strong> {result.hash}
</p>
<a
href={result.explorerUrl}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#007bff', fontSize: 13 }}
>
View on Explorer
</a>
<div style={{ marginTop: 16 }}>
<button onClick={handleReset} style={primaryButtonStyle}>
Send Another
</button>
</div>
</div>
)}
{/* Form (hidden during success) */}
{status !== 'success' && (
<>
{/* Chain selector */}
<div style={{ marginBottom: 16 }}>
<label style={labelStyle}>Network</label>
<select
value={chain}
onChange={(e) => setChain(e.target.value as SendChain)}
style={selectStyle}
disabled={status === 'sending'}
>
{SEND_CHAIN_OPTIONS.map((c) => (
<option key={c} value={c}>{SEND_CHAINS[c].label}</option>
))}
</select>
</div>
{/* Token selector */}
<div style={{ marginBottom: 16 }}>
<label style={labelStyle}>Token</label>
<select
value={token}
onChange={(e) => setToken(e.target.value)}
style={selectStyle}
disabled={status === 'sending'}
>
{tokenOptions.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
{availableBalance !== null && (
<p style={{ margin: '4px 0 0', fontSize: 12, color: '#666' }}>
Available: {availableBalance} {token}
</p>
)}
</div>
{/* Recipient address */}
<div style={{ marginBottom: 16 }}>
<label style={labelStyle}>Recipient Address</label>
<div style={{ display: 'flex', gap: 8 }}>
<input
type="text"
placeholder={`Enter ${SEND_CHAINS[chain].label} address`}
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
style={{ ...inputStyle, flex: 1 }}
disabled={status === 'sending'}
/>
<button
onClick={() => setScannerOpen(true)}
style={{
padding: '8px 12px',
border: '1px solid #ccc',
borderRadius: 4,
cursor: 'pointer',
whiteSpace: 'nowrap',
fontSize: 13,
}}
disabled={status === 'sending'}
>
Scan QR
</button>
</div>
{addressValidation && !addressValidation.valid && (
<p style={{ margin: '4px 0 0', fontSize: 12, color: 'red' }}>{addressValidation.error}</p>
)}
{addressValidation?.valid && (
<p style={{ margin: '4px 0 0', fontSize: 12, color: '#28a745' }}>Valid address</p>
)}
</div>
{/* Amount */}
<div style={{ marginBottom: 16 }}>
<label style={labelStyle}>Amount</label>
<div style={{ display: 'flex', gap: 8 }}>
<input
type="text"
inputMode="decimal"
placeholder="0.00"
value={amount}
onChange={(e) => {
const v = e.target.value;
if (/^\d*\.?\d*$/.test(v)) setAmount(v);
}}
style={{ ...inputStyle, flex: 1 }}
disabled={status === 'sending'}
/>
{availableBalance !== null && (
<button
onClick={handleMax}
style={{
padding: '8px 12px',
border: '1px solid #ccc',
borderRadius: 4,
cursor: 'pointer',
fontSize: 13,
}}
disabled={status === 'sending'}
>
Max
</button>
)}
</div>
</div>
{/* Gas settings (ETH only) */}
{chain === 'ETH' && (
<div style={{ marginBottom: 16, padding: 12, border: '1px solid #eee', borderRadius: 4 }}>
<label style={labelStyle}>Gas Speed</label>
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
{GAS_MODES.map((mode) => (
<button
key={mode}
onClick={() => gas.setGasMode(mode)}
style={{
flex: 1,
padding: '6px 4px',
border: gas.gasMode === mode ? '2px solid #333' : '1px solid #ccc',
borderRadius: 4,
cursor: 'pointer',
fontSize: 12,
fontWeight: gas.gasMode === mode ? 600 : 400,
}}
>
{GAS_MODE_LABELS[mode]}
</button>
))}
</div>
{gas.gasMode === 'custom' && (
<input
type="text"
inputMode="decimal"
placeholder="Max fee in gwei"
value={gas.customGwei}
onChange={(e) => gas.setCustomGwei(e.target.value)}
style={inputStyle}
/>
)}
<p style={{ margin: '4px 0 0', fontSize: 12, color: '#666' }}>
Estimated gas: {gas.displayGwei}
</p>
</div>
)}
{/* Fee info for non-ETH chains */}
{chain !== 'ETH' && (
<div style={{ marginBottom: 16 }}>
<p style={{ fontSize: 12, color: '#666' }}>
{chain === 'SOL' && 'Fee: Auto (~0.000005 SOL)'}
{chain === 'TRX' && 'Fee: Auto (Energy/Bandwidth)'}
{chain === 'BTC' && 'Fee: Auto (market rate sat/vB)'}
</p>
</div>
)}
<p style={{ marginBottom: 16, fontSize: 12, color: '#999' }}>
Platform fee: 0.7% per transaction
</p>
{/* Review section */}
{status === 'review' && (
<div style={{ border: '1px solid #ddd', borderRadius: 8, padding: 16, marginBottom: 16 }}>
<h3 style={{ marginTop: 0 }}>Review Transaction</h3>
<div style={reviewRowStyle}>
<span style={{ color: '#666' }}>From:</span>
<span style={{ wordBreak: 'break-all', fontSize: 12, fontFamily: 'monospace' }}>{fromAddress}</span>
</div>
<div style={reviewRowStyle}>
<span style={{ color: '#666' }}>To:</span>
<span style={{ wordBreak: 'break-all', fontSize: 12, fontFamily: 'monospace' }}>{recipient}</span>
</div>
<div style={reviewRowStyle}>
<span style={{ color: '#666' }}>Amount:</span>
<span style={{ fontWeight: 600 }}>{amount} {token}</span>
</div>
<div style={reviewRowStyle}>
<span style={{ color: '#666' }}>Network:</span>
<span>{SEND_CHAINS[chain].label}</span>
</div>
<div style={{ marginTop: 12 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13 }}>
<input
type="checkbox"
checked={confirmed}
onChange={(e) => setConfirmed(e.target.checked)}
/>
I confirm this transaction is correct. This action is irreversible.
</label>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<button
onClick={() => { setStatus('idle'); setConfirmed(false); }}
style={{ ...navButtonStyle, flex: 1 }}
>
Back
</button>
<button
onClick={handleSend}
disabled={!confirmed}
style={{
...primaryButtonStyle,
flex: 1,
opacity: confirmed ? 1 : 0.5,
cursor: confirmed ? 'pointer' : 'not-allowed',
}}
>
Confirm & Send
</button>
</div>
</div>
)}
{/* Sending state */}
{status === 'sending' && (
<div style={{ textAlign: 'center', padding: 20 }}>
<p>Sending transaction...</p>
<p style={{ fontSize: 12, color: '#666' }}>Please wait, do not close this page.</p>
</div>
)}
{/* Error display */}
{error && (
<p style={{ color: 'red', marginBottom: 12, fontSize: 13 }}>{error}</p>
)}
{/* Action buttons */}
{(status === 'idle' || status === 'error') && (
<button onClick={handleReview} style={primaryButtonStyle}>
Review Transaction
</button>
)}
</>
)}
</div>
);
}
// ─── Styles ───
const labelStyle: React.CSSProperties = {
display: 'block',
marginBottom: 4,
fontSize: 13,
fontWeight: 600,
};
const selectStyle: React.CSSProperties = {
width: '100%',
padding: '8px 12px',
border: '1px solid #ccc',
borderRadius: 4,
fontSize: 14,
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '8px 12px',
border: '1px solid #ccc',
borderRadius: 4,
fontSize: 14,
boxSizing: 'border-box',
};
const navButtonStyle: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '6px 12px',
border: '1px solid #ccc',
borderRadius: 4,
textDecoration: 'none',
color: 'inherit',
cursor: 'pointer',
background: '#fff',
};
const primaryButtonStyle: React.CSSProperties = {
width: '100%',
padding: '10px 16px',
border: '1px solid #333',
borderRadius: 4,
cursor: 'pointer',
background: '#333',
color: '#fff',
fontSize: 14,
fontWeight: 600,
};
const reviewRowStyle: React.CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 12,
padding: '6px 0',
borderBottom: '1px solid #f0f0f0',
};
const overlayStyle: React.CSSProperties = {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.6)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1000,
};
const modalStyle: React.CSSProperties = {
background: '#fff',
borderRadius: 12,
padding: 20,
maxWidth: 440,
width: '90%',
};

View File

@@ -1,94 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useAuthStore } from '@/store/auth-store';
import { SeedPhraseModal } from '@/components/SeedPhraseModal';
export default function SettingsPage() {
const router = useRouter();
const { user } = useAuthStore();
const [showSeedModal, setShowSeedModal] = useState(false);
useEffect(() => {
if (!user) {
router.push('/login');
}
}, [user, router]);
if (!user) return null;
return (
<div style={{ maxWidth: 520, margin: '50px auto', padding: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<h1>Settings</h1>
<Link href="/dashboard" style={navButtonStyle}>
Dashboard
</Link>
</div>
{/* Security Section */}
<div style={{ border: '1px solid #ccc', borderRadius: 4, padding: 16, marginBottom: 16 }}>
<h3 style={{ marginTop: 0 }}>Security</h3>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16 }}>
<div>
<p style={{ margin: 0, fontWeight: 600 }}>Seed Phrase</p>
<p style={{ margin: '4px 0 0', color: '#666', fontSize: 13 }}>
View your 12-word recovery phrase. Requires password verification.
</p>
</div>
<button
onClick={() => setShowSeedModal(true)}
style={primaryButtonStyle}
>
View
</button>
</div>
</div>
{/* Account Section */}
<div style={{ border: '1px solid #ccc', borderRadius: 4, padding: 16, marginBottom: 16 }}>
<h3 style={{ marginTop: 0 }}>Account</h3>
<div>
<p style={{ margin: 0, fontWeight: 600 }}>Email</p>
<p style={{ margin: '4px 0 0', color: '#666', fontSize: 13 }}>{user.email}</p>
</div>
</div>
<SeedPhraseModal
isOpen={showSeedModal}
onClose={() => setShowSeedModal(false)}
/>
</div>
);
}
// ── Styles ──
const navButtonStyle: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '6px 12px',
border: '1px solid #ccc',
borderRadius: 4,
textDecoration: 'none',
color: 'inherit',
cursor: 'pointer',
background: '#fff',
};
const primaryButtonStyle: React.CSSProperties = {
padding: '8px 16px',
border: '1px solid #333',
borderRadius: 4,
cursor: 'pointer',
background: '#333',
color: '#fff',
fontSize: 14,
fontWeight: 600,
whiteSpace: 'nowrap',
};

View File

@@ -1,333 +0,0 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import {
type SwapChain,
SWAP_TOKEN_OPTIONS_BY_CHAIN,
CHAIN_DEFAULT_TOKENS,
getSlippageBpsForChain,
getExplorerTxUrl,
} from '@/lib/swap/constants';
import { useSwap, type MultiChainSwapRequest } from '@/hooks/useSwap';
import { useGasPrice } from '@/hooks/useGasPrice';
import { useGasSettings, type GasMode } from '@/hooks/useGasSettings';
import { useAuthStore } from '@/store/auth-store';
const GAS_MODE_LABELS: Record<GasMode, string> = {
slow: 'Slow',
normal: 'Normal',
fast: 'Fast',
custom: 'Custom',
};
const GAS_MODES: GasMode[] = ['slow', 'normal', 'fast', 'custom'];
const CHAINS: SwapChain[] = ['ETH', 'SOL', 'TRX', 'BSC'];
export default function SwapPage() {
const router = useRouter();
const user = useAuthStore((state) => state.user);
const { data: gasPriceData, loading: gasLoading } = useGasPrice();
const gas = useGasSettings(gasPriceData);
const {
status,
quote,
error,
txHash,
approvalHashes,
liveEstimate,
chain,
setChain,
fetchQuote,
submitSwap,
resetSwap,
estimateOutput,
} = useSwap();
const tokenOptions = SWAP_TOKEN_OPTIONS_BY_CHAIN[chain];
const defaults = CHAIN_DEFAULT_TOKENS[chain];
const [fromSymbol, setFromSymbol] = useState(defaults.from);
const [toSymbol, setToSymbol] = useState(defaults.to);
const [amount, setAmount] = useState('');
const [confirmed, setConfirmed] = useState(false);
const slippageBps = useMemo(() => getSlippageBpsForChain(chain, fromSymbol, toSymbol), [chain, fromSymbol, toSymbol]);
const slippagePercent = (slippageBps / 100).toFixed(2);
const request = useMemo<MultiChainSwapRequest>(
() => ({
chain,
fromSymbol,
toSymbol,
amount,
slippageBps,
}),
[chain, amount, fromSymbol, slippageBps, toSymbol]
);
useEffect(() => {
if (!user) {
router.push('/login');
}
}, [router, user]);
useEffect(() => {
estimateOutput(request);
}, [estimateOutput, request]);
if (!user) {
return null;
}
const canQuote = fromSymbol !== toSymbol && Number(amount) > 0 && request.slippageBps > 0;
const canSwap = !!quote && confirmed && status !== 'approving' && status !== 'swapping' && status !== 'quoting';
const handleChainChange = (newChain: SwapChain) => {
setChain(newChain);
const newDefaults = CHAIN_DEFAULT_TOKENS[newChain];
setFromSymbol(newDefaults.from);
setToSymbol(newDefaults.to);
setAmount('');
setConfirmed(false);
resetSwap();
};
const handleQuote = async () => {
setConfirmed(false);
await fetchQuote(request);
};
const handleSwap = async () => {
await submitSwap(request, gas.effectiveMaxFee, gas.effectivePriorityFee);
};
const handleFieldReset = () => {
setConfirmed(false);
resetSwap();
};
const tierGwei = (mode: GasMode): string => {
if (mode === 'custom') return '';
if (!gasPriceData) return '...';
const v = gasPriceData[mode].maxFeePerGas;
if (v >= 1) return v.toFixed(2);
const s = v.toFixed(4);
return s.replace(/0+$/, '').replace(/\.$/, '');
};
return (
<div style={{ maxWidth: 720, margin: '50px auto', padding: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<h1>Swap</h1>
<Link href="/dashboard" style={navButtonStyle}>
Back to Dashboard
</Link>
</div>
<div style={{ border: '1px solid #ccc', padding: 16 }}>
{/* Chain selector */}
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
{CHAINS.map((c) => (
<button
key={c}
onClick={() => handleChainChange(c)}
style={{
flex: 1,
padding: '10px 8px',
border: chain === c ? '2px solid #333' : '1px solid #ccc',
borderRadius: 6,
background: chain === c ? '#f0f0f0' : '#fff',
cursor: 'pointer',
fontWeight: chain === c ? 700 : 400,
fontSize: 15,
}}
>
{c}
</button>
))}
</div>
<div style={fieldGroupStyle}>
<label>From</label>
<select value={fromSymbol} onChange={(event) => { setFromSymbol(event.target.value); handleFieldReset(); }} style={inputStyle}>
{tokenOptions.map((symbol) => (
<option key={symbol} value={symbol}>{symbol}</option>
))}
</select>
</div>
<div style={fieldGroupStyle}>
<label>To</label>
<select value={toSymbol} onChange={(event) => { setToSymbol(event.target.value); handleFieldReset(); }} style={inputStyle}>
{tokenOptions.map((symbol) => (
<option key={symbol} value={symbol}>{symbol}</option>
))}
</select>
</div>
<div style={fieldGroupStyle}>
<label>Amount</label>
<input value={amount} onChange={(event) => { setAmount(event.target.value); handleFieldReset(); }} type="number" min="0" step="any" style={inputStyle} />
{liveEstimate && fromSymbol !== toSymbol && (
<p style={{ marginTop: 4, fontSize: 14, color: '#666' }}>
{liveEstimate.loading ? '...' : `~${liveEstimate.amountOut} ${toSymbol}`}
</p>
)}
</div>
{/* Gas speed — only for ETH */}
{chain === 'ETH' && (
<div style={fieldGroupStyle}>
<label>Gas Speed {gasLoading ? '(loading...)' : ''}</label>
<div style={{ display: 'flex', gap: 6 }}>
{GAS_MODES.map((mode) => (
<button
key={mode}
onClick={() => gas.setGasMode(mode)}
style={{
flex: 1,
padding: '6px 4px',
border: gas.gasMode === mode ? '2px solid #333' : '1px solid #ccc',
borderRadius: 4,
background: gas.gasMode === mode ? '#f0f0f0' : '#fff',
cursor: 'pointer',
fontSize: 13,
}}
>
<div>{GAS_MODE_LABELS[mode]}</div>
{mode !== 'custom' && (
<div style={{ fontSize: 11, color: '#666', marginTop: 2 }}>
{tierGwei(mode)} gwei
</div>
)}
</button>
))}
</div>
{gas.gasMode === 'custom' && (
<input
value={gas.customGwei}
onChange={(event) => gas.setCustomGwei(event.target.value)}
type="number"
min="0"
step="0.01"
placeholder="Enter gwei"
style={{ ...inputStyle, marginTop: 6 }}
/>
)}
<p style={{ marginTop: 4, fontSize: 13, color: '#666' }}>
Effective: {gas.displayGwei}
</p>
</div>
)}
{/* Fee info for non-ETH */}
{chain === 'SOL' && (
<p style={{ marginBottom: 12, fontSize: 14, color: '#666' }}>
Fee: <strong>Auto</strong> (Jupiter Priority)
</p>
)}
{chain === 'TRX' && (
<p style={{ marginBottom: 12, fontSize: 14, color: '#666' }}>
Fee: <strong>Auto</strong> (Energy/Bandwidth)
</p>
)}
{chain === 'BSC' && (
<p style={{ marginBottom: 12, fontSize: 14, color: '#666' }}>
Fee: <strong>0.055 gwei</strong> (BSC fixed)
</p>
)}
<p style={{ marginBottom: 12, fontSize: 14, color: '#666' }}>
Slippage: <strong>{slippagePercent}%</strong> (auto)
</p>
<p style={{ marginBottom: 12, fontSize: 13, color: '#999' }}>
Platform fee: 0.7% per swap
</p>
{fromSymbol === toSymbol && (
<p style={{ color: 'red', marginBottom: 12 }}>From and To tokens must be different.</p>
)}
<button onClick={() => void handleQuote()} disabled={!canQuote || status === 'quoting'} style={{ padding: '8px 16px' }}>
{status === 'quoting' ? 'Getting Quote...' : 'Get Quote'}
</button>
</div>
{quote && (
<div style={{ border: '1px solid #ccc', padding: 16, marginTop: 20 }}>
<h2 style={{ marginBottom: 12 }}>Review</h2>
<p>Expected output: <strong>{quote.amountOutFormatted} {toSymbol}</strong></p>
<p>Minimum output after slippage: <strong>{quote.minimumAmountOutFormatted} {toSymbol}</strong></p>
{'executionPrice' in quote && <p>Execution price: <strong>{quote.executionPrice}</strong></p>}
{'priceImpact' in quote && <p>Price impact: <strong>{quote.priceImpact}%</strong></p>}
{'routeSymbols' in quote && <p>Route: <strong>{quote.routeSymbols.join(' -> ')}</strong></p>}
{'routeFees' in quote && <p>Pool fees: <strong>{quote.routeFees.join(' / ')}</strong></p>}
{'routeLabels' in quote && (quote as any).routeLabels?.length > 0 && (
<p>Route: <strong>{(quote as any).routeLabels.join(' → ')}</strong></p>
)}
{chain === 'ETH' && <p>Gas: <strong>{gas.displayGwei} ({GAS_MODE_LABELS[gas.gasMode]})</strong></p>}
<p>Slippage: <strong>{slippagePercent}%</strong></p>
<label style={{ display: 'flex', gap: 8, marginTop: 16, alignItems: 'flex-start' }}>
<input type="checkbox" checked={confirmed} onChange={(event) => setConfirmed(event.target.checked)} />
<span>I confirm the amount, route and slippage shown above.</span>
</label>
<button onClick={() => void handleSwap()} disabled={!canSwap} style={{ padding: '8px 16px', marginTop: 16 }}>
{status === 'approving' ? 'Approving...' : status === 'swapping' ? 'Swapping...' : 'Swap'}
</button>
</div>
)}
{(approvalHashes.length > 0 || txHash) && (
<div style={{ border: '1px solid #ccc', padding: 16, marginTop: 20 }}>
<h2 style={{ marginBottom: 12 }}>Transaction Status</h2>
{approvalHashes.map((hash) => (
<p key={hash}>
Approval tx:{' '}
<a href={getExplorerTxUrl(chain, hash)} target="_blank" rel="noreferrer">
{hash.slice(0, 16)}...
</a>
</p>
))}
{txHash && (
<p>
Swap tx:{' '}
<a href={getExplorerTxUrl(chain, txHash)} target="_blank" rel="noreferrer">
{txHash.slice(0, 16)}...
</a>
</p>
)}
</div>
)}
{(error || status === 'error') && (
<p style={{ color: 'red', marginTop: 16 }}>
{error ?? 'Swap failed'}
</p>
)}
</div>
);
}
const fieldGroupStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 6,
marginBottom: 12,
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: 8,
};
const navButtonStyle: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '6px 12px',
border: '1px solid #ccc',
borderRadius: 4,
};

View File

@@ -1,283 +0,0 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { walletApi } from '@/lib/api';
import { decryptVault } from '@/lib/crypto/vault';
interface SeedPhraseModalProps {
isOpen: boolean;
onClose: () => void;
}
const AUTO_HIDE_SECONDS = 60;
const CLIPBOARD_CLEAR_SECONDS = 30;
export function SeedPhraseModal({ isOpen, onClose }: SeedPhraseModalProps) {
const [password, setPassword] = useState('');
const [mnemonic, setMnemonic] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [countdown, setCountdown] = useState(AUTO_HIDE_SECONDS);
const [revealed, setRevealed] = useState(false);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const clipboardTimerRef = useRef<NodeJS.Timeout | null>(null);
const clearSensitiveData = useCallback(() => {
setPassword('');
setMnemonic(null);
setError(null);
setRevealed(false);
setCountdown(AUTO_HIDE_SECONDS);
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
if (clipboardTimerRef.current) {
clearTimeout(clipboardTimerRef.current);
clipboardTimerRef.current = null;
}
}, []);
const handleClose = useCallback(() => {
clearSensitiveData();
onClose();
}, [clearSensitiveData, onClose]);
// Auto-hide countdown
useEffect(() => {
if (!mnemonic) return;
setCountdown(AUTO_HIDE_SECONDS);
timerRef.current = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
handleClose();
return 0;
}
return prev - 1;
});
}, 1000);
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
}, [mnemonic, handleClose]);
// Cleanup on unmount
useEffect(() => {
return () => {
clearSensitiveData();
};
}, [clearSensitiveData]);
if (!isOpen) return null;
const handleVerify = async () => {
setError(null);
setLoading(true);
try {
// Get vault data from backend
const result = await walletApi.unlock();
// Attempt client-side decryption with password only
const decrypted = await decryptVault(
result.encryptedVault,
result.vaultSalt,
password,
);
setMnemonic(decrypted);
} catch (err: any) {
const msg = err?.message || '';
if (msg.includes('Too many attempts')) {
setError('Too many attempts. Try again in 15 minutes.');
} else {
setError('Wrong password');
}
} finally {
setLoading(false);
}
};
const handleCopy = async () => {
if (!mnemonic) return;
try {
await navigator.clipboard.writeText(mnemonic);
clipboardTimerRef.current = setTimeout(() => {
try {
void navigator.clipboard.writeText('');
} catch {
// Ignore
}
}, CLIPBOARD_CLEAR_SECONDS * 1000);
} catch {
// Clipboard API not available
}
};
const words = mnemonic?.split(' ') || [];
return (
<div style={overlayStyle} onClick={handleClose}>
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 style={{ margin: 0 }}>
{mnemonic ? 'Seed Phrase' : 'Verify Identity'}
</h3>
<button onClick={handleClose} style={{ padding: '4px 8px', cursor: 'pointer' }}>
&times;
</button>
</div>
{!mnemonic ? (
<div style={{ marginTop: 16 }}>
<p style={{ color: '#666', fontSize: 13 }}>
Enter your password to view your seed phrase.
</p>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}>Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
style={inputStyle}
placeholder="Enter password"
autoComplete="off"
/>
</div>
{error && (
<p style={{ color: 'red', fontSize: 13, margin: '0 0 12px' }}>{error}</p>
)}
<button
onClick={handleVerify}
disabled={loading || password.length < 8}
style={{
...primaryButtonStyle,
width: '100%',
opacity: loading || password.length < 8 ? 0.5 : 1,
cursor: loading || password.length < 8 ? 'not-allowed' : 'pointer',
}}
>
{loading ? 'Verifying...' : 'Verify & Show Seed Phrase'}
</button>
</div>
) : (
<div style={{ marginTop: 16 }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
}}>
<p style={{ color: '#b45309', fontSize: 13, margin: 0, fontWeight: 600 }}>
Auto-hide in {countdown}s
</p>
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={() => setRevealed(!revealed)}
style={navButtonStyle}
>
{revealed ? 'Hide' : 'Reveal'}
</button>
<button onClick={handleCopy} style={navButtonStyle}>
Copy
</button>
</div>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr 1fr',
gap: 8,
}}>
{words.map((word, i) => (
<div key={i} style={{
padding: '6px 8px',
border: '1px solid #ccc',
borderRadius: 4,
fontSize: 13,
}}>
<span style={{ color: '#666', fontSize: 11 }}>{i + 1}.</span>{' '}
<span style={{
filter: revealed ? 'none' : 'blur(6px)',
transition: 'filter 0.2s',
userSelect: revealed ? 'text' : 'none',
}}>
{word}
</span>
</div>
))}
</div>
<p style={{ color: '#666', fontSize: 11, marginTop: 12, textAlign: 'center' }}>
Clipboard will be cleared in {CLIPBOARD_CLEAR_SECONDS}s after copying.
</p>
</div>
)}
</div>
</div>
);
}
// -- Styles --
const overlayStyle: React.CSSProperties = {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.6)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1000,
};
const modalStyle: React.CSSProperties = {
background: '#fff',
borderRadius: 12,
padding: 20,
maxWidth: 480,
width: '90%',
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '8px 12px',
border: '1px solid #ccc',
borderRadius: 4,
fontSize: 14,
boxSizing: 'border-box',
};
const navButtonStyle: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '6px 12px',
border: '1px solid #ccc',
borderRadius: 4,
cursor: 'pointer',
background: '#fff',
fontSize: 13,
};
const primaryButtonStyle: React.CSSProperties = {
padding: '10px 16px',
border: '1px solid #333',
borderRadius: 4,
cursor: 'pointer',
background: '#333',
color: '#fff',
fontSize: 14,
fontWeight: 600,
};

View File

@@ -1,52 +0,0 @@
'use client';
import { useCallback, useEffect } from 'react';
import { useAuthStore } from '@/store/auth-store';
import { useBalanceStore } from '@/store/balance-store';
const BALANCE_REFRESH_INTERVAL_MS = 30_000;
export function useBalances() {
const user = useAuthStore((state) => state.user);
const wallets = useAuthStore((state) => state.wallets);
const portfolio = useBalanceStore((state) => state.portfolio);
const loading = useBalanceStore((state) => state.loading);
const refreshing = useBalanceStore((state) => state.refreshing);
const error = useBalanceStore((state) => state.error);
const fetchBalances = useBalanceStore((state) => state.fetchBalances);
const clearBalances = useBalanceStore((state) => state.clearBalances);
const refresh = useCallback(async () => {
if (!user || !wallets.length) {
clearBalances();
return;
}
await fetchBalances(wallets);
}, [clearBalances, fetchBalances, user, wallets]);
useEffect(() => {
if (!user || !wallets.length) {
clearBalances();
return;
}
void fetchBalances(wallets);
const intervalId = window.setInterval(() => {
void fetchBalances(wallets);
}, BALANCE_REFRESH_INTERVAL_MS);
return () => {
window.clearInterval(intervalId);
};
}, [clearBalances, fetchBalances, user, wallets]);
return {
portfolio,
loading,
refreshing,
error,
refresh,
};
}

View File

@@ -1,191 +0,0 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { executeBridge, type ExecuteBridgeResult } from '@/lib/bridge/execute';
import { getBridgeQuote, type BridgeQuoteResult } from '@/lib/bridge/quote';
import { getBridgeStatus, isBridgeTerminalStatus, type BridgeStatusResult } from '@/lib/bridge/status';
import {
BRIDGE_CHAINS,
getTokenConfig,
type BridgeChainKey,
} from '@/lib/bridge/constants';
import { useAuthStore } from '@/store/auth-store';
export type BridgeStatus = 'idle' | 'quoting' | 'quoted' | 'executing' | 'monitoring' | 'success' | 'error';
const BRIDGE_POLL_INTERVAL_MS = 1_000;
export interface BridgeRequestParams {
sourceChain: BridgeChainKey;
sourceToken: string;
destChain: BridgeChainKey;
destToken: string;
amount: string;
}
export function useBridge() {
const wallets = useAuthStore((state) => state.wallets);
const [status, setStatus] = useState<BridgeStatus>('idle');
const [quote, setQuote] = useState<BridgeQuoteResult | null>(null);
const [bridgeStatus, setBridgeStatus] = useState<BridgeStatusResult | null>(null);
const [requestId, setRequestId] = useState<string | null>(null);
const [txHashes, setTxHashes] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const [sourceChain, setSourceChain] = useState<BridgeChainKey>('ETH');
const pollTimeoutRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (pollTimeoutRef.current) {
window.clearTimeout(pollTimeoutRef.current);
}
};
}, []);
const sourceWallet = useMemo(
() => wallets.find((w) => w.chain === BRIDGE_CHAINS[sourceChain].walletChain) ?? null,
[wallets, sourceChain],
);
function getWalletAddress(chainKey: BridgeChainKey): string | null {
const chain = BRIDGE_CHAINS[chainKey];
const wallet = wallets.find((w) => w.chain === chain.walletChain);
return wallet?.address ?? null;
}
const fetchQuote = async (request: BridgeRequestParams) => {
const userAddress = getWalletAddress(request.sourceChain);
if (!userAddress) {
throw new Error(`${request.sourceChain} wallet is not available`);
}
const recipientAddress = getWalletAddress(request.destChain);
if (!recipientAddress) {
throw new Error(`${request.destChain} wallet is not available`);
}
setStatus('quoting');
setError(null);
setBridgeStatus(null);
setRequestId(null);
setTxHashes([]);
try {
const nextQuote = await getBridgeQuote({
...request,
userAddress,
recipientAddress,
});
setQuote(nextQuote);
setStatus('quoted');
return nextQuote;
} catch (nextError) {
setQuote(null);
setStatus('error');
setError(getErrorMessage(nextError));
throw nextError;
}
};
const submitBridge = async (
request: BridgeRequestParams,
maxFeeGwei?: string | null,
priorityFeeGwei?: string | null,
) => {
if (!sourceWallet?.privateKey) {
throw new Error(`${request.sourceChain} private key is not available`);
}
if (!quote) {
throw new Error('Get a bridge quote before executing');
}
setStatus('executing');
setError(null);
try {
const tokenConfig = getTokenConfig(request.sourceChain, request.sourceToken);
const execution = await executeBridge({
sourceChain: request.sourceChain,
sourceToken: request.sourceToken,
originalAmount: request.amount,
sourceTokenDecimals: tokenConfig.decimals,
sourceTokenAddress: tokenConfig.address,
privateKey: sourceWallet.privateKey,
quote: quote.quote,
maxFeeGwei,
priorityFeeGwei,
});
setTxHashes(execution.txHashes);
if (!execution.requestId) {
throw new Error('Relay request ID was not returned');
}
setRequestId(execution.requestId);
setStatus('monitoring');
await pollBridgeStatus(execution.requestId);
return execution;
} catch (nextError) {
setStatus('error');
setError(getErrorMessage(nextError));
throw nextError;
}
};
const resetBridge = () => {
if (pollTimeoutRef.current) {
window.clearTimeout(pollTimeoutRef.current);
pollTimeoutRef.current = null;
}
setStatus('idle');
setQuote(null);
setBridgeStatus(null);
setRequestId(null);
setTxHashes([]);
setError(null);
};
const pollBridgeStatus = async (nextRequestId: string): Promise<BridgeStatusResult> => {
const nextStatus = await getBridgeStatus(nextRequestId);
setBridgeStatus(nextStatus);
if (isBridgeTerminalStatus(nextStatus.status)) {
if (nextStatus.status === 'success') {
setStatus('success');
} else {
setStatus('error');
setError(nextStatus.details || `Bridge finished with status: ${nextStatus.status}`);
}
return nextStatus;
}
await new Promise<void>((resolve) => {
pollTimeoutRef.current = window.setTimeout(() => resolve(), BRIDGE_POLL_INTERVAL_MS);
});
return pollBridgeStatus(nextRequestId);
};
return {
status,
quote,
bridgeStatus,
requestId,
txHashes,
error,
sourceChain,
setSourceChain,
sourceWallet,
fetchQuote,
submitBridge,
resetBridge,
};
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return 'Bridge request failed';
}

View File

@@ -1,36 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { fetchGasPrices, type GasPriceData } from '@/lib/gas-price';
const GAS_REFRESH_INTERVAL_MS = 30_000;
export function useGasPrice() {
const [data, setData] = useState<GasPriceData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
const refresh = async () => {
try {
const next = await fetchGasPrices();
if (!cancelled) setData(next);
} catch {
/* keep last known data */
} finally {
if (!cancelled) setLoading(false);
}
};
void refresh();
const intervalId = setInterval(refresh, GAS_REFRESH_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(intervalId);
};
}, []);
return { data, loading };
}

View File

@@ -1,64 +0,0 @@
'use client';
import { useMemo, useState } from 'react';
import type { GasPriceData } from '@/lib/gas-price';
export type GasMode = 'slow' | 'normal' | 'fast' | 'custom';
export interface GasSettings {
gasMode: GasMode;
setGasMode: (mode: GasMode) => void;
customGwei: string;
setCustomGwei: (value: string) => void;
effectiveMaxFee: string | null;
effectivePriorityFee: string | null;
displayGwei: string;
}
function fmt(n: number): string {
if (n >= 1) return n.toFixed(2);
const s = n.toFixed(6);
return s.replace(/0+$/, '').replace(/\.$/, '');
}
export function useGasSettings(gasPriceData: GasPriceData | null): GasSettings {
const [gasMode, setGasMode] = useState<GasMode>('normal');
const [customGwei, setCustomGwei] = useState('');
const { effectiveMaxFee, effectivePriorityFee, displayGwei } = useMemo(() => {
if (gasMode === 'custom') {
const v = customGwei.trim();
if (!v || Number(v) <= 0) {
return { effectiveMaxFee: null, effectivePriorityFee: null, displayGwei: '-' };
}
const base = Number(v);
const priority = Math.max(0.01, base * 0.1);
return {
effectiveMaxFee: v,
effectivePriorityFee: fmt(priority),
displayGwei: `${v} gwei`,
};
}
if (!gasPriceData) {
return { effectiveMaxFee: null, effectivePriorityFee: null, displayGwei: '...' };
}
const tier = gasPriceData[gasMode];
return {
effectiveMaxFee: fmt(tier.maxFeePerGas),
effectivePriorityFee: fmt(tier.maxPriorityFeePerGas),
displayGwei: `${fmt(tier.maxFeePerGas)} gwei`,
};
}, [gasMode, customGwei, gasPriceData]);
return {
gasMode,
setGasMode,
customGwei,
setCustomGwei,
effectiveMaxFee,
effectivePriorityFee,
displayGwei,
};
}

View File

@@ -1,274 +0,0 @@
'use client';
import { useCallback, useMemo, useRef, useState } from 'react';
import { ensureSwapApproval } from '@/lib/swap/approve';
import { executeSwap } from '@/lib/swap/execute';
import { getSwapQuote, type SwapQuoteResult } from '@/lib/swap/quote';
import { getSolSwapQuote, type SolSwapQuoteResult } from '@/lib/swap/sol/quote';
import { executeSolSwap } from '@/lib/swap/sol/execute';
import { getTrxSwapQuote, type TrxSwapQuoteResult } from '@/lib/swap/trx/quote';
import { executeTrxSwap } from '@/lib/swap/trx/execute';
import { getBscSwapQuote, type BscSwapQuoteResult } from '@/lib/swap/bsc/quote';
import { executeBscSwap } from '@/lib/swap/bsc/execute';
import { mapSwapError } from '@/lib/swap/errors';
import type { SwapQuoteRequest } from '@/lib/swap/constants';
import { type SwapChain, getSlippageBpsForChain } from '@/lib/swap/constants';
import { useAuthStore } from '@/store/auth-store';
const ESTIMATE_DEBOUNCE_MS = 500;
export type SwapStatus = 'idle' | 'quoting' | 'quoted' | 'approving' | 'swapping' | 'success' | 'error';
export interface LiveEstimate {
amountOut: string;
loading: boolean;
}
export type AnyQuoteResult = SwapQuoteResult | SolSwapQuoteResult | TrxSwapQuoteResult | BscSwapQuoteResult;
export interface MultiChainSwapRequest {
chain: SwapChain;
fromSymbol: string;
toSymbol: string;
amount: string;
slippageBps: number;
}
export function useSwap() {
const wallets = useAuthStore((state) => state.wallets);
const [status, setStatus] = useState<SwapStatus>('idle');
const [quote, setQuote] = useState<AnyQuoteResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [txHash, setTxHash] = useState<string | null>(null);
const [approvalHashes, setApprovalHashes] = useState<string[]>([]);
const [liveEstimate, setLiveEstimate] = useState<LiveEstimate | null>(null);
const [chain, setChain] = useState<SwapChain>('ETH');
const estimateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const currentWallet = useMemo(
() => wallets.find((w) => w.chain === chain) ?? null,
[wallets, chain]
);
const fetchQuote = async (request: MultiChainSwapRequest) => {
setStatus('quoting');
setError(null);
setTxHash(null);
setApprovalHashes([]);
try {
const nextQuote = await fetchQuoteForChain(request);
setQuote(nextQuote);
setStatus('quoted');
return nextQuote;
} catch (nextError) {
setQuote(null);
setStatus('error');
setError(mapSwapError(request.chain, nextError));
throw nextError;
}
};
const submitSwap = async (
request: MultiChainSwapRequest,
maxFeeGwei?: string | null,
priorityFeeGwei?: string | null,
) => {
if (!currentWallet?.privateKey) {
const walletError = new Error(`${request.chain} private key is not available`);
setStatus('error');
setError(walletError.message);
throw walletError;
}
if (!quote) {
const quoteError = new Error('Get a quote before swapping');
setStatus('error');
setError(quoteError.message);
throw quoteError;
}
setError(null);
setApprovalHashes([]);
try {
let result: { hash: string };
switch (request.chain) {
case 'ETH': {
// Existing ETH swap logic
if (request.fromSymbol !== 'ETH') {
setStatus('approving');
const approvalResult = await ensureSwapApproval({
privateKey: currentWallet.privateKey,
tokenSymbol: request.fromSymbol as any,
amount: request.amount,
maxFeeGwei,
priorityFeeGwei,
});
setApprovalHashes(approvalResult.approvalHashes);
}
setStatus('swapping');
const ethQuote = quote as SwapQuoteResult;
result = await executeSwap({
privateKey: currentWallet.privateKey,
request: request as SwapQuoteRequest,
quote: ethQuote,
maxFeeGwei,
priorityFeeGwei,
});
break;
}
case 'SOL': {
setStatus('swapping');
const solQuote = quote as SolSwapQuoteResult;
result = await executeSolSwap({
privateKeyHex: currentWallet.privateKey,
userPublicKey: currentWallet.address,
quoteResponse: solQuote.quoteResponse,
});
break;
}
case 'TRX': {
setStatus('swapping');
const trxQuote = quote as TrxSwapQuoteResult;
const trxResult = await executeTrxSwap({
privateKeyHex: currentWallet.privateKey,
from: request.fromSymbol,
to: request.toSymbol,
amount: trxQuote.amountInRaw,
amountOutMin: trxQuote.minimumAmountOutRaw,
userAddress: currentWallet.address,
});
setApprovalHashes(trxResult.approvalHashes);
result = trxResult;
break;
}
case 'BSC': {
setStatus('swapping');
const bscQuote = quote as BscSwapQuoteResult;
const bscResult = await executeBscSwap({
privateKeyHex: currentWallet.privateKey,
from: request.fromSymbol,
to: request.toSymbol,
amount: bscQuote.amountIn,
amountOutMin: bscQuote.minimumAmountOutRaw,
userAddress: currentWallet.address,
});
setApprovalHashes(bscResult.approvalHashes);
result = bscResult;
break;
}
}
setTxHash(result.hash);
setStatus('success');
return result;
} catch (nextError) {
setStatus('error');
setError(mapSwapError(request.chain, nextError));
throw nextError;
}
};
const estimateOutput = useCallback((request: MultiChainSwapRequest) => {
if (
request.fromSymbol === request.toSymbol ||
!request.amount ||
Number(request.amount) <= 0 ||
request.slippageBps <= 0
) {
setLiveEstimate(null);
return;
}
if (estimateTimeoutRef.current) {
clearTimeout(estimateTimeoutRef.current);
estimateTimeoutRef.current = null;
}
setLiveEstimate({ amountOut: '', loading: true });
estimateTimeoutRef.current = setTimeout(async () => {
estimateTimeoutRef.current = null;
try {
const result = await fetchQuoteForChain(request);
setLiveEstimate({
amountOut: result.amountOutFormatted,
loading: false,
});
} catch {
setLiveEstimate(null);
}
}, ESTIMATE_DEBOUNCE_MS);
}, []);
const resetSwap = useCallback(() => {
if (estimateTimeoutRef.current) {
clearTimeout(estimateTimeoutRef.current);
estimateTimeoutRef.current = null;
}
setStatus('idle');
setQuote(null);
setError(null);
setTxHash(null);
setApprovalHashes([]);
setLiveEstimate(null);
}, []);
return {
status,
quote,
error,
txHash,
approvalHashes,
liveEstimate,
chain,
setChain,
currentWallet,
fetchQuote,
submitSwap,
resetSwap,
estimateOutput,
};
}
async function fetchQuoteForChain(request: MultiChainSwapRequest): Promise<AnyQuoteResult> {
switch (request.chain) {
case 'ETH':
return getSwapQuote({
fromSymbol: request.fromSymbol as any,
toSymbol: request.toSymbol as any,
amount: request.amount,
slippageBps: request.slippageBps,
});
case 'SOL':
return getSolSwapQuote({
fromSymbol: request.fromSymbol,
toSymbol: request.toSymbol,
amount: request.amount,
slippageBps: request.slippageBps,
});
case 'TRX':
return getTrxSwapQuote({
fromSymbol: request.fromSymbol,
toSymbol: request.toSymbol,
amount: request.amount,
slippageBps: request.slippageBps,
});
case 'BSC':
return getBscSwapQuote({
fromSymbol: request.fromSymbol,
toSymbol: request.toSymbol,
amount: request.amount,
slippageBps: request.slippageBps,
});
}
}

View File

@@ -1,133 +0,0 @@
import { webEnv } from './env';
const API_URL = webEnv.apiUrl;
const BITOK_BASE = process.env.NEXT_PUBLIC_BITOK_URL || 'http://localhost:8000';
let accessToken: string | null = null;
export function setAccessToken(token: string | null) {
accessToken = token;
}
export function getAccessToken() {
return accessToken;
}
// ── BITOK auth calls (httpOnly cookies + access_token in body) ──
async function bitokRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
};
const res = await fetch(`${BITOK_BASE}${path}`, {
...options,
headers,
credentials: 'include',
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || body.error || `Request failed (${res.status})`);
}
return res.json();
}
export const bitokAuth = {
registrationStart: (email: string) =>
bitokRequest<{ success: boolean }>('/v1/auth/registration/start', {
method: 'POST',
body: JSON.stringify({ email }),
}),
registrationComplete: (email: string, password: string, code: string) =>
bitokRequest<{ id: string; email: string; access_token: string }>('/v1/auth/registration/complete', {
method: 'POST',
body: JSON.stringify({ email, password, code }),
}),
loginStart: (email: string) =>
bitokRequest<{ success: boolean }>('/v1/auth/login/start', {
method: 'POST',
body: JSON.stringify({ email }),
}),
loginComplete: (email: string, password: string, code: string) =>
bitokRequest<{ id: string; email: string; access_token: string }>('/v1/auth/login/complete', {
method: 'POST',
body: JSON.stringify({ email, password, code }),
}),
refresh: () =>
bitokRequest<{ result: boolean; access_token: string }>('/v1/jwt/refresh', { method: 'POST' }),
logout: () =>
bitokRequest<{ ok: boolean }>('/v1/auth/logout', { method: 'POST' }),
};
// ── Wallet API calls (uses Bearer token from BITOK) ──
async function walletRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
};
if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
}
const res = await fetch(`${API_URL}${path}`, {
...options,
headers,
credentials: 'include',
});
const data = await res.json();
if (!data.success) {
throw new Error(data.error || 'Request failed');
}
return data.data;
}
export interface WalletSetupPayload {
encryptedVault: string;
vaultSalt: string;
wallets: { chain: string; address: string; derivationPath: string }[];
}
export interface WalletUnlockResponse {
encryptedVault: string;
vaultSalt: string;
wallets: { chain: string; address: string; derivationPath: string }[];
mnemonicShown: boolean;
}
export const walletApi = {
setup: (data: WalletSetupPayload) =>
walletRequest<any>('/api/wallet/setup', {
method: 'POST',
body: JSON.stringify(data),
}),
unlock: () =>
walletRequest<WalletUnlockResponse>('/api/wallet/unlock'),
getWallets: () => walletRequest<any>('/api/wallets'),
confirmMnemonic: () =>
walletRequest<any>('/api/wallet/confirm-mnemonic', { method: 'POST' }),
};
// ── Legacy api object (keep for components that still reference it) ──
export const api = {
getWallets: () => walletRequest<any>('/api/wallets'),
getVault: () => walletRequest<any>('/api/vault'),
confirmMnemonic: () =>
walletRequest<any>('/api/wallet/confirm-mnemonic', { method: 'POST' }),
};

View File

@@ -1,185 +0,0 @@
import { ethers } from 'ethers';
import { webEnv } from '@/lib/env';
import type { ChainBalance, TokenBalance, TokenDefinition } from './types';
const BSC_CHAIN_ID = 56;
const BSC_BALANCE_TIMEOUT_MS = 6_000;
const BSC_RPC_HEALTHCHECK_TIMEOUT_MS = 4_000;
const BEP20_ABI = ['function balanceOf(address owner) view returns (uint256)'];
const BSC_RPC_CANDIDATES = dedupeUrls([
webEnv.bscRpcUrl,
'https://bsc-dataseed1.defibit.io',
'https://bsc-dataseed1.ninicoin.io',
'https://bsc-dataseed.binance.org',
]);
const BSC_TOKENS: TokenDefinition[] = [
{
chain: 'BSC',
symbol: 'BNB',
decimals: 18,
contractAddress: 'native',
coinGeckoId: 'binancecoin',
isNative: true,
},
{
chain: 'BSC',
symbol: 'USDT',
decimals: 18,
contractAddress: '0x55d398326f99059fF775485246999027B3197955',
coinGeckoId: 'tether',
isNative: false,
},
{
chain: 'BSC',
symbol: 'DOGE',
decimals: 8,
contractAddress: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43',
coinGeckoId: 'dogecoin',
isNative: false,
},
];
export async function fetchBscBalances(address: string): Promise<ChainBalance> {
let provider: ethers.providers.StaticJsonRpcProvider;
try {
provider = await getHealthyBscProvider();
} catch (error) {
return {
chain: 'BSC',
address,
tokens: BSC_TOKENS.map(createEmptyTokenBalance),
totalUsd: null,
error: getErrorMessage(error),
};
}
const settled = await Promise.allSettled(
BSC_TOKENS.map(async (token) => readBscTokenBalance(provider, address, token))
);
const tokens: TokenBalance[] = [];
const errors: Array<{ symbol: string; message: string }> = [];
settled.forEach((result, index) => {
const token = BSC_TOKENS[index];
if (result.status === 'fulfilled') {
tokens.push(result.value);
return;
}
tokens.push(createEmptyTokenBalance(token));
errors.push({ symbol: token.symbol, message: getErrorMessage(result.reason) });
});
const uniqueMessages = [...new Set(errors.map((item) => item.message))];
const error =
errors.length === BSC_TOKENS.length && uniqueMessages.length === 1
? uniqueMessages[0]
: errors.length
? errors.map((item) => `${item.symbol}: ${item.message}`).join(' | ')
: null;
return {
chain: 'BSC',
address,
tokens,
totalUsd: null,
error,
};
}
async function getHealthyBscProvider(): Promise<ethers.providers.StaticJsonRpcProvider> {
let lastError: unknown = new Error('No BSC RPC endpoints configured');
for (const rpcUrl of BSC_RPC_CANDIDATES) {
const provider = new ethers.providers.StaticJsonRpcProvider(rpcUrl, BSC_CHAIN_ID);
try {
await withTimeout(
provider.getBlockNumber(),
BSC_RPC_HEALTHCHECK_TIMEOUT_MS,
`BSC RPC health-check timed out for ${rpcUrl}`
);
return provider;
} catch (error) {
lastError = error;
}
}
throw lastError;
}
async function readBscTokenBalance(provider: ethers.providers.StaticJsonRpcProvider, address: string, token: TokenDefinition): Promise<TokenBalance> {
if (token.isNative) {
const balance = await withTimeout(
provider.getBalance(address),
BSC_BALANCE_TIMEOUT_MS,
'BNB balance request timed out'
);
return {
...token,
balanceRaw: balance.toString(),
balanceFormatted: ethers.utils.formatEther(balance),
priceUsd: null,
valueUsd: null,
};
}
const contract = new ethers.Contract(token.contractAddress, BEP20_ABI, provider);
const balance = (await withTimeout(
contract.balanceOf(address),
BSC_BALANCE_TIMEOUT_MS,
`${token.symbol} balance request timed out`
)) as ethers.BigNumber;
return {
...token,
balanceRaw: balance.toString(),
balanceFormatted: ethers.utils.formatUnits(balance, token.decimals),
priceUsd: null,
valueUsd: null,
};
}
function createEmptyTokenBalance(token: TokenDefinition): TokenBalance {
return {
...token,
balanceRaw: '0',
balanceFormatted: '0',
priceUsd: null,
valueUsd: null,
};
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
promise
.then((value) => {
clearTimeout(timeoutId);
resolve(value);
})
.catch((error) => {
clearTimeout(timeoutId);
reject(error);
});
});
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) {
if (error.message.includes('Failed to fetch')) {
return 'BSC RPC is temporarily unavailable';
}
return error.message;
}
return 'Unable to load token balance';
}
function dedupeUrls(urls: string[]): string[] {
return [...new Set(urls.map((url) => url.trim()).filter(Boolean))];
}

View File

@@ -1,101 +0,0 @@
import { webEnv } from '@/lib/env';
import type { ChainBalance, TokenDefinition } from './types';
const BTC_BALANCE_TIMEOUT_MS = 12_000;
const BTC_TOKEN: TokenDefinition = {
chain: 'BTC',
symbol: 'BTC',
decimals: 8,
contractAddress: 'native',
coinGeckoId: 'bitcoin',
isNative: true,
};
interface BlockstreamAddressResponse {
chain_stats?: {
funded_txo_sum?: number;
spent_txo_sum?: number;
};
}
export async function fetchBtcBalances(address: string): Promise<ChainBalance> {
try {
const response = await fetchJsonWithTimeout<BlockstreamAddressResponse>(
`${webEnv.btcApiUrl}/address/${address}`,
BTC_BALANCE_TIMEOUT_MS
);
const funded = response.chain_stats?.funded_txo_sum ?? 0;
const spent = response.chain_stats?.spent_txo_sum ?? 0;
const sats = Math.max(funded - spent, 0);
return {
chain: 'BTC',
address,
tokens: [
{
...BTC_TOKEN,
balanceRaw: sats.toString(),
balanceFormatted: formatFixedBalance(sats, BTC_TOKEN.decimals),
priceUsd: null,
valueUsd: null,
},
],
totalUsd: null,
error: null,
};
} catch (error) {
return {
chain: 'BTC',
address,
tokens: [
{
...BTC_TOKEN,
balanceRaw: '0',
balanceFormatted: '0',
priceUsd: null,
valueUsd: null,
},
],
totalUsd: null,
error: getErrorMessage(error),
};
}
}
async function fetchJsonWithTimeout<T>(url: string, timeoutMs: number): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
signal: controller.signal,
cache: 'no-store',
});
if (!response.ok) {
throw new Error(`BTC API returned ${response.status}`);
}
return (await response.json()) as T;
} finally {
clearTimeout(timeoutId);
}
}
function formatFixedBalance(rawValue: number, decimals: number): string {
if (rawValue === 0) {
return '0';
}
return (rawValue / 10 ** decimals).toString();
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return 'Unable to load BTC balance';
}

View File

@@ -1,257 +0,0 @@
import { ethers } from 'ethers';
import { webEnv } from '@/lib/env';
import type { ChainBalance, TokenBalance, TokenDefinition } from './types';
const ETH_CHAIN_ID = 1;
const ETH_BALANCE_TIMEOUT_MS = 6_000;
const ETH_RPC_HEALTHCHECK_TIMEOUT_MS = 4_000;
const ERC20_ABI = ['function balanceOf(address owner) view returns (uint256)'];
const ETH_RPC_CANDIDATES = dedupeUrls([
webEnv.ethRpcUrl,
'https://ethereum-rpc.publicnode.com',
'https://rpc.ankr.com/eth',
'https://eth.llamarpc.com',
]);
const ETH_TOKENS: TokenDefinition[] = [
{
chain: 'ETH',
symbol: 'ETH',
decimals: 18,
contractAddress: 'native',
coinGeckoId: 'ethereum',
isNative: true,
},
{
chain: 'ETH',
symbol: 'USDT',
decimals: 6,
contractAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7',
coinGeckoId: 'tether',
isNative: false,
},
{
chain: 'ETH',
symbol: 'USDC',
decimals: 6,
contractAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
coinGeckoId: 'usd-coin',
isNative: false,
},
{
chain: 'ETH',
symbol: 'XAUT',
decimals: 6,
contractAddress: '0x68749665FF8D2d112Fa859AA293F07A622782F38',
coinGeckoId: 'tether-gold',
isNative: false,
},
{
chain: 'ETH',
symbol: 'UNI',
decimals: 18,
contractAddress: '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984',
coinGeckoId: 'uniswap',
isNative: false,
},
{
chain: 'ETH',
symbol: 'PEPE',
decimals: 18,
contractAddress: '0x6982508145454Ce325dDbE47a25d4ec3d2311933',
coinGeckoId: 'pepe',
isNative: false,
},
{
chain: 'ETH',
symbol: 'stETH',
decimals: 18,
contractAddress: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84',
coinGeckoId: 'staked-ether',
isNative: false,
},
{
chain: 'ETH',
symbol: 'SHIB',
decimals: 18,
contractAddress: '0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE',
coinGeckoId: 'shiba-inu',
isNative: false,
},
{
chain: 'ETH',
symbol: 'LINK',
decimals: 18,
contractAddress: '0x514910771AF9Ca656af840dff83E8264EcF986CA',
coinGeckoId: 'chainlink',
isNative: false,
},
{
chain: 'ETH',
symbol: 'POL',
decimals: 18,
contractAddress: '0x455e53CBB86018Ac2B8092FdCd39d8444aFFC3F6',
coinGeckoId: 'polygon-ecosystem-token',
isNative: false,
},
{
chain: 'ETH',
symbol: 'WLFI',
decimals: 18,
contractAddress: '0x66f85E3865D0cFDC009acf6280a8621f12e46CCf',
coinGeckoId: 'world-liberty-financial',
isNative: false,
},
{
chain: 'ETH',
symbol: 'AAVE',
decimals: 18,
contractAddress: '0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9',
coinGeckoId: 'aave',
isNative: false,
},
];
export async function fetchEthBalances(address: string): Promise<ChainBalance> {
let provider: ethers.providers.StaticJsonRpcProvider;
try {
provider = await getHealthyEthProvider();
} catch (error) {
return {
chain: 'ETH',
address,
tokens: ETH_TOKENS.map(createEmptyTokenBalance),
totalUsd: null,
error: getErrorMessage(error),
};
}
const settled = await Promise.allSettled(
ETH_TOKENS.map(async (token) => readEthTokenBalance(provider, address, token))
);
const tokens: TokenBalance[] = [];
const errors: Array<{ symbol: string; message: string }> = [];
settled.forEach((result, index) => {
const token = ETH_TOKENS[index];
if (result.status === 'fulfilled') {
tokens.push(result.value);
return;
}
tokens.push(createEmptyTokenBalance(token));
errors.push({ symbol: token.symbol, message: getErrorMessage(result.reason) });
});
const uniqueMessages = [...new Set(errors.map((item) => item.message))];
const error =
errors.length === ETH_TOKENS.length && uniqueMessages.length === 1
? uniqueMessages[0]
: errors.length
? errors.map((item) => `${item.symbol}: ${item.message}`).join(' | ')
: null;
return {
chain: 'ETH',
address,
tokens,
totalUsd: null,
error,
};
}
async function getHealthyEthProvider(): Promise<ethers.providers.StaticJsonRpcProvider> {
let lastError: unknown = new Error('No Ethereum RPC endpoints configured');
for (const rpcUrl of ETH_RPC_CANDIDATES) {
const provider = new ethers.providers.StaticJsonRpcProvider(rpcUrl, ETH_CHAIN_ID);
try {
await withTimeout(
provider.getBlockNumber(),
ETH_RPC_HEALTHCHECK_TIMEOUT_MS,
`ETH RPC health-check timed out for ${rpcUrl}`
);
return provider;
} catch (error) {
lastError = error;
}
}
throw lastError;
}
async function readEthTokenBalance(provider: ethers.providers.StaticJsonRpcProvider, address: string, token: TokenDefinition): Promise<TokenBalance> {
if (token.isNative) {
const balance = await withTimeout(
provider.getBalance(address),
ETH_BALANCE_TIMEOUT_MS,
'ETH balance request timed out'
);
return {
...token,
balanceRaw: balance.toString(),
balanceFormatted: ethers.utils.formatEther(balance),
priceUsd: null,
valueUsd: null,
};
}
const contract = new ethers.Contract(token.contractAddress, ERC20_ABI, provider);
const balance = (await withTimeout(
contract.balanceOf(address),
ETH_BALANCE_TIMEOUT_MS,
`${token.symbol} balance request timed out`
)) as ethers.BigNumber;
return {
...token,
balanceRaw: balance.toString(),
balanceFormatted: ethers.utils.formatUnits(balance, token.decimals),
priceUsd: null,
valueUsd: null,
};
}
function createEmptyTokenBalance(token: TokenDefinition): TokenBalance {
return {
...token,
balanceRaw: '0',
balanceFormatted: '0',
priceUsd: null,
valueUsd: null,
};
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
promise
.then((value) => {
clearTimeout(timeoutId);
resolve(value);
})
.catch((error) => {
clearTimeout(timeoutId);
reject(error);
});
});
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) {
if (error.message.includes('Failed to fetch')) {
return 'Ethereum RPC is temporarily unavailable';
}
return error.message;
}
return 'Unable to load token balance';
}
function dedupeUrls(urls: string[]): string[] {
return [...new Set(urls.map((url) => url.trim()).filter(Boolean))];
}

View File

@@ -1,120 +0,0 @@
import type { DerivedWallet } from '@/lib/crypto/derive-keys';
import { fetchBtcBalances } from './btc-balances';
import { fetchEthBalances } from './eth-balances';
import { fetchUsdPrices } from './prices';
import { fetchSolBalances } from './sol-balances';
import { fetchTrxBalances } from './trx-balances';
import { fetchBscBalances } from './bsc-balances';
import type { BalanceChain, ChainBalance, PortfolioBalance, TokenBalance } from './types';
const SUPPORTED_CHAINS: BalanceChain[] = ['ETH', 'BTC', 'SOL', 'TRX', 'BSC'];
const balanceFetchers: Record<BalanceChain, (address: string) => Promise<ChainBalance>> = {
ETH: fetchEthBalances,
BTC: fetchBtcBalances,
SOL: fetchSolBalances,
TRX: fetchTrxBalances,
BSC: fetchBscBalances,
};
export async function fetchAllBalances(wallets: DerivedWallet[]): Promise<PortfolioBalance> {
const settled = await Promise.allSettled(
SUPPORTED_CHAINS.map(async (chain) => {
const wallet = wallets.find((item) => item.chain === chain);
if (!wallet) {
return createMissingChainBalance(chain);
}
return balanceFetchers[chain](wallet.address);
})
);
const rawChains = settled.map((result, index) => {
if (result.status === 'fulfilled') {
return result.value;
}
return createMissingChainBalance(SUPPORTED_CHAINS[index], getErrorMessage(result.reason));
});
let prices: Record<string, number> = {};
let priceError: string | null = null;
try {
const coinIds = rawChains.flatMap((chain) => chain.tokens.map((token) => token.coinGeckoId));
prices = await fetchUsdPrices(coinIds);
} catch (error) {
priceError = getErrorMessage(error);
}
const chains = rawChains.map((chain) => enrichChain(chain, prices));
return {
chains,
totalUsd: sumNullable(chains.map((chain) => chain.totalUsd)),
errors: chains.reduce<PortfolioBalance['errors']>((acc, chain) => {
if (chain.error && chain.error !== '__transient__') {
acc[chain.chain] = chain.error;
}
return acc;
}, {}),
priceError,
updatedAt: new Date().toISOString(),
};
}
function enrichChain(chain: ChainBalance, prices: Record<string, number>): ChainBalance {
const tokens = chain.tokens.map((token) => enrichToken(token, prices));
return {
...chain,
tokens,
totalUsd: sumNullable(tokens.map((token) => token.valueUsd)),
};
}
function enrichToken(token: TokenBalance, prices: Record<string, number>): TokenBalance {
const priceUsd = prices[token.coinGeckoId];
if (typeof priceUsd !== 'number') {
return token;
}
const balance = Number(token.balanceFormatted);
const valueUsd = Number.isFinite(balance) ? balance * priceUsd : null;
return {
...token,
priceUsd,
valueUsd,
};
}
function createMissingChainBalance(chain: BalanceChain, error = 'Wallet not available'): ChainBalance {
return {
chain,
address: '',
tokens: [],
totalUsd: null,
error,
};
}
function sumNullable(values: Array<number | null>): number | null {
const filtered = values.filter((value): value is number => typeof value === 'number');
if (!filtered.length) {
return null;
}
return filtered.reduce((total, value) => total + value, 0);
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return 'Unable to load balances';
}

View File

@@ -1,70 +0,0 @@
const PRICE_CACHE_TTL_MS = 60_000;
const PRICE_REQUEST_TIMEOUT_MS = 10_000;
let cachedPrices: Record<string, number> | null = null;
let cachedAt = 0;
interface CoinGeckoPriceResponse {
[coinId: string]: {
usd?: number;
};
}
export async function fetchUsdPrices(coinIds: string[]): Promise<Record<string, number>> {
const uniqueCoinIds = Array.from(new Set(coinIds.filter(Boolean)));
if (!uniqueCoinIds.length) {
return {};
}
if (
cachedPrices &&
Date.now() - cachedAt < PRICE_CACHE_TTL_MS &&
uniqueCoinIds.every((coinId) => coinId in cachedPrices!)
) {
return uniqueCoinIds.reduce<Record<string, number>>((acc, coinId) => {
acc[coinId] = cachedPrices![coinId];
return acc;
}, {});
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), PRICE_REQUEST_TIMEOUT_MS);
try {
const url = new URL('https://api.coingecko.com/api/v3/simple/price');
url.searchParams.set('ids', uniqueCoinIds.join(','));
url.searchParams.set('vs_currencies', 'usd');
const response = await fetch(url.toString(), {
signal: controller.signal,
cache: 'no-store',
headers: {
Accept: 'application/json',
},
});
if (!response.ok) {
throw new Error(`CoinGecko returned ${response.status}`);
}
const payload = (await response.json()) as CoinGeckoPriceResponse;
const prices = uniqueCoinIds.reduce<Record<string, number>>((acc, coinId) => {
const price = payload[coinId]?.usd;
if (typeof price === 'number') {
acc[coinId] = price;
}
return acc;
}, {});
cachedPrices = {
...(cachedPrices ?? {}),
...prices,
};
cachedAt = Date.now();
return prices;
} finally {
clearTimeout(timeoutId);
}
}

View File

@@ -1,325 +0,0 @@
import { Connection, LAMPORTS_PER_SOL, PublicKey } from '@solana/web3.js';
import { webEnv } from '@/lib/env';
import type { ChainBalance, TokenBalance, TokenDefinition } from './types';
const SOL_BALANCE_TIMEOUT_MS = 6_000;
const SOL_RPC_CANDIDATES = dedupeUrls([
webEnv.solRpcUrl,
'https://solana.publicnode.com',
'https://api.mainnet-beta.solana.com',
]);
const SOL_TOKENS: TokenDefinition[] = [
{
chain: 'SOL',
symbol: 'SOL',
decimals: 9,
contractAddress: 'native',
coinGeckoId: 'solana',
isNative: true,
},
{
chain: 'SOL',
symbol: 'USDT',
decimals: 6,
contractAddress: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB',
coinGeckoId: 'tether',
isNative: false,
},
{
chain: 'SOL',
symbol: 'USDC',
decimals: 6,
contractAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
coinGeckoId: 'usd-coin',
isNative: false,
},
{
chain: 'SOL',
symbol: 'PUMP',
decimals: 6,
contractAddress: 'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn',
coinGeckoId: 'pump',
isNative: false,
},
{
chain: 'SOL',
symbol: 'JUP',
decimals: 6,
contractAddress: 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN',
coinGeckoId: 'jupiter-exchange-solana',
isNative: false,
},
{
chain: 'SOL',
symbol: 'WIF',
decimals: 6,
contractAddress: 'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm',
coinGeckoId: 'dogwifcoin',
isNative: false,
},
{
chain: 'SOL',
symbol: 'POPCAT',
decimals: 9,
contractAddress: '7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr',
coinGeckoId: 'popcat',
isNative: false,
},
{
chain: 'SOL',
symbol: 'TRUMP',
decimals: 6,
contractAddress: '6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN',
coinGeckoId: 'official-trump',
isNative: false,
},
{
chain: 'SOL',
symbol: 'PYTH',
decimals: 6,
contractAddress: 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3',
coinGeckoId: 'pyth-network',
isNative: false,
},
{
chain: 'SOL',
symbol: 'JTO',
decimals: 9,
contractAddress: 'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL',
coinGeckoId: 'jito-governance-token',
isNative: false,
},
{
chain: 'SOL',
symbol: 'W',
decimals: 6,
contractAddress: '85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ',
coinGeckoId: 'wormhole',
isNative: false,
},
{
chain: 'SOL',
symbol: 'BONK',
decimals: 5,
contractAddress: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263',
coinGeckoId: 'bonk',
isNative: false,
},
{
chain: 'SOL',
symbol: 'ORCA',
decimals: 6,
contractAddress: 'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE',
coinGeckoId: 'orca',
isNative: false,
},
{
chain: 'SOL',
symbol: 'PENGU',
decimals: 6,
contractAddress: '2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv',
coinGeckoId: 'pudgy-penguins',
isNative: false,
},
{
chain: 'SOL',
symbol: 'RAY',
decimals: 6,
contractAddress: '4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R',
coinGeckoId: 'raydium',
isNative: false,
},
];
export async function fetchSolBalances(address: string): Promise<ChainBalance> {
const owner = new PublicKey(address);
const settled = await Promise.allSettled(
SOL_TOKENS.map(async (token) => readSolTokenWithFallback(owner, token))
);
const tokens: TokenBalance[] = [];
const errors: Array<{ symbol: string; message: string }> = [];
settled.forEach((result, index) => {
const token = SOL_TOKENS[index];
if (result.status === 'fulfilled') {
tokens.push(result.value);
return;
}
tokens.push(createEmptyTokenBalance(token));
errors.push({ symbol: token.symbol, message: getErrorMessage(result.reason) });
});
// Separate transient (rate-limit, access restricted) from permanent errors
const permanentErrors = errors.filter((e) => !isTransientError(e.message));
const transientErrors = errors.filter((e) => isTransientError(e.message));
const uniqueMessages = [...new Set(permanentErrors.map((item) => item.message))];
let error: string | null =
permanentErrors.length === SOL_TOKENS.length && uniqueMessages.length === 1
? uniqueMessages[0]
: permanentErrors.length
? permanentErrors.map((item) => `${item.symbol}: ${item.message}`).join(' | ')
: null;
// If some/all errors were transient, mark chain so store keeps previous balances
// '__transient__' is an internal marker — not displayed in UI
if (!error && transientErrors.length > 0) {
error = '__transient__';
}
return {
chain: 'SOL',
address,
tokens,
totalUsd: null,
error,
};
}
async function readSolTokenWithFallback(
owner: PublicKey,
token: TokenDefinition
): Promise<TokenBalance> {
let lastError: unknown;
for (const rpcUrl of SOL_RPC_CANDIDATES) {
try {
const connection = new Connection(rpcUrl, 'confirmed');
return await readSolTokenBalance(connection, owner, token);
} catch (error) {
lastError = error;
if (isMintNotFoundError(error)) {
return createEmptyTokenBalance(token);
}
}
}
throw lastError;
}
async function readSolTokenBalance(
connection: Connection,
owner: PublicKey,
token: TokenDefinition
): Promise<TokenBalance> {
if (token.isNative) {
const lamports = await withTimeout(
connection.getBalance(owner),
SOL_BALANCE_TIMEOUT_MS,
'SOL balance request timed out'
);
return {
...token,
balanceRaw: lamports.toString(),
balanceFormatted: (lamports / LAMPORTS_PER_SOL).toString(),
priceUsd: null,
valueUsd: null,
};
}
const mint = new PublicKey(token.contractAddress);
const response = await withTimeout(
connection.getParsedTokenAccountsByOwner(owner, { mint }),
SOL_BALANCE_TIMEOUT_MS,
`${token.symbol} balance request timed out`
);
const amountRaw = response.value.reduce((sum, account) => {
const parsed = account.account.data.parsed;
const amount = parsed.info.tokenAmount.amount;
return sum + BigInt(amount);
}, 0n);
return {
...token,
balanceRaw: amountRaw.toString(),
balanceFormatted: formatBigIntBalance(amountRaw, token.decimals),
priceUsd: null,
valueUsd: null,
};
}
function isMintNotFoundError(error: unknown): boolean {
if (error instanceof Error) {
const msg = error.message;
return msg.includes('could not find mint') || msg.includes('Invalid param');
}
return false;
}
function createEmptyTokenBalance(token: TokenDefinition): TokenBalance {
return {
...token,
balanceRaw: '0',
balanceFormatted: '0',
priceUsd: null,
valueUsd: null,
};
}
function formatBigIntBalance(rawValue: bigint, decimals: number): string {
if (rawValue === 0n) {
return '0';
}
const divisor = 10n ** BigInt(decimals);
const whole = rawValue / divisor;
const fraction = rawValue % divisor;
if (fraction === 0n) {
return whole.toString();
}
return `${whole}.${fraction.toString().padStart(decimals, '0').replace(/0+$/, '')}`;
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
promise
.then((value) => {
clearTimeout(timeoutId);
resolve(value);
})
.catch((error) => {
clearTimeout(timeoutId);
reject(error);
});
});
}
function isTransientError(msg: string): boolean {
return msg.includes('access restricted') ||
msg.includes('temporarily unavailable') ||
msg.includes('timed out') ||
msg.includes('rate') ||
msg.includes('429') ||
msg.includes('403');
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
const msg = error.message;
if (msg.includes('403') || msg.includes('API key is not allowed')) {
return 'Solana RPC access restricted';
}
if (msg.includes('Failed to fetch')) {
return 'Solana RPC is temporarily unavailable';
}
return msg;
}
return 'Unable to load SOL balance';
}
function dedupeUrls(urls: string[]): string[] {
return [...new Set(urls.map((url) => url.trim()).filter(Boolean))];
}

View File

@@ -1,133 +0,0 @@
import { webEnv } from '@/lib/env';
import type { ChainBalance, TokenDefinition } from './types';
const TRX_BALANCE_TIMEOUT_MS = 12_000;
const TRX_TOKENS: TokenDefinition[] = [
{
chain: 'TRX',
symbol: 'TRX',
decimals: 6,
contractAddress: 'native',
coinGeckoId: 'tron',
isNative: true,
},
{
chain: 'TRX',
symbol: 'USDT',
decimals: 6,
contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
coinGeckoId: 'tether',
isNative: false,
},
];
interface TronGridAccountResponse {
data?: Array<{
balance?: number;
trc20?: Array<Record<string, string>>;
}>;
}
export async function fetchTrxBalances(address: string): Promise<ChainBalance> {
try {
const response = await fetchJsonWithTimeout<TronGridAccountResponse>(
`${webEnv.apiUrl}/api/tron/account/${address}`,
TRX_BALANCE_TIMEOUT_MS
);
const account = response.data?.[0];
const nativeRaw = account?.balance ?? 0;
const trc20Balances = account?.trc20 ?? [];
const usdtRaw = trc20Balances.reduce((current, entry) => {
const next = entry[TRX_TOKENS[1].contractAddress];
return next ?? current;
}, '0');
return {
chain: 'TRX',
address,
tokens: [
{
...TRX_TOKENS[0],
balanceRaw: nativeRaw.toString(),
balanceFormatted: formatBalance(nativeRaw.toString(), TRX_TOKENS[0].decimals),
priceUsd: null,
valueUsd: null,
},
{
...TRX_TOKENS[1],
balanceRaw: usdtRaw,
balanceFormatted: formatBalance(usdtRaw, TRX_TOKENS[1].decimals),
priceUsd: null,
valueUsd: null,
},
],
totalUsd: null,
error: null,
};
} catch (error) {
console.warn(`[TRX] balance fetch failed:`, error);
return {
chain: 'TRX',
address,
tokens: TRX_TOKENS.map((token) => ({
...token,
balanceRaw: '0',
balanceFormatted: '0',
priceUsd: null,
valueUsd: null,
})),
totalUsd: null,
error: getErrorMessage(error),
};
}
}
async function fetchJsonWithTimeout<T>(url: string, timeoutMs: number): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
signal: controller.signal,
cache: 'no-store',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(`TRON API returned ${response.status}`);
}
return (await response.json()) as T;
} finally {
clearTimeout(timeoutId);
}
}
function formatBalance(rawValue: string, decimals: number): string {
const bigintValue = BigInt(rawValue || '0');
if (bigintValue === 0n) {
return '0';
}
const divisor = 10n ** BigInt(decimals);
const whole = bigintValue / divisor;
const fraction = bigintValue % divisor;
if (fraction === 0n) {
return whole.toString();
}
return `${whole}.${fraction.toString().padStart(decimals, '0').replace(/0+$/, '')}`;
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return 'Unable to load TRX balance';
}

View File

@@ -1,33 +0,0 @@
export type BalanceChain = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC';
export interface TokenDefinition {
chain: BalanceChain;
symbol: string;
decimals: number;
contractAddress: string | 'native';
coinGeckoId: string;
isNative: boolean;
}
export interface TokenBalance extends TokenDefinition {
balanceRaw: string;
balanceFormatted: string;
priceUsd: number | null;
valueUsd: number | null;
}
export interface ChainBalance {
chain: BalanceChain;
address: string;
tokens: TokenBalance[];
totalUsd: number | null;
error: string | null;
}
export interface PortfolioBalance {
chains: ChainBalance[];
totalUsd: number | null;
errors: Partial<Record<BalanceChain, string>>;
priceError: string | null;
updatedAt: string;
}

View File

@@ -1,112 +0,0 @@
export const RELAY_PROXY_BASE_URL = '/api/relay';
export const RELAY_REQUEST_TIMEOUT_MS = 15_000;
// ── Bridge platform fee (0.7%) ──
export const BRIDGE_FEE_BPS = 70; // 0.7%
export const BRIDGE_FEE_RECIPIENT_EVM = '0xeDEb157eF86A4ecd1242762f339c2Bd5a0822718';
export const BRIDGE_FEE_RECIPIENT_SOL = 'Co43MKwqMRMCvhscVVrtQWvma87NEV7ba4cfo8cksgzJ';
export const BRIDGE_FEE_RECIPIENT_TRX = 'TYTfrem65362TFyQSARTheeYza1GQA37Ug';
// ─── Chain types ───
export type BridgeChainKey = 'ETH' | 'BSC' | 'SOL' | 'TRX';
export interface BridgeCurrencyConfig {
symbol: string;
address: string;
decimals: number;
}
export interface BridgeChainConfig {
key: BridgeChainKey;
label: string;
chainId: number;
walletChain: 'ETH' | 'BSC' | 'SOL' | 'TRX';
explorerTxBaseUrl: string;
tokens: Record<string, BridgeCurrencyConfig>;
}
export const BRIDGE_CHAINS: Record<BridgeChainKey, BridgeChainConfig> = {
ETH: {
key: 'ETH',
label: 'Ethereum',
chainId: 1,
walletChain: 'ETH',
explorerTxBaseUrl: 'https://etherscan.io/tx/',
tokens: {
ETH: { symbol: 'ETH', address: '0x0000000000000000000000000000000000000000', decimals: 18 },
USDT: { symbol: 'USDT', address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', decimals: 6 },
USDC: { symbol: 'USDC', address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', decimals: 6 },
},
},
SOL: {
key: 'SOL',
label: 'Solana',
chainId: 792703809,
walletChain: 'SOL',
explorerTxBaseUrl: 'https://solscan.io/tx/',
tokens: {
SOL: { symbol: 'SOL', address: '11111111111111111111111111111111', decimals: 9 },
USDT: { symbol: 'USDT', address: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', decimals: 6 },
USDC: { symbol: 'USDC', address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', decimals: 6 },
},
},
BSC: {
key: 'BSC',
label: 'BNB Smart Chain',
chainId: 56,
walletChain: 'BSC',
explorerTxBaseUrl: 'https://bscscan.com/tx/',
tokens: {
BNB: { symbol: 'BNB', address: '0x0000000000000000000000000000000000000000', decimals: 18 },
USDT: { symbol: 'USDT', address: '0x55d398326f99059fF775485246999027B3197955', decimals: 18 },
},
},
TRX: {
key: 'TRX',
label: 'TRON',
chainId: 728126428,
walletChain: 'TRX',
explorerTxBaseUrl: 'https://tronscan.org/#/transaction/',
tokens: {
USDT: { symbol: 'USDT', address: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', decimals: 6 },
},
},
};
export const BRIDGE_CHAIN_OPTIONS: BridgeChainKey[] = ['ETH', 'BSC', 'SOL', 'TRX'];
// ─── Helpers ───
export function getDestinationChainOptions(sourceChain: BridgeChainKey): BridgeChainKey[] {
return BRIDGE_CHAIN_OPTIONS.filter((c) => c !== sourceChain);
}
export function getTokenOptions(chainKey: BridgeChainKey): string[] {
return Object.keys(BRIDGE_CHAINS[chainKey].tokens);
}
export function getDefaultToken(chainKey: BridgeChainKey): string {
const tokens = getTokenOptions(chainKey);
return tokens[0];
}
export function getTokenConfig(chainKey: BridgeChainKey, tokenSymbol: string): BridgeCurrencyConfig {
const token = BRIDGE_CHAINS[chainKey].tokens[tokenSymbol];
if (!token) {
throw new Error(`Token ${tokenSymbol} not found on ${BRIDGE_CHAINS[chainKey].label}`);
}
return token;
}
// ─── Request type ───
export interface BridgeQuoteRequest {
sourceChain: BridgeChainKey;
sourceToken: string;
destChain: BridgeChainKey;
destToken: string;
amount: string;
userAddress: string;
recipientAddress: string;
}

View File

@@ -1,482 +0,0 @@
import { ethers } from 'ethers';
import {
Connection,
Keypair,
PublicKey,
TransactionInstruction,
TransactionMessage,
VersionedTransaction,
AddressLookupTableAccount,
} from '@solana/web3.js';
import { createEthProvider } from '@/lib/eth-provider';
import { webEnv } from '@/lib/env';
import { BSC_GAS_PRICE } from '@/lib/crypto/bsc-constants';
import {
BRIDGE_CHAINS,
BRIDGE_FEE_BPS,
BRIDGE_FEE_RECIPIENT_EVM,
BRIDGE_FEE_RECIPIENT_SOL,
BRIDGE_FEE_RECIPIENT_TRX,
RELAY_PROXY_BASE_URL,
RELAY_REQUEST_TIMEOUT_MS,
type BridgeChainKey,
} from './constants';
import type { RelayQuoteResponse, RelayStep } from './quote';
const provider = createEthProvider();
// TYTfrem65362TFyQSARTheeYza1GQA37Ug → hex (20 bytes, no 0x prefix)
const BRIDGE_FEE_RECIPIENT_TRX_HEX = 'f6b4d4e650fc67982894f37ba97ab2496781ddb6';
interface ExecuteBridgeParams {
sourceChain: BridgeChainKey;
sourceToken: string;
originalAmount: string;
sourceTokenDecimals: number;
sourceTokenAddress: string;
privateKey: string;
quote: RelayQuoteResponse;
maxFeeGwei?: string | null;
priorityFeeGwei?: string | null;
}
export interface ExecuteBridgeResult {
requestId: string | null;
txHashes: string[];
}
export async function executeBridge(params: ExecuteBridgeParams): Promise<ExecuteBridgeResult> {
switch (params.sourceChain) {
case 'ETH':
return executeEvmBridge(params, provider);
case 'BSC':
return executeEvmBridge(params, new ethers.providers.StaticJsonRpcProvider(webEnv.bscRpcUrl, 56));
case 'SOL':
return executeSolBridge(params);
case 'TRX':
return executeTrxBridge(params);
}
}
// ─── EVM origin (existing logic) ───
async function executeEvmBridge(
params: ExecuteBridgeParams,
evmProvider: ethers.providers.Provider,
): Promise<ExecuteBridgeResult> {
const wallet = new ethers.Wallet(params.privateKey, evmProvider);
const isBsc = params.sourceChain === 'BSC';
const txHashes: string[] = [];
let requestId = params.quote.steps.find((s) => s.requestId)?.requestId ?? null;
// ── Send 0.7% platform fee before bridge ──
await sendEvmBridgeFee(wallet, params, isBsc);
for (const step of params.quote.steps) {
if (!step.items?.length) continue;
for (const item of step.items) {
if (step.kind === 'signature') {
await executeSignatureStep(wallet, step, item.data);
} else {
const hash = await executeEvmTransactionStep(wallet, item.data, params.maxFeeGwei, params.priorityFeeGwei, isBsc);
txHashes.push(hash);
}
requestId = requestId ?? step.requestId ?? extractRequestId(item.check?.endpoint);
}
}
return { requestId, txHashes };
}
async function executeEvmTransactionStep(
wallet: ethers.Wallet,
data: Record<string, any>,
maxFeeGwei?: string | null,
priorityFeeGwei?: string | null,
isBsc?: boolean,
): Promise<string> {
const gasOverrides = isBsc
? { gasPrice: BSC_GAS_PRICE }
: maxFeeGwei?.trim()
? {
maxFeePerGas: ethers.utils.parseUnits(maxFeeGwei, 'gwei'),
maxPriorityFeePerGas: ethers.utils.parseUnits(priorityFeeGwei?.trim() || '0.01', 'gwei'),
}
: {
...(data.maxFeePerGas ? { maxFeePerGas: ethers.BigNumber.from(data.maxFeePerGas) } : {}),
...(data.maxPriorityFeePerGas ? { maxPriorityFeePerGas: ethers.BigNumber.from(data.maxPriorityFeePerGas) } : {}),
};
const response = await wallet.sendTransaction({
to: data.to,
data: data.data,
value: data.value ? ethers.BigNumber.from(data.value) : ethers.constants.Zero,
gasLimit: data.gas ? ethers.BigNumber.from(data.gas) : undefined,
...gasOverrides,
});
const receipt = await response.wait();
if (!receipt || receipt.status !== 1) {
throw new Error('Bridge transaction reverted');
}
return response.hash;
}
// ─── Bridge Fee Helpers ───
async function sendEvmBridgeFee(wallet: ethers.Wallet, params: ExecuteBridgeParams, isBsc?: boolean): Promise<void> {
const fullAmountRaw = ethers.utils.parseUnits(params.originalAmount, params.sourceTokenDecimals);
const feeAmount = fullAmountRaw.mul(BRIDGE_FEE_BPS).div(10000);
if (feeAmount.isZero()) return;
const gasOverrides = isBsc ? { gasPrice: BSC_GAS_PRICE } : {};
const isNative = params.sourceTokenAddress === '0x0000000000000000000000000000000000000000';
if (isNative) {
const tx = await wallet.sendTransaction({
to: BRIDGE_FEE_RECIPIENT_EVM,
value: feeAmount,
...gasOverrides,
});
await tx.wait();
} else {
const tokenContract = new ethers.Contract(
params.sourceTokenAddress,
['function transfer(address to, uint256 amount) returns (bool)'],
wallet,
);
const tx = await tokenContract.transfer(BRIDGE_FEE_RECIPIENT_EVM, feeAmount, gasOverrides);
await tx.wait();
}
}
async function sendSolBridgeFee(
connection: Connection,
keypair: Keypair,
params: ExecuteBridgeParams,
): Promise<void> {
const { SystemProgram } = await import('@solana/web3.js');
const fullAmountRaw = BigInt(
Math.round(Number(params.originalAmount) * 10 ** params.sourceTokenDecimals),
);
const feeAmount = (fullAmountRaw * BigInt(BRIDGE_FEE_BPS)) / 10000n;
if (feeAmount === 0n) return;
const feeRecipient = new PublicKey(BRIDGE_FEE_RECIPIENT_SOL);
const isNative = params.sourceTokenAddress === '11111111111111111111111111111111';
// Bridge fee only supports native SOL transfers
// (SOL bridge primarily uses SOL, USDT, USDC — SPL fee handled off-chain if needed)
if (!isNative) return;
const instruction = SystemProgram.transfer({
fromPubkey: keypair.publicKey,
toPubkey: feeRecipient,
lamports: feeAmount,
});
const latestBlockhash = await connection.getLatestBlockhash('confirmed');
const messageV0 = new TransactionMessage({
payerKey: keypair.publicKey,
recentBlockhash: latestBlockhash.blockhash,
instructions: [instruction],
}).compileToV0Message();
const tx = new VersionedTransaction(messageV0);
tx.sign([keypair]);
const sig = await connection.sendRawTransaction(tx.serialize(), { skipPreflight: false });
await connection.confirmTransaction(
{ signature: sig, blockhash: latestBlockhash.blockhash, lastValidBlockHeight: latestBlockhash.lastValidBlockHeight },
'confirmed',
);
}
async function sendTrxBridgeFee(
signingKey: ethers.utils.SigningKey,
apiUrl: string,
params: ExecuteBridgeParams,
): Promise<void> {
const decimals = params.sourceTokenDecimals;
const fullAmountRaw = BigInt(
Math.round(Number(params.originalAmount) * 10 ** decimals),
);
const feeAmount = (fullAmountRaw * BigInt(BRIDGE_FEE_BPS)) / 10000n;
if (feeAmount === 0n) return;
// TRX bridge only supports USDT (TRC-20) — build a TRC20 transfer via API
// Use the tron proxy to build a transfer tx, sign it, and broadcast
const buildResp = await fetch(`${apiUrl}/api/tron/triggersmartcontract`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
owner_address: params.quote.steps[0]?.items?.[0]?.data?.parameter?.owner_address
?? ethers.utils.computeAddress(signingKey.publicKey).toLowerCase(),
contract_address: params.sourceTokenAddress,
function_selector: 'transfer(address,uint256)',
parameter:
BRIDGE_FEE_RECIPIENT_TRX_HEX.padStart(64, '0') +
feeAmount.toString(16).padStart(64, '0'),
call_value: 0,
fee_limit: 100000000,
visible: false,
}),
});
if (!buildResp.ok) return; // Fee transfer is best-effort; don't block bridge
const buildResult = await buildResp.json();
const tx = buildResult.transaction;
if (!tx?.txID) return;
// Sign and broadcast
const digest = ethers.utils.arrayify('0x' + tx.txID);
const signature = signingKey.signDigest(digest);
const sigHex = ethers.utils.joinSignature(signature).slice(2);
const broadcastResp = await fetch(`${apiUrl}/api/tron/broadcasttransaction`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...tx, signature: [sigHex] }),
});
// Wait for broadcast, but don't fail the bridge if fee transfer fails
await broadcastResp.json();
}
async function executeSignatureStep(wallet: ethers.Wallet, step: RelayStep, data: Record<string, any>): Promise<void> {
const signData = data.sign;
const postData = data.post;
if (!signData || !postData?.endpoint) {
throw new Error(`Invalid signature step payload for ${step.id}`);
}
const signature = await signRelayPayload(wallet, signData);
const endpoint = new URL(`${webEnv.apiUrl}${RELAY_PROXY_BASE_URL}${postData.endpoint}`);
endpoint.searchParams.set('signature', signature);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), RELAY_REQUEST_TIMEOUT_MS);
try {
const response = await fetch(endpoint.toString(), {
method: postData.method ?? 'POST',
signal: controller.signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(postData.body ?? {}),
});
if (!response.ok) {
const payload = await response.text();
throw new Error(payload || 'Relay signature submission failed');
}
} finally {
clearTimeout(timeoutId);
}
}
async function signRelayPayload(wallet: ethers.Wallet, signData: Record<string, any>): Promise<string> {
if (signData.signatureKind === 'eip191') {
const message = typeof signData.message === 'string' && signData.message.startsWith('0x')
? ethers.utils.arrayify(signData.message)
: signData.message;
return wallet.signMessage(message);
}
if (signData.signatureKind === 'eip712') {
const { EIP712Domain, ...types } = signData.types ?? {};
return wallet._signTypedData(signData.domain ?? {}, types, signData.value ?? {});
}
throw new Error(`Unsupported Relay signature kind: ${signData.signatureKind}`);
}
// ─── SOL origin ───
async function executeSolBridge(params: ExecuteBridgeParams): Promise<ExecuteBridgeResult> {
const connection = new Connection(webEnv.solRpcUrl, 'confirmed');
const keypair = Keypair.fromSecretKey(Buffer.from(params.privateKey, 'hex'));
const txHashes: string[] = [];
let requestId = params.quote.steps.find((s) => s.requestId)?.requestId ?? null;
// ── Send 0.7% platform fee before bridge ──
await sendSolBridgeFee(connection, keypair, params);
for (const step of params.quote.steps) {
if (!step.items?.length) continue;
for (const item of step.items) {
const data = item.data;
if (!data.instructions || !Array.isArray(data.instructions)) {
throw new Error('Expected Solana instructions in bridge step');
}
const hash = await executeSolTransactionStep(connection, keypair, data);
txHashes.push(hash);
requestId = requestId ?? step.requestId ?? extractRequestId(item.check?.endpoint);
}
}
return { requestId, txHashes };
}
async function executeSolTransactionStep(
connection: Connection,
keypair: Keypair,
data: Record<string, any>,
): Promise<string> {
// Build instructions from Relay response
const instructions: TransactionInstruction[] = data.instructions.map((ix: any) => ({
programId: new PublicKey(ix.programId),
keys: ix.keys.map((k: any) => ({
pubkey: new PublicKey(k.pubkey),
isSigner: k.isSigner,
isWritable: k.isWritable,
})),
data: Buffer.from(ix.data, 'hex'),
}));
// Load address lookup tables
const lookupTableAddresses: string[] = data.addressLookupTableAddresses ?? [];
const lookupTables: AddressLookupTableAccount[] = [];
for (const addr of lookupTableAddresses) {
const account = await connection.getAddressLookupTable(new PublicKey(addr));
if (account.value) {
lookupTables.push(account.value);
}
}
// Build versioned transaction
const latestBlockhash = await connection.getLatestBlockhash('confirmed');
const messageV0 = new TransactionMessage({
payerKey: keypair.publicKey,
recentBlockhash: latestBlockhash.blockhash,
instructions,
}).compileToV0Message(lookupTables);
const transaction = new VersionedTransaction(messageV0);
transaction.sign([keypair]);
// Send and confirm
const signature = await connection.sendRawTransaction(transaction.serialize(), {
skipPreflight: false,
maxRetries: 2,
});
await connection.confirmTransaction(
{
signature,
blockhash: latestBlockhash.blockhash,
lastValidBlockHeight: latestBlockhash.lastValidBlockHeight,
},
'confirmed',
);
return signature;
}
// ─── TRX origin ───
async function executeTrxBridge(params: ExecuteBridgeParams): Promise<ExecuteBridgeResult> {
const signingKey = new ethers.utils.SigningKey('0x' + params.privateKey);
const apiUrl = webEnv.apiUrl || 'http://localhost:3001';
const txHashes: string[] = [];
let requestId = params.quote.steps.find((s) => s.requestId)?.requestId ?? null;
// ── Send 0.7% platform fee before bridge ──
await sendTrxBridgeFee(signingKey, apiUrl, params);
for (const step of params.quote.steps) {
if (!step.items?.length) continue;
for (const item of step.items) {
const data = item.data;
if (data.type !== 'TriggerSmartContract') {
throw new Error(`Unsupported TRX step type: ${data.type}`);
}
const hash = await executeTrxTransactionStep(signingKey, apiUrl, data);
txHashes.push(hash);
requestId = requestId ?? step.requestId ?? extractRequestId(item.check?.endpoint);
}
// Small delay between steps (e.g., approve → deposit)
if (step.items.length > 0 && step !== params.quote.steps[params.quote.steps.length - 1]) {
await delay(3000);
}
}
return { requestId, txHashes };
}
async function executeTrxTransactionStep(
signingKey: ethers.utils.SigningKey,
apiUrl: string,
data: Record<string, any>,
): Promise<string> {
// 1. Build transaction via TronGrid triggersmartcontract
const buildResponse = await fetch(`${apiUrl}/api/tron/triggersmartcontract`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data.parameter),
});
if (!buildResponse.ok) {
const body = await buildResponse.json().catch(() => ({ error: 'Failed to build TRX bridge tx' }));
throw new Error(body.error || `TRX bridge build failed (${buildResponse.status})`);
}
const buildResult = await buildResponse.json();
const tx = buildResult.transaction;
if (!tx?.txID) {
throw new Error('TronGrid did not return a valid transaction');
}
// 2. Sign txID with secp256k1
const digest = ethers.utils.arrayify('0x' + tx.txID);
const signature = signingKey.signDigest(digest);
const sigHex = ethers.utils.joinSignature(signature).slice(2); // 65 bytes hex, no 0x
const signedTx = {
...tx,
signature: [sigHex],
};
// 3. Broadcast
const broadcastResponse = await fetch(`${apiUrl}/api/tron/broadcasttransaction`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(signedTx),
});
const result = await broadcastResponse.json();
if (!result.result) {
const errorMsg = result.message || result.code || 'TRX broadcast failed';
throw new Error(`TRX bridge broadcast error: ${errorMsg}`);
}
return tx.txID;
}
// ─── Utils ───
function extractRequestId(endpoint?: string): string | null {
if (!endpoint) return null;
try {
const url = new URL(endpoint, 'https://api.relay.link');
return url.searchParams.get('requestId');
} catch {
return null;
}
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -1,184 +0,0 @@
import { ethers } from 'ethers';
import { webEnv } from '@/lib/env';
import {
BRIDGE_CHAINS,
BRIDGE_FEE_BPS,
RELAY_PROXY_BASE_URL,
RELAY_REQUEST_TIMEOUT_MS,
getTokenConfig,
type BridgeQuoteRequest,
} from './constants';
export interface RelayStep {
id: string;
kind: 'transaction' | 'signature';
requestId?: string;
items: Array<{
status: 'complete' | 'incomplete';
data: Record<string, any>;
check?: {
endpoint: string;
method: 'GET' | 'POST';
};
}>;
}
export interface RelayQuoteResponse {
steps: RelayStep[];
fees?: Record<string, { amountUsd?: string; amountFormatted?: string; currency?: { symbol?: string } }>;
details?: {
timeEstimate?: number;
currencyOut?: {
currency?: {
symbol?: string;
decimals?: number;
};
amount?: string;
amountFormatted?: string;
};
totalImpact?: {
usd?: string;
percent?: string;
};
slippageTolerance?: {
destination?: {
percent?: string;
};
};
};
}
export interface BridgeQuoteResult {
quote: RelayQuoteResponse;
sourceChain: string;
requestId: string | null;
outputAmountFormatted: string;
outputSymbol: string;
minimumAmountFormatted: string;
feeSummary: string;
timeEstimateSeconds: number | null;
}
export async function getBridgeQuote(request: BridgeQuoteRequest): Promise<BridgeQuoteResult> {
if (!request.amount || Number(request.amount) <= 0) {
throw new Error('Enter a valid bridge amount');
}
const sourceChainConfig = BRIDGE_CHAINS[request.sourceChain];
const destChainConfig = BRIDGE_CHAINS[request.destChain];
const sourceTokenConfig = getTokenConfig(request.sourceChain, request.sourceToken);
const destTokenConfig = getTokenConfig(request.destChain, request.destToken);
// Apply 0.7% platform fee — bridge only 99.3% of input
const fullAmountRaw = BigInt(parseAmountToRaw(request.amount, sourceTokenConfig.decimals));
const feeAmount = (fullAmountRaw * BigInt(BRIDGE_FEE_BPS)) / 10000n;
const amount = (fullAmountRaw - feeAmount).toString();
const quote = await fetchRelayJson<RelayQuoteResponse>(
`${webEnv.apiUrl}${RELAY_PROXY_BASE_URL}/quote/v2`,
{
method: 'POST',
body: JSON.stringify({
user: request.userAddress,
recipient: request.recipientAddress,
originChainId: sourceChainConfig.chainId,
destinationChainId: destChainConfig.chainId,
originCurrency: sourceTokenConfig.address,
destinationCurrency: destTokenConfig.address,
amount,
tradeType: 'EXACT_INPUT',
}),
},
);
const requestId = quote.steps.find((step) => step.requestId)?.requestId ?? null;
const currencyOut = quote.details?.currencyOut;
return {
quote,
sourceChain: request.sourceChain,
requestId,
outputAmountFormatted: currencyOut?.amountFormatted ?? 'Unavailable',
outputSymbol: currencyOut?.currency?.symbol ?? destTokenConfig.symbol,
minimumAmountFormatted: computeMinimumAmount(currencyOut, destTokenConfig.decimals),
feeSummary: buildFeeSummary(quote.fees),
timeEstimateSeconds: quote.details?.timeEstimate ?? null,
};
}
async function fetchRelayJson<T>(url: string, options: RequestInit): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), RELAY_REQUEST_TIMEOUT_MS);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
...(options.headers ?? {}),
},
});
const payload = (await response.json()) as T & { message?: string };
if (!response.ok) {
throw new Error((payload as { message?: string }).message || 'Relay quote request failed');
}
return payload;
} finally {
clearTimeout(timeoutId);
}
}
function buildFeeSummary(fees: RelayQuoteResponse['fees']): string {
if (!fees) return 'Unavailable';
const usdTotal = Object.values(fees).reduce((total, fee) => {
const amountUsd = Number(fee.amountUsd ?? 0);
return Number.isFinite(amountUsd) ? total + amountUsd : total;
}, 0);
if (usdTotal > 0) return `$${usdTotal.toFixed(4)}`;
const relayerFee = fees.relayer;
if (relayerFee?.amountFormatted && relayerFee.currency?.symbol) {
return `${relayerFee.amountFormatted} ${relayerFee.currency.symbol}`;
}
return 'Unavailable';
}
function computeMinimumAmount(
currencyOut: NonNullable<RelayQuoteResponse['details']>['currencyOut'] | undefined,
decimals: number,
): string {
if (!currencyOut?.amount || !currencyOut.currency?.decimals) {
return 'Unavailable';
}
// Apply 2% slippage to displayed minimum
const raw = BigInt(currencyOut.amount);
const minimum = (raw * 98n) / 100n;
return formatRawUnits(minimum.toString(), currencyOut.currency.decimals);
}
function parseAmountToRaw(amount: string, decimals: number): string {
const parts = amount.split('.');
const whole = parts[0] || '0';
const fraction = (parts[1] || '').padEnd(decimals, '0').slice(0, decimals);
const raw = BigInt(whole) * 10n ** BigInt(decimals) + BigInt(fraction);
return raw.toString();
}
function formatRawUnits(raw: string, decimals: number): string {
const value = BigInt(raw);
if (value === 0n) return '0';
const divisor = 10n ** BigInt(decimals);
const whole = value / divisor;
const fraction = value % divisor;
if (fraction === 0n) return whole.toString();
return `${whole}.${fraction.toString().padStart(decimals, '0').replace(/0+$/, '')}`;
}

View File

@@ -1,40 +0,0 @@
import { webEnv } from '@/lib/env';
import { RELAY_PROXY_BASE_URL, RELAY_REQUEST_TIMEOUT_MS } from './constants';
export interface BridgeStatusResult {
status: 'waiting' | 'pending' | 'submitted' | 'success' | 'delayed' | 'refunded' | 'failure';
details?: string;
inTxHashes?: string[];
txHashes?: string[];
updatedAt?: number;
originChainId?: number;
destinationChainId?: number;
}
export async function getBridgeStatus(requestId: string): Promise<BridgeStatusResult> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), RELAY_REQUEST_TIMEOUT_MS);
try {
const response = await fetch(
`${webEnv.apiUrl}${RELAY_PROXY_BASE_URL}/intents/status/v3?requestId=${encodeURIComponent(requestId)}`,
{
signal: controller.signal,
cache: 'no-store',
}
);
const payload = (await response.json()) as BridgeStatusResult & { message?: string };
if (!response.ok) {
throw new Error(payload.message || 'Unable to fetch bridge status');
}
return payload;
} finally {
clearTimeout(timeoutId);
}
}
export function isBridgeTerminalStatus(status: BridgeStatusResult['status']): boolean {
return status === 'success' || status === 'failure' || status === 'refunded';
}

View File

@@ -1,4 +0,0 @@
import { ethers } from 'ethers';
/** Fixed gas price for all BSC transactions (swaps & sends) */
export const BSC_GAS_PRICE = ethers.utils.parseUnits('0.055', 'gwei');

View File

@@ -1,17 +0,0 @@
import { HDKey } from '@scure/bip32';
import * as bitcoin from 'bitcoinjs-lib';
export function deriveBtcWallet(seed: Uint8Array) {
const root = HDKey.fromMasterSeed(seed);
const child = root.derive("m/84'/0'/0'/0/0");
const { address } = bitcoin.payments.p2wpkh({
pubkey: Buffer.from(child.publicKey!),
network: bitcoin.networks.bitcoin,
});
return {
address: address!,
privateKey: Buffer.from(child.privateKey!).toString('hex'),
};
}

View File

@@ -1,54 +0,0 @@
import { generateMnemonic, mnemonicToSeedBytes } from './mnemonic';
import { deriveEthWallet } from './eth';
import { deriveBtcWallet } from './btc';
import { deriveSolWallet } from './sol';
import { deriveTrxWallet } from './trx';
export type Chain = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC';
const DERIVATION_PATHS: Record<Chain, string> = {
ETH: "m/44'/60'/0'/0/0",
BTC: "m/84'/0'/0'/0/0",
SOL: "m/44'/501'/0'/0'",
TRX: "m/44'/195'/0'/0/0",
BSC: "m/44'/60'/0'/0/0",
};
export interface DerivedWallet {
chain: Chain;
address: string;
privateKey: string;
derivationPath: string;
}
export interface DerivedKeys {
mnemonic: string;
wallets: DerivedWallet[];
}
export async function generateWallets(): Promise<DerivedKeys> {
const mnemonic = generateMnemonic();
return deriveWalletsFromMnemonic(mnemonic);
}
export async function deriveWalletsFromMnemonic(mnemonic: string): Promise<DerivedKeys> {
const seed = await mnemonicToSeedBytes(mnemonic);
const eth = deriveEthWallet(mnemonic);
const btc = deriveBtcWallet(seed);
const sol = deriveSolWallet(seed);
const trx = deriveTrxWallet(mnemonic);
// BSC uses the same secp256k1 key as ETH (identical derivation path m/44'/60'/0'/0/0)
const bsc = deriveEthWallet(mnemonic);
return {
mnemonic,
wallets: [
{ chain: 'ETH', address: eth.address, privateKey: eth.privateKey, derivationPath: DERIVATION_PATHS.ETH },
{ chain: 'BTC', address: btc.address, privateKey: btc.privateKey, derivationPath: DERIVATION_PATHS.BTC },
{ chain: 'SOL', address: sol.address, privateKey: sol.privateKey, derivationPath: DERIVATION_PATHS.SOL },
{ chain: 'TRX', address: trx.address, privateKey: trx.privateKey, derivationPath: DERIVATION_PATHS.TRX },
{ chain: 'BSC', address: bsc.address, privateKey: bsc.privateKey, derivationPath: DERIVATION_PATHS.BSC },
],
};
}

View File

@@ -1,9 +0,0 @@
import { ethers } from 'ethers';
export function deriveEthWallet(mnemonicPhrase: string) {
const wallet = ethers.Wallet.fromMnemonic(mnemonicPhrase, "m/44'/60'/0'/0/0");
return {
address: wallet.address,
privateKey: wallet.privateKey,
};
}

View File

@@ -1,14 +0,0 @@
import { generateMnemonic as genMnemonic, mnemonicToSeed, validateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english.js';
export function generateMnemonic(): string {
return genMnemonic(wordlist, 128);
}
export async function mnemonicToSeedBytes(mnemonic: string): Promise<Uint8Array> {
return mnemonicToSeed(mnemonic);
}
export function isValidMnemonic(mnemonic: string): boolean {
return validateMnemonic(mnemonic, wordlist);
}

View File

@@ -1,13 +0,0 @@
import { Keypair } from '@solana/web3.js';
import { derivePath } from 'ed25519-hd-key';
export function deriveSolWallet(seed: Uint8Array) {
const path = "m/44'/501'/0'/0'";
const derived = derivePath(path, Buffer.from(seed).toString('hex'));
const keypair = Keypair.fromSeed(derived.key);
return {
address: keypair.publicKey.toBase58(),
privateKey: Buffer.from(keypair.secretKey).toString('hex'),
};
}

View File

@@ -1,58 +0,0 @@
import { ethers } from 'ethers';
export function deriveTrxWallet(mnemonicPhrase: string) {
const hdNode = ethers.utils.HDNode.fromMnemonic(mnemonicPhrase).derivePath("m/44'/195'/0'/0/0");
const ethAddress = ethers.utils.computeAddress(hdNode.publicKey);
const address = ethToTronAddress(ethAddress);
return {
address,
privateKey: hdNode.privateKey.slice(2),
};
}
function ethToTronAddress(ethAddress: string): string {
const hex = '41' + ethAddress.slice(2);
return hexToBase58Check(hex);
}
function hexToBase58Check(hex: string): string {
const bytes = hexToBytes(hex);
const hash1 = sha256Sync(bytes);
const hash2 = sha256Sync(hash1);
const checksum = hash2.slice(0, 4);
const payload = new Uint8Array(bytes.length + 4);
payload.set(bytes);
payload.set(checksum, bytes.length);
return base58Encode(payload);
}
function hexToBytes(hex: string): Uint8Array {
const arr = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
arr[i / 2] = parseInt(hex.substring(i, i + 2), 16);
}
return arr;
}
function sha256Sync(data: Uint8Array): Uint8Array {
const { createHash } = require('crypto');
return new Uint8Array(createHash('sha256').update(data).digest());
}
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
function base58Encode(data: Uint8Array): string {
let num = BigInt('0x' + Array.from(data).map(b => b.toString(16).padStart(2, '0')).join(''));
let result = '';
while (num > 0n) {
const mod = Number(num % 58n);
result = BASE58_ALPHABET[mod] + result;
num = num / 58n;
}
for (const byte of data) {
if (byte === 0) result = '1' + result;
else break;
}
return result;
}

View File

@@ -1,107 +0,0 @@
const PBKDF2_ITERATIONS = 600_000;
export async function encryptVault(
mnemonic: string,
password: string,
): Promise<{ encryptedVault: string; vaultSalt: string }> {
const salt = crypto.getRandomValues(new Uint8Array(32));
const keyMaterial = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(password),
'PBKDF2',
false,
['deriveKey']
);
const aesKey = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: salt as BufferSource, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt']
);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: iv as BufferSource },
aesKey,
new TextEncoder().encode(mnemonic)
);
const blob = new Uint8Array(iv.length + ciphertext.byteLength);
blob.set(iv, 0);
blob.set(new Uint8Array(ciphertext), iv.length);
return {
encryptedVault: uint8ToBase64(blob),
vaultSalt: uint8ToHex(salt),
};
}
export async function decryptVault(
encryptedVault: string,
vaultSalt: string,
password: string,
): Promise<string> {
const salt = hexToUint8(vaultSalt);
const raw = base64ToUint8(encryptedVault);
const iv = raw.slice(0, 12);
const ciphertextWithTag = raw.slice(12);
const keyMaterial = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(password),
'PBKDF2',
false,
['deriveKey']
);
const aesKey = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: salt as BufferSource, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['decrypt']
);
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: iv as BufferSource },
aesKey,
ciphertextWithTag
);
return new TextDecoder().decode(plaintext);
}
function uint8ToBase64(arr: Uint8Array): string {
let binary = '';
for (let i = 0; i < arr.length; i++) {
binary += String.fromCharCode(arr[i]);
}
return btoa(binary);
}
function base64ToUint8(b64: string): Uint8Array {
const binary = atob(b64);
const arr = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
arr[i] = binary.charCodeAt(i);
}
return arr;
}
function uint8ToHex(arr: Uint8Array): string {
return Array.from(arr)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
function hexToUint8(hex: string): Uint8Array {
const arr = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
arr[i / 2] = parseInt(hex.substring(i, i + 2), 16);
}
return arr;
}

View File

@@ -1,16 +0,0 @@
function readEnv(name: string, fallback: string): string {
const value = process.env[name];
return value && value.trim() ? value : fallback;
}
function readUrlEnv(name: string, fallback: string): string {
return readEnv(name, fallback).replace(/\/+$/, '');
}
export const webEnv = {
apiUrl: readUrlEnv('NEXT_PUBLIC_API_URL', 'http://localhost:3001'),
ethRpcUrl: readUrlEnv('NEXT_PUBLIC_ETH_RPC_URL', 'https://ethereum-rpc.publicnode.com'),
solRpcUrl: readUrlEnv('NEXT_PUBLIC_SOL_RPC_URL', 'https://solana.publicnode.com'),
btcApiUrl: readUrlEnv('NEXT_PUBLIC_BTC_API_URL', 'https://blockstream.info/api'),
bscRpcUrl: readUrlEnv('NEXT_PUBLIC_BSC_RPC_URL', 'https://bsc-dataseed.binance.org'),
} as const;

View File

@@ -1,25 +0,0 @@
import { ethers } from 'ethers';
import { webEnv } from '@/lib/env';
const MAINNET_NETWORK = { chainId: 1, name: 'mainnet' } as const;
const ETH_RPC_FALLBACKS = [
'https://ethereum-rpc.publicnode.com',
'https://rpc.ankr.com/eth',
'https://eth.llamarpc.com',
];
function getEthRpcUrls(): string[] {
return [...new Set([webEnv.ethRpcUrl, ...ETH_RPC_FALLBACKS].map((url) => url.trim()).filter(Boolean))];
}
export function createEthProvider(): ethers.providers.FallbackProvider {
const providerConfigs = getEthRpcUrls().map((url, index) => ({
provider: new ethers.providers.StaticJsonRpcProvider(url, MAINNET_NETWORK),
priority: index + 1,
weight: 1,
stallTimeout: 1_200,
}));
return new ethers.providers.FallbackProvider(providerConfigs, 1);
}

Some files were not shown because too many files have changed in this diff Show More