From 3a890b79ee7421478bd7c7b0e4f9a924199ff7cf Mon Sep 17 00:00:00 2001 From: ZOMBIIIIIII <120676065+Metaton241@users.noreply.github.com> Date: Wed, 13 May 2026 00:37:00 +0300 Subject: [PATCH] initkkk --- apps/api/src/app.ts | 4 +- apps/api/src/controllers/wallet.controller.ts | 36 ++-- apps/api/src/models/user.model.ts | 77 ++++++++- apps/api/src/services/wallet-ops.service.ts | 154 +++++++++++++++--- docker-compose.yml | 2 +- 5 files changed, 230 insertions(+), 43 deletions(-) diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 67add42..738afcc 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -10,7 +10,6 @@ import { authMiddleware } from './middleware/auth'; import { csrfMiddleware } from './middleware/csrf'; import { globalLimiter, mutateLimiter, sensitiveLimiter, mnemonicRevealLimiter } from './middleware/rate-limit'; import { errorHandler } from './middleware/error-handler'; -import { WalletController } from './controllers/wallet.controller'; import walletRoutes from './routes/wallet.routes'; import relayProxyRoutes from './routes/relay-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]; // Sensitive — самый строгий лимит. Каждый POST защищён JWT + CSRF. +app.use('/api/wallets/create', ...protect, sensitiveLimiter); app.use('/api/wallets/mnemonic/reveal', ...protect, mnemonicRevealLimiter); app.use('/api/wallets/:chain/send', ...protect, sensitiveLimiter); // 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/relay', ...protect, mutateLimiter, relayProxyRoutes); app.use('/api/tron', ...protect, mutateLimiter, tronProxyRoutes); diff --git a/apps/api/src/controllers/wallet.controller.ts b/apps/api/src/controllers/wallet.controller.ts index 5e49701..24def54 100644 --- a/apps/api/src/controllers/wallet.controller.ts +++ b/apps/api/src/controllers/wallet.controller.ts @@ -30,9 +30,8 @@ export const WalletController = { * GET /api/wallets — все адреса юзера. */ async getWallets(req: Request, res: Response) { - const userId = '01KPKAFN6J1NJBY15DX8JE2QYB'; try { - const wallets = await WalletModel.findByUserId(userId); + const wallets = await WalletModel.findByUserId(req.auth!.userId); res.json({ success: true, data: wallets.map((w) => ({ @@ -42,7 +41,7 @@ export const WalletController = { })), }); } 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' }); } }, @@ -54,7 +53,7 @@ export const WalletController = { * Возвращает: ТОЛЬКО адреса. Mnemonic клиенту не отдаём. */ async createWallet(req: Request, res: Response) { - const userId = '01KPKAFN6J1NJBY15DX8JE2QYB'; + const userId = req.auth!.userId; if (!isCryptoReady()) { res.status(503).json({ success: false, error: 'Crypto service not ready' }); @@ -65,20 +64,36 @@ export const WalletController = { try { 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' }); return; } + if (claimResult === 'no_user') { + res.status(404).json({ success: false, error: 'User not found or deleted' }); + return; + } + // claimResult === 'claimed' — proceed mnemonic = generateMnemonic(); const derived = await deriveAllAddresses(mnemonic); const blob = encryptMnemonic(mnemonic); const created = await db.transaction(async (trx) => { - const claimed = await UserModel.setEncryptedMnemonicIfAbsent(userId, blob, trx); - if (!claimed) { - throw new ConflictError(); - } + // H32 — finalize placeholder (must succeed since claim won earlier) + await UserModel.finalizeWalletSlot(userId, blob, trx); await WalletModel.createMany( derived.map((w) => ({ user_id: userId, @@ -88,8 +103,7 @@ export const WalletController = { })), trx, ); - // Дублируем ETH-адрес в users.erc20 — это поле прода-схемы - // (custodial wallet's ETH address, доступный через простой SELECT без JOIN'а на wallets). + // Дублируем ETH-адрес в users.erc20 const ethWallet = derived.find((w) => w.chain === 'ETH'); if (ethWallet) { await UserModel.setErc20Address(userId, ethWallet.address, trx); diff --git a/apps/api/src/models/user.model.ts b/apps/api/src/models/user.model.ts index 84035d5..58ba797 100644 --- a/apps/api/src/models/user.model.ts +++ b/apps/api/src/models/user.model.ts @@ -66,11 +66,17 @@ export const UserModel = { }, /** - * Set-once: возвращает true только если этот вызов реально занял slot. - * Защита от race: два параллельных createWallet не могут оба перезаписать. - * Также filter is_deleted=false — не давать zombie-account resurrection. + * Set-once: возвращает 'claimed' / 'already_has' / 'no_user' (H32). + * Defense-in-depth: distinguishes wrong outcomes так что caller отдаёт правильный код: + * - 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 { + async setEncryptedMnemonicIfAbsent( + id: string, + blob: string, + trx?: Knex.Transaction, + ): Promise<'claimed' | 'already_has' | 'no_user'> { const k = trx || db; const affected = await k('users') .where({ id, is_deleted: false }) @@ -79,7 +85,60 @@ export const UserModel = { encrypted_mnemonic: blob, 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 { + 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 { + 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 { @@ -100,16 +159,18 @@ export const UserModel = { /** * Записать ETH-адрес custodial-кошелька в users.erc20. - * Вызывается из createWallet() внутри той же transaction что и WalletModel.createMany, - * чтобы rollback был consistent (без orphan записей). + * Throws (rolls back tx) if user не существует / is_deleted (H31). */ async setErc20Address(id: string, address: string, trx?: Knex.Transaction): Promise { const k = trx || db; - await k('users') + const affected = await k('users') .where({ id, is_deleted: false }) .update({ erc20: address, updated_at: k.fn.now(), }); + if (affected !== 1) { + throw new Error(`setErc20Address: user ${id} not found or deleted (affected=${affected})`); + } }, }; diff --git a/apps/api/src/services/wallet-ops.service.ts b/apps/api/src/services/wallet-ops.service.ts index 03a77e9..80a1fe8 100644 --- a/apps/api/src/services/wallet-ops.service.ts +++ b/apps/api/src/services/wallet-ops.service.ts @@ -3,6 +3,7 @@ * Server-side signing now lives in `wallet-signer.service.ts` (custodial). */ import { ethers } from 'ethers'; +import { createHash } from 'crypto'; import { env } from '../config/env'; import { getEvmTokens, getTrxTokens, getSolTokens } from '../lib/token-registry'; @@ -177,10 +178,11 @@ async function evmBalance( tokens: { symbol: string; addr: string }[], ): Promise<{ native: string; tokens: Record }> { 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 = {}; - await Promise.all( + await Promise.allSettled( tokens.map(async ({ symbol, addr }) => { try { 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 }> { @@ -316,18 +319,42 @@ async function scanTransactions( async function btcTransactions(address: string, limit: number): Promise { const txs = await fetchJson(`${BLOCKSTREAM}/address/${address}/txs`); return (txs as any[]).slice(0, limit).map((tx) => { - const inSelf = tx.vin.some((v: any) => v.prevout?.scriptpubkey_address === address); - const outSelf = tx.vout.some((v: any) => v.scriptpubkey_address === address); - const direction: TxItem['direction'] = inSelf && outSelf ? 'self' : inSelf ? 'out' : 'in'; + const vin = Array.isArray(tx.vin) ? tx.vin : []; + const vout = Array.isArray(tx.vout) ? tx.vout : []; + 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 { txid: tx.txid, timestamp: tx.status?.block_time ?? null, direction, - amount: String( - 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), - ), + amount: String(amountSat), }; }); } @@ -358,7 +385,7 @@ async function trxTransactions(address: string, limit: number): Promise { - const res = await fetchJson(SOL_RPC, { + const sigsRes = await fetchJson(SOL_RPC, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -368,11 +395,56 @@ async function solTransactions(address: string, limit: number): Promise ({ - txid: sig.signature, - timestamp: sig.blockTime ?? null, - direction: 'self' as const, // без deep parsing — направление неизвестно - })); + // 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, + timestamp: sig.blockTime ?? null, + direction, + amount, + }); + } catch { + // Если getTransaction fails — fallback на minimal entry + results.push({ + txid: sig.signature, + timestamp: sig.blockTime ?? null, + direction: 'self', + }); + } + } + return results; } // ─────────────────────── HELPERS ─────────────────────── @@ -584,10 +656,52 @@ function tronAddressToHex(address: string): string { 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 { - // Формат TronGrid: 41xxxx (20 bytes after prefix). Это base58check. - // Для простоты возвращаем как есть hex (можно улучшить на full base58check если нужно). - return hex; + if (!hex) return ''; + // Принимаем 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; + } + 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 { diff --git a/docker-compose.yml b/docker-compose.yml index 73daadb..db8c2ce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: # Bind to loopback only — TLS termination + WAF на reverse proxy (Caddy / Nginx). # Для direct exposure в dev → поменяй на "3001:3001". ports: - - "3001:3001" + - "127.0.0.1:3001:3001" env_file: - .env environment: