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

View File

@@ -1,28 +1,47 @@
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@latest --activate
# Build stage
FROM node:20-alpine AS builder
RUN apk add --no-cache python3 make g++
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
WORKDIR /app
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
# Copy workspace config
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml turbo.json ./
COPY apps/api/package.json apps/api/
COPY packages/shared/package.json packages/shared/
RUN pnpm install --frozen-lockfile --prod=false
FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules 2>/dev/null || true
COPY . .
RUN cd apps/api && pnpm build
# Enable hoisting so tsc can find all deps
RUN echo "node-linker=hoisted" > .npmrc
RUN pnpm install --frozen-lockfile
# Copy source
COPY apps/api/ apps/api/
COPY packages/shared/ packages/shared/
# Build api (node_modules are hoisted, tsc available at root)
RUN cd apps/api && ../../node_modules/.bin/tsc \
&& rm -f dist/db/migrations/*.d.ts dist/db/migrations/*.d.ts.map dist/db/migrations/*.js.map
# Runtime stage
FROM node:20-alpine
RUN apk add --no-cache curl
FROM node:20-alpine AS runtime
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
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",
"migrate": "node --require ts-node/register node_modules/knex/bin/cli.js migrate:latest --knexfile src/db/knexfile.ts",
"migrate:rollback": "node --require ts-node/register node_modules/knex/bin/cli.js migrate:rollback --knexfile src/db/knexfile.ts",
"db:fresh": "pnpm db:reset && pnpm migrate",
"db:reset": "node --require ts-node/register src/db/reset-db.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@cryptowallet/shared": "workspace:*",
"amqplib": "^1.0.3",
"bcrypt": "^5.1.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.0",
"ethers": "5.7.2",
"express": "^4.21.0",
"express-rate-limit": "^7.4.0",
"helmet": "^8.0.0",
"jose": "^6.2.2",
"jsonwebtoken": "^9.0.0",
"knex": "^3.1.0",
"pg": "^8.13.0",
"swagger-ui-express": "^5.0.1",
"ulidx": "^2.4.1",
"uuid": "^11.0.0",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/amqplib": "^0.10.8",
"@types/bcrypt": "^5.0.2",
"@types/cookie-parser": "^1.4.7",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/express-serve-static-core": "^5.1.1",
"@types/jsonwebtoken": "^9.0.0",
"@types/node": "^20.0.0",
"@types/swagger-ui-express": "^4.1.8",
"@types/uuid": "^10.0.0",
"ts-node": "^10.9.0",
"ts-node-dev": "^2.0.0",
"typescript": "^5.6.0"

View File

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

View File

@@ -1,22 +1,16 @@
import dotenv from 'dotenv';
import path from 'path';
import { fetchVaultSecrets } from './vault';
dotenv.config({ path: path.resolve(__dirname, '../../../../.env') });
export const env = {
export let env = {
db: {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432'),
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'postgres',
name: process.env.DB_NAME || 'cryptowallet_v2',
},
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 || '',
name: process.env.DB_NAME || 'cryptowallet',
},
port: parseInt(process.env.API_PORT || '3001'),
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:3000',
@@ -24,5 +18,38 @@ export const env = {
tronApiKey: process.env.TRON_API_KEY || null,
jupiterApiKey: process.env.JUPITER_API_KEY || null,
jupiterReferralAccount: process.env.JUPITER_REFERRAL_ACCOUNT || null,
jupiterFeeBps: parseInt(process.env.JUPITER_FEE_BPS || '70'),
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 { UserModel } from '../models/user.model';
import { WalletModel } from '../models/wallet.model';
export const WalletController = {
async getWallets(req: Request, res: Response) {
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({
success: true,
data: wallets.map((w) => ({

View File

@@ -12,7 +12,7 @@ const config: Knex.Config = {
port: parseInt(process.env.DB_PORT || '5432'),
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'postgres',
database: process.env.DB_NAME || 'cryptowallet_v2',
database: process.env.DB_NAME || 'cryptowallet',
},
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> {
await knex.schema.createTable('users', (t) => {
t.string('id', 26).primary();
t.string('email', 255).notNullable().unique();
t.string('password_hash', 255).notNullable();
t.string('last_name', 128).nullable();
t.string('first_name', 128).nullable();
t.string('middle_name', 128).nullable();
t.date('birth_date').nullable();
t.string('crypto_wallet', 255).nullable();
t.string('phone', 16).nullable();
t.string('bik', 9).nullable();
t.string('account_number', 20).nullable();
t.string('card_number', 19).nullable();
t.string('inn', 12).nullable();
t.boolean('kyc_verified').notNullable().defaultTo(false);
t.timestamp('kyc_verified_at', { useTz: true }).nullable();
t.boolean('is_deleted').notNullable().defaultTo(false);
t.string('username', 64).notNullable().unique();
t.text('password_hash').notNullable();
t.text('pin_hash').notNullable();
t.text('encrypted_vault').notNullable();
t.string('vault_salt', 128).notNullable();
t.boolean('mnemonic_shown').notNullable().defaultTo(false);
t.timestamp('created_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> {
await knex.schema.createTable('sessions', (t) => {
t.string('id', 26).primary();
t.string('sid', 26).notNullable().unique();
t.string('user_id', 26).notNullable().references('id').inTable('users').onDelete('CASCADE');
t.string('device_id', 26).nullable();
t.string('user_agent', 500).nullable();
t.string('first_ip', 64).nullable();
t.string('last_ip', 64).nullable();
t.timestamp('last_seen_at', { useTz: true }).nullable();
t.timestamp('revoked_at', { useTz: true }).nullable();
t.string('refresh_jti_hash', 255).nullable();
t.timestamp('refresh_expires_at', { useTz: true }).nullable();
t.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());
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_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> {

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

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 {
id: string;
email: string;
password_hash: string;
last_name: string | null;
first_name: string | null;
middle_name: string | null;
birth_date: string | null;
crypto_wallet: string | null;
phone: string | null;
bik: string | null;
account_number: string | null;
card_number: string | null;
inn: string | null;
bitok_user_id: string;
email: string | null;
encrypted_vault: string;
vault_salt: string;
mnemonic_shown: boolean;
kyc_verified: boolean;
kyc_verified_at: Date | null;
is_deleted: boolean;
kyc_level: string | null;
deleted: boolean;
created_at: Date;
updated_at: Date;
}
export const UserModel = {
async findByEmail(email: string): Promise<UserRow | undefined> {
return db('users').where({ email, is_deleted: false }).first();
},
async findById(id: string): Promise<UserRow | undefined> {
return db('users').where({ id, is_deleted: false }).first();
return db('users').where({ id }).first();
},
async create(data: {
email: string;
password_hash: string;
async findByBitokUserId(bitokUserId: string): Promise<UserRow | undefined> {
return db('users').where({ bitok_user_id: bitokUserId }).first();
},
async createFromBitok(data: {
bitokUserId: string;
email?: string | null;
encryptedVault: string;
vaultSalt: string;
}): 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')
.where({ id })
.update({ ...data, updated_at: db.fn.now() })
.insert({
id: generateUlid(),
bitok_user_id: data.bitokUserId,
email: data.email || null,
encrypted_vault: data.encryptedVault,
vault_salt: data.vaultSalt,
})
.returning('*');
return user;
},
async setMnemonicShown(id: string): Promise<void> {
await db('users').where({ id }).update({ mnemonic_shown: true, updated_at: db.fn.now() });
},
async updateVault(id: string, encrypted_vault: string, vault_salt: string): Promise<void> {
await db('users')
.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 { WalletController } from '../controllers/wallet.controller';
import { authMiddleware } from '../middleware/auth';
import { bitokAuth } from '../middleware/bitok-auth';
const router = Router();
router.get('/', authMiddleware, WalletController.getWallets);
router.get('/', bitokAuth, WalletController.getWallets);
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 [confirmed, setConfirmed] = useState(false);
useEffect(() => {
if (!user) router.push('/login');
}, [router, user]);
const destChainOptions = useMemo(() => getDestinationChainOptions(sourceChain), [sourceChain]);
const sourceTokenOptions = useMemo(() => getTokenOptions(sourceChain), [sourceChain]);

View File

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

View File

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

View File

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

View File

@@ -66,6 +66,11 @@ export default function SwapPage() {
[chain, amount, fromSymbol, slippageBps, toSymbol]
);
useEffect(() => {
if (!user) {
router.push('/login');
}
}, [router, user]);
useEffect(() => {
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';
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> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
};
const res = await fetch(`${BITOK_BASE}${path}`, {
...options,
headers,
credentials: 'include',
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || body.error || `Request failed (${res.status})`);
}
return res.json();
}
export const bitokAuth = {
registrationStart: (email: string) =>
bitokRequest<{ success: boolean }>('/v1/auth/registration/start', {
method: 'POST',
body: JSON.stringify({ email }),
}),
registrationComplete: (email: string, password: string, code: string) =>
bitokRequest<{ id: string; email: string; access_token: string }>('/v1/auth/registration/complete', {
method: 'POST',
body: JSON.stringify({ email, password, code }),
}),
loginStart: (email: string) =>
bitokRequest<{ success: boolean }>('/v1/auth/login/start', {
method: 'POST',
body: JSON.stringify({ email }),
}),
loginComplete: (email: string, password: string, code: string) =>
bitokRequest<{ id: string; email: string; access_token: string }>('/v1/auth/login/complete', {
method: 'POST',
body: JSON.stringify({ email, password, code }),
}),
refresh: () =>
bitokRequest<{ result: boolean; access_token: string }>('/v1/jwt/refresh', { method: 'POST' }),
logout: () =>
bitokRequest<{ ok: boolean }>('/v1/auth/logout', { method: 'POST' }),
};
// ── Wallet API calls (uses Bearer token from BITOK) ──
async function walletRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
};
if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
}
const res = await fetch(`${API_URL}${path}`, {
...options,
headers,
@@ -23,6 +94,40 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
return data.data;
}
export const api = {
getWallets: () => request<any>('/api/wallets'),
export interface WalletSetupPayload {
encryptedVault: string;
vaultSalt: string;
wallets: { chain: string; address: string; derivationPath: string }[];
}
export interface WalletUnlockResponse {
encryptedVault: string;
vaultSalt: string;
wallets: { chain: string; address: string; derivationPath: string }[];
mnemonicShown: boolean;
}
export const walletApi = {
setup: (data: WalletSetupPayload) =>
walletRequest<any>('/api/wallet/setup', {
method: 'POST',
body: JSON.stringify(data),
}),
unlock: () =>
walletRequest<WalletUnlockResponse>('/api/wallet/unlock'),
getWallets: () => walletRequest<any>('/api/wallets'),
confirmMnemonic: () =>
walletRequest<any>('/api/wallet/confirm-mnemonic', { method: 'POST' }),
};
// ── Legacy api object (keep for components that still reference it) ──
export const api = {
getWallets: () => walletRequest<any>('/api/wallets'),
getVault: () => walletRequest<any>('/api/vault'),
confirmMnemonic: () =>
walletRequest<any>('/api/wallet/confirm-mnemonic', { method: 'POST' }),
};

View File

@@ -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';
import { create } from 'zustand';
import { api } from '@/lib/api';
interface Wallet {
chain: string;
address: string;
derivationPath: string;
}
import { bitokAuth, walletApi, setAccessToken } from '@/lib/api';
import { encryptVault, decryptVault } from '@/lib/crypto/vault';
import { generateWallets, deriveWalletsFromMnemonic, type DerivedWallet } from '@/lib/crypto/derive-keys';
interface AuthState {
user: { id: string; email: string } | null;
wallets: Wallet[];
wallets: DerivedWallet[];
mnemonic: string | null;
mnemonicShown: boolean;
loading: boolean;
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;
clearMnemonic: () => void;
clearError: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
wallets: [],
mnemonic: null,
mnemonicShown: true,
loading: false,
error: null,
init: async () => {
registerStart: async (email) => {
set({ loading: true, error: null });
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({
user: { id: '', email: '' },
user: { id: authData.id, email: authData.email },
wallets,
mnemonic,
mnemonicShown: false,
loading: false,
});
} catch {
set({ user: null, wallets: [], loading: false });
} catch (err: any) {
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: () => {
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 }),
}));