feat: security audit fixes
This commit is contained in:
@@ -1,35 +1,20 @@
|
||||
/**
|
||||
* Audit log — append-only JSON lines в `logs/audit.log`.
|
||||
* Используется для критических custodial операций.
|
||||
* Audit log — durable durable durable.
|
||||
*
|
||||
* Two sinks:
|
||||
* 1. **DB `audit_log` table** — primary, used by `auditLogStrict` для critical
|
||||
* операций. INSERT pending → mutation → UPDATE success/failure с txid.
|
||||
* Если INSERT fails — operation must NOT proceed (fail-secure).
|
||||
* 2. **stdout JSON line** — для log-aggregator (Docker logs / Loki etc).
|
||||
* Best-effort, всегда (даже если DB sink fails).
|
||||
*
|
||||
* НИКОГДА не логирует mnemonic / privkey / encrypted blob.
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { logger } from './logger';
|
||||
import { ulid } from 'ulidx';
|
||||
import { db } from '../config/database';
|
||||
import { getTraceId } from './trace-store';
|
||||
|
||||
const AUDIT_DIR = path.resolve(__dirname, '../../../../logs');
|
||||
const AUDIT_FILE = path.join(AUDIT_DIR, 'audit.log');
|
||||
|
||||
let initialized = false;
|
||||
|
||||
async function ensureFile(): Promise<void> {
|
||||
if (initialized) return;
|
||||
try {
|
||||
await fs.mkdir(AUDIT_DIR, { recursive: true });
|
||||
const handle = await fs.open(AUDIT_FILE, 'a', 0o600);
|
||||
await handle.close();
|
||||
try {
|
||||
await fs.chmod(AUDIT_FILE, 0o600);
|
||||
} catch {
|
||||
// Windows chmod — игнор
|
||||
}
|
||||
initialized = true;
|
||||
} catch (err: any) {
|
||||
logger.error(`Audit log init failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
import { logger } from './logger';
|
||||
|
||||
export interface AuditEntry {
|
||||
event: string;
|
||||
@@ -40,36 +25,90 @@ export interface AuditEntry {
|
||||
errorCode?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort write. Если запись провалилась — только log, не throws.
|
||||
* Используется для не-критических событий (wallet.create success, etc).
|
||||
*/
|
||||
export async function auditLog(entry: AuditEntry): Promise<void> {
|
||||
await ensureFile();
|
||||
const line = JSON.stringify({
|
||||
function buildStdoutLine(entry: AuditEntry, status: 'pending' | 'success' | 'failure'): string {
|
||||
return JSON.stringify({
|
||||
level: 'audit',
|
||||
status,
|
||||
timestamp: new Date().toISOString(),
|
||||
trace_id: getTraceId(),
|
||||
...entry,
|
||||
});
|
||||
}) + '\n';
|
||||
}
|
||||
|
||||
function writeStdoutBestEffort(line: string): void {
|
||||
try {
|
||||
await fs.appendFile(AUDIT_FILE, line + '\n', { mode: 0o600 });
|
||||
} catch (err: any) {
|
||||
logger.error(`Audit log write failed: ${err.message}`);
|
||||
process.stdout.write(line);
|
||||
} catch {
|
||||
// swallow
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-secure write. Если запись провалилась — throws.
|
||||
* Используется для critical security событий (mnemonic.reveal, wallet.send),
|
||||
* где compliance требует чтобы операция НЕ происходила без audit-trail.
|
||||
* Best-effort: stdout only. Используется для info-level событий
|
||||
* (wallet.create success, lookup, etc). Не блокирует request на DB.
|
||||
*/
|
||||
export async function auditLogStrict(entry: AuditEntry): Promise<void> {
|
||||
await ensureFile();
|
||||
const line = JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
trace_id: getTraceId(),
|
||||
...entry,
|
||||
});
|
||||
// Без try/catch — caller обрабатывает failure
|
||||
await fs.appendFile(AUDIT_FILE, line + '\n', { mode: 0o600 });
|
||||
export async function auditLog(entry: AuditEntry): Promise<void> {
|
||||
const status: 'success' | 'failure' = entry.result === 'failure' ? 'failure' : 'success';
|
||||
writeStdoutBestEffort(buildStdoutLine(entry, status));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-secure audit для critical custodial операций (mnemonic.reveal, wallet.send,
|
||||
* wallet.sign_raw_evm).
|
||||
*
|
||||
* Семантика: INSERT row в `audit_log` table перед mutation. Если INSERT FAILS
|
||||
* (DB down, connection pool exhausted, constraint violation) — throws.
|
||||
* Caller ОБЯЗАН abort'нуть mutation, не вернуть response с funds-action.
|
||||
*
|
||||
* Возвращает audit row id — caller использует его в `completeAudit()` после mutation.
|
||||
*/
|
||||
export async function auditLogStrict(entry: AuditEntry & { status?: 'pending' | 'success' | 'failure' }): Promise<string> {
|
||||
const id = ulid();
|
||||
const status = entry.status ?? 'pending';
|
||||
|
||||
// DB INSERT — fail-secure (throws on DB failure)
|
||||
await db('audit_log').insert({
|
||||
id,
|
||||
user_id: entry.userId,
|
||||
event: entry.event,
|
||||
status,
|
||||
error_code: entry.errorCode ?? null,
|
||||
ip: entry.ip ?? null,
|
||||
trace_id: getTraceId() ?? null,
|
||||
meta: entry.meta ? JSON.stringify(entry.meta) : null,
|
||||
});
|
||||
|
||||
// Mirror to stdout (best-effort, не критично)
|
||||
writeStdoutBestEffort(buildStdoutLine(entry, status));
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update audit row после mutation (success или failure с txid/error).
|
||||
* Best-effort — если update fails, операция уже произошла, мы just log warning.
|
||||
*/
|
||||
export async function completeAudit(
|
||||
auditId: string,
|
||||
result: 'success' | 'failure',
|
||||
meta?: Record<string, unknown>,
|
||||
errorCode?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await db('audit_log')
|
||||
.where({ id: auditId })
|
||||
.update({
|
||||
status: result,
|
||||
error_code: errorCode ?? null,
|
||||
meta: meta ? JSON.stringify(meta) : db.raw('meta'),
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
} catch (err: any) {
|
||||
logger.error(`completeAudit failed for ${auditId}: ${err?.message}`);
|
||||
}
|
||||
// Mirror to stdout
|
||||
writeStdoutBestEffort(buildStdoutLine(
|
||||
{ event: `audit.update.${auditId}`, userId: '<see-audit-row>', meta, errorCode, result },
|
||||
result,
|
||||
));
|
||||
}
|
||||
|
||||
116
apps/api/src/lib/evm-tx-policy.ts
Normal file
116
apps/api/src/lib/evm-tx-policy.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Security policy для `signAndBroadcastRawEvm`.
|
||||
*
|
||||
* Защита от drain-вектора: stolen JWT + один POST = пустой кошелёк, если подписывать
|
||||
* произвольный `to`+`data`. Этот модуль применяет несколько слоёв защиты:
|
||||
*
|
||||
* 1. **Selector blacklist** — `approve()`, `permit()`, `setApprovalForAll()` etc.
|
||||
* Безопасный swap НЕ требует approve через наш sign-raw — клиент должен делать
|
||||
* approve через другой кастодиальный flow (если бы был такой), но самый чистый
|
||||
* дизайн — никогда не давать sign-raw отозвать approval из bridge-quote.
|
||||
*
|
||||
* 2. **`to` allowlist** — только Relay router-адреса для каждого chainId.
|
||||
* Native send (`data === '0x'`) тоже whitelist'ит `to` чтобы атакер не мог
|
||||
* drain native через sign-raw-evm-tx.
|
||||
*
|
||||
* 3. **Caps** — `gas`, `value`, `gas*maxFeePerGas` — против poisoned quote.
|
||||
*/
|
||||
|
||||
import { ethers } from 'ethers';
|
||||
|
||||
/** Relay-protocol router/depository contract addresses per chainId. */
|
||||
const RELAY_ROUTERS: Record<number, Set<string>> = {
|
||||
// Ethereum mainnet — Relay router contracts (lowercase for canonical match)
|
||||
1: new Set([
|
||||
'0xa5f565650890fba1824ee0f21ebbbf660a179934', // Relay Depository v1 ETH
|
||||
'0xf70da97812cb96acdf810712aa562db8dfa3dbef', // Relay Router (legacy)
|
||||
]),
|
||||
// BSC mainnet
|
||||
56: new Set([
|
||||
'0xa5f565650890fba1824ee0f21ebbbf660a179934', // Relay Depository v1 BSC
|
||||
]),
|
||||
};
|
||||
|
||||
/** Method selectors which are NEVER allowed via sign-raw-evm-tx (drain vectors). */
|
||||
const FORBIDDEN_SELECTORS: Record<string, string> = {
|
||||
'0x095ea7b3': 'approve(address,uint256)',
|
||||
'0x39509351': 'increaseAllowance(address,uint256)',
|
||||
'0xd505accf': 'permit(address,address,uint256,uint256,uint8,bytes32,bytes32)',
|
||||
'0x8fcbaf0c': 'permit(address,address,uint256,uint256,bool,uint8,bytes32,bytes32)',
|
||||
'0xa22cb465': 'setApprovalForAll(address,bool)',
|
||||
'0x42842e0e': 'safeTransferFrom(address,address,uint256)', // NFT transferFrom
|
||||
'0xb88d4fde': 'safeTransferFrom(address,address,uint256,bytes)',
|
||||
};
|
||||
|
||||
export interface PolicyParams {
|
||||
chainId: number;
|
||||
to: string;
|
||||
data: string;
|
||||
value: string;
|
||||
gas: string;
|
||||
maxFeePerGas: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Caps (per-chain budget). Стоит выше любого realistic Relay tx, но защищает
|
||||
* от absurd-poisoned quote, которые сжигают весь баланс на gas.
|
||||
*/
|
||||
const CAPS = {
|
||||
// gas: max 1.5M (типичный complex swap ~300-500k; cap есть запас)
|
||||
maxGas: ethers.BigNumber.from('1500000'),
|
||||
// gas budget: gas × maxFeePerGas ≤ 0.05 native (= 0.05 ETH ≈ $130 worst case)
|
||||
// Уровень который покрывает legitimate bridge/swap но не drain
|
||||
maxGasBudgetWei: ethers.utils.parseEther('0.05'),
|
||||
// value: max 100 native в одной tx (защита от случайного drain через value)
|
||||
// Native send большего объёма через sign-raw-evm-tx — explicit user confirmation
|
||||
// нужен. Через /send route (структурированный) — ограничения другие.
|
||||
maxValueWei: ethers.utils.parseEther('100'),
|
||||
};
|
||||
|
||||
/**
|
||||
* Применяет security policy. Throws if disallowed.
|
||||
*
|
||||
* Возвращает result-обoject для info-логирования (matched router name, selector name).
|
||||
*/
|
||||
export function applyEvmTxPolicy(p: PolicyParams): { routerName?: string; selectorName?: string } {
|
||||
// 1. `to` validation — должен быть в Relay router allowlist для этого chainId
|
||||
const toLower = p.to.toLowerCase();
|
||||
const routers = RELAY_ROUTERS[p.chainId];
|
||||
if (!routers) {
|
||||
throw new Error(`Sign-raw policy: chainId ${p.chainId} not in router allowlist`);
|
||||
}
|
||||
if (!routers.has(toLower)) {
|
||||
throw new Error(`Sign-raw policy: 'to' address ${p.to} not in Relay router allowlist for chainId ${p.chainId}`);
|
||||
}
|
||||
|
||||
// 2. Selector blacklist — `approve()` etc. никогда не подписывается
|
||||
let selectorName: string | undefined;
|
||||
if (p.data.length >= 10) {
|
||||
const selector = p.data.slice(0, 10).toLowerCase();
|
||||
if (FORBIDDEN_SELECTORS[selector]) {
|
||||
throw new Error(`Sign-raw policy: forbidden selector ${selector} (${FORBIDDEN_SELECTORS[selector]}) — drain vector`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. gas caps
|
||||
const gas = ethers.BigNumber.from(p.gas);
|
||||
if (gas.gt(CAPS.maxGas)) {
|
||||
throw new Error(`Sign-raw policy: gas ${gas.toString()} exceeds cap ${CAPS.maxGas.toString()}`);
|
||||
}
|
||||
const maxFee = ethers.BigNumber.from(p.maxFeePerGas);
|
||||
const budget = gas.mul(maxFee);
|
||||
if (budget.gt(CAPS.maxGasBudgetWei)) {
|
||||
throw new Error(`Sign-raw policy: gas budget ${ethers.utils.formatEther(budget)} ETH exceeds cap ${ethers.utils.formatEther(CAPS.maxGasBudgetWei)} ETH`);
|
||||
}
|
||||
|
||||
// 4. value cap
|
||||
const value = ethers.BigNumber.from(p.value);
|
||||
if (value.gt(CAPS.maxValueWei)) {
|
||||
throw new Error(`Sign-raw policy: value ${ethers.utils.formatEther(value)} exceeds cap ${ethers.utils.formatEther(CAPS.maxValueWei)} native units`);
|
||||
}
|
||||
|
||||
return {
|
||||
routerName: routers.has(toLower) ? `relay-router-${p.chainId}` : undefined,
|
||||
selectorName,
|
||||
};
|
||||
}
|
||||
92
apps/api/src/lib/idempotency.ts
Normal file
92
apps/api/src/lib/idempotency.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Idempotency-Key handling — C3 защита от double-spend при retry.
|
||||
*
|
||||
* Контракт:
|
||||
* Client передаёт header `Idempotency-Key: <opaque-string-up-to-128-chars>`.
|
||||
* Server:
|
||||
* 1. INSERT row (user_id, key, request_hash) — PK conflict = retry detected.
|
||||
* 2. На retry: SELECT existing row. Если response_status is null — operation
|
||||
* ещё in-flight → return 409 "retry too soon". Если response_status set →
|
||||
* return cached response (same status, same body).
|
||||
* Retention: 24h. Cleanup via cron.
|
||||
*/
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
import { db } from '../config/database';
|
||||
|
||||
export interface IdempotencyClaim {
|
||||
fresh: boolean;
|
||||
cached?: { status: number; body: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to claim the key. If first time → fresh=true, caller proceeds with mutation.
|
||||
* If duplicate с existing response → fresh=false + cached response.
|
||||
* If duplicate с pending in-flight → throws (caller returns 409).
|
||||
*/
|
||||
export async function claimIdempotency(
|
||||
userId: string,
|
||||
key: string,
|
||||
requestBody: unknown,
|
||||
): Promise<IdempotencyClaim> {
|
||||
const requestHash = createHash('sha256')
|
||||
.update(JSON.stringify(requestBody ?? {}))
|
||||
.digest('hex');
|
||||
|
||||
try {
|
||||
await db('idempotency_keys').insert({
|
||||
user_id: userId,
|
||||
key,
|
||||
request_hash: requestHash,
|
||||
});
|
||||
return { fresh: true };
|
||||
} catch (err: any) {
|
||||
// PK violation = retry
|
||||
const existing = await db('idempotency_keys')
|
||||
.where({ user_id: userId, key })
|
||||
.first();
|
||||
if (!existing) throw err;
|
||||
|
||||
// Verify request body matches (защита от replay с другим body)
|
||||
if (existing.request_hash !== requestHash) {
|
||||
throw new Error(`Idempotency-Key reuse with different request body. Use a new key.`);
|
||||
}
|
||||
|
||||
if (existing.response_status === null || existing.response_status === undefined) {
|
||||
throw new Error('Operation already in flight; retry after a few seconds.');
|
||||
}
|
||||
|
||||
return {
|
||||
fresh: false,
|
||||
cached: {
|
||||
status: existing.response_status as number,
|
||||
body: existing.response_body as string,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Сохранить response в idempotency row (после mutation succeeds/fails). */
|
||||
export async function saveIdempotencyResponse(
|
||||
userId: string,
|
||||
key: string,
|
||||
status: number,
|
||||
body: string,
|
||||
): Promise<void> {
|
||||
await db('idempotency_keys')
|
||||
.where({ user_id: userId, key })
|
||||
.update({
|
||||
response_status: status,
|
||||
response_body: body,
|
||||
});
|
||||
}
|
||||
|
||||
/** Validate header format. Returns null if missing/invalid (caller may make mandatory). */
|
||||
export function extractIdempotencyKey(headerValue: unknown): string | null {
|
||||
if (typeof headerValue !== 'string') return null;
|
||||
const v = headerValue.trim();
|
||||
if (!v) return null;
|
||||
// Restrict charset: alphanum + dash/underscore, max 128
|
||||
if (!/^[A-Za-z0-9_-]{1,128}$/.test(v)) return null;
|
||||
return v;
|
||||
}
|
||||
38
apps/api/src/lib/send-lock.ts
Normal file
38
apps/api/src/lib/send-lock.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Per-user-per-chain mutex для send / sign operations.
|
||||
*
|
||||
* C3 — nonce race: `provider.getTransactionCount('pending')` без lock'а возвращает
|
||||
* одинаковый nonce для двух parallel send'ов → один tx replaces другой, или оба
|
||||
* с разными gas → mempool collision. Plus retry после ECONNRESET = double-spend.
|
||||
*
|
||||
* Этот lock — in-process Map+queue. Для multi-replica deployment нужен Redis.
|
||||
*/
|
||||
|
||||
type ReleaseFn = () => void;
|
||||
|
||||
const locks = new Map<string, Promise<void>>();
|
||||
|
||||
/**
|
||||
* Acquire lock для `userId:chain`. Возвращает release function.
|
||||
* Если другой await уже идёт, наш await ждёт его release.
|
||||
*
|
||||
* Usage:
|
||||
* const release = await acquireSendLock(userId, chain);
|
||||
* try { ... } finally { release(); }
|
||||
*/
|
||||
export async function acquireSendLock(userId: string, chain: string): Promise<ReleaseFn> {
|
||||
const key = `${userId}:${chain}`;
|
||||
// Wait для предыдущего lock
|
||||
while (locks.has(key)) {
|
||||
await locks.get(key);
|
||||
}
|
||||
let release: ReleaseFn = () => {};
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
release = () => {
|
||||
locks.delete(key);
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
locks.set(key, promise);
|
||||
return release;
|
||||
}
|
||||
80
apps/api/src/lib/token-registry.ts
Normal file
80
apps/api/src/lib/token-registry.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Реестр известных токенов per-chain. Используется в getBalance для
|
||||
* параллельного запроса баланса всех токенов адреса.
|
||||
*
|
||||
* Адреса контрактов / mint'ы — из публичных on-chain данных и используются
|
||||
* также в swap-proxy роутах (BSC TOKEN_MAP, SOL ALLOWED_MINTS).
|
||||
*/
|
||||
|
||||
import type { ChainCode } from './address-validators';
|
||||
|
||||
export interface EvmToken {
|
||||
symbol: string;
|
||||
contractAddress: string;
|
||||
decimals: number;
|
||||
}
|
||||
|
||||
export interface TrxToken {
|
||||
symbol: string;
|
||||
contractAddress: string; // T...base58
|
||||
decimals: number;
|
||||
}
|
||||
|
||||
export interface SolToken {
|
||||
symbol: string;
|
||||
mint: string; // SPL mint pubkey (base58)
|
||||
decimals: number;
|
||||
}
|
||||
|
||||
export const ETH_TOKENS: EvmToken[] = [
|
||||
{ symbol: 'USDT', contractAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7', decimals: 6 },
|
||||
{ symbol: 'USDC', contractAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', decimals: 6 },
|
||||
{ symbol: 'DAI', contractAddress: '0x6B175474E89094C44Da98b954EedeAC495271d0F', decimals: 18 },
|
||||
{ symbol: 'WBTC', contractAddress: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', decimals: 8 },
|
||||
{ symbol: 'LINK', contractAddress: '0x514910771AF9Ca656af840dff83E8264EcF986CA', decimals: 18 },
|
||||
{ symbol: 'UNI', contractAddress: '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984', decimals: 18 },
|
||||
];
|
||||
|
||||
export const BSC_TOKENS: EvmToken[] = [
|
||||
{ symbol: 'USDT', contractAddress: '0x55d398326f99059fF775485246999027B3197955', decimals: 18 },
|
||||
{ symbol: 'USDC', contractAddress: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', decimals: 18 },
|
||||
{ symbol: 'DOGE', contractAddress: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43', decimals: 8 },
|
||||
{ symbol: 'WBNB', contractAddress: '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c', decimals: 18 },
|
||||
{ symbol: 'BUSD', contractAddress: '0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56', decimals: 18 },
|
||||
];
|
||||
|
||||
export const TRX_TOKENS: TrxToken[] = [
|
||||
{ symbol: 'USDT', contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', decimals: 6 },
|
||||
{ symbol: 'USDC', contractAddress: 'TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8', decimals: 6 },
|
||||
];
|
||||
|
||||
export const SOL_TOKENS: SolToken[] = [
|
||||
{ symbol: 'USDT', mint: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', decimals: 6 },
|
||||
{ symbol: 'USDC', mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', decimals: 6 },
|
||||
{ symbol: 'PUMP', mint: 'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn', decimals: 6 },
|
||||
{ symbol: 'JUP', mint: 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', decimals: 6 },
|
||||
{ symbol: 'WIF', mint: 'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm', decimals: 6 },
|
||||
{ symbol: 'POPCAT', mint: '7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', decimals: 9 },
|
||||
{ symbol: 'TRUMP', mint: '6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN', decimals: 6 },
|
||||
{ symbol: 'PYTH', mint: 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3', decimals: 6 },
|
||||
{ symbol: 'JTO', mint: 'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL', decimals: 9 },
|
||||
{ symbol: 'W', mint: '85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ', decimals: 6 },
|
||||
{ symbol: 'BONK', mint: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263', decimals: 5 },
|
||||
{ symbol: 'ORCA', mint: 'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE', decimals: 6 },
|
||||
{ symbol: 'PENGU', mint: '2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv', decimals: 6 },
|
||||
{ symbol: 'RAY', mint: '4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R', decimals: 6 },
|
||||
];
|
||||
|
||||
export function getEvmTokens(chain: ChainCode): EvmToken[] {
|
||||
if (chain === 'ETH') return ETH_TOKENS;
|
||||
if (chain === 'BSC') return BSC_TOKENS;
|
||||
return [];
|
||||
}
|
||||
|
||||
export function getTrxTokens(): TrxToken[] {
|
||||
return TRX_TOKENS;
|
||||
}
|
||||
|
||||
export function getSolTokens(): SolToken[] {
|
||||
return SOL_TOKENS;
|
||||
}
|
||||
43
apps/api/src/lib/wallet-binding.ts
Normal file
43
apps/api/src/lib/wallet-binding.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* JWT ↔ wallet-address binding.
|
||||
*
|
||||
* Защита от drain-вектора: проксируемые endpoint'ы (swap-build, relay-quote) принимают
|
||||
* `userAddress`/`recipient` как body param. Если не привязать к JWT-юзеру —
|
||||
* authenticated user A может set `userAddress=<user B's addr>` и:
|
||||
* - swap output идёт к B (бесплатный slippage steal)
|
||||
* - reentrancy: B's address — malicious contract, callback дрянит
|
||||
* - bridge `recipient=attacker` → victim signs → funds to attacker
|
||||
*
|
||||
* Этот хелпер находит wallet user'а по chain и проверяет match.
|
||||
*/
|
||||
|
||||
import { WalletModel } from '../models/wallet.model';
|
||||
import type { ChainCode } from './address-validators';
|
||||
|
||||
/**
|
||||
* Throws if address ≠ user's wallet для данного chain.
|
||||
* Returns the canonical (DB-stored) address для использования вместо user input.
|
||||
*
|
||||
* Сравнение case-insensitive только для EVM (где checksum mixed-case = same address).
|
||||
* Для BTC/TRX/SOL — strict equality (base58/bech32 case-sensitive).
|
||||
*/
|
||||
export async function assertUserOwnsAddress(
|
||||
userId: string,
|
||||
chain: ChainCode,
|
||||
candidateAddress: string,
|
||||
): Promise<string> {
|
||||
const wallet = await WalletModel.findByUserAndChain(userId, chain);
|
||||
if (!wallet) {
|
||||
throw new Error(`No ${chain} wallet for user — POST /wallets/create first`);
|
||||
}
|
||||
const isEvm = chain === 'ETH' || chain === 'BSC';
|
||||
const dbAddr = wallet.address;
|
||||
const candidate = String(candidateAddress ?? '').trim();
|
||||
const match = isEvm
|
||||
? candidate.toLowerCase() === dbAddr.toLowerCase()
|
||||
: candidate === dbAddr;
|
||||
if (!match) {
|
||||
throw new Error(`Address ${candidate} does not match user's ${chain} wallet ${dbAddr}`);
|
||||
}
|
||||
return dbAddr;
|
||||
}
|
||||
Reference in New Issue
Block a user