update project

This commit is contained in:
ZOMBIIIIIII
2026-04-14 13:30:26 +03:00
parent a81e29807c
commit 37146f7375
65 changed files with 3782 additions and 629 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
.next
dist
.env
.env.local
*.log
.git
.turbo

View File

@@ -1,24 +1,24 @@
# PostgreSQL # PostgreSQL
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/cryptowallet_devphase3
DB_HOST=localhost DB_HOST=localhost
DB_PORT=5432 DB_PORT=5432
DB_USER=postgres DB_USER=postgres
DB_PASSWORD=postgres DB_PASSWORD=postgres
DB_NAME=cryptowallet_v2 DB_NAME=cryptowallet_devphase3
# JWT (external auth service)
JWT_JWKS_URL=
JWT_PUBLIC_KEY=
JWT_ALGORITHM=RS256
JWT_ISSUER=
JWT_AUDIENCE=
# Server # Server
API_PORT=3001 API_PORT=3001
FRONTEND_URL=http://localhost:3000 FRONTEND_URL=http://localhost:3000
RELAY_API_KEY= RELAY_API_KEY=
# TRON # BITOK auth service
TRON_API_KEY= BITOK_JWKS_URL=http://localhost:8000/.well-known/jwks.json
BITOK_ISSUER=auth-service
BITOK_AUDIENCE=wallet-service
# Jupiter (Solana DEX aggregator) # RabbitMQ
JUPITER_API_KEY= RABBITMQ_URL=amqp://guest:guest@localhost:5672/
# Rate Limiting
RATE_LIMIT_LOGIN_MAX=5
RATE_LIMIT_LOGIN_WINDOW_MS=900000

22
BITOK/.dockerignore Normal file
View File

@@ -0,0 +1,22 @@
__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 Normal file
View File

@@ -0,0 +1,141 @@
# 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

View File

@@ -1,28 +1,47 @@
FROM node:20-alpine AS base # Build stage
RUN corepack enable && corepack prepare pnpm@latest --activate 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 WORKDIR /app
FROM base AS deps # Copy workspace config
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ COPY package.json pnpm-workspace.yaml pnpm-lock.yaml turbo.json ./
COPY apps/api/package.json apps/api/ COPY apps/api/package.json apps/api/
COPY packages/shared/package.json packages/shared/ COPY packages/shared/package.json packages/shared/
RUN pnpm install --frozen-lockfile --prod=false
FROM base AS build # Enable hoisting so tsc can find all deps
COPY --from=deps /app/node_modules ./node_modules RUN echo "node-linker=hoisted" > .npmrc
COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules RUN pnpm install --frozen-lockfile
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules 2>/dev/null || true
COPY . . # Copy source
RUN cd apps/api && pnpm build 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
FROM node:20-alpine AS runtime
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules
COPY --from=build /app/apps/api/dist ./apps/api/dist
COPY --from=build /app/apps/api/package.json ./apps/api/
COPY --from=build /app/packages/shared ./packages/shared
WORKDIR /app/apps/api # 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 EXPOSE 3001
CMD ["node", "dist/index.js"]
ENTRYPOINT ["./docker-entrypoint.sh"]

View File

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

View File

@@ -8,30 +8,39 @@
"start": "node dist/index.js", "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": "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", "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"
}, },
"dependencies": { "dependencies": {
"@cryptowallet/shared": "workspace:*", "@cryptowallet/shared": "workspace:*",
"amqplib": "^1.0.3",
"bcrypt": "^5.1.1",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.0", "dotenv": "^16.4.0",
"ethers": "5.7.2", "ethers": "5.7.2",
"express": "^4.21.0", "express": "^4.21.0",
"express-rate-limit": "^7.4.0",
"helmet": "^8.0.0", "helmet": "^8.0.0",
"jose": "^6.2.2", "jose": "^6.2.2",
"jsonwebtoken": "^9.0.0",
"knex": "^3.1.0", "knex": "^3.1.0",
"pg": "^8.13.0", "pg": "^8.13.0",
"swagger-ui-express": "^5.0.1",
"ulidx": "^2.4.1", "ulidx": "^2.4.1",
"uuid": "^11.0.0",
"zod": "^3.23.0" "zod": "^3.23.0"
}, },
"devDependencies": { "devDependencies": {
"@types/amqplib": "^0.10.8",
"@types/bcrypt": "^5.0.2",
"@types/cookie-parser": "^1.4.7", "@types/cookie-parser": "^1.4.7",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/express-serve-static-core": "^5.1.1", "@types/express-serve-static-core": "^5.1.1",
"@types/jsonwebtoken": "^9.0.0",
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"@types/swagger-ui-express": "^4.1.8", "@types/uuid": "^10.0.0",
"ts-node": "^10.9.0", "ts-node": "^10.9.0",
"ts-node-dev": "^2.0.0", "ts-node-dev": "^2.0.0",
"typescript": "^5.6.0" "typescript": "^5.6.0"

View File

@@ -2,11 +2,11 @@ import express from 'express';
import helmet from 'helmet'; import helmet from 'helmet';
import cors from 'cors'; import cors from 'cors';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import swaggerUi from 'swagger-ui-express';
import { env } from './config/env'; import { env } from './config/env';
import { swaggerSpec } from './config/swagger';
import { errorHandler } from './middleware/error-handler'; import { errorHandler } from './middleware/error-handler';
import walletSetupRoutes from './routes/wallet-setup.routes';
import walletRoutes from './routes/wallet.routes'; import walletRoutes from './routes/wallet.routes';
import vaultRoutes from './routes/vault.routes';
import relayProxyRoutes from './routes/relay-proxy.routes'; import relayProxyRoutes from './routes/relay-proxy.routes';
import tronProxyRoutes from './routes/tron-proxy.routes'; import tronProxyRoutes from './routes/tron-proxy.routes';
import solSwapProxyRoutes from './routes/sol-swap-proxy.routes'; import solSwapProxyRoutes from './routes/sol-swap-proxy.routes';
@@ -25,12 +25,9 @@ app.get('/api/health', (_req, res) => {
res.json({ success: true, data: { status: 'ok' } }); res.json({ success: true, data: { status: 'ok' } });
}); });
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec)); app.use('/api/wallet', walletSetupRoutes);
app.get('/api/docs/swagger.json', (_req, res) => {
res.json(swaggerSpec);
});
app.use('/api/wallets', walletRoutes); app.use('/api/wallets', walletRoutes);
app.use('/api/vault', vaultRoutes);
app.use('/api/relay', relayProxyRoutes); app.use('/api/relay', relayProxyRoutes);
app.use('/api/tron', tronProxyRoutes); app.use('/api/tron', tronProxyRoutes);
app.use('/api/sol/swap', solSwapProxyRoutes); app.use('/api/sol/swap', solSwapProxyRoutes);

View File

@@ -1,22 +1,16 @@
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import path from 'path'; import path from 'path';
import { fetchVaultSecrets } from './vault';
dotenv.config({ path: path.resolve(__dirname, '../../../../.env') }); dotenv.config({ path: path.resolve(__dirname, '../../../../.env') });
export const env = { export let env = {
db: { db: {
host: process.env.DB_HOST || 'localhost', host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432'), port: parseInt(process.env.DB_PORT || '5432'),
user: process.env.DB_USER || 'postgres', user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'postgres', password: process.env.DB_PASSWORD || 'postgres',
name: process.env.DB_NAME || 'cryptowallet_v2', name: process.env.DB_NAME || 'cryptowallet',
},
jwt: {
jwksUrl: process.env.JWT_JWKS_URL || '',
publicKey: process.env.JWT_PUBLIC_KEY || '',
algorithm: process.env.JWT_ALGORITHM || 'RS256',
issuer: process.env.JWT_ISSUER || '',
audience: process.env.JWT_AUDIENCE || '',
}, },
port: parseInt(process.env.API_PORT || '3001'), port: parseInt(process.env.API_PORT || '3001'),
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:3000', frontendUrl: process.env.FRONTEND_URL || 'http://localhost:3000',
@@ -24,5 +18,38 @@ export const env = {
tronApiKey: process.env.TRON_API_KEY || null, tronApiKey: process.env.TRON_API_KEY || null,
jupiterApiKey: process.env.JUPITER_API_KEY || null, jupiterApiKey: process.env.JUPITER_API_KEY || null,
jupiterReferralAccount: process.env.JUPITER_REFERRAL_ACCOUNT || null, jupiterReferralAccount: process.env.JUPITER_REFERRAL_ACCOUNT || null,
jupiterFeeBps: parseInt(process.env.JUPITER_FEE_BPS || '70'), 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',
}; };
export async function initEnv(): Promise<void> {
const secrets = await fetchVaultSecrets();
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');
}
}

View File

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

View File

@@ -0,0 +1,30 @@
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;
try {
const res = await fetch(`${vaultAddr}/v1/kv/data/cryptowallet`, {
headers: { 'X-Vault-Token': vaultToken },
});
if (!res.ok) return null;
const body = (await res.json()) as { data: { data: VaultSecrets } };
return body.data.data;
} catch {
return null;
}
}

View File

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

View File

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

View File

