This commit is contained in:
ZOMBIIIIIII
2026-05-13 00:37:00 +03:00
parent 7d9907be9c
commit 3a890b79ee
5 changed files with 230 additions and 43 deletions

View File

@@ -10,7 +10,6 @@ import { authMiddleware } from './middleware/auth';
import { csrfMiddleware } from './middleware/csrf'; import { csrfMiddleware } from './middleware/csrf';
import { globalLimiter, mutateLimiter, sensitiveLimiter, mnemonicRevealLimiter } from './middleware/rate-limit'; import { globalLimiter, mutateLimiter, sensitiveLimiter, mnemonicRevealLimiter } from './middleware/rate-limit';
import { errorHandler } from './middleware/error-handler'; import { errorHandler } from './middleware/error-handler';
import { WalletController } from './controllers/wallet.controller';
import walletRoutes from './routes/wallet.routes'; import walletRoutes from './routes/wallet.routes';
import relayProxyRoutes from './routes/relay-proxy.routes'; import relayProxyRoutes from './routes/relay-proxy.routes';
import tronProxyRoutes from './routes/tron-proxy.routes'; import tronProxyRoutes from './routes/tron-proxy.routes';
@@ -85,12 +84,11 @@ app.use('/api/docs', docsGate, swaggerUi.serve, swaggerUi.setup(swaggerSpec));
const protect = [authMiddleware, csrfMiddleware]; const protect = [authMiddleware, csrfMiddleware];
// Sensitive — самый строгий лимит. Каждый POST защищён JWT + CSRF. // Sensitive — самый строгий лимит. Каждый POST защищён JWT + CSRF.
app.use('/api/wallets/create', ...protect, sensitiveLimiter);
app.use('/api/wallets/mnemonic/reveal', ...protect, mnemonicRevealLimiter); app.use('/api/wallets/mnemonic/reveal', ...protect, mnemonicRevealLimiter);
app.use('/api/wallets/:chain/send', ...protect, sensitiveLimiter); app.use('/api/wallets/:chain/send', ...protect, sensitiveLimiter);
// Mutating (proxy + read endpoints) — повышенный лимит // Mutating (proxy + read endpoints) — повышенный лимит
app.post('/api/wallets/create', sensitiveLimiter, WalletController.createWallet);
app.get('/api/wallets', mutateLimiter, WalletController.getWallets);
app.use('/api/wallets', ...protect, mutateLimiter, walletRoutes); app.use('/api/wallets', ...protect, mutateLimiter, walletRoutes);
app.use('/api/relay', ...protect, mutateLimiter, relayProxyRoutes); app.use('/api/relay', ...protect, mutateLimiter, relayProxyRoutes);
app.use('/api/tron', ...protect, mutateLimiter, tronProxyRoutes); app.use('/api/tron', ...protect, mutateLimiter, tronProxyRoutes);

View File

