349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
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;
|
||
}
|
||
},
|
||
};
|