@@ -1,10 +1,17 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { UserModel } from '../models/user.model';
import { WalletModel } from '../models/wallet.model'; import { WalletModel } from '../models/wallet.model';
export const WalletController = { export const WalletController = {
async getWallets(req: Request, res: Response) { async getWallets(req: Request, res: Response) {
try { try {
const wallets = await WalletModel.findByUserId(req.auth!.userId); 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);
res.json({ res.json({
success: true, success: true,
data: wallets.map((w) => ({ data: wallets.map((w) => ({

View File

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

View File

@@ -3,21 +3,12 @@ import type { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> { export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('users', (t) => { await knex.schema.createTable('users', (t) => {
t.string('id', 26).primary(); t.string('id', 26).primary();
t.string('email', 255).notNullable().unique(); t.string('username', 64).notNullable().unique();
t.string('password_hash', 255).notNullable(); t.text('password_hash').notNullable();
t.string('last_name', 128).nullable(); t.text('pin_hash').notNullable();
t.string('first_name', 128).nullable(); t.text('encrypted_vault').notNullable();
t.string('middle_name', 128).nullable(); t.string('vault_salt', 128).notNullable();
t.date('birth_date').nullable(); t.boolean('mnemonic_shown').notNullable().defaultTo(false);
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('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
t.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now()); t.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,23 +1,23 @@
import knex from 'knex';
import knexConfig from './db/knexfile';
import app from './app'; import app from './app';
import { env } from './config/env'; import { env, initEnv } from './config/env';
import { createRabbitConnection } from './events/connection';
import { startConsumer } from './events/consumer';
async function main() { async function main() {
const db = knex(knexConfig); await initEnv();
console.log('[API] Running migrations...'); // Start RabbitMQ consumer
await db.migrate.latest(); try {
console.log('[API] Migrations complete.'); const channel = await createRabbitConnection();
await startConsumer(channel);
await db.destroy(); console.log('[API] RabbitMQ consumer started');
} catch (err: any) {
console.warn('[API] RabbitMQ not available, events will not be consumed:', err.message);
}
app.listen(env.port, () => { app.listen(env.port, () => {
console.log(`[API] Server running on port ${env.port}`); console.log(`[API] Server running on port ${env.port}`);
}); });
} }
main().catch((err) => { main().catch(console.error);
console.error('[API] Failed to start:', err);
process.exit(1);
});

View File

@@ -1,39 +0,0 @@
import { Request, Response, NextFunction } from 'express';
import { verifyAccessToken, AuthContext } from '../services/jwt.service';
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) {
res.status(err.status || 401).json({ success: false, error: err.message || 'Invalid token' });
}
}

View File

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

View File

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

View File

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

View File

@@ -3,47 +3,71 @@ import { generateUlid } from '../utils/ulid';
export interface UserRow { export interface UserRow {
id: string; id: string;
email: string; bitok_user_id: string;
password_hash: string; email: string | null;
last_name: string | null; encrypted_vault: string;
first_name: string | null; vault_salt: string;
middle_name: string | null; mnemonic_shown: boolean;
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_verified: boolean;
kyc_verified_at: Date | null; kyc_level: string | null;
is_deleted: boolean; deleted: boolean;
created_at: Date; created_at: Date;
updated_at: Date; updated_at: Date;
} }
export const UserModel = { 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> { async findById(id: string): Promise<UserRow | undefined> {
return db('users').where({ id, is_deleted: false }).first(); return db('users').where({ id }).first();
}, },
async create(data: { async findByBitokUserId(bitokUserId: string): Promise<UserRow | undefined> {
email: string; return db('users').where({ bitok_user_id: bitokUserId }).first();
password_hash: string; },
async createFromBitok(data: {
bitokUserId: string;
email?: string | null;
encryptedVault: string;
vaultSalt: string;
}): Promise<UserRow> { }): Promise<UserRow> {
const [user] = await db('users').insert({ id: generateUlid(), ...data }).returning('*');
return user;
},
async update(id: string, data: Partial<Omit<UserRow, 'id' | 'created_at'>>): Promise<UserRow | undefined> {
const [user] = await db('users') const [user] = await db('users')
.where({ id }) .insert({
.update({ ...data, updated_at: db.fn.now() }) id: generateUlid(),
bitok_user_id: data.bitokUserId,
email: data.email || null,
encrypted_vault: data.encryptedVault,
vault_salt: data.vaultSalt,
})
.returning('*'); .returning('*');
return user; 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')
.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(),
});
},
}; };

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,92 +0,0 @@
import * as jose from 'jose';
import { env } from '../config/env';
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;
}
let jwks: ReturnType<typeof jose.createRemoteJWKSet> | null = null;
let localKey: Awaited<ReturnType<typeof jose.importSPKI>> | null = null;
function getJWKS(): ReturnType<typeof jose.createRemoteJWKSet> {
if (!jwks && env.jwt.jwksUrl) {
jwks = jose.createRemoteJWKSet(new URL(env.jwt.jwksUrl));
}
if (!jwks) {
throw new Error('JWT_JWKS_URL is not configured');
}
return jwks;
}
async function getLocalKey(): Promise<Awaited<ReturnType<typeof jose.importSPKI>>> {
if (!localKey && env.jwt.publicKey) {
localKey = await jose.importSPKI(env.jwt.publicKey, env.jwt.algorithm);
}
if (!localKey) {
throw new Error('No JWT public key available');
}
return localKey;
}
export async function verifyAccessToken(token: string): Promise<AuthContext> {
let payload: jose.JWTPayload;
try {
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;
if (env.jwt.jwksUrl) {
const result = await jose.jwtVerify(token, getJWKS(), verifyOptions);
payload = result.payload;
} else {
const key = await getLocalKey();
const result = await jose.jwtVerify(token, key, verifyOptions);
payload = result.payload;
}
} catch (err: any) {
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,
},
};
}

View File

@@ -1,101 +0,0 @@
{
"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" }
}
}
}
}
}
}
}
}

34
apps/web/Dockerfile Normal file
View File

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

View File

@@ -42,6 +42,9 @@ export default function BridgePage() {
const [amount, setAmount] = useState(''); const [amount, setAmount] = useState('');
const [confirmed, setConfirmed] = useState(false); const [confirmed, setConfirmed] = useState(false);
useEffect(() => {
if (!user) router.push('/login');
}, [router, user]);
const destChainOptions = useMemo(() => getDestinationChainOptions(sourceChain), [sourceChain]); const destChainOptions = useMemo(() => getDestinationChainOptions(sourceChain), [sourceChain]);
const sourceTokenOptions = useMemo(() => getTokenOptions(sourceChain), [sourceChain]); const sourceTokenOptions = useMemo(() => getTokenOptions(sourceChain), [sourceChain]);

View File

@@ -1,20 +1,31 @@
'use client'; 'use client';
import { useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useBalances } from '@/hooks/useBalances'; import { useBalances } from '@/hooks/useBalances';
import type { ChainBalance } from '@/lib/balances/types'; import type { ChainBalance } from '@/lib/balances/types';
import { useAuthStore } from '@/store/auth-store'; import { useAuthStore } from '@/store/auth-store';
export default function DashboardPage() { export default function DashboardPage() {
const { user, wallets } = useAuthStore(); const router = useRouter();
const { user, wallets, logout } = useAuthStore();
const { portfolio, loading, refreshing, error, refresh } = useBalances(); const { portfolio, loading, refreshing, error, refresh } = useBalances();
useEffect(() => {
if (!user) {
router.push('/login');
}
}, [user, router]);
if (!user) return null;
return ( return (
<div style={{ maxWidth: 820, margin: '50px auto', padding: 20 }}> <div style={{ maxWidth: 820, margin: '50px auto', padding: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16 }}>
<h1>Dashboard</h1> <h1>Dashboard</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', justifyContent: 'flex-end' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<span>{user?.email || 'Not authenticated'}</span> <span>{user.email}</span>
<Link href="/send" style={navButtonStyle}> <Link href="/send" style={navButtonStyle}>
Send Send
</Link> </Link>
@@ -33,6 +44,9 @@ export default function DashboardPage() {
<button onClick={() => void refresh()} style={navButtonStyle} disabled={loading || refreshing}> <button onClick={() => void refresh()} style={navButtonStyle} disabled={loading || refreshing}>
{refreshing ? 'Refreshing...' : 'Refresh'} {refreshing ? 'Refreshing...' : 'Refresh'}
</button> </button>
<button onClick={logout} style={{ padding: '6px 12px' }}>
Logout
</button>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

@@ -24,6 +24,9 @@ export default function ReceivePage() {
const [amount, setAmount] = useState(''); const [amount, setAmount] = useState('');
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
useEffect(() => {
if (!user) router.push('/login');
}, [user, router]);
// Reset token when chain changes // Reset token when chain changes
useEffect(() => { useEffect(() => {

View File

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

View File

@@ -48,6 +48,9 @@ export default function SendPage() {
const [result, setResult] = useState<SendResult | null>(null); const [result, setResult] = useState<SendResult | null>(null);
const [scannerOpen, setScannerOpen] = useState(false); const [scannerOpen, setScannerOpen] = useState(false);
useEffect(() => {
if (!user) router.push('/login');
}, [user, router]);
// Reset token on chain change // Reset token on chain change
useEffect(() => { useEffect(() => {

View File

@@ -1,11 +1,23 @@
'use client'; 'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useAuthStore } from '@/store/auth-store'; import { useAuthStore } from '@/store/auth-store';
import { SeedPhraseModal } from '@/components/SeedPhraseModal';
export default function SettingsPage() { export default function SettingsPage() {
const router = useRouter();
const { user } = useAuthStore(); const { user } = useAuthStore();
const [showSeedModal, setShowSeedModal] = useState(false);
useEffect(() => {
if (!user) {
router.push('/login');
}
}, [user, router]);
if (!user) return null;
return ( return (
<div style={{ maxWidth: 520, margin: '50px auto', padding: 20 }}> <div style={{ maxWidth: 520, margin: '50px auto', padding: 20 }}>
@@ -16,15 +28,40 @@ export default function SettingsPage() {
</Link> </Link>
</div> </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 */} {/* Account Section */}
<div style={{ border: '1px solid #ccc', borderRadius: 4, padding: 16, marginBottom: 16 }}> <div style={{ border: '1px solid #ccc', borderRadius: 4, padding: 16, marginBottom: 16 }}>
<h3 style={{ marginTop: 0 }}>Account</h3> <h3 style={{ marginTop: 0 }}>Account</h3>
<div> <div>
<p style={{ margin: 0, fontWeight: 600 }}>Email</p> <p style={{ margin: 0, fontWeight: 600 }}>Email</p>
<p style={{ margin: '4px 0 0', color: '#666', fontSize: 13 }}>{user?.email || 'Not authenticated'}</p> <p style={{ margin: '4px 0 0', color: '#666', fontSize: 13 }}>{user.email}</p>
</div> </div>
</div> </div>
<SeedPhraseModal
isOpen={showSeedModal}
onClose={() => setShowSeedModal(false)}
/>
</div> </div>
); );
} }
@@ -43,3 +80,15 @@ const navButtonStyle: React.CSSProperties = {
cursor: 'pointer', cursor: 'pointer',
background: '#fff', background: '#fff',
}; };
const primaryButtonStyle: React.CSSProperties = {
padding: '8px 16px',
border: '1px solid #333',
borderRadius: 4,
cursor: 'pointer',
background: '#333',
color: '#fff',
fontSize: 14,
fontWeight: 600,
whiteSpace: 'nowrap',
};

View File

@@ -66,6 +66,11 @@ export default function SwapPage() {
[chain, amount, fromSymbol, slippageBps, toSymbol] [chain, amount, fromSymbol, slippageBps, toSymbol]
); );
useEffect(() => {
if (!user) {
router.push('/login');
}
}, [router, user]);
useEffect(() => { useEffect(() => {
estimateOutput(request); estimateOutput(request);

View File

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

View File

@@ -1,13 +1,84 @@
import { webEnv } from './env'; import { webEnv } from './env';
const API_URL = webEnv.apiUrl; const API_URL = webEnv.apiUrl;
const BITOK_BASE = process.env.NEXT_PUBLIC_BITOK_URL || 'http://localhost:8000';
async function request<T>(path: string, options: RequestInit = {}): Promise<T> { 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> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(options.headers as Record<string, string>), ...(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}`, { const res = await fetch(`${API_URL}${path}`, {
...options, ...options,
headers, headers,
@@ -23,6 +94,40 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
return data.data; return data.data;
} }
export const api = { export interface WalletSetupPayload {
getWallets: () => request<any>('/api/wallets'), encryptedVault: string;
vaultSalt: string;
wallets: { chain: string; address: string; derivationPath: string }[];
}
export interface WalletUnlockResponse {
encryptedVault: string;
vaultSalt: string;
wallets: { chain: string; address: string; derivationPath: string }[];
mnemonicShown: boolean;
}
export const walletApi = {
setup: (data: WalletSetupPayload) =>
walletRequest<any>('/api/wallet/setup', {
method: 'POST',
body: JSON.stringify(data),
}),
unlock: () =>
walletRequest<WalletUnlockResponse>('/api/wallet/unlock'),
getWallets: () => walletRequest<any>('/api/wallets'),
confirmMnemonic: () =>
walletRequest<any>('/api/wallet/confirm-mnemonic', { method: 'POST' }),
};
// ── Legacy api object (keep for components that still reference it) ──
export const api = {
getWallets: () => walletRequest<any>('/api/wallets'),
getVault: () => walletRequest<any>('/api/vault'),
confirmMnemonic: () =>
walletRequest<any>('/api/wallet/confirm-mnemonic', { method: 'POST' }),
}; };

View File

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

View File

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

View File

@@ -1,48 +1,137 @@
'use client'; 'use client';
import { create } from 'zustand'; import { create } from 'zustand';
import { api } from '@/lib/api'; import { bitokAuth, walletApi, setAccessToken } from '@/lib/api';
import { encryptVault, decryptVault } from '@/lib/crypto/vault';
interface Wallet { import { generateWallets, deriveWalletsFromMnemonic, type DerivedWallet } from '@/lib/crypto/derive-keys';
chain: string;
address: string;
derivationPath: string;
}
interface AuthState { interface AuthState {
user: { id: string; email: string } | null; user: { id: string; email: string } | null;
wallets: Wallet[]; wallets: DerivedWallet[];
mnemonic: string | null;
mnemonicShown: boolean;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
init: () => Promise<void>; // 2-step registration
registerStart: (email: string) => Promise<void>;
registerComplete: (email: string, password: string, code: string) => Promise<void>;
// 2-step login
loginStart: (email: string) => Promise<void>;
loginComplete: (email: string, password: string, code: string) => Promise<void>;
confirmMnemonic: () => Promise<void>;
logout: () => void; logout: () => void;
clearMnemonic: () => void;
clearError: () => void; clearError: () => void;
} }
export const useAuthStore = create<AuthState>((set) => ({ export const useAuthStore = create<AuthState>((set, get) => ({
user: null, user: null,
wallets: [], wallets: [],
mnemonic: null,
mnemonicShown: true,
loading: false, loading: false,
error: null, error: null,
init: async () => { registerStart: async (email) => {
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
const wallets = await api.getWallets(); await bitokAuth.registrationStart(email);
set({ loading: false });
} catch (err: any) {
set({ loading: false, error: err.message });
}
},
registerComplete: async (email, password, code) => {
set({ loading: true, error: null });
try {
// Step 1: Complete BITOK registration, get JWT
const authData = await bitokAuth.registrationComplete(email, password, code);
setAccessToken(authData.access_token);
// Step 2: Generate mnemonic & derive wallets
const { mnemonic, wallets } = await generateWallets();
// Step 3: Encrypt vault with password only
const { encryptedVault, vaultSalt } = await encryptVault(mnemonic, password);
// Step 4: Send wallet data to backend
await walletApi.setup({
encryptedVault,
vaultSalt,
wallets: wallets.map((w) => ({
chain: w.chain,
address: w.address,
derivationPath: w.derivationPath,
})),
});
set({ set({
user: { id: '', email: '' }, user: { id: authData.id, email: authData.email },
wallets, wallets,
mnemonic,
mnemonicShown: false,
loading: false, loading: false,
}); });
} catch { } catch (err: any) {
set({ user: null, wallets: [], loading: false }); set({ loading: false, error: err.message });
}
},
loginStart: async (email) => {
set({ loading: true, error: null });
try {
await bitokAuth.loginStart(email);
set({ loading: false });
} catch (err: any) {
set({ loading: false, error: err.message });
}
},
loginComplete: async (email, password, code) => {
set({ loading: true, error: null });
try {
// Step 1: Complete BITOK login, get JWT
const authData = await bitokAuth.loginComplete(email, password, code);
setAccessToken(authData.access_token);
// Step 2: Get vault data from wallet API
const vaultData = await walletApi.unlock();
// Step 3: Decrypt vault client-side with password only
const mnemonic = await decryptVault(vaultData.encryptedVault, vaultData.vaultSalt, password);
const { wallets } = await deriveWalletsFromMnemonic(mnemonic);
set({
user: { id: authData.id, email: authData.email },
wallets,
mnemonic: vaultData.mnemonicShown ? null : mnemonic,
mnemonicShown: vaultData.mnemonicShown,
loading: false,
});
} catch (err: any) {
set({ loading: false, error: err.message });
}
},
confirmMnemonic: async () => {
try {
await walletApi.confirmMnemonic();
set({ mnemonicShown: true, mnemonic: null });
} catch (err: any) {
set({ error: err.message });
} }
}, },
logout: () => { logout: () => {
set({ user: null, wallets: [] }); bitokAuth.logout().catch(() => {});
setAccessToken(null);
set({ user: null, wallets: [], mnemonic: null, mnemonicShown: true });
}, },
clearMnemonic: () => set({ mnemonic: null }),
clearError: () => set({ error: null }), clearError: () => set({ error: null }),
})); }));

35
docker-check.bat Normal file
View File

@@ -0,0 +1,35 @@
@echo off
cd /d "%~dp0"
echo.
echo === Docker diagnostic ===
echo.
echo 1. Docker version:
docker version
echo.
echo 2. Docker Compose:
docker compose version 2>nul
if errorlevel 1 (
echo docker compose failed, trying docker-compose...
docker-compose --version 2>nul
)
echo.
echo 3. Docker context:
docker context show
echo.
echo 4. Docker info ^(engine status^):
docker info 2>&1 | findstr /C:"Server Version" /C:"ERROR" /C:"cannot" /C:"failed"
echo.
echo 5. Attempting: docker compose up -d postgres
echo.
docker compose up -d postgres
echo.
echo Exit code: %errorlevel%
echo.
pause

179
docker-compose.yml Normal file
View File

@@ -0,0 +1,179 @@
services:
postgres:
image: postgres:16-alpine
container_name: cryptowallet-db
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: cryptowallet_devphase3
ports:
- '5432:5432'
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 10
vault:
image: hashicorp/vault:1.18
container_name: cryptowallet-vault
cap_add:
- IPC_LOCK
environment:
VAULT_ADDR: "http://127.0.0.1:8200"
ports:
- '8200:8200'
volumes:
- ./vault/vault.hcl:/vault/config/vault.hcl:ro
- vault_data:/vault/file
command: ["vault", "server", "-config=/vault/config/vault.hcl"]
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8200/v1/sys/seal-status > /dev/null 2>&1 || exit 0"]
interval: 5s
timeout: 5s
retries: 10
start_period: 5s
vault-init:
image: hashicorp/vault:1.18
container_name: cryptowallet-vault-init
entrypoint: /bin/sh
command: ["/scripts/vault-init.sh"]
volumes:
- ./scripts/vault-init.sh:/scripts/vault-init.sh:ro
- vault_data:/vault/file
environment:
VAULT_ADDR: http://vault:8200
depends_on:
vault:
condition: service_healthy
restart: "no"
api:
build:
context: .
dockerfile: apps/api/Dockerfile
container_name: cryptowallet-api
ports:
- '3001:3001'
volumes:
- vault_data:/vault/file:ro
environment:
VAULT_ADDR: http://vault:8200
DB_HOST: postgres
DB_PORT: 5432
DB_USER: postgres
DB_PASSWORD: postgres
DB_NAME: cryptowallet_devphase3
API_PORT: 3001
FRONTEND_URL: http://localhost:3000
RELAY_API_KEY: ${RELAY_API_KEY:-}
BITOK_JWKS_URL: http://bitok-auth:8000/.well-known/jwks.json
BITOK_ISSUER: auth-service
BITOK_AUDIENCE: wallet-service
RABBITMQ_URL: amqp://guest:guest@rabbitmq:5672/
depends_on:
postgres:
condition: service_healthy
vault-init:
condition: service_completed_successfully
rabbitmq:
condition: service_healthy
web:
build:
context: .
dockerfile: apps/web/Dockerfile
container_name: cryptowallet-web
ports:
- '3000:3000'
environment:
NEXT_PUBLIC_API_URL: http://localhost:3001
NEXT_PUBLIC_BITOK_URL: http://localhost:8000
depends_on:
- api
rabbitmq:
image: rabbitmq:3.13-management-alpine
container_name: cryptowallet-rabbitmq
ports:
- '5672:5672'
- '15672:15672'
environment:
RABBITMQ_DEFAULT_USER: guest
RABBITMQ_DEFAULT_PASS: guest
volumes:
- rabbitmq_data:/var/lib/rabbitmq
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "check_port_connectivity"]
interval: 10s
timeout: 5s
retries: 10
keydb:
image: eqalpha/keydb
container_name: cryptowallet-keydb
ports:
- '6379:6379'
volumes:
- keydb_data:/data
command:
- keydb-server
- --requirepass
- keydb
- --appendonly
- "yes"
healthcheck:
test: ["CMD", "redis-cli", "-a", "keydb", "ping"]
interval: 5s
timeout: 2s
retries: 20
bitok-auth:
build:
context: ./BITOK
dockerfile: Dockerfile
container_name: cryptowallet-bitok
ports:
- '8000:8000'
volumes:
- vault_data:/vault/file:ro
- ./scripts/bitok-entrypoint.sh:/app/entrypoint.sh:ro
entrypoint: ["sh", "/app/entrypoint.sh"]
environment:
VAULT_ADDR: http://vault:8200
VAULT_MOUNT_POINT: secrets
REDIS_HOST: keydb
REDIS_PORT: 6379
REDIS_PASSWORD: keydb
JWT_AUDIENCE: "bitforce,wallet-service"
JWT_ISSUER: auth-service
CORS_ORIGINS: "http://localhost:3000,http://localhost:8000"
RABBIT_HOST: rabbitmq
RABBIT_PORT: 5672
RABBIT_USER: guest
RABBIT_PASSWORD: guest
RABBIT_EVENTS_EXCHANGE: bitok.events
RABBIT_EMAIL_CODE_QUEUE: email.verification_code
OUTBOX_POLL_INTERVAL_MS: 500
APP_HOST: "0.0.0.0"
APP_PORT: "8000"
APP_WORKERS: "1"
depends_on:
postgres:
condition: service_healthy
vault-init:
condition: service_completed_successfully
keydb:
condition: service_healthy
rabbitmq:
condition: service_healthy
restart: "no"
volumes:
pgdata:
vault_data:
rabbitmq_data:
keydb_data:

View File

@@ -0,0 +1,43 @@
# TRON Backend Proxy Design
## Problem
Frontend calls TronGrid API directly from the browser. This causes:
- 429 rate-limit errors (API key passed as query param, not recognized properly)
- API key exposed in `NEXT_PUBLIC_` env var (visible to clients)
- CORS issues possible depending on browser/TronGrid config
## Solution
Route TRON balance requests through the backend API proxy, matching the existing relay-proxy pattern.
## Architecture
```
Browser -> GET /api/tron/account/:address -> Express API -> GET https://api.trongrid.io/v1/accounts/:address
Header: TRON-PRO-API-KEY: <key>
```
## Changes
### Backend
1. **New file: `apps/api/src/routes/tron-proxy.routes.ts`**
- `GET /account/:address` - proxies to TronGrid `/v1/accounts/:address`
- Validates address format (starts with T, 34 chars, base58)
- Sends `TRON-PRO-API-KEY` header (correct TronGrid auth method)
- 10s timeout with AbortController
- Returns TronGrid JSON response as-is
2. **`apps/api/src/config/env.ts`** - add `tronApiKey` field
3. **`apps/api/src/app.ts`** - register `/api/tron` route
### Frontend
4. **`apps/web/src/lib/balances/trx-balances.ts`** - call own API instead of TronGrid
5. **`apps/web/src/lib/env.ts`** - remove `tronApiUrl` and `tronApiKey`
6. **`apps/web/.env.local`** - remove `NEXT_PUBLIC_TRON_*` vars
### Config
7. **`.env`** - add `TRON_API_KEY=b874d775-4adc-4273-965b-cd6be5f66d68`

View File

@@ -0,0 +1,82 @@
# Phase 6: Send & Receive
## Context
Кошелёк поддерживает ETH, SOL, TRX, BTC. Балансы работают для ETH/SOL/TRX. BTC имеет деривированный адрес, но нет RPC/балансов. Нужен полноценный Send/Receive с QR кодами.
## Receive — генерация QR
**Формат QR**: стандартные URI схемы (совместимы с другими кошельками):
- ETH: `ethereum:0xAddress` / `ethereum:0xAddress@1/transfer?address=0xTokenContract&uint256=amount` (EIP-681)
- SOL: `solana:Address` / `solana:Address?spl-token=MintAddress&amount=1.5`
- TRX: `tron:TAddress` / `tron:TAddress?token=TR7...&amount=100`
- BTC: `bitcoin:bc1Address` / `bitcoin:bc1Address?amount=0.01`
**UI flow**: Выбрал сеть → выбрал токен → опционально ввёл сумму → QR + адрес текстом + Copy.
## Send — отправка + сканер QR
**UI flow**: Выбрал сеть → выбрал токен → ввёл адрес (или Scan QR) → ввёл сумму → Gas (ETH only) → Review → Confirm → tx.
**QR сканер**: кнопка "Scan QR" → камера → распознаёт URI → заполняет chain/token/address/amount.
**Отправка по сетям**:
- **ETH**: `ethers.Wallet.sendTransaction()` для ETH, `contract.transfer()` для ERC20
- **SOL**: `SystemProgram.transfer()` для SOL, `createTransferInstruction()` для SPL tokens
- **TRX**: TronGrid `createtransaction` для native TRX, `triggersmartcontract` для TRC20
- **BTC**: `bitcoinjs-lib` PSBT → broadcast через Blockstream/Mempool API
## Библиотеки
- `qrcode.react` — генерация QR
- `@yudiel/react-qr-scanner` — сканирование QR камерой
## Безопасность
| Принцип | Реализация |
|---------|-----------|
| Address validation | Regex + checksum для каждого формата |
| QR parsing validation | URI парсится → chain/address валидируются → token в whitelist |
| Confirm before send | Review экран + checkbox |
| Private keys | Подписание в браузере, ключи не покидают клиент |
| Amount validation | Проверка баланса перед отправкой |
## Файлы
### Новые (8)
| Файл | Назначение |
|------|-----------|
| `apps/web/src/app/send/page.tsx` | Send page |
| `apps/web/src/app/receive/page.tsx` | Receive page |
| `apps/web/src/lib/send/execute.ts` | Транзакции по 4 сетям |
| `apps/web/src/lib/send/validate.ts` | Валидация адресов |
| `apps/web/src/lib/send/constants.ts` | Токены, decimals, контракты |
| `apps/web/src/lib/qr/generate.ts` | Генерация URI для QR |
| `apps/web/src/lib/qr/parse.ts` | Парсинг URI из QR |
| `apps/api/src/routes/btc-proxy.routes.ts` | Bitcoin RPC proxy |
### Изменяемые (3)
| Файл | Изменение |
|------|-----------|
| `apps/web/src/app/dashboard/page.tsx` | Send/Receive кнопки |
| `apps/web/package.json` | qrcode.react, @yudiel/react-qr-scanner |
| `apps/api/src/app.ts` | Регистрация btc-proxy routes |
## Порядок выполнения
| # | Задача |
|---|--------|
| 1 | install qrcode.react + @yudiel/react-qr-scanner |
| 2 | send/constants.ts — токены, decimals, контракты для 4 сетей |
| 3 | send/validate.ts — валидация адресов по сетям |
| 4 | qr/generate.ts — URI генератор |
| 5 | qr/parse.ts — URI парсер |
| 6 | btc-proxy.routes.ts — Bitcoin UTXO + broadcast |
| 7 | app.ts — регистрация btc routes |
| 8 | send/execute.ts — отправка по 4 сетям |
| 9 | receive/page.tsx — Receive page |
| 10 | send/page.tsx — Send page + QR scanner |
| 11 | dashboard/page.tsx — Send/Receive кнопки |
| 12 | typecheck |

View File

@@ -0,0 +1,489 @@
# Add Tokens + BSC Chain Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add new tokens to ETH/SOL swap+balance+send, add BNB Smart Chain as a new EVM network with PancakeSwap V2 swap.
**Architecture:** Extend existing multi-chain config-driven architecture. BSC reuses ETH's EVM signing logic (same derivation path, same ethers.Wallet). BSC swap via PancakeSwap V2 Router (Uniswap V2 fork) through a new API proxy. All new SOL tokens go through Jupiter (existing proxy). All new ETH tokens go through Uniswap (existing flow).
**Tech Stack:** ethers.js v5, Next.js, Express, PancakeSwap V2 ABI, Jupiter API, Uniswap V3/V4 SDK
---
## Token Reference
### New ETH Tokens (Uniswap swap)
| Symbol | Contract (checksummed) | Decimals | CoinGecko ID |
|--------|------------------------|----------|--------------|
| stETH | `0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84` | 18 | `staked-ether` |
| SHIB | `0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE` | 18 | `shiba-inu` |
| LINK | `0x514910771AF9Ca656af840dff83E8264EcF986CA` | 18 | `chainlink` |
| POL | `0x455e53CBB86018Ac2B8092FdCd39d8444aFFC3F6` | 18 | `polygon-ecosystem-token` |
| WLFI | `0x66f85e3865D0cFDc009aCF6280A8621f12e46CCf` | 18 | `world-liberty-financial` |
| AAVE | `0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9` | 18 | `aave` |
### New SOL Tokens (Jupiter swap)
| Symbol | Mint | Decimals | CoinGecko ID |
|--------|------|----------|--------------|
| WIF | `EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm` | 6 | `dogwifcoin` |
| POPCAT | `7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr` | 9 | `popcat` |
| TRUMP | `6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN` | 6 | `official-trump` |
| PYTH | `HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3` | 6 | `pyth-network` |
| JTO | `jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL` | 9 | `jito-governance-token` |
| W | `85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ` | 6 | `wormhole` |
| BONK | `DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263` | 5 | `bonk` |
| ORCA | `orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE` | 6 | `orca` |
| PENGU | `2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv` | 6 | `pudgy-penguins` |
| RAY | `4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R` | 6 | `raydium` |
### BSC Chain Config
| Property | Value |
|----------|-------|
| ChainId | 56 |
| Native | BNB (18 decimals) |
| WBNB | `0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c` |
| DOGE (BEP-20) | `0xbA2aE424d960c26247Dd6c32edC70B295c744C43` (8 decimals) |
| PancakeSwap V2 Router | `0x10ED43C718714eb63d5aA57B78B54704E256024E` |
| Derivation | Same as ETH: `m/44'/60'/0'/0/0` |
| RPC | `https://bsc-dataseed.binance.org` + fallbacks |
| Explorer | `https://bscscan.com/tx/` |
---
## Task 1: Add new ETH tokens to swap constants
**Files:**
- Modify: `apps/web/src/lib/swap/constants.ts`
**Steps:**
1. Add 6 new Token instances after PEPE:
```ts
const STETH = new Token(ETHEREUM_CHAIN_ID, '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', 18, 'stETH', 'Lido Staked Ether');
const SHIB = new Token(ETHEREUM_CHAIN_ID, '0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE', 18, 'SHIB', 'Shiba Inu');
const LINK = new Token(ETHEREUM_CHAIN_ID, '0x514910771AF9Ca656af840dff83E8264EcF986CA', 18, 'LINK', 'Chainlink');
const POL = new Token(ETHEREUM_CHAIN_ID, '0x455e53CBB86018Ac2B8092FdCd39d8444aFFC3F6', 18, 'POL', 'Polygon');
const WLFI = new Token(ETHEREUM_CHAIN_ID, '0x66f85e3865D0cFDc009aCF6280A8621f12e46CCf', 18, 'WLFI', 'World Liberty Financial');
const AAVE_TOKEN = new Token(ETHEREUM_CHAIN_ID, '0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9', 18, 'AAVE', 'Aave');
```
2. Update `SwapTokenSymbol` type to include new symbols:
```ts
export type SwapTokenSymbol = 'ETH' | 'USDT' | 'USDC' | 'XAUT' | 'UNI' | 'PEPE' | 'stETH' | 'SHIB' | 'LINK' | 'POL' | 'WLFI' | 'AAVE';
```
3. Add entries to `SWAP_TOKENS` record for each new token (same pattern as existing).
4. Update `SWAP_TOKEN_OPTIONS` and `SWAP_TOKEN_OPTIONS_BY_CHAIN.ETH` to include new symbols.
5. Add V3 pool candidates for new tokens (WETH pairs with MEDIUM/HIGH fees):
```ts
// stETH
{ tokenA: WETH, tokenB: STETH, fee: FeeAmount.LOW },
{ tokenA: WETH, tokenB: STETH, fee: FeeAmount.MEDIUM },
// SHIB
{ tokenA: WETH, tokenB: SHIB, fee: FeeAmount.MEDIUM },
{ tokenA: WETH, tokenB: SHIB, fee: FeeAmount.HIGH },
// LINK
{ tokenA: WETH, tokenB: LINK, fee: FeeAmount.LOW },
{ tokenA: WETH, tokenB: LINK, fee: FeeAmount.MEDIUM },
// POL
{ tokenA: WETH, tokenB: POL, fee: FeeAmount.MEDIUM },
{ tokenA: WETH, tokenB: POL, fee: FeeAmount.HIGH },
// WLFI
{ tokenA: WETH, tokenB: WLFI, fee: FeeAmount.MEDIUM },
{ tokenA: WETH, tokenB: WLFI, fee: FeeAmount.HIGH },
// AAVE
{ tokenA: WETH, tokenB: AAVE_TOKEN, fee: FeeAmount.LOW },
{ tokenA: WETH, tokenB: AAVE_TOKEN, fee: FeeAmount.MEDIUM },
```
6. Add V4 pool key candidates similarly.
7. Update `getSlippageBps` to include new volatile tokens:
```ts
const volatileTokens: SwapTokenSymbol[] = ['PEPE', 'XAUT', 'UNI', 'SHIB', 'WLFI', 'stETH', 'LINK', 'POL', 'AAVE'];
```
---
## Task 2: Add new ETH tokens to balance fetcher
**Files:**
- Modify: `apps/web/src/lib/balances/eth-balances.ts`
**Steps:**
1. Add 6 new entries to `ETH_TOKENS` array (after PEPE):
```ts
{ 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 },
```
---
## Task 3: Add new ETH tokens to send constants
**Files:**
- Modify: `apps/web/src/lib/send/constants.ts`
**Steps:**
1. Add 6 new token entries under `SEND_CHAINS.ETH.tokens`:
```ts
stETH: { symbol: 'stETH', contractAddress: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', decimals: 18 },
SHIB: { symbol: 'SHIB', contractAddress: '0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE', decimals: 18 },
LINK: { symbol: 'LINK', contractAddress: '0x514910771AF9Ca656af840dff83E8264EcF986CA', decimals: 18 },
POL: { symbol: 'POL', contractAddress: '0x455e53CBB86018Ac2B8092FdCd39d8444aFFC3F6', decimals: 18 },
WLFI: { symbol: 'WLFI', contractAddress: '0x66f85e3865D0cFDc009aCF6280A8621f12e46CCf', decimals: 18 },
AAVE: { symbol: 'AAVE', contractAddress: '0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9', decimals: 18 },
```
2. Add corresponding `CONTRACT_TO_SYMBOL` entries (lowercase addresses).
---
## Task 4: Add new SOL tokens to swap constants
**Files:**
- Modify: `apps/web/src/lib/swap/constants.ts`
**Steps:**
1. Add new entries to `SOL_TOKEN_MINTS`:
```ts
WIF: 'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm',
POPCAT: '7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr',
TRUMP: '6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN',
PYTH: 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3',
JTO: 'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL',
W: '85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ',
BONK: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263',
ORCA: 'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE',
PENGU: '2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv',
RAY: '4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R',
```
2. Add entries to `SOL_TOKEN_DECIMALS`:
```ts
WIF: 6, POPCAT: 9, TRUMP: 6, PYTH: 6, JTO: 9, W: 6, BONK: 5, ORCA: 6, PENGU: 6, RAY: 6,
```
3. Update `SWAP_TOKEN_OPTIONS_BY_CHAIN.SOL`:
```ts
SOL: ['SOL', 'USDT', 'USDC', 'PUMP', 'JUP', 'WIF', 'POPCAT', 'TRUMP', 'PYTH', 'JTO', 'W', 'BONK', 'ORCA', 'PENGU', 'RAY'],
```
---
## Task 5: Add new SOL tokens to balance fetcher + Jupiter whitelist
**Files:**
- Modify: `apps/web/src/lib/balances/sol-balances.ts`
- Modify: `apps/api/src/routes/sol-swap-proxy.routes.ts`
**Steps:**
1. Add 10 new entries to `SOL_TOKENS` array in `sol-balances.ts`:
```ts
{ 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 },
```
2. Add all 10 new mint addresses to `ALLOWED_MINTS` in `sol-swap-proxy.routes.ts`:
```ts
const ALLOWED_MINTS = new Set([
'So11111111111111111111111111111111111111112', // SOL
'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', // USDT
'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', // USDC
'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn', // PUMP
'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', // JUP
'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm', // WIF
'7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', // POPCAT
'6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN', // TRUMP
'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3', // PYTH
'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL', // JTO
'85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ', // W
'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263', // BONK
'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE', // ORCA
'2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv', // PENGU
'4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R', // RAY
]);
```
---
## Task 6: Add new SOL tokens to send constants
**Files:**
- Modify: `apps/web/src/lib/send/constants.ts`
**Steps:**
1. Add 10 new token entries under `SEND_CHAINS.SOL.tokens` (same mint addresses as balance fetcher).
2. Add corresponding `CONTRACT_TO_SYMBOL` entries for the new SOL mints.
---
## Task 7: Add BSC chain — wallet derivation
**Files:**
- Modify: `apps/web/src/lib/crypto/derive-keys.ts`
- Modify: `apps/web/src/lib/balances/types.ts`
**Steps:**
1. In `derive-keys.ts`, update `Chain` type:
```ts
export type Chain = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC';
```
2. Add BSC derivation path (same as ETH):
```ts
BSC: "m/44'/60'/0'/0/0",
```
3. In `deriveWalletsFromMnemonic`, add BSC wallet. Since BSC uses the same key as ETH:
```ts
const bsc = deriveEthWallet(mnemonic); // same key derivation
// ...
{ chain: 'BSC', address: bsc.address, privateKey: bsc.privateKey, derivationPath: DERIVATION_PATHS.BSC },
```
4. In `types.ts`, update `BalanceChain`:
```ts
export type BalanceChain = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC';
```
---
## Task 8: Add BSC chain — environment + balance fetcher
**Files:**
- Modify: `apps/web/src/lib/env.ts`
- Create: `apps/web/src/lib/balances/bsc-balances.ts`
- Modify: `apps/web/src/lib/balances/index.ts`
**Steps:**
1. Add BSC RPC to `env.ts`:
```ts
bscRpcUrl: readUrlEnv('NEXT_PUBLIC_BSC_RPC_URL', 'https://bsc-dataseed.binance.org'),
```
2. Create `bsc-balances.ts` — copy `eth-balances.ts` pattern with:
- `BSC_CHAIN_ID = 56`
- `BSC_RPC_CANDIDATES`: `webEnv.bscRpcUrl`, `https://bsc-dataseed1.defibit.io`, `https://bsc-dataseed1.ninicoin.io`
- `BSC_TOKENS`: BNB native (18 dec, coinGeckoId `binancecoin`) + DOGE BEP-20 (8 dec, `0xbA2aE424d960c26247Dd6c32edC70B295c744C43`, coinGeckoId `dogecoin`)
- `fetchBscBalances(address)` — same logic as `fetchEthBalances` but with BSC provider
- All internal references use `chain: 'BSC'`
3. Register in `balances/index.ts`:
```ts
import { fetchBscBalances } from './bsc-balances';
// ...
const SUPPORTED_CHAINS: BalanceChain[] = ['ETH', 'BTC', 'SOL', 'TRX', 'BSC'];
// ...
BSC: fetchBscBalances,
```
---
## Task 9: Add BSC chain — swap via PancakeSwap V2 API proxy
**Files:**
- Create: `apps/api/src/routes/bsc-swap-proxy.routes.ts`
- Modify: `apps/api/src/app.ts`
**Steps:**
1. Create `bsc-swap-proxy.routes.ts` — PancakeSwap V2 swap proxy (same pattern as `tron-swap-proxy.routes.ts` but simpler since it's EVM):
```
Router endpoints:
GET /quote — calls PancakeSwap V2 Router getAmountsOut via BSC RPC
POST /build — builds swap calldata (approve + swap tx)
Key constants:
PANCAKE_ROUTER = '0x10ED43C718714eb63d5aA57B78B54704E256024E'
WBNB = '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c'
DOGE = '0xbA2aE424d960c26247Dd6c32edC70B295c744C43'
BSC_RPC = 'https://bsc-dataseed.binance.org'
Quote logic:
- Create ethers provider with BSC RPC
- Call router.getAmountsOut(amountIn, [fromToken, toToken])
- Return { amountIn, amountOut, from, to }
Build logic:
- For BNB->Token: encode swapExactETHForTokensSupportingFeeOnTransferTokens calldata
- For Token->BNB: check/build approve tx + encode swapExactTokensForETHSupportingFeeOnTransferTokens calldata
- Return { calldata, to, value, approveTx? }
```
2. Register in `app.ts`:
```ts
import bscSwapProxyRoutes from './routes/bsc-swap-proxy.routes';
// ...
app.use('/api/bsc/swap', bscSwapProxyRoutes);
```
---
## Task 10: Add BSC to swap constants + UI
**Files:**
- Modify: `apps/web/src/lib/swap/constants.ts`
**Steps:**
1. Update `SwapChain`:
```ts
export type SwapChain = 'ETH' | 'SOL' | 'TRX' | 'BSC';
```
2. Add BSC token configs:
```ts
export const BSC_TOKEN_ADDRESSES: Record<string, string> = {
BNB: 'native',
DOGE: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43',
};
export const BSC_TOKEN_DECIMALS: Record<string, number> = {
BNB: 18,
DOGE: 8,
};
```
3. Add BSC to `SWAP_TOKEN_OPTIONS_BY_CHAIN`:
```ts
BSC: ['BNB', 'DOGE'],
```
4. Add BSC to `CHAIN_DEFAULT_TOKENS`:
```ts
BSC: { from: 'BNB', to: 'DOGE' },
```
5. Update `getSlippageBpsForChain` to handle BSC:
```ts
if (chain === 'BSC') return 50; // 0.50%
```
6. Update `getExplorerTxUrl`:
```ts
case 'BSC': return `https://bscscan.com/tx/${txHash}`;
```
---
## Task 11: Add BSC swap execution (client-side)
**Files:**
- Create: `apps/web/src/lib/swap/bsc/execute.ts`
- Modify: swap UI hooks/page to add BSC chain option
**Steps:**
1. Create `bsc/execute.ts`:
```ts
// Similar to TRX swap execute but using ethers.Wallet directly
// 1. Fetch quote from /api/bsc/swap/quote
// 2. Build tx from /api/bsc/swap/build
// 3. If approve needed: sign and send approve tx, wait for confirmation
// 4. Sign and send swap tx via ethers.Wallet connected to BSC RPC
// 5. Return { hash, explorerUrl }
```
2. Update swap page `CHAINS` array:
```ts
const CHAINS: SwapChain[] = ['ETH', 'SOL', 'TRX', 'BSC'];
```
3. Update `useSwap` hook to handle BSC chain (dispatch to `executeBscSwap`).
---
## Task 12: Add BSC to send/receive
**Files:**
- Modify: `apps/web/src/lib/send/constants.ts`
- Modify: `apps/web/src/lib/send/execute.ts`
- Modify: `apps/web/src/lib/send/validate.ts`
- Modify: `apps/web/src/lib/qr/generate.ts`
- Modify: `apps/web/src/lib/qr/parse.ts`
**Steps:**
1. In `send/constants.ts`:
- Update `SendChain`: `'ETH' | 'SOL' | 'TRX' | 'BTC' | 'BSC'`
- Add BSC config:
```ts
BSC: {
key: 'BSC',
label: 'BNB Smart Chain',
walletChain: 'BSC',
tokens: {
BNB: { symbol: 'BNB', contractAddress: null, decimals: 18 },
DOGE: { symbol: 'DOGE', contractAddress: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43', decimals: 8 },
},
explorerTxUrl: 'https://bscscan.com/tx/',
},
```
- Add to `SEND_CHAIN_OPTIONS`
2. In `send/execute.ts`:
- Add BSC case — same as ETH but with BSC provider:
```ts
case 'BSC':
return executeBscSend(params);
```
- `executeBscSend`: create `ethers.providers.StaticJsonRpcProvider(bscRpcUrl, 56)`, use `ethers.Wallet` for native BNB or BEP-20 transfer (identical to ETH logic).
3. In `send/validate.ts`:
- BSC uses same address format as ETH — add `case 'BSC': return validateEthAddress(address);`
- Update `detectChainFromAddress` — BSC can't be auto-detected (same format as ETH)
4. In `qr/generate.ts`:
- Add BSC case using `ethereum:` URI scheme with `@56` chain discriminator:
```ts
case 'BSC':
return generateBscUri(address, token, amount);
```
- `generateBscUri`: `ethereum:${address}@56` for native, `ethereum:${contract}@56/transfer?address=${to}` for BEP-20
5. In `qr/parse.ts`:
- Handle `@56` chain discriminator in ethereum: URIs to distinguish BSC from ETH
---
## Task 13: Run typecheck and verify
**Steps:**
1. Run `pnpm typecheck` from monorepo root
2. Fix any TypeScript errors
3. Verify all imports resolve correctly
---
## Commit Strategy
- After Tasks 1-3 (ETH tokens): commit "feat: add stETH, SHIB, LINK, POL, WLFI, AAVE tokens to ETH swap/balance/send"
- After Tasks 4-6 (SOL tokens): commit "feat: add WIF, POPCAT, TRUMP, PYTH, JTO, W, BONK, ORCA, PENGU, RAY to SOL swap/balance/send"
- After Tasks 7-12 (BSC chain): commit "feat: add BNB Smart Chain with PancakeSwap V2 swap, BNB + DOGE tokens"
- After Task 13: commit "fix: resolve typecheck errors" (if needed)

View File

@@ -1,3 +1,3 @@
export { CHAINS, DERIVATION_PATHS } from './constants/chains'; export { CHAINS, DERIVATION_PATHS } from './constants/chains';
export type { Chain } from './constants/chains'; export type { Chain } from './constants/chains';
export type { ApiResponse, AccessTokenPayload, AuthContext } from './types/auth'; export type { RegisterRequest, LoginRequest, AuthResponse, ApiResponse } from './types/auth';

View File

@@ -1,22 +1,31 @@
import { Chain } from '../constants/chains';
export interface RegisterRequest {
username: string;
password: string;
pin: string;
encryptedVault: string;
vaultSalt: string;
wallets: { chain: Chain; address: string; derivationPath: string }[];
}
export interface LoginRequest {
username: string;
password: string;
pin: string;
}
export interface AuthResponse {
accessToken: string;
user: { id: string; username: string };
encryptedVault?: string;
vaultSalt?: string;
wallets?: { chain: Chain; address: string; derivationPath: string }[];
mnemonicShown: boolean;
}
export interface ApiResponse<T = unknown> { export interface ApiResponse<T = unknown> {
success: boolean; success: boolean;
data?: T; data?: T;
error?: string; error?: string;
} }
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;
}

473
pnpm-lock.yaml generated
View File

@@ -20,6 +20,12 @@ importers:
'@cryptowallet/shared': '@cryptowallet/shared':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/shared version: link:../../packages/shared
amqplib:
specifier: ^1.0.3
version: 1.0.3
bcrypt:
specifier: ^5.1.1
version: 5.1.1
cookie-parser: cookie-parser:
specifier: ^1.4.7 specifier: ^1.4.7
version: 1.4.7 version: 1.4.7
@@ -31,32 +37,44 @@ importers:
version: 16.6.1 version: 16.6.1
ethers: ethers:
specifier: 5.7.2 specifier: 5.7.2
version: 5.7.2(bufferutil@4.1.0) version: 5.7.2(bufferutil@4.1.0)(utf-8-validate@6.0.6)
express: express:
specifier: ^4.21.0 specifier: ^4.21.0
version: 4.22.1 version: 4.22.1
express-rate-limit:
specifier: ^7.4.0
version: 7.5.1(express@4.22.1)
helmet: helmet:
specifier: ^8.0.0 specifier: ^8.0.0
version: 8.1.0 version: 8.1.0
jose: jose:
specifier: ^6.2.2 specifier: ^6.2.2
version: 6.2.2 version: 6.2.2
jsonwebtoken:
specifier: ^9.0.0
version: 9.0.3
knex: knex:
specifier: ^3.1.0 specifier: ^3.1.0
version: 3.1.0(pg@8.20.0) version: 3.1.0(pg@8.20.0)
pg: pg:
specifier: ^8.13.0 specifier: ^8.13.0
version: 8.20.0 version: 8.20.0
swagger-ui-express:
specifier: ^5.0.1
version: 5.0.1(express@4.22.1)
ulidx: ulidx:
specifier: ^2.4.1 specifier: ^2.4.1
version: 2.4.1 version: 2.4.1
uuid:
specifier: ^11.0.0
version: 11.1.0
zod: zod:
specifier: ^3.23.0 specifier: ^3.23.0
version: 3.25.76 version: 3.25.76
devDependencies: devDependencies:
'@types/amqplib':
specifier: ^0.10.8
version: 0.10.8
'@types/bcrypt':
specifier: ^5.0.2
version: 5.0.2
'@types/cookie-parser': '@types/cookie-parser':
specifier: ^1.4.7 specifier: ^1.4.7
version: 1.4.10(@types/express@5.0.6) version: 1.4.10(@types/express@5.0.6)
@@ -69,12 +87,15 @@ importers:
'@types/express-serve-static-core': '@types/express-serve-static-core':
specifier: ^5.1.1 specifier: ^5.1.1
version: 5.1.1 version: 5.1.1
'@types/jsonwebtoken':
specifier: ^9.0.0
version: 9.0.10
'@types/node': '@types/node':
specifier: ^20.0.0 specifier: ^20.0.0
version: 20.19.37 version: 20.19.37
'@types/swagger-ui-express': '@types/uuid':
specifier: ^4.1.8 specifier: ^10.0.0
version: 4.1.8 version: 10.0.0
ts-node: ts-node:
specifier: ^10.9.0 specifier: ^10.9.0
version: 10.9.2(@types/node@20.19.37)(typescript@5.9.3) version: 10.9.2(@types/node@20.19.37)(typescript@5.9.3)
@@ -524,6 +545,10 @@ packages:
'@jridgewell/trace-mapping@0.3.9': '@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@mapbox/node-pre-gyp@1.0.11':
resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==}
hasBin: true
'@next/env@16.1.6': '@next/env@16.1.6':
resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==} resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==}
@@ -692,9 +717,6 @@ packages:
'@openzeppelin/contracts@5.0.2': '@openzeppelin/contracts@5.0.2':
resolution: {integrity: sha512-ytPc6eLGcHHnapAZ9S+5qsdomhjo6QBHTDRRBFfTxXIpsicMhVPouPgmUPebZZZGX7vt9USA+Z+0M0dSVtSUEA==} resolution: {integrity: sha512-ytPc6eLGcHHnapAZ9S+5qsdomhjo6QBHTDRRBFfTxXIpsicMhVPouPgmUPebZZZGX7vt9USA+Z+0M0dSVtSUEA==}
'@scarf/scarf@1.4.0':
resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==}
'@scure/base@1.1.9': '@scure/base@1.1.9':
resolution: {integrity: sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==} resolution: {integrity: sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==}
@@ -791,6 +813,12 @@ packages:
'@tsconfig/node16@1.0.4': '@tsconfig/node16@1.0.4':
resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
'@types/amqplib@0.10.8':
resolution: {integrity: sha512-vtDp8Pk1wsE/AuQ8/Rgtm6KUZYqcnTgNvEHwzCkX8rL7AGsC6zqAfKAAJhUZXFhM/Pp++tbnUHiam/8vVpPztA==}
'@types/bcrypt@5.0.2':
resolution: {integrity: sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==}
'@types/body-parser@1.19.6': '@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
@@ -817,6 +845,12 @@ packages:
'@types/http-errors@2.0.5': '@types/http-errors@2.0.5':
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
'@types/jsonwebtoken@9.0.10':
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node@12.20.55': '@types/node@12.20.55':
resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==}
@@ -849,9 +883,6 @@ packages:
'@types/strip-json-comments@0.0.30': '@types/strip-json-comments@0.0.30':
resolution: {integrity: sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==} resolution: {integrity: sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==}
'@types/swagger-ui-express@4.1.8':
resolution: {integrity: sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==}
'@types/uuid@10.0.0': '@types/uuid@10.0.0':
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
@@ -923,6 +954,9 @@ packages:
react: ^17 || ^18 || ^19 react: ^17 || ^18 || ^19
react-dom: ^17 || ^18 || ^19 react-dom: ^17 || ^18 || ^19
abbrev@1.1.1:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
accepts@1.3.8: accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -955,6 +989,10 @@ packages:
resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==}
engines: {node: '>=8'} engines: {node: '>=8'}
amqplib@1.0.3:
resolution: {integrity: sha512-nsrXa59tvX4HPjPPW8JJGuBb/Db0MOq3DOhGdSbIY7bTsQDMV2fmF9c3i74g/8Gt9+QWyrxfEPppOOoV9lK1uQ==}
engines: {node: '>=18'}
ansi-align@3.0.1: ansi-align@3.0.1:
resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==}
@@ -978,6 +1016,14 @@ packages:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
aproba@2.1.0:
resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==}
are-we-there-yet@2.0.0:
resolution: {integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==}
engines: {node: '>=10'}
deprecated: This package is no longer supported.
arg@4.1.3: arg@4.1.3:
resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
@@ -1014,6 +1060,10 @@ packages:
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
bcrypt@5.1.1:
resolution: {integrity: sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==}
engines: {node: '>= 10.0.0'}
bech32@1.1.4: bech32@1.1.4:
resolution: {integrity: sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ==} resolution: {integrity: sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ==}
@@ -1084,9 +1134,15 @@ packages:
bs58check@4.0.0: bs58check@4.0.0:
resolution: {integrity: sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==} resolution: {integrity: sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==}
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
buffer-from@1.1.2: buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
buffer-more-ints@1.0.0:
resolution: {integrity: sha512-EMetuGFz5SLsT0QTnXzINh4Ksr+oo4i+UGTXEshiGCQWnsgSs7ZhJ8fzlwQ+OzEMs0MpDAMr1hxnblp5a4vcHg==}
buffer@6.0.3: buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
@@ -1133,6 +1189,10 @@ packages:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'} engines: {node: '>= 14.16.0'}
chownr@2.0.0:
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
engines: {node: '>=10'}
ci-info@2.0.0: ci-info@2.0.0:
resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==} resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==}
@@ -1161,6 +1221,10 @@ packages:
color-name@1.1.4: color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
color-support@1.1.3:
resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==}
hasBin: true
colorette@2.0.19: colorette@2.0.19:
resolution: {integrity: sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==} resolution: {integrity: sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==}
@@ -1185,6 +1249,9 @@ packages:
concat-map@0.0.1: concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
console-control-strings@1.1.0:
resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==}
content-disposition@0.5.4: content-disposition@0.5.4:
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -1271,6 +1338,9 @@ packages:
resolution: {integrity: sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==} resolution: {integrity: sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==}
engines: {node: '>=10'} engines: {node: '>=10'}
delegates@1.0.0:
resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==}
depd@2.0.0: depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -1306,6 +1376,9 @@ packages:
dynamic-dedupe@0.3.0: dynamic-dedupe@0.3.0:
resolution: {integrity: sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==} resolution: {integrity: sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==}
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
ecpair@3.0.1: ecpair@3.0.1:
resolution: {integrity: sha512-uz8wMFvtdr58TLrXnAesBsoMEyY8UudLOfApcyg40XfZjP+gt1xO4cuZSIkZ8hTMTQ8+ETgt7xSIV4eM7M6VNw==} resolution: {integrity: sha512-uz8wMFvtdr58TLrXnAesBsoMEyY8UudLOfApcyg40XfZjP+gt1xO4cuZSIkZ8hTMTQ8+ETgt7xSIV4eM7M6VNw==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -1386,6 +1459,12 @@ packages:
eventemitter3@5.0.4: eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
express-rate-limit@7.5.1:
resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==}
engines: {node: '>= 16'}
peerDependencies:
express: '>= 4.11'
express@4.22.1: express@4.22.1:
resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==}
engines: {node: '>= 0.10.0'} engines: {node: '>= 0.10.0'}
@@ -1450,6 +1529,10 @@ packages:
resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==}
engines: {node: '>=6 <7 || >=8'} engines: {node: '>=6 <7 || >=8'}
fs-minipass@2.1.0:
resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==}
engines: {node: '>= 8'}
fs.realpath@1.0.0: fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
@@ -1461,6 +1544,11 @@ packages:
function-bind@1.1.2: function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
gauge@3.0.2:
resolution: {integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==}
engines: {node: '>=10'}
deprecated: This package is no longer supported.
get-caller-file@2.0.5: get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*} engines: {node: 6.* || 8.* || >= 10.*}
@@ -1532,6 +1620,9 @@ packages:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
has-unicode@2.0.1:
resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==}
hash-base@3.1.2: hash-base@3.1.2:
resolution: {integrity: sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==} resolution: {integrity: sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -1676,6 +1767,16 @@ packages:
jsonfile@4.0.0: jsonfile@4.0.0:
resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
jsonwebtoken@9.0.3:
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
engines: {node: '>=12', npm: '>=6'}
jwa@2.0.1:
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
keccak@3.0.4: keccak@3.0.4:
resolution: {integrity: sha512-3vKuW0jV8J3XNTzvfyicFR5qvxrSAGl7KIhvgOu5cmWwM7tZRj3fMbj/pfIf4be7aznbc+prBWGjywox/g2Y6Q==} resolution: {integrity: sha512-3vKuW0jV8J3XNTzvfyicFR5qvxrSAGl7KIhvgOu5cmWwM7tZRj3fMbj/pfIf4be7aznbc+prBWGjywox/g2Y6Q==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
@@ -1715,6 +1816,27 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} engines: {node: '>=10'}
lodash.includes@4.3.0:
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
lodash.isboolean@3.0.3:
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
lodash.isinteger@4.0.4:
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
lodash.isnumber@3.0.3:
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
lodash.isstring@4.0.1:
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
lodash@4.17.23: lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
@@ -1725,6 +1847,10 @@ packages:
lru_map@0.3.3: lru_map@0.3.3:
resolution: {integrity: sha512-Pn9cox5CsMYngeDbmChANltQl+5pi6XmTrraMSzhPmMBbmgcxmqWry0U3PGapCU1yB4/LqCcom7qhHZiF/jGfQ==} resolution: {integrity: sha512-Pn9cox5CsMYngeDbmChANltQl+5pi6XmTrraMSzhPmMBbmgcxmqWry0U3PGapCU1yB4/LqCcom7qhHZiF/jGfQ==}
make-dir@3.1.0:
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
engines: {node: '>=8'}
make-error@1.3.6: make-error@1.3.6:
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
@@ -1785,6 +1911,18 @@ packages:
minimist@1.2.8: minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minipass@3.3.6:
resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==}
engines: {node: '>=8'}
minipass@5.0.0:
resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==}
engines: {node: '>=8'}
minizlib@2.1.2:
resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==}
engines: {node: '>= 8'}
mkdirp@1.0.4: mkdirp@1.0.4:
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -1840,6 +1978,9 @@ packages:
node-addon-api@2.0.2: node-addon-api@2.0.2:
resolution: {integrity: sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==} resolution: {integrity: sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==}
node-addon-api@5.1.0:
resolution: {integrity: sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==}
node-fetch@2.7.0: node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0} engines: {node: 4.x || >=6.0.0}
@@ -1853,10 +1994,19 @@ packages:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true hasBin: true
nopt@5.0.0:
resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==}
engines: {node: '>=6'}
hasBin: true
normalize-path@3.0.0: normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
npmlog@5.0.1:
resolution: {integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==}
deprecated: This package is no longer supported.
object-assign@4.1.1: object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -2057,6 +2207,11 @@ packages:
deprecated: Rimraf versions prior to v4 are no longer supported deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true hasBin: true
rimraf@3.0.2:
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
ripemd160@2.0.3: ripemd160@2.0.3:
resolution: {integrity: sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==} resolution: {integrity: sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -2106,6 +2261,9 @@ packages:
resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
set-function-length@1.2.2: set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2138,6 +2296,9 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
solc@0.8.26: solc@0.8.26:
resolution: {integrity: sha512-yiPQNVf5rBFHwN6SIf3TUUvVAFKcQqmSUFeq+fb6pNRCo0ZCgpYOZDi3BVoezCPIAcKrVYd/qXlBLUP9wVrZ9g==} resolution: {integrity: sha512-yiPQNVf5rBFHwN6SIf3TUUvVAFKcQqmSUFeq+fb6pNRCo0ZCgpYOZDi3BVoezCPIAcKrVYd/qXlBLUP9wVrZ9g==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
@@ -2224,19 +2385,15 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
swagger-ui-dist@5.32.1:
resolution: {integrity: sha512-6HQoo7+j8PA2QqP5kgAb9dl1uxUjvR0SAoL/WUp1sTEvm0F6D5npgU2OGCLwl++bIInqGlEUQ2mpuZRZYtyCzQ==}
swagger-ui-express@5.0.1:
resolution: {integrity: sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==}
engines: {node: '>= v0.10.32'}
peerDependencies:
express: '>=4.0.0 || >=5.0.0-beta'
tagged-tag@1.0.0: tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'} engines: {node: '>=20'}
tar@6.2.1:
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
engines: {node: '>=10'}
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
tarn@3.0.2: tarn@3.0.2:
resolution: {integrity: sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==} resolution: {integrity: sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==}
engines: {node: '>=8.0.0'} engines: {node: '>=8.0.0'}
@@ -2473,6 +2630,9 @@ packages:
resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
wide-align@1.1.5:
resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==}
widest-line@3.1.0: widest-line@3.1.0:
resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -2534,6 +2694,9 @@ packages:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'} engines: {node: '>=10'}
yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
yargs-parser@20.2.9: yargs-parser@20.2.9:
resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -2861,32 +3024,6 @@ snapshots:
dependencies: dependencies:
'@ethersproject/logger': 5.8.0 '@ethersproject/logger': 5.8.0
'@ethersproject/providers@5.7.2(bufferutil@4.1.0)':
dependencies:
'@ethersproject/abstract-provider': 5.8.0
'@ethersproject/abstract-signer': 5.8.0
'@ethersproject/address': 5.8.0
'@ethersproject/base64': 5.8.0
'@ethersproject/basex': 5.8.0
'@ethersproject/bignumber': 5.8.0
'@ethersproject/bytes': 5.8.0
'@ethersproject/constants': 5.8.0
'@ethersproject/hash': 5.8.0
'@ethersproject/logger': 5.8.0
'@ethersproject/networks': 5.8.0
'@ethersproject/properties': 5.8.0
'@ethersproject/random': 5.8.0
'@ethersproject/rlp': 5.8.0
'@ethersproject/sha2': 5.8.0
'@ethersproject/strings': 5.8.0
'@ethersproject/transactions': 5.8.0
'@ethersproject/web': 5.8.0
bech32: 1.1.4
ws: 7.4.6(bufferutil@4.1.0)
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@ethersproject/providers@5.7.2(bufferutil@4.1.0)(utf-8-validate@6.0.6)': '@ethersproject/providers@5.7.2(bufferutil@4.1.0)(utf-8-validate@6.0.6)':
dependencies: dependencies:
'@ethersproject/abstract-provider': 5.8.0 '@ethersproject/abstract-provider': 5.8.0
@@ -3181,6 +3318,21 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@mapbox/node-pre-gyp@1.0.11':
dependencies:
detect-libc: 2.1.2
https-proxy-agent: 5.0.1
make-dir: 3.1.0
node-fetch: 2.7.0
nopt: 5.0.0
npmlog: 5.0.1
rimraf: 3.0.2
semver: 7.7.4
tar: 6.2.1
transitivePeerDependencies:
- encoding
- supports-color
'@next/env@16.1.6': {} '@next/env@16.1.6': {}
'@next/swc-darwin-arm64@16.1.6': '@next/swc-darwin-arm64@16.1.6':
@@ -3298,8 +3450,6 @@ snapshots:
'@openzeppelin/contracts@5.0.2': {} '@openzeppelin/contracts@5.0.2': {}
'@scarf/scarf@1.4.0': {}
'@scure/base@1.1.9': {} '@scure/base@1.1.9': {}
'@scure/base@1.2.6': {} '@scure/base@1.2.6': {}
@@ -3444,6 +3594,14 @@ snapshots:
'@tsconfig/node16@1.0.4': {} '@tsconfig/node16@1.0.4': {}
'@types/amqplib@0.10.8':
dependencies:
'@types/node': 20.19.37
'@types/bcrypt@5.0.2':
dependencies:
'@types/node': 20.19.37
'@types/body-parser@1.19.6': '@types/body-parser@1.19.6':
dependencies: dependencies:
'@types/connect': 3.4.38 '@types/connect': 3.4.38
@@ -3478,6 +3636,13 @@ snapshots:
'@types/http-errors@2.0.5': {} '@types/http-errors@2.0.5': {}
'@types/jsonwebtoken@9.0.10':
dependencies:
'@types/ms': 2.1.0
'@types/node': 20.19.37
'@types/ms@2.1.0': {}
'@types/node@12.20.55': {} '@types/node@12.20.55': {}
'@types/node@20.19.37': '@types/node@20.19.37':
@@ -3509,11 +3674,6 @@ snapshots:
'@types/strip-json-comments@0.0.30': {} '@types/strip-json-comments@0.0.30': {}
'@types/swagger-ui-express@4.1.8':
dependencies:
'@types/express': 5.0.6
'@types/serve-static': 2.2.0
'@types/uuid@10.0.0': {} '@types/uuid@10.0.0': {}
'@types/ws@7.4.7': '@types/ws@7.4.7':
@@ -3651,6 +3811,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- '@types/emscripten' - '@types/emscripten'
abbrev@1.1.1: {}
accepts@1.3.8: accepts@1.3.8:
dependencies: dependencies:
mime-types: 2.1.35 mime-types: 2.1.35
@@ -3681,6 +3843,10 @@ snapshots:
clean-stack: 2.2.0 clean-stack: 2.2.0
indent-string: 4.0.0 indent-string: 4.0.0
amqplib@1.0.3:
dependencies:
buffer-more-ints: 1.0.0
ansi-align@3.0.1: ansi-align@3.0.1:
dependencies: dependencies:
string-width: 4.2.3 string-width: 4.2.3
@@ -3702,6 +3868,13 @@ snapshots:
normalize-path: 3.0.0 normalize-path: 3.0.0
picomatch: 2.3.1 picomatch: 2.3.1
aproba@2.1.0: {}
are-we-there-yet@2.0.0:
dependencies:
delegates: 1.0.0
readable-stream: 3.6.2
arg@4.1.3: {} arg@4.1.3: {}
argparse@2.0.1: {} argparse@2.0.1: {}
@@ -3732,6 +3905,14 @@ snapshots:
baseline-browser-mapping@2.10.0: {} baseline-browser-mapping@2.10.0: {}
bcrypt@5.1.1:
dependencies:
'@mapbox/node-pre-gyp': 1.0.11
node-addon-api: 5.1.0
transitivePeerDependencies:
- encoding
- supports-color
bech32@1.1.4: {} bech32@1.1.4: {}
bech32@2.0.0: {} bech32@2.0.0: {}
@@ -3837,8 +4018,12 @@ snapshots:
'@noble/hashes': 1.8.0 '@noble/hashes': 1.8.0
bs58: 6.0.0 bs58: 6.0.0
buffer-equal-constant-time@1.0.1: {}
buffer-from@1.1.2: {} buffer-from@1.1.2: {}
buffer-more-ints@1.0.0: {}
buffer@6.0.3: buffer@6.0.3:
dependencies: dependencies:
base64-js: 1.5.1 base64-js: 1.5.1
@@ -3895,6 +4080,8 @@ snapshots:
dependencies: dependencies:
readdirp: 4.1.2 readdirp: 4.1.2
chownr@2.0.0: {}
ci-info@2.0.0: {} ci-info@2.0.0: {}
cipher-base@1.0.7: cipher-base@1.0.7:
@@ -3921,6 +4108,8 @@ snapshots:
color-name@1.1.4: {} color-name@1.1.4: {}
color-support@1.1.3: {}
colorette@2.0.19: {} colorette@2.0.19: {}
command-exists@1.2.9: {} command-exists@1.2.9: {}
@@ -3935,6 +4124,8 @@ snapshots:
concat-map@0.0.1: {} concat-map@0.0.1: {}
console-control-strings@1.1.0: {}
content-disposition@0.5.4: content-disposition@0.5.4:
dependencies: dependencies:
safe-buffer: 5.2.1 safe-buffer: 5.2.1
@@ -4008,12 +4199,13 @@ snapshots:
delay@5.0.0: {} delay@5.0.0: {}
delegates@1.0.0: {}
depd@2.0.0: {} depd@2.0.0: {}
destroy@1.2.0: {} destroy@1.2.0: {}
detect-libc@2.1.2: detect-libc@2.1.2: {}
optional: true
diff@4.0.4: {} diff@4.0.4: {}
@@ -4033,6 +4225,10 @@ snapshots:
dependencies: dependencies:
xtend: 4.0.2 xtend: 4.0.2
ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer: 5.2.1
ecpair@3.0.1(typescript@5.9.3): ecpair@3.0.1(typescript@5.9.3):
dependencies: dependencies:
uint8array-tools: 0.0.8 uint8array-tools: 0.0.8
@@ -4117,42 +4313,6 @@ snapshots:
'@scure/bip32': 1.4.0 '@scure/bip32': 1.4.0
'@scure/bip39': 1.3.0 '@scure/bip39': 1.3.0
ethers@5.7.2(bufferutil@4.1.0):
dependencies:
'@ethersproject/abi': 5.7.0
'@ethersproject/abstract-provider': 5.7.0
'@ethersproject/abstract-signer': 5.7.0
'@ethersproject/address': 5.7.0
'@ethersproject/base64': 5.7.0
'@ethersproject/basex': 5.7.0
'@ethersproject/bignumber': 5.7.0
'@ethersproject/bytes': 5.7.0
'@ethersproject/constants': 5.7.0
'@ethersproject/contracts': 5.7.0
'@ethersproject/hash': 5.7.0
'@ethersproject/hdnode': 5.7.0
'@ethersproject/json-wallets': 5.7.0
'@ethersproject/keccak256': 5.7.0
'@ethersproject/logger': 5.7.0
'@ethersproject/networks': 5.7.1
'@ethersproject/pbkdf2': 5.7.0
'@ethersproject/properties': 5.7.0
'@ethersproject/providers': 5.7.2(bufferutil@4.1.0)
'@ethersproject/random': 5.7.0
'@ethersproject/rlp': 5.7.0
'@ethersproject/sha2': 5.7.0
'@ethersproject/signing-key': 5.7.0
'@ethersproject/solidity': 5.7.0
'@ethersproject/strings': 5.7.0
'@ethersproject/transactions': 5.7.0
'@ethersproject/units': 5.7.0
'@ethersproject/wallet': 5.7.0
'@ethersproject/web': 5.7.1
'@ethersproject/wordlists': 5.7.0
transitivePeerDependencies:
- bufferutil
- utf-8-validate
ethers@5.7.2(bufferutil@4.1.0)(utf-8-validate@6.0.6): ethers@5.7.2(bufferutil@4.1.0)(utf-8-validate@6.0.6):
dependencies: dependencies:
'@ethersproject/abi': 5.7.0 '@ethersproject/abi': 5.7.0
@@ -4191,6 +4351,10 @@ snapshots:
eventemitter3@5.0.4: {} eventemitter3@5.0.4: {}
express-rate-limit@7.5.1(express@4.22.1):
dependencies:
express: 4.22.1
express@4.22.1: express@4.22.1:
dependencies: dependencies:
accepts: 1.3.8 accepts: 1.3.8
@@ -4278,6 +4442,10 @@ snapshots:
jsonfile: 4.0.0 jsonfile: 4.0.0
universalify: 0.1.2 universalify: 0.1.2
fs-minipass@2.1.0:
dependencies:
minipass: 3.3.6
fs.realpath@1.0.0: {} fs.realpath@1.0.0: {}
fsevents@2.3.3: fsevents@2.3.3:
@@ -4285,6 +4453,18 @@ snapshots:
function-bind@1.1.2: {} function-bind@1.1.2: {}
gauge@3.0.2:
dependencies:
aproba: 2.1.0
color-support: 1.1.3
console-control-strings: 1.1.0
has-unicode: 2.0.1
object-assign: 4.1.1
signal-exit: 3.0.7
string-width: 4.2.3
strip-ansi: 6.0.1
wide-align: 1.1.5
get-caller-file@2.0.5: {} get-caller-file@2.0.5: {}
get-intrinsic@1.3.0: get-intrinsic@1.3.0:
@@ -4400,6 +4580,8 @@ snapshots:
dependencies: dependencies:
has-symbols: 1.1.0 has-symbols: 1.1.0
has-unicode@2.0.1: {}
hash-base@3.1.2: hash-base@3.1.2:
dependencies: dependencies:
inherits: 2.0.4 inherits: 2.0.4
@@ -4542,6 +4724,30 @@ snapshots:
optionalDependencies: optionalDependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
jsonwebtoken@9.0.3:
dependencies:
jws: 4.0.1
lodash.includes: 4.3.0
lodash.isboolean: 3.0.3
lodash.isinteger: 4.0.4
lodash.isnumber: 3.0.3
lodash.isplainobject: 4.0.6
lodash.isstring: 4.0.1
lodash.once: 4.1.1
ms: 2.1.3
semver: 7.7.4
jwa@2.0.1:
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
jws@4.0.1:
dependencies:
jwa: 2.0.1
safe-buffer: 5.2.1
keccak@3.0.4: keccak@3.0.4:
dependencies: dependencies:
node-addon-api: 2.0.2 node-addon-api: 2.0.2
@@ -4575,6 +4781,20 @@ snapshots:
dependencies: dependencies:
p-locate: 5.0.0 p-locate: 5.0.0
lodash.includes@4.3.0: {}
lodash.isboolean@3.0.3: {}
lodash.isinteger@4.0.4: {}
lodash.isnumber@3.0.3: {}
lodash.isplainobject@4.0.6: {}
lodash.isstring@4.0.1: {}
lodash.once@4.1.1: {}
lodash@4.17.23: {} lodash@4.17.23: {}
log-symbols@4.1.0: log-symbols@4.1.0:
@@ -4584,6 +4804,10 @@ snapshots:
lru_map@0.3.3: {} lru_map@0.3.3: {}
make-dir@3.1.0:
dependencies:
semver: 6.3.1
make-error@1.3.6: {} make-error@1.3.6: {}
math-intrinsics@1.1.0: {} math-intrinsics@1.1.0: {}
@@ -4634,6 +4858,17 @@ snapshots:
minimist@1.2.8: {} minimist@1.2.8: {}
minipass@3.3.6:
dependencies:
yallist: 4.0.0
minipass@5.0.0: {}
minizlib@2.1.2:
dependencies:
minipass: 3.3.6
yallist: 4.0.0
mkdirp@1.0.4: {} mkdirp@1.0.4: {}
mnemonist@0.38.5: mnemonist@0.38.5:
@@ -4699,14 +4934,27 @@ snapshots:
node-addon-api@2.0.2: {} node-addon-api@2.0.2: {}
node-addon-api@5.1.0: {}
node-fetch@2.7.0: node-fetch@2.7.0:
dependencies: dependencies:
whatwg-url: 5.0.0 whatwg-url: 5.0.0
node-gyp-build@4.8.4: {} node-gyp-build@4.8.4: {}
nopt@5.0.0:
dependencies:
abbrev: 1.1.1
normalize-path@3.0.0: {} normalize-path@3.0.0: {}
npmlog@5.0.1:
dependencies:
are-we-there-yet: 2.0.0
console-control-strings: 1.1.0
gauge: 3.0.2
set-blocking: 2.0.0
object-assign@4.1.1: {} object-assign@4.1.1: {}
object-inspect@1.13.4: {} object-inspect@1.13.4: {}
@@ -4885,6 +5133,10 @@ snapshots:
dependencies: dependencies:
glob: 7.2.3 glob: 7.2.3
rimraf@3.0.2:
dependencies:
glob: 7.2.3
ripemd160@2.0.3: ripemd160@2.0.3:
dependencies: dependencies:
hash-base: 3.1.2 hash-base: 3.1.2
@@ -4919,8 +5171,7 @@ snapshots:
semver@6.3.1: {} semver@6.3.1: {}
semver@7.7.4: semver@7.7.4: {}
optional: true
send@0.19.2: send@0.19.2:
dependencies: dependencies:
@@ -4953,6 +5204,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
set-blocking@2.0.0: {}
set-function-length@1.2.2: set-function-length@1.2.2:
dependencies: dependencies:
define-data-property: 1.1.4 define-data-property: 1.1.4
@@ -5030,6 +5283,8 @@ snapshots:
side-channel-map: 1.0.1 side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2 side-channel-weakmap: 1.0.2
signal-exit@3.0.7: {}
solc@0.8.26(debug@4.4.3): solc@0.8.26(debug@4.4.3):
dependencies: dependencies:
command-exists: 1.2.9 command-exists: 1.2.9
@@ -5102,17 +5357,17 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {} supports-preserve-symlinks-flag@1.0.0: {}
swagger-ui-dist@5.32.1:
dependencies:
'@scarf/scarf': 1.4.0
swagger-ui-express@5.0.1(express@4.22.1):
dependencies:
express: 4.22.1
swagger-ui-dist: 5.32.1
tagged-tag@1.0.0: {} tagged-tag@1.0.0: {}
tar@6.2.1:
dependencies:
chownr: 2.0.0
fs-minipass: 2.1.0
minipass: 5.0.0
minizlib: 2.1.2
mkdirp: 1.0.4
yallist: 4.0.0
tarn@3.0.2: {} tarn@3.0.2: {}
text-encoding-utf-8@1.0.2: {} text-encoding-utf-8@1.0.2: {}
@@ -5321,6 +5576,10 @@ snapshots:
gopd: 1.2.0 gopd: 1.2.0
has-tostringtag: 1.0.2 has-tostringtag: 1.0.2
wide-align@1.1.5:
dependencies:
string-width: 4.2.3
widest-line@3.1.0: widest-line@3.1.0:
dependencies: dependencies:
string-width: 4.2.3 string-width: 4.2.3
@@ -5339,10 +5598,6 @@ snapshots:
wrappy@1.0.2: {} wrappy@1.0.2: {}
ws@7.4.6(bufferutil@4.1.0):
optionalDependencies:
bufferutil: 4.1.0
ws@7.4.6(bufferutil@4.1.0)(utf-8-validate@6.0.6): ws@7.4.6(bufferutil@4.1.0)(utf-8-validate@6.0.6):
optionalDependencies: optionalDependencies:
bufferutil: 4.1.0 bufferutil: 4.1.0
@@ -5362,6 +5617,8 @@ snapshots:
y18n@5.0.8: {} y18n@5.0.8: {}
yallist@4.0.0: {}
yargs-parser@20.2.9: {} yargs-parser@20.2.9: {}
yargs-unparser@2.0.0: yargs-unparser@2.0.0:

View File

@@ -0,0 +1,18 @@
#!/bin/sh
set -e
# Read Vault root token from shared volume (written by vault-init)
TOKEN_FILE="/vault/file/root-token"
if [ -f "$TOKEN_FILE" ]; then
export VAULT_TOKEN=$(cat "$TOKEN_FILE" | tr -d '\n\r ')
echo "[bitok-entrypoint] Loaded VAULT_TOKEN from $TOKEN_FILE"
else
echo "[bitok-entrypoint] WARNING: $TOKEN_FILE not found, using VAULT_TOKEN from env"
fi
# Start BITOK auth service
exec granian --interface asgi ${APP_MODULE:-src.main:app} \
--host ${APP_HOST:-0.0.0.0} \
--port ${APP_PORT:-8000} \
--workers ${APP_WORKERS:-1} \
--loop uvloop

128
scripts/vault-init.sh Normal file
View File

@@ -0,0 +1,128 @@
#!/bin/sh
set -e
export VAULT_ADDR=http://vault:8200
INIT_FILE="/vault/file/init-keys.json"
TOKEN_FILE="/vault/file/root-token"
echo "[vault-init] Waiting for Vault to respond..."
until wget -qO- ${VAULT_ADDR}/v1/sys/seal-status > /dev/null 2>&1; do
sleep 1
done
# Check if Vault is initialized (use HTTP API — vault CLI may suppress output when sealed)
STATUS_JSON=$(wget -qO- ${VAULT_ADDR}/v1/sys/seal-status 2>/dev/null || echo '{}')
INITIALIZED=$(echo "$STATUS_JSON" | grep -o '"initialized":[a-z]*' | cut -d: -f2)
echo "[vault-init] Vault initialized=$INITIALIZED"
FIRST_RUN="false"
if [ "$INITIALIZED" != "true" ]; then
echo "[vault-init] First run — initializing Vault..."
FIRST_RUN="true"
# Init with 1 key share for dev simplicity
vault operator init -key-shares=1 -key-threshold=1 -format=json > "$INIT_FILE"
UNSEAL_KEY=$(tr -d ' \n' < "$INIT_FILE" | grep -o '"unseal_keys_b64":\["[^"]*"' | cut -d'"' -f4)
ROOT_TOKEN=$(tr -d ' \n' < "$INIT_FILE" | grep -o '"root_token":"[^"]*"' | cut -d'"' -f4)
echo "[vault-init] Unsealing with key: ${UNSEAL_KEY:0:8}..."
vault operator unseal "$UNSEAL_KEY"
export VAULT_TOKEN="$ROOT_TOKEN"
else
echo "[vault-init] Vault already initialized."
SEALED=$(echo "$STATUS_JSON" | grep -o '"sealed":[a-z]*' | cut -d: -f2)
echo "[vault-init] Vault sealed=$SEALED"
if [ "$SEALED" = "true" ]; then
echo "[vault-init] Vault is sealed, unsealing..."
if [ ! -f "$INIT_FILE" ]; then
echo "[vault-init] ERROR: init-keys.json not found. Cannot unseal."
exit 1
fi
UNSEAL_KEY=$(tr -d ' \n' < "$INIT_FILE" | grep -o '"unseal_keys_b64":\["[^"]*"' | cut -d'"' -f4)
vault operator unseal "$UNSEAL_KEY"
echo "[vault-init] Vault unsealed."
else
echo "[vault-init] Vault already unsealed."
fi
# Load root token
if [ -f "$INIT_FILE" ]; then
ROOT_TOKEN=$(tr -d ' \n' < "$INIT_FILE" | grep -o '"root_token":"[^"]*"' | cut -d'"' -f4)
export VAULT_TOKEN="$ROOT_TOKEN"
fi
fi
# ── Write root-token file for other containers ──
echo "$ROOT_TOKEN" > "$TOKEN_FILE"
# ── Ensure secrets engines exist ──
# Enable kv mount (wallet) — ignore error if already enabled
vault secrets enable -path=kv -version=2 kv 2>/dev/null || true
# Enable secrets mount (BITOK) — ignore error if already enabled
vault secrets enable -path=secrets -version=2 kv 2>/dev/null || true
# ── Wallet infrastructure secrets ──
echo "[vault-init] Writing wallet secrets..."
vault kv put kv/cryptowallet \
db_host=postgres \
db_port=5432 \
db_user=postgres \
db_password=postgres \
db_name=cryptowallet_devphase3 \
relay_api_key=""
# ── BITOK database secret ──
echo "[vault-init] Writing BITOK secrets..."
vault kv put secrets/database \
HOST=postgres \
PORT=5432 \
USER=postgres \
PASSWORD=postgres \
NAME=bitok_dev
# ── BITOK RabbitMQ secret ──
vault kv put secrets/rabbitmq \
HOST=rabbitmq \
PORT=5672 \
USER=guest \
PASSWORD=guest \
VHOST=/
# ── BITOK CSRF secret ──
vault kv put secrets/csrf \
KEY=dev-csrf-secret-key-minimum-32-characters-long
# ── BITOK JWT RS256 key pair (only generate on first run) ──
# Check if JWT keys already exist
JWT_EXISTS=$(vault kv get -format=json secrets/jwt/kid 2>/dev/null && echo "yes" || echo "no")
if [ "$JWT_EXISTS" = "no" ]; then
echo "[vault-init] Generating RSA-2048 key pair for JWT..."
apk add --no-cache openssl > /dev/null 2>&1 || true
openssl genrsa -out /tmp/jwt_private.pem 2048 2>/dev/null
openssl rsa -in /tmp/jwt_private.pem -pubout -out /tmp/jwt_public.pem 2>/dev/null
PRIVATE_KEY=$(cat /tmp/jwt_private.pem)
PUBLIC_KEY=$(cat /tmp/jwt_public.pem)
vault kv put secrets/jwt/kid \
active=kid-dev-001 \
previous=""
vault kv put secrets/jwt/kids/kid-dev-001 \
private_key="$PRIVATE_KEY" \
public_key="$PUBLIC_KEY"
rm -f /tmp/jwt_private.pem /tmp/jwt_public.pem
echo "[vault-init] JWT keys generated."
else
echo "[vault-init] JWT keys already exist, skipping generation."
fi
echo "[vault-init] All secrets ready (wallet + BITOK). Done."

192
start.bat
View File

@@ -4,7 +4,7 @@ cd /d "%~dp0"
echo. echo.
echo ========================================== echo ==========================================
echo CryptoWallet - Local Dev Starter echo CryptoWallet + BITOK Auth - Local Dev
echo ========================================== echo ==========================================
echo. echo.
@@ -16,68 +16,123 @@ if errorlevel 1 (
exit /b 1 exit /b 1
) )
:: ── 2. Locate PostgreSQL binaries ───────────────────────────────────────────── :: ── 2. Check Docker ──────────────────────────────────────────────────────────
set "PGBIN=" where docker >nul 2>&1
if exist "C:\Program Files\PostgreSQL\16\bin\pg_isready.exe" set "PGBIN=C:\Program Files\PostgreSQL\16\bin"
if not defined PGBIN (
where pg_isready >nul 2>&1
if errorlevel 1 ( if errorlevel 1 (
echo [ERROR] PostgreSQL 16 not found. Expected at C:\Program Files\PostgreSQL\16\bin echo [ERROR] Docker not found. Install Docker Desktop and ensure it is running.
pause pause
exit /b 1 exit /b 1
) )
)
set "PGPASSWORD=postgres" :: ── 3. Stop local PostgreSQL (if any) so Docker gets port 5432 ─────────────────
echo [1/8] Preparing environment...
:: ── 3. Start local PostgreSQL service ──────────────────────────────────────── net stop postgresql-x64-16 2>nul
echo [1/4] Starting PostgreSQL...
net start postgresql-x64-16 2>nul
timeout /t 2 /nobreak >nul timeout /t 2 /nobreak >nul
:: ── 4. Wait for Docker Engine ─────────────────────────────────────────────────
echo Waiting for Docker Engine...
set /a docker_attempts=0
:waitdocker
set /a docker_attempts+=1
if !docker_attempts! gtr 30 (
echo [ERROR] Docker Engine did not respond after 60 seconds.
echo Make sure Docker Desktop is fully started ^(whale icon in tray^).
pause
exit /b 1
)
docker info >nul 2>&1
if errorlevel 1 (
timeout /t 2 /nobreak >nul
goto waitdocker
)
echo Docker ready.
:: ── 5. Start infrastructure via Docker Compose ────────────────────────────────
echo [2/8] Starting infrastructure (PostgreSQL, Vault, RabbitMQ, KeyDB)...
docker compose up -d postgres vault vault-init rabbitmq keydb 2>&1
if errorlevel 1 (
echo Retrying with docker-compose...
docker-compose up -d postgres vault vault-init rabbitmq keydb 2>&1
)
if errorlevel 1 (
echo.
echo [ERROR] Could not start Docker Compose.
echo.
echo Common fixes:
echo 1. Wait 30-60 sec after opening Docker Desktop
echo 2. Restart Docker Desktop ^(right-click tray icon -^> Restart^)
echo 3. Run: docker compose up -d postgres vault vault-init rabbitmq keydb
echo.
pause
exit /b 1
)
:: Wait for PostgreSQL to be ready
set /a pg_attempts=0 set /a pg_attempts=0
:waitpg :waitpg
set /a pg_attempts+=1 set /a pg_attempts+=1
if !pg_attempts! gtr 15 ( if !pg_attempts! gtr 30 (
echo [ERROR] PostgreSQL not responding after 30 seconds. echo [ERROR] PostgreSQL not responding after 60 seconds.
pause pause
exit /b 1 exit /b 1
) )
if defined PGBIN (
"%PGBIN%\pg_isready.exe" -U postgres -q 2>nul
) else (
pg_isready -U postgres -q 2>nul
)
if errorlevel 1 (
timeout /t 2 /nobreak >nul timeout /t 2 /nobreak >nul
goto waitpg docker exec cryptowallet-db pg_isready -U postgres -q 2>nul
) if errorlevel 1 goto waitpg
echo PostgreSQL is ready. echo PostgreSQL is ready.
:: ── 4. Ensure DB exists (create only if missing) ──────────────────────────── :: Wait for RabbitMQ to be ready
echo [2/4] Ensuring database... echo Waiting for RabbitMQ...
if defined PGBIN ( set /a rmq_attempts=0
"%PGBIN%\psql.exe" -U postgres -tc "SELECT 1 FROM pg_database WHERE datname='cryptowallet_v2'" 2>nul | findstr "1" >nul 2>nul :waitrmq
) else ( set /a rmq_attempts+=1
psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname='cryptowallet_v2'" 2>nul | findstr "1" >nul 2>nul if !rmq_attempts! gtr 30 (
) echo [WARN] RabbitMQ slow to start, continuing anyway...
if errorlevel 1 ( goto rmq_done
echo Creating database cryptowallet_v2...
if defined PGBIN (
"%PGBIN%\psql.exe" -U postgres -c "CREATE DATABASE cryptowallet_v2" 2>nul
) else (
psql -U postgres -c "CREATE DATABASE cryptowallet_v2" 2>nul
)
) else (
echo Database exists.
) )
timeout /t 2 /nobreak >nul
docker exec cryptowallet-rabbitmq rabbitmq-diagnostics check_port_connectivity >nul 2>&1
if errorlevel 1 goto waitrmq
:rmq_done
echo RabbitMQ is ready.
:: ── 5. Install deps + migrate ──────────────────────────────────────────────── :: ── 6. Create databases ──────────────────────────────────────────────────────
echo [3/4] Installing dependencies... echo [3/8] Ensuring databases...
docker exec cryptowallet-db psql -U postgres -c "CREATE DATABASE cryptowallet_devphase3" 2>nul
if errorlevel 1 (
echo cryptowallet_devphase3 may already exist, continuing...
)
docker exec cryptowallet-db psql -U postgres -c "CREATE DATABASE bitok_dev" 2>nul
if errorlevel 1 (
echo bitok_dev may already exist, continuing...
)
echo Databases ready.
:: ── 7. Wait for vault-init to finish ─────────────────────────────────────────
echo [4/8] Waiting for Vault initialization...
set /a vault_attempts=0
:waitvault
set /a vault_attempts+=1
if !vault_attempts! gtr 30 (
echo [WARN] vault-init taking long, continuing...
goto vault_done
)
timeout /t 2 /nobreak >nul
docker inspect cryptowallet-vault-init --format="{{.State.Status}}" 2>nul | findstr /C:"exited" >nul 2>&1
if errorlevel 1 goto waitvault
:vault_done
echo Vault initialized.
:: ── 8. Update .env ───────────────────────────────────────────────────────────
echo [5/8] Updating .env...
if not exist .env ( if not exist .env (
copy .env.example .env >nul copy .env.example .env >nul
echo Created .env from .env.example
) )
powershell -NoProfile -Command "(Get-Content .env) -replace '^DB_NAME=.*', 'DB_NAME=cryptowallet_devphase3' -replace '^DATABASE_URL=.*', 'DATABASE_URL=postgresql://postgres:postgres@localhost:5432/cryptowallet_devphase3' | Set-Content .env"
echo .env updated.
:: ── 9. Install deps and run migrations ────────────────────────────────────────
echo [6/8] Installing dependencies...
call pnpm install call pnpm install
if errorlevel 1 ( if errorlevel 1 (
echo [ERROR] pnpm install failed. echo [ERROR] pnpm install failed.
@@ -86,39 +141,82 @@ if errorlevel 1 (
) )
echo Dependencies installed. echo Dependencies installed.
echo Running migrations... echo Running database migrations...
cd apps\api cd apps\api
call pnpm migrate call pnpm migrate
if errorlevel 1 ( if errorlevel 1 (
echo [ERROR] Migrations failed. echo Migration failed - resetting DB and retrying...
call pnpm db:reset
call pnpm migrate
)
if errorlevel 1 (
echo [ERROR] Migrations failed - check output above.
pause pause
exit /b 1 exit /b 1
) )
cd ..\.. cd ..\..
echo Migrations done. echo Migrations done.
:: ── 6. Stop old processes and start servers ────────────────────────────────── :: ── 10. Initialize BITOK database tables ──────────────────────────────────────
echo [4/4] Starting servers... echo [7/8] Initializing BITOK database...
if exist BITOK\sql\tables.sql (
docker exec -i cryptowallet-db psql -U postgres -d bitok_dev < BITOK\sql\tables.sql 2>nul
echo BITOK tables initialized.
) else (
echo BITOK/sql/tables.sql not found, skipping...
)
:: ── 11. Start BITOK auth service (Docker) ─────────────────────────────────────
echo Starting BITOK auth service...
docker compose up -d bitok-auth 2>&1
if errorlevel 1 (
echo [WARN] BITOK auth container failed to start. Check: docker logs cryptowallet-bitok
)
:: Wait briefly for BITOK to be responsive
set /a bitok_attempts=0
:waitbitok
set /a bitok_attempts+=1
if !bitok_attempts! gtr 15 (
echo [WARN] BITOK slow to start, continuing...
goto bitok_done
)
timeout /t 2 /nobreak >nul
powershell -NoProfile -Command "try { $r = Invoke-WebRequest -Uri http://localhost:8000/ping -TimeoutSec 2 -UseBasicParsing; exit 0 } catch { exit 1 }" >nul 2>&1
if errorlevel 1 goto waitbitok
:bitok_done
echo BITOK auth service ready.
:: ── 12. Stop old dev servers and clear lock ─────────────────────────────────────
echo [8/8] Starting wallet servers...
powershell -NoProfile -Command "Get-NetTCPConnection -LocalPort 3000,3001 -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }" powershell -NoProfile -Command "Get-NetTCPConnection -LocalPort 3000,3001 -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }"
if exist apps\web\.next\dev\lock del apps\web\.next\dev\lock 2>nul if exist apps\web\.next\dev\lock del apps\web\.next\dev\lock 2>nul
timeout /t 1 /nobreak >nul timeout /t 1 /nobreak >nul
:: Start wallet API and Web
start "CryptoWallet API [port 3001]" cmd /k "cd /d %~dp0apps\api && pnpm dev" start "CryptoWallet API [port 3001]" cmd /k "cd /d %~dp0apps\api && pnpm dev"
timeout /t 2 /nobreak >nul timeout /t 2 /nobreak >nul
start "CryptoWallet Web [port 3000]" cmd /k "cd /d %~dp0apps\web && pnpm dev" start "CryptoWallet Web [port 3000]" cmd /k "cd /d %~dp0apps\web && pnpm dev"
:: ── Done ─────────────────────────────────────────────────────────────────────
echo. echo.
echo ========================================== echo ==========================================
echo All services started! echo All services started!
echo. echo.
echo Web: http://localhost:3000 echo Web: http://localhost:3000
echo API: http://localhost:3001 echo API: http://localhost:3001
echo DB: localhost:5432 / cryptowallet_v2 echo BITOK: http://localhost:8000
echo RabbitMQ: http://localhost:15672 (guest/guest)
echo DB: localhost:5432
echo wallet DB: cryptowallet_devphase3
echo BITOK DB: bitok_dev
echo user: postgres pass: postgres
echo ========================================== echo ==========================================
echo. echo.
timeout /t 3 /nobreak >nul timeout /t 3 /nobreak >nul
start http://localhost:3000 start http://localhost:3000
echo Press any key to close this launcher...
pause >nul pause >nul
endlocal endlocal

12
vault/vault.hcl Normal file
View File

@@ -0,0 +1,12 @@
ui = true
storage "file" {
path = "/vault/file"
}
listener "tcp" {
address = "0.0.0.0:8200"
tls_disable = 1
}
disable_mlock = true