Files
cryptowallet/apps/api/src/controllers/wallet.controller.ts

349 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ChainCode>(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;
}
},
};