@@ -30,9 +30,8 @@ export const WalletController = {
* GET /api/wallets — все адреса юзера. * GET /api/wallets — все адреса юзера.
*/ */
async getWallets(req: Request, res: Response) { async getWallets(req: Request, res: Response) {
const userId = '01KPKAFN6J1NJBY15DX8JE2QYB';
try { try {
const wallets = await WalletModel.findByUserId(userId); const wallets = await WalletModel.findByUserId(req.auth!.userId);
res.json({ res.json({
success: true, success: true,
data: wallets.map((w) => ({ data: wallets.map((w) => ({
@@ -42,7 +41,7 @@ export const WalletController = {
})), })),
}); });
} catch (err: any) { } catch (err: any) {
logger.error(`getWallets failed for user ${userId}: ${err.stack || err.message}`); logger.error(`getWallets failed for user ${req.auth!.userId}: ${err.stack || err.message}`);
res.status(500).json({ success: false, error: 'Internal error' }); res.status(500).json({ success: false, error: 'Internal error' });
} }
}, },
@@ -54,7 +53,7 @@ export const WalletController = {
* Возвращает: ТОЛЬКО адреса. Mnemonic клиенту не отдаём. * Возвращает: ТОЛЬКО адреса. Mnemonic клиенту не отдаём.
*/ */
async createWallet(req: Request, res: Response) { async createWallet(req: Request, res: Response) {
const userId = '01KPKAFN6J1NJBY15DX8JE2QYB'; const userId = req.auth!.userId;
if (!isCryptoReady()) { if (!isCryptoReady()) {
res.status(503).json({ success: false, error: 'Crypto service not ready' }); res.status(503).json({ success: false, error: 'Crypto service not ready' });
@@ -65,20 +64,36 @@ export const WalletController = {
try { try {
await UserModel.ensureExists(userId); await UserModel.ensureExists(userId);
if (await UserModel.hasMnemonic(userId)) { // H14 — gate wallet creation behind KYC verification (опционально, controlled by env).
// Если REQUIRE_KYC_FOR_WALLET=true в env, требуется kyc_verified=true.
if (process.env.REQUIRE_KYC_FOR_WALLET === 'true') {
const isVerified = await UserModel.isKycVerified(userId);
if (!isVerified) {
res.status(403).json({ success: false, error: 'KYC verification required before wallet creation' });
return;
}
}
// H27 — claim placeholder ПЕРЕД derive. Loser race не тратит CPU + memory secrets.
// Атомарно: UPDATE WHERE encrypted_mnemonic IS NULL SET = 'PENDING_DERIVATION'
const claimResult = await UserModel.claimWalletSlot(userId);
if (claimResult === 'already_has') {
res.status(409).json({ success: false, error: 'Wallet already exists' }); res.status(409).json({ success: false, error: 'Wallet already exists' });
return; return;
} }
if (claimResult === 'no_user') {
res.status(404).json({ success: false, error: 'User not found or deleted' });
return;
}
// claimResult === 'claimed' — proceed
mnemonic = generateMnemonic(); mnemonic = generateMnemonic();
const derived = await deriveAllAddresses(mnemonic); const derived = await deriveAllAddresses(mnemonic);
const blob = encryptMnemonic(mnemonic); const blob = encryptMnemonic(mnemonic);
const created = await db.transaction(async (trx) => { const created = await db.transaction(async (trx) => {
const claimed = await UserModel.setEncryptedMnemonicIfAbsent(userId, blob, trx); // H32 — finalize placeholder (must succeed since claim won earlier)
if (!claimed) { await UserModel.finalizeWalletSlot(userId, blob, trx);
throw new ConflictError();
}
await WalletModel.createMany( await WalletModel.createMany(
derived.map((w) => ({ derived.map((w) => ({
user_id: userId, user_id: userId,
@@ -88,8 +103,7 @@ export const WalletController = {
})), })),
trx, trx,
); );
// Дублируем ETH-адрес в users.erc20 — это поле прода-схемы // Дублируем ETH-адрес в users.erc20
// (custodial wallet's ETH address, доступный через простой SELECT без JOIN'а на wallets).
const ethWallet = derived.find((w) => w.chain === 'ETH'); const ethWallet = derived.find((w) => w.chain === 'ETH');
if (ethWallet) { if (ethWallet) {
await UserModel.setErc20Address(userId, ethWallet.address, trx); await UserModel.setErc20Address(userId, ethWallet.address, trx);

View File

@@ -66,11 +66,17 @@ export const UserModel = {
}, },
/** /**
* Set-once: возвращает true только если этот вызов реально занял slot. * Set-once: возвращает 'claimed' / 'already_has' / 'no_user' (H32).
* Защита от race: два параллельных createWallet не могут оба перезаписать. * Defense-in-depth: distinguishes wrong outcomes так что caller отдаёт правильный код:
* Также filter is_deleted=false — не давать zombie-account resurrection. * - claimed → каждый параллельный call'у вернёт already_has потом (мы выиграли gонку)
* - already_has → existing encrypted_mnemonic в DB
* - no_user → row not found OR is_deleted=true
*/ */
async setEncryptedMnemonicIfAbsent(id: string, blob: string, trx?: Knex.Transaction): Promise<boolean> { async setEncryptedMnemonicIfAbsent(
id: string,
blob: string,
trx?: Knex.Transaction,
): Promise<'claimed' | 'already_has' | 'no_user'> {
const k = trx || db; const k = trx || db;
const affected = await k('users') const affected = await k('users')
.where({ id, is_deleted: false }) .where({ id, is_deleted: false })
@@ -79,7 +85,60 @@ export const UserModel = {
encrypted_mnemonic: blob, encrypted_mnemonic: blob,
updated_at: k.fn.now(), updated_at: k.fn.now(),
}); });
return affected === 1; if (affected === 1) return 'claimed';
// Affected 0 — либо user gone, либо уже есть mnemonic. Distinguish.
const row = await k('users')
.where({ id, is_deleted: false })
.select('encrypted_mnemonic')
.first();
if (!row) return 'no_user';
return row.encrypted_mnemonic ? 'already_has' : 'no_user';
},
/**
* Claim placeholder row перед derive — экономит CPU + heap-secret для loser race.
* Используется как pre-step в createWallet flow (H27).
*/
async claimWalletSlot(id: string, trx?: Knex.Transaction): Promise<'claimed' | 'already_has' | 'no_user'> {
const k = trx || db;
const PLACEHOLDER = 'PENDING_DERIVATION';
const affected = await k('users')
.where({ id, is_deleted: false })
.whereNull('encrypted_mnemonic')
.update({
encrypted_mnemonic: PLACEHOLDER,
updated_at: k.fn.now(),
});
if (affected === 1) return 'claimed';
const row = await k('users')
.where({ id, is_deleted: false })
.select('encrypted_mnemonic')
.first();
if (!row) return 'no_user';
return row.encrypted_mnemonic && row.encrypted_mnemonic !== PLACEHOLDER ? 'already_has' : 'no_user';
},
/** Finalize after claimWalletSlot — overwrite placeholder с real blob. */
async finalizeWalletSlot(id: string, blob: string, trx?: Knex.Transaction): Promise<void> {
const k = trx || db;
const affected = await k('users')
.where({ id, is_deleted: false })
.update({
encrypted_mnemonic: blob,
updated_at: k.fn.now(),
});
if (affected !== 1) {
throw new Error(`finalizeWalletSlot: expected 1 row affected, got ${affected} for user ${id}`);
}
},
/** Check KYC status (H14) */
async isKycVerified(id: string): Promise<boolean> {
const row = await db('users')
.where({ id, is_deleted: false })
.select('kyc_verified', 'kyc_verified_at')
.first();
return Boolean(row?.kyc_verified && row?.kyc_verified_at);
}, },
async getEncryptedMnemonic(id: string): Promise<string | null> { async getEncryptedMnemonic(id: string): Promise<string | null> {
@@ -100,16 +159,18 @@ export const UserModel = {
/** /**
* Записать ETH-адрес custodial-кошелька в users.erc20. * Записать ETH-адрес custodial-кошелька в users.erc20.
* Вызывается из createWallet() внутри той же transaction что и WalletModel.createMany, * Throws (rolls back tx) if user не существует / is_deleted (H31).
* чтобы rollback был consistent (без orphan записей).
*/ */
async setErc20Address(id: string, address: string, trx?: Knex.Transaction): Promise<void> { async setErc20Address(id: string, address: string, trx?: Knex.Transaction): Promise<void> {
const k = trx || db; const k = trx || db;
await k('users') const affected = await k('users')
.where({ id, is_deleted: false }) .where({ id, is_deleted: false })
.update({ .update({
erc20: address, erc20: address,
updated_at: k.fn.now(), updated_at: k.fn.now(),
}); });
if (affected !== 1) {
throw new Error(`setErc20Address: user ${id} not found or deleted (affected=${affected})`);
}
}, },
}; };

View File

@@ -3,6 +3,7 @@
* Server-side signing now lives in `wallet-signer.service.ts` (custodial). * Server-side signing now lives in `wallet-signer.service.ts` (custodial).
*/ */
import { ethers } from 'ethers'; import { ethers } from 'ethers';
import { createHash } from 'crypto';
import { env } from '../config/env'; import { env } from '../config/env';
import { getEvmTokens, getTrxTokens, getSolTokens } from '../lib/token-registry'; import { getEvmTokens, getTrxTokens, getSolTokens } from '../lib/token-registry';
@@ -177,10 +178,11 @@ async function evmBalance(
tokens: { symbol: string; addr: string }[], tokens: { symbol: string; addr: string }[],
): Promise<{ native: string; tokens: Record<string, string> }> { ): Promise<{ native: string; tokens: Record<string, string> }> {
const provider = new ethers.providers.StaticJsonRpcProvider(rpc); const provider = new ethers.providers.StaticJsonRpcProvider(rpc);
const native = await withTimeout(provider.getBalance(address), TIMEOUT_MS, 'EVM balance timeout'); // H52 — Promise.allSettled: одна сбоящая RPC subcall не валит весь endpoint в 502
const nativeP = withTimeout(provider.getBalance(address), TIMEOUT_MS, 'EVM balance timeout').then(b => b.toString()).catch(() => '0');
const tokenBalances: Record<string, string> = {}; const tokenBalances: Record<string, string> = {};
await Promise.all( await Promise.allSettled(
tokens.map(async ({ symbol, addr }) => { tokens.map(async ({ symbol, addr }) => {
try { try {
const c = new ethers.Contract(addr, ERC20_ABI, provider); const c = new ethers.Contract(addr, ERC20_ABI, provider);
@@ -192,7 +194,8 @@ async function evmBalance(
}), }),
); );
return { native: native.toString(), tokens: tokenBalances }; const native = await nativeP;
return { native, tokens: tokenBalances };
} }
async function solBalance(address: string): Promise<{ native: string; tokens: Record<string, string> }> { async function solBalance(address: string): Promise<{ native: string; tokens: Record<string, string> }> {
@@ -316,18 +319,42 @@ async function scanTransactions(
async function btcTransactions(address: string, limit: number): Promise<TxItem[]> { async function btcTransactions(address: string, limit: number): Promise<TxItem[]> {
const txs = await fetchJson(`${BLOCKSTREAM}/address/${address}/txs`); const txs = await fetchJson(`${BLOCKSTREAM}/address/${address}/txs`);
return (txs as any[]).slice(0, limit).map((tx) => { return (txs as any[]).slice(0, limit).map((tx) => {
const inSelf = tx.vin.some((v: any) => v.prevout?.scriptpubkey_address === address); const vin = Array.isArray(tx.vin) ? tx.vin : [];
const outSelf = tx.vout.some((v: any) => v.scriptpubkey_address === address); const vout = Array.isArray(tx.vout) ? tx.vout : [];
const direction: TxItem['direction'] = inSelf && outSelf ? 'self' : inSelf ? 'out' : 'in'; const inSelf = vin.some((v: any) => v.prevout?.scriptpubkey_address === address);
const allOutsSelf = vout.length > 0 && vout.every((v: any) => v.scriptpubkey_address === address);
const anyOutsExternal = vout.some((v: any) => v.scriptpubkey_address && v.scriptpubkey_address !== address);
// H49 — корректная direction logic:
// self = consolidation/tx where ВСЕ outs идут обратно к нам (rare; обычно нет change)
// out = мы spend'им (inSelf=true) И есть external recipient
// in = мы получаем (НЕ inSelf, есть out к нам)
let direction: TxItem['direction'];
if (inSelf && allOutsSelf) {
direction = 'self';
} else if (inSelf && anyOutsExternal) {
direction = 'out';
} else {
direction = 'in';
}
// amount: для 'out' — sum external outs; для 'in' — sum outs к нам; для 'self' — 0
let amountSat = 0n;
if (direction === 'in') {
amountSat = vout
.filter((v: any) => v.scriptpubkey_address === address)
.reduce((s: bigint, v: any) => s + BigInt(v.value || 0), 0n);
} else if (direction === 'out') {
amountSat = vout
.filter((v: any) => v.scriptpubkey_address && v.scriptpubkey_address !== address)
.reduce((s: bigint, v: any) => s + BigInt(v.value || 0), 0n);
}
return { return {
txid: tx.txid, txid: tx.txid,
timestamp: tx.status?.block_time ?? null, timestamp: tx.status?.block_time ?? null,
direction, direction,
amount: String( amount: String(amountSat),
tx.vout
.filter((v: any) => (direction === 'in' ? v.scriptpubkey_address === address : v.scriptpubkey_address !== address))
.reduce((s: bigint, v: any) => s + BigInt(v.value), 0n),
),
}; };
}); });
} }
@@ -358,7 +385,7 @@ async function trxTransactions(address: string, limit: number): Promise<TxItem[]
} }
async function solTransactions(address: string, limit: number): Promise<TxItem[]> { async function solTransactions(address: string, limit: number): Promise<TxItem[]> {
const res = await fetchJson(SOL_RPC, { const sigsRes = await fetchJson(SOL_RPC, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -368,11 +395,56 @@ async function solTransactions(address: string, limit: number): Promise<TxItem[]
params: [address, { limit }], params: [address, { limit }],
}), }),
}); });
return ((res.result as any[]) || []).map((sig) => ({ // H50 — H18: filter out failed txs + deep-parse selected (limit small concurrency).
const allSigs = ((sigsRes.result as any[]) || []).filter((s) => s.err === null);
// Fetch tx details для balance deltas — batch parallel но небольшим limit'ом
const results: TxItem[] = [];
for (const sig of allSigs.slice(0, limit)) {
try {
const txRes = await fetchJson(SOL_RPC, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'getTransaction',
params: [sig.signature, { maxSupportedTransactionVersion: 0, encoding: 'json' }],
}),
});
const tx = txRes.result;
const accountKeys = tx?.transaction?.message?.accountKeys || [];
const idx = accountKeys.indexOf(address);
const pre = tx?.meta?.preBalances?.[idx];
const post = tx?.meta?.postBalances?.[idx];
let direction: TxItem['direction'] = 'self';
let amount: string | undefined;
if (typeof pre === 'number' && typeof post === 'number') {
const delta = post - pre;
if (delta < 0) {
direction = 'out';
amount = String(-delta);
} else if (delta > 0) {
direction = 'in';
amount = String(delta);
}
}
results.push({
txid: sig.signature, txid: sig.signature,
timestamp: sig.blockTime ?? null, timestamp: sig.blockTime ?? null,
direction: 'self' as const, // без deep parsing — направление неизвестно direction,
})); amount,
});
} catch {
// Если getTransaction fails — fallback на minimal entry
results.push({
txid: sig.signature,
timestamp: sig.blockTime ?? null,
direction: 'self',
});
}
}
return results;
} }
// ─────────────────────── HELPERS ─────────────────────── // ─────────────────────── HELPERS ───────────────────────
@@ -584,10 +656,52 @@ function tronAddressToHex(address: string): string {
return hex.slice(2, 42); // 20 bytes без префикса 0x41 return hex.slice(2, 42); // 20 bytes без префикса 0x41
} }
/**
* Encode TRON address: 20-byte hex (без 0x41 prefix) → base58check 'T...' string.
* H51 — фронтенд получал raw hex `41...` instead of readable `T...`. Fix.
*/
function hexToTron(hex: string): string { function hexToTron(hex: string): string {
// Формат TronGrid: 41xxxx (20 bytes after prefix). Это base58check. if (!hex) return '';
// Для простоты возвращаем как есть hex (можно улучшить на full base58check если нужно). // Принимаем hex с или без префикса 0x41
let bytesHex = hex.toLowerCase();
if (bytesHex.startsWith('0x')) bytesHex = bytesHex.slice(2);
// Если уже 21 byte (с prefix) — оставляем; если 20 — добавляем 0x41
if (bytesHex.length === 40) {
bytesHex = '41' + bytesHex;
} else if (bytesHex.length !== 42) {
// Unknown length — fail-safe return raw input для backward compat
return hex; return hex;
}
if (!/^[0-9a-f]+$/.test(bytesHex)) return hex;
const payload = Buffer.from(bytesHex, 'hex');
// SHA256d checksum (4 bytes)
const h1 = createHash('sha256').update(payload).digest();
const h2 = createHash('sha256').update(h1).digest();
const fullBytes = Buffer.concat([payload, h2.subarray(0, 4)]);
// base58 encode
return base58Encode(fullBytes);
}
const BASE58_ALPHABET_OPS = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
function base58Encode(bytes: Buffer): string {
let num = 0n;
for (const b of bytes) {
num = (num << 8n) + BigInt(b);
}
let s = '';
while (num > 0n) {
s = BASE58_ALPHABET_OPS[Number(num % 58n)] + s;
num /= 58n;
}
// Leading zero bytes → leading '1's
for (const b of bytes) {
if (b === 0) s = '1' + s;
else break;
}
return s;
} }
async function fetchJson(url: string, init?: RequestInit): Promise<any> { async function fetchJson(url: string, init?: RequestInit): Promise<any> {

View File

@@ -8,7 +8,7 @@ services:
# Bind to loopback only — TLS termination + WAF на reverse proxy (Caddy / Nginx). # Bind to loopback only — TLS termination + WAF на reverse proxy (Caddy / Nginx).
# Для direct exposure в dev → поменяй на "3001:3001". # Для direct exposure в dev → поменяй на "3001:3001".
ports: ports:
- "3001:3001" - "127.0.0.1:3001:3001"
env_file: env_file:
- .env - .env
environment: environment: