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 { 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);

View File

@@ -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);

View File

@@ -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<boolean> {
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<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> {
@@ -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<void> {
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})`);
}
},
};

View File

@@ -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<string, string> }> {
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> = {};
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<string, string> }> {
@@ -316,18 +319,42 @@ async function scanTransactions(
async function btcTransactions(address: string, limit: number): Promise<TxItem[]> {
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<TxItem[]
}
async function solTransactions(address: string, limit: number): Promise<TxItem[]> {
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<TxItem[]
params: [address, { limit }],
}),
});
return ((res.result as any[]) || []).map((sig) => ({
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<any> {

View File

@@ -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: