new version
This commit is contained in:
@@ -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/
|
||||
|
||||
76
.env.example
76
.env.example
@@ -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
11
.gitignore
vendored
@@ -1,11 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.next/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.turbo/
|
||||
coverage/
|
||||
.DS_Store
|
||||
vault/data/
|
||||
vault/init-keys.json
|
||||
@@ -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
141
BITOK/.gitignore
vendored
@@ -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
83
Dockerfile
Normal 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
130
README.md
Normal 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
20
apps/api/.eslintrc.json
Normal 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/"]
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
5
apps/api/src/config/swagger.ts
Normal file
5
apps/api/src/config/swagger.ts
Normal 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'));
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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' });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
38
apps/api/src/lib/logger.ts
Normal file
38
apps/api/src/lib/logger.ts
Normal 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),
|
||||
};
|
||||
7
apps/api/src/lib/trace-store.ts
Normal file
7
apps/api/src/lib/trace-store.ts
Normal 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';
|
||||
}
|
||||
41
apps/api/src/middleware/auth.ts
Normal file
41
apps/api/src/middleware/auth.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
15
apps/api/src/middleware/trace.ts
Normal file
15
apps/api/src/middleware/trace.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
66
apps/api/src/models/session.model.ts
Normal file
66
apps/api/src/models/session.model.ts
Normal 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() });
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
127
apps/api/src/services/jwt.service.ts
Normal file
127
apps/api/src/services/jwt.service.ts
Normal 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
101
apps/api/swagger.json
Normal 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
41
apps/web/.gitignore
vendored
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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
1770
apps/web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,3 +0,0 @@
|
||||
ignoredBuiltDependencies:
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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,
|
||||
};
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Home() {
|
||||
redirect('/login');
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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%',
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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' }}>
|
||||
×
|
||||
</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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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' }),
|
||||
};
|
||||
@@ -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))];
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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))];
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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))];
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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+$/, '')}`;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
Reference in New Issue
Block a user