import { Request, Response } from 'express'; import { db } from '../config/database'; import { WalletModel } from '../models/wallet.model'; import { UserModel } from '../models/user.model'; import { getBalance, getTransactions } from '../services/wallet-ops.service'; import { isValidAddress, isValidAmount, ChainCode } from '../lib/address-validators'; import { generateMnemonic, deriveAllAddresses, ALL_CHAINS } from '../services/wallet-generator.service'; import { encryptMnemonic, decryptMnemonic, isCryptoReady } from '../services/crypto.service'; import { signAndBroadcast } from '../services/wallet-signer.service'; import { auditLog } from '../lib/audit-log'; import { logger } from '../lib/logger'; const ALLOWED_CHAINS = new Set(ALL_CHAINS); const MAX_TX_LIMIT = 100; class ConflictError extends Error { constructor() { super('Wallet already exists'); } } function isChain(value: unknown): value is ChainCode { return typeof value === 'string' && ALLOWED_CHAINS.has(value as ChainCode); } export const WalletController = { /** * GET /api/wallets — все адреса юзера (chain + address + derivationPath). */ async getWallets(req: Request, res: Response) { try { const wallets = await WalletModel.findByUserId(req.auth!.userId); res.json({ success: true, data: wallets.map((w) => ({ chain: w.chain, address: w.address, derivationPath: w.derivation_path, })), }); } catch (err: any) { logger.error(`getWallets failed for user ${req.auth!.userId}: ${err.stack || err.message}`); res.status(500).json({ success: false, error: 'Internal error' }); } }, /** * POST /api/wallets/create — custodial wallet bootstrap. * * 1) Проверка: у юзера ещё нет коша (иначе 409) * 2) Генерим BIP39 mnemonic (128 bit, 12 слов) * 3) Деривим адреса для всех 5 chains * 4) Шифруем mnemonic AES-GCM (master-key из Vault) * 5) Транзакционно пишем encrypted_mnemonic + wallets * 6) Возвращаем ТОЛЬКО адреса. Mnemonic клиенту не отдаём. */ async createWallet(req: Request, res: Response) { const userId = req.auth!.userId; if (!isCryptoReady()) { res.status(503).json({ success: false, error: 'Crypto service not ready' }); return; } let mnemonic: string | null = null; try { await UserModel.ensureExists(userId); // Сначала проверка для быстрого 409 (не атомарна — реальная защита ниже) if (await UserModel.hasMnemonic(userId)) { res.status(409).json({ success: false, error: 'Wallet already exists' }); return; } mnemonic = generateMnemonic(); const derived = await deriveAllAddresses(mnemonic); const blob = encryptMnemonic(mnemonic); // ── АТОМАРНО: UPDATE users WHERE encrypted_mnemonic IS NULL + INSERT wallets ── // Защита от funds-loss race: если параллельный запрос успел проставить mnemonic, // наш UPDATE вернёт affected=0 → откатываем транзакцию, возвращаем 409. const created = await db.transaction(async (trx) => { const claimed = await UserModel.setEncryptedMnemonicIfAbsent(userId, blob, trx); if (!claimed) { // Кто-то другой выиграл race — наш mnemonic становится бессмысленным throw new ConflictError(); } await WalletModel.createMany( derived.map((w) => ({ user_id: userId, chain: w.chain, address: w.address, derivation_path: w.derivationPath, })), trx, ); return derived; }); await auditLog({ event: 'wallet.create', userId, ip: req.ip || null, result: 'success', meta: { chains: created.map((w) => w.chain) }, }); res.status(201).json({ success: true, data: created.map((w) => ({ chain: w.chain, address: w.address, derivationPath: w.derivationPath, })), }); } catch (err: any) { if (err instanceof ConflictError) { res.status(409).json({ success: false, error: 'Wallet already exists' }); return; } logger.error(`createWallet failed for user ${userId}: ${err.message}`); await auditLog({ event: 'wallet.create', userId, ip: req.ip || null, result: 'failure', errorCode: err.code || 'INTERNAL', }); res.status(500).json({ success: false, error: 'Failed to create wallet' }); } finally { mnemonic = null; // best-effort GC hint } }, /** * POST /api/wallets/mnemonic/reveal — reveal mnemonic для settings-страницы. * * Защита (defense-in-depth): * 1. POST (CSRF token обязателен) * 2. Тело должно содержать { confirm: "I_UNDERSTAND_SEED_IS_SECRET" } — защита * от случайного XHR / image-tag / CSRF-attack which forge только URL * 3. Rate-limit 5/час per-user * 4. Audit-log на каждый вызов (success + failure) */ async revealMnemonic(req: Request, res: Response) { const userId = req.auth!.userId; if (!isCryptoReady()) { res.status(503).json({ success: false, error: 'Crypto service not ready' }); return; } // Confirmation token защищает от случайных XHR из чужих origin (даже если CSRF // фронтенд сломан, атакующему нужно знать секретную фразу — она задокументирована в API) if (req.body?.confirm !== 'I_UNDERSTAND_SEED_IS_SECRET') { res.status(400).json({ success: false, error: 'Missing or invalid confirm token. Send { "confirm": "I_UNDERSTAND_SEED_IS_SECRET" } in body.', }); return; } try { const blob = await UserModel.getEncryptedMnemonic(userId); if (!blob) { await auditLog({ event: 'mnemonic.reveal', userId, ip: req.ip || null, result: 'failure', errorCode: 'NOT_FOUND', }); res.status(404).json({ success: false, error: 'Wallet not created yet' }); return; } const mnemonic = decryptMnemonic(blob); await auditLog({ event: 'mnemonic.reveal', userId, ip: req.ip || null, result: 'success', }); res.json({ success: true, data: { mnemonic } }); } catch (err: any) { logger.error(`revealMnemonic failed for user ${userId}: ${err.message}`); await auditLog({ event: 'mnemonic.reveal', userId, ip: req.ip || null, result: 'failure', errorCode: 'INTERNAL', }); res.status(500).json({ success: false, error: 'Failed to reveal mnemonic' }); } }, /** * GET /api/wallets/:chain/balance */ async getChainBalance(req: Request, res: Response) { const userId = req.auth!.userId; const chain = String(req.params.chain).toUpperCase(); if (!isChain(chain)) { res.status(400).json({ success: false, error: 'Invalid chain parameter' }); return; } try { const wallet = await WalletModel.findByUserAndChain(userId, chain); if (!wallet) { res.status(404).json({ success: false, error: 'Wallet not found for this chain' }); return; } const balance = await getBalance(chain, wallet.address); res.json({ success: true, data: balance }); } catch (err: any) { logger.error(`getChainBalance ${chain} failed for user ${userId}: ${err.stack || err.message}`); res.status(502).json({ success: false, error: 'Upstream RPC error' }); } }, /** * GET /api/wallets/:chain/transactions */ async getChainTransactions(req: Request, res: Response) { const userId = req.auth!.userId; const chain = String(req.params.chain).toUpperCase(); if (!isChain(chain)) { res.status(400).json({ success: false, error: 'Invalid chain parameter' }); return; } const limit = Math.min(Math.max(parseInt(String(req.query.limit || '20')) || 20, 1), MAX_TX_LIMIT); try { const wallet = await WalletModel.findByUserAndChain(userId, chain); if (!wallet) { res.status(404).json({ success: false, error: 'Wallet not found for this chain' }); return; } const txs = await getTransactions(chain, wallet.address, limit); res.json({ success: true, data: txs }); } catch (err: any) { logger.error(`getChainTransactions ${chain} failed for user ${userId}: ${err.stack || err.message}`); res.status(502).json({ success: false, error: 'Upstream RPC error' }); } }, /** * POST /api/wallets/:chain/send — custodial sign + broadcast. * Сервер расшифровывает мнемонику, деривит privkey, подписывает, broadcast'ит. * Возвращает { txid }. */ async sendFromChain(req: Request, res: Response) { const userId = req.auth!.userId; const chain = String(req.params.chain).toUpperCase(); if (!isChain(chain)) { res.status(400).json({ success: false, error: 'Invalid chain parameter' }); return; } if (!isCryptoReady()) { res.status(503).json({ success: false, error: 'Crypto service not ready' }); return; } const { to, amount, token } = req.body ?? {}; if (!isValidAddress(chain, String(to))) { res.status(400).json({ success: false, error: 'Invalid recipient address for chain' }); return; } if (!isValidAmount(String(amount))) { res.status(400).json({ success: false, error: 'Invalid amount (must be positive integer string)' }); return; } let normalizedToken: string | undefined; if (token !== undefined && token !== null) { if (typeof token !== 'string' || !/^[A-Z0-9]{2,10}$/.test(token)) { res.status(400).json({ success: false, error: 'Invalid token symbol' }); return; } normalizedToken = token.toUpperCase(); } let mnemonic: string | null = null; try { const wallet = await WalletModel.findByUserAndChain(userId, chain); if (!wallet) { res.status(404).json({ success: false, error: 'Wallet not found for this chain' }); return; } const blob = await UserModel.getEncryptedMnemonic(userId); if (!blob) { res.status(404).json({ success: false, error: 'Encrypted mnemonic not found' }); return; } mnemonic = decryptMnemonic(blob); const result = await signAndBroadcast({ chain, mnemonic, to: String(to), amount: String(amount), token: normalizedToken, expectedFromAddress: wallet.address, }); await auditLog({ event: 'wallet.send', userId, ip: req.ip || null, result: 'success', meta: { chain, hasToken: !!normalizedToken, txid: result.txid }, }); res.json({ success: true, data: { txid: result.txid, chain } }); } catch (err: any) { logger.error(`send failed for user ${userId} chain ${chain}: ${err.message}`); await auditLog({ event: 'wallet.send', userId, ip: req.ip || null, result: 'failure', meta: { chain }, errorCode: 'BROADCAST_FAILED', }); const msg = err?.message?.toLowerCase?.().includes('insufficient') ? 'Insufficient balance' : err?.message?.toLowerCase?.().includes('not supported') ? 'Token/chain combination not supported' : 'Failed to broadcast transaction'; res.status(502).json({ success: false, error: msg }); } finally { // Best-effort cleanup mnemonic в RAM mnemonic = null; } }, };