feat: security audit fixes

This commit is contained in:
ZOMBIIIIIII
2026-05-13 00:17:32 +03:00
parent e87d178d71
commit 1498ed3431
31 changed files with 2198 additions and 339 deletions

View File

@@ -56,8 +56,13 @@ export async function fetchMasterKey(
throw new Error('Failed to load crypto master key from Vault');
}
const raw = secrets.key || secrets.master_key || secrets.MASTER_KEY;
// H12 — pin one field name. Multiple aliases → silent key drift on Vault misconfig.
// Reject if alternates present but primary missing → signals misconfig.
const raw = secrets.key;
if (!raw || typeof raw !== 'string') {
if (secrets.master_key || secrets.MASTER_KEY) {
throw new Error('Crypto master key misconfigured: Vault has alternate field but missing canonical "key"');
}
throw new Error('Crypto master key invalid: expected hex string in Vault field "key"');
}
if (!/^[0-9a-fA-F]{64}$/.test(raw)) {

View File

@@ -0,0 +1,125 @@
/**
* Gas oracle для EVM-чейнов (ETH/BSC). Парсит fees из RPC через `eth_feeHistory` —
* это запрос за последние N блоков с percentile-distribution of priority tips.
*
* Возвращает 3 tier'а:
* slow — p25 priority (дешевле, дольше confirmation)
* normal — p50 priority (median, рекомендованный default)
* fast — p75 priority (быстрее, дороже)
*
* maxFeePerGas = baseFee_latest * 2 + maxPriorityFeePerGas (стандартная EIP-1559 формула).
*
* Floor:
* - BSC baseFee часто ~0 (chain не полностью EIP-1559) → без floor получится 0.001 gwei
* которое реджектится min-relay. Floor = 0.05 gwei.
* - ETH реалистичный min ~1 gwei.
*
* Cap (sanity check): чтобы compromised RPC не подсунул insanely-high fees.
*/
import { ethers } from 'ethers';
const ETH_RPC = 'https://ethereum-rpc.publicnode.com';
const BSC_RPC = 'https://bsc-dataseed.binance.org';
const RPC: Record<'ETH' | 'BSC', string> = { ETH: ETH_RPC, BSC: BSC_RPC };
// Realistic mainnet floors (gwei).
const FLOOR_GWEI: Record<'ETH' | 'BSC', string> = {
ETH: '0.5',
BSC: '0.05',
};
// Sanity cap (gwei). Соответствует MAX_GAS_PRICE_GWEI=500 в signer'е.
const CAP_GWEI = 500;
const BLOCKS_TO_SAMPLE = 5;
const PERCENTILES = [25, 50, 75]; // slow, normal, fast
export type FeeTier = 'slow' | 'normal' | 'fast';
export interface FeeQuote {
maxFeePerGas: string; // wei (decimal string)
maxPriorityFeePerGas: string; // wei (decimal string)
gweiTotal: number; // display-friendly (rounded to 3 dec)
gweiPriority: number;
}
export interface FeeTiers {
chain: 'ETH' | 'BSC';
baseFeeGwei: number;
slow: FeeQuote;
normal: FeeQuote;
fast: FeeQuote;
}
function median(arr: ethers.BigNumber[]): ethers.BigNumber {
if (arr.length === 0) return ethers.BigNumber.from(0);
const sorted = [...arr].sort((a, b) => (a.lt(b) ? -1 : a.eq(b) ? 0 : 1));
return sorted[Math.floor(sorted.length / 2)];
}
function gweiNum(wei: ethers.BigNumber): number {
return Math.round(Number(ethers.utils.formatUnits(wei, 'gwei')) * 1000) / 1000;
}
export async function getEvmFeeTiers(chain: 'ETH' | 'BSC'): Promise<FeeTiers> {
const provider = new ethers.providers.StaticJsonRpcProvider(RPC[chain]);
const history = await provider.send('eth_feeHistory', [
ethers.utils.hexValue(BLOCKS_TO_SAMPLE),
'latest',
PERCENTILES,
]);
const baseFees: string[] = history.baseFeePerGas ?? [];
const rewards: string[][] = history.reward ?? [];
// baseFee для следующего блока — последний элемент массива
const baseFeeNext = baseFees.length > 0
? ethers.BigNumber.from(baseFees[baseFees.length - 1])
: ethers.BigNumber.from(0);
const floorWei = ethers.utils.parseUnits(FLOOR_GWEI[chain], 'gwei');
const capWei = ethers.utils.parseUnits(String(CAP_GWEI), 'gwei');
const priorityForTier = (idx: number): ethers.BigNumber => {
const vals = rewards
.map((row) => row?.[idx])
.filter((v): v is string => typeof v === 'string')
.map((v) => ethers.BigNumber.from(v));
return median(vals);
};
const buildQuote = (rawPriority: ethers.BigNumber): FeeQuote => {
// Apply floor and cap
let priority = rawPriority.lt(floorWei) ? floorWei : rawPriority;
if (priority.gt(capWei)) priority = capWei;
let maxFee = baseFeeNext.mul(2).add(priority);
if (maxFee.gt(capWei)) maxFee = capWei;
return {
maxFeePerGas: maxFee.toString(),
maxPriorityFeePerGas: priority.toString(),
gweiTotal: gweiNum(maxFee),
gweiPriority: gweiNum(priority),
};
};
return {
chain,
baseFeeGwei: gweiNum(baseFeeNext),
slow: buildQuote(priorityForTier(0)),
normal: buildQuote(priorityForTier(1)),
fast: buildQuote(priorityForTier(2)),
};
}
/**
* Получить fee-quote для одного tier'а — utility для signer'а.
*/
export async function getEvmFeeForTier(
chain: 'ETH' | 'BSC',
tier: FeeTier,
): Promise<FeeQuote> {
const tiers = await getEvmFeeTiers(chain);
return tiers[tier];
}

View File

@@ -11,13 +11,31 @@ let timer: NodeJS.Timeout | null = null;
// Inflight guard — reentrant calls share the same promise (audit#4 C2/C3).
// Без этого две параллельные refreshAllKeys могут torn-state'ить keyMap/csrf/crypto.
let inflight: Promise<void> | null = null;
let inflight: Promise<RefreshResult> | null = null;
// H15/H18 — track refresh outcomes для health endpoint + alarm.
export interface RefreshResult {
ok: boolean;
reason?: string;
timestamp: number;
}
let lastRefresh: RefreshResult = { ok: false, reason: 'not_yet_run', timestamp: 0 };
let consecutiveFailures = 0;
export function getLastRefreshResult(): RefreshResult {
return lastRefresh;
}
export function getConsecutiveFailures(): number {
return consecutiveFailures;
}
/**
* Atomic refresh: pre-fetch JWT/CSRF/crypto secrets, swap globals только если необходимые получены.
* Reentrant-safe.
* H18 — returns RefreshResult так что caller знает реально ли refresh succeeded.
*/
export async function refreshAllKeys(): Promise<void> {
export async function refreshAllKeys(): Promise<RefreshResult> {
if (inflight) return inflight;
inflight = doRefresh().finally(() => {
inflight = null;
@@ -25,22 +43,23 @@ export async function refreshAllKeys(): Promise<void> {
return inflight;
}
async function doRefresh(): Promise<void> {
async function doRefresh(): Promise<RefreshResult> {
const { addr, roleId, secretId, mount, jwtKidPath, jwtKidsPrefix, csrfPath, cryptoKeyPath } = env.vault;
const fail = (reason: string): RefreshResult => {
consecutiveFailures += 1;
lastRefresh = { ok: false, reason, timestamp: Date.now() };
logger.error(`Vault refresh failed: ${reason} (consecutive=${consecutiveFailures})`);
return lastRefresh;
};
if (!addr || !roleId || !secretId) {
logger.warn('Vault not configured, skipping key refresh');
return;
return fail('vault_not_configured');
}
// КАЖДЫЙ refresh — свежий AppRole login. Vault token TTL обычно ≤1 час, а refresh-интервал
// тоже ~1 час → кэшировать токен между tick'ами = expired token на 2-м tick → silent fail.
// Стоимость fresh login: один HTTP-запрос в час — пренебрежимо. Безопасность: гарантированно
// валидный токен для всех последующих fetches.
// КАЖДЫЙ refresh — свежий AppRole login. Vault token TTL обычно ≤1 час.
const token = await vaultAppRoleLogin(addr, roleId, secretId);
if (!token) {
logger.error('Key refresh: Vault AppRole login failed');
return;
return fail('approle_login_failed');
}
const jwtPromise = fetchJwtKeysFromVault(addr, token, mount, jwtKidPath, jwtKidsPrefix);
@@ -50,21 +69,17 @@ async function doRefresh(): Promise<void> {
const [jwtResult, csrfResult, cryptoResult] = await Promise.allSettled([jwtPromise, csrfPromise, cryptoPromise]);
if (jwtResult.status === 'rejected') {
logger.error(`Key refresh ABORTED — JWT keys fetch failed: ${jwtResult.reason?.message || jwtResult.reason}`);
return;
return fail(`jwt_fetch_failed: ${jwtResult.reason?.message || jwtResult.reason}`);
}
if (csrfPath && csrfResult.status === 'rejected') {
logger.error(`Key refresh ABORTED — CSRF fetch failed: ${csrfResult.reason?.message || csrfResult.reason}`);
return;
return fail(`csrf_fetch_failed: ${csrfResult.reason?.message || csrfResult.reason}`);
}
// Master-key: первый load обязателен, дальнейшие failures толерантны.
if (cryptoKeyPath && !isCryptoReady() && cryptoResult.status === 'rejected') {
logger.error(`Key refresh ABORTED — Crypto master key fetch failed: ${cryptoResult.reason?.message || cryptoResult.reason}`);
return;
return fail(`crypto_fetch_failed: ${cryptoResult.reason?.message || cryptoResult.reason}`);
}
// Atomic synchronous swap. JS single-threaded — между swap'ами нет await,
// т.е. observers видят либо все старые, либо все новые значения.
// Atomic swap. JS single-threaded → observers видят либо все старые, либо все новые.
swapKeyMap(jwtResult.value);
if (csrfResult.status === 'fulfilled' && csrfResult.value) {
swapCsrfConfig(csrfResult.value);
@@ -74,18 +89,29 @@ async function doRefresh(): Promise<void> {
swapMasterKey(cryptoResult.value);
logger.info('Crypto master key loaded');
} else if (!masterKeyMatches(cryptoResult.value)) {
logger.warn(
'Vault crypto/master key DIFFERS from in-memory key. Service continues with old key. ' +
'Rotating master-key bricks all existing encrypted_mnemonic — revert Vault or plan re-encryption migration.'
);
// H16 — master-key drift detected. По умолчанию FATAL: если operator не выставил
// ALLOW_MASTER_KEY_ROTATION=true explicitly, мы НЕ продолжаем silently на старом key.
const allowRotation = process.env.ALLOW_MASTER_KEY_ROTATION === 'true';
const msg = 'Vault crypto/master key DIFFERS from in-memory key. ALL existing encrypted_mnemonic will become undecryptable.';
if (allowRotation) {
logger.warn(msg + ' (continuing because ALLOW_MASTER_KEY_ROTATION=true)');
} else {
logger.error(msg + ' Set ALLOW_MASTER_KEY_ROTATION=true to acknowledge migration intent. FATAL — service will exit.');
// Defer exit so rest of refresh logs flush
setImmediate(() => process.exit(1));
return fail('master_key_drift');
}
}
}
consecutiveFailures = 0;
lastRefresh = { ok: true, timestamp: Date.now() };
logger.info(
`Keys refreshed atomically: JWT keys=${getKeyMapSize()}` +
(csrfPath ? `, CSRF=${csrfResult.status === 'fulfilled' ? 'updated' : 'unchanged'}` : '') +
`, Crypto=${isCryptoReady() ? 'ready' : 'NOT-READY'}`
);
return lastRefresh;
}
export function startKeyRotation(intervalMs: number = DEFAULT_INTERVAL_MS): void {

View File

@@ -4,6 +4,7 @@
*/
import { ethers } from 'ethers';
import { env } from '../config/env';
import { getEvmTokens, getTrxTokens, getSolTokens } from '../lib/token-registry';
export type ChainCode = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC';
@@ -16,10 +17,6 @@ const BSC_RPC = 'https://bsc-dataseed.binance.org';
const ETH_RPC = 'https://ethereum-rpc.publicnode.com';
const SOL_RPC = 'https://api.mainnet-beta.solana.com';
const USDT_TRC20 = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t';
const USDT_BEP20 = '0x55d398326f99059fF775485246999027B3197955';
const USDT_ERC20 = '0xdAC17F958D2ee523a2206206994597C13D831ec7';
const ERC20_ABI = [
'function balanceOf(address owner) view returns (uint256)',
'function transfer(address to, uint256 amount) returns (bool)',
@@ -28,31 +25,106 @@ const ERC20_ABI = [
// ─────────────────────── BALANCE ───────────────────────
export interface FormattedAmount {
raw: string; // smallest units (string-encoded BigInt — без потери точности)
formatted: string; // human-readable, e.g. "0.003"
decimals: number; // decimals chain'а/токена
}
export interface BalanceResult {
chain: ChainCode;
address: string;
native: string; // в smallest units (satoshi/wei/lamports/sun)
tokens?: Record<string, string>; // например { USDT: "12345678" }
native: FormattedAmount;
tokens?: Record<string, FormattedAmount>;
}
// Native decimals per chain
const NATIVE_DECIMALS: Record<ChainCode, number> = {
ETH: 18, // wei
BSC: 18, // wei (BNB)
BTC: 8, // satoshi
TRX: 6, // sun
SOL: 9, // lamports
};
/**
* Convert raw bigint string in smallest units → human-readable decimal string.
* Без потери точности (string-based, не Number/Float).
*
* formatUnits("3000000000000000", 18) → "0.003"
* formatUnits("1500000", 6) → "1.5"
* formatUnits("123456", 0) → "123456"
*/
export function formatUnits(raw: string, decimals: number): string {
if (!/^-?\d+$/.test(raw)) return '0';
if (decimals === 0) return raw;
const negative = raw.startsWith('-');
const abs = negative ? raw.slice(1) : raw;
const padded = abs.padStart(decimals + 1, '0');
const whole = padded.slice(0, padded.length - decimals);
const frac = padded.slice(-decimals).replace(/0+$/, '');
const result = frac ? `${whole}.${frac}` : whole;
return negative ? `-${result}` : result;
}
function fmt(raw: string, decimals: number): FormattedAmount {
return { raw, formatted: formatUnits(raw, decimals), decimals };
}
function fmtTokens(
raw: Record<string, string>,
decimalsLookup: Record<string, number>,
): Record<string, FormattedAmount> {
const out: Record<string, FormattedAmount> = {};
for (const [sym, val] of Object.entries(raw)) {
out[sym] = fmt(val, decimalsLookup[sym] ?? 0);
}
return out;
}
export async function getBalance(chain: ChainCode, address: string): Promise<BalanceResult> {
const nativeDecimals = NATIVE_DECIMALS[chain];
switch (chain) {
case 'BTC':
return { chain, address, native: await btcBalance(address) };
return {
chain, address,
native: fmt(await btcBalance(address), nativeDecimals),
};
case 'TRX': {
const { trx, usdt } = await trxBalance(address);
return { chain, address, native: trx, tokens: { USDT: usdt } };
}
case 'BSC': {
const { native, tokens } = await evmBalance(BSC_RPC, address, [{ symbol: 'USDT', addr: USDT_BEP20 }]);
return { chain, address, native, tokens };
const { trx, tokens } = await trxBalance(address);
const decimalsMap = Object.fromEntries(getTrxTokens().map((t) => [t.symbol, t.decimals]));
return {
chain, address,
native: fmt(trx, nativeDecimals),
tokens: fmtTokens(tokens, decimalsMap),
};
}
case 'BSC':
case 'ETH': {
const { native, tokens } = await evmBalance(ETH_RPC, address, [{ symbol: 'USDT', addr: USDT_ERC20 }]);
return { chain, address, native, tokens };
const tokenList = getEvmTokens(chain);
const rpc = chain === 'BSC' ? BSC_RPC : ETH_RPC;
const { native, tokens } = await evmBalance(
rpc,
address,
tokenList.map((t) => ({ symbol: t.symbol, addr: t.contractAddress })),
);
const decimalsMap = Object.fromEntries(tokenList.map((t) => [t.symbol, t.decimals]));
return {
chain, address,
native: fmt(native, nativeDecimals),
tokens: fmtTokens(tokens, decimalsMap),
};
}
case 'SOL': {
const { native, tokens } = await solBalance(address);
const decimalsMap = Object.fromEntries(getSolTokens().map((t) => [t.symbol, t.decimals]));
return {
chain, address,
native: fmt(native, nativeDecimals),
tokens: fmtTokens(tokens, decimalsMap),
};
}
case 'SOL':
return { chain, address, native: await solBalance(address) };
}
}
@@ -63,29 +135,40 @@ async function btcBalance(address: string): Promise<string> {
return sat.toString();
}
async function trxBalance(address: string): Promise<{ trx: string; usdt: string }> {
async function trxBalance(address: string): Promise<{ trx: string; tokens: Record<string, string> }> {
const headers: Record<string, string> = { Accept: 'application/json' };
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
const accRes = await fetchJson(`${TRONGRID}/v1/accounts/${address}`, { headers });
const trx = accRes.data?.[0]?.balance ? String(accRes.data[0].balance) : '0';
// USDT TRC20 balance
const usdtRes = await fetchJson(`${TRONGRID}/wallet/triggerconstantcontract`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
owner_address: address,
contract_address: USDT_TRC20,
function_selector: 'balanceOf(address)',
parameter: tronAddressToHex(address).padStart(64, '0'),
visible: true,
}),
});
const usdtHex = usdtRes.constant_result?.[0];
const usdt = usdtHex && !/^0+$/.test(usdtHex) ? BigInt('0x' + usdtHex).toString() : '0';
// Parallel TRC20 balanceOf для всех зарегистрированных токенов
const tokens: Record<string, string> = {};
const addrHex = tronAddressToHex(address).padStart(64, '0');
return { trx, usdt };
await Promise.all(
getTrxTokens().map(async ({ symbol, contractAddress }) => {
try {
const res = await fetchJson(`${TRONGRID}/wallet/triggerconstantcontract`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
owner_address: address,
contract_address: contractAddress,
function_selector: 'balanceOf(address)',
parameter: addrHex,
visible: true,
}),
});
const hex = res.constant_result?.[0];
tokens[symbol] = hex && !/^0+$/.test(hex) ? BigInt('0x' + hex).toString() : '0';
} catch {
tokens[symbol] = '0';
}
}),
);
return { trx, tokens };
}
async function evmBalance(
@@ -112,8 +195,9 @@ async function evmBalance(
return { native: native.toString(), tokens: tokenBalances };
}
async function solBalance(address: string): Promise<string> {
const res = await fetchJson(SOL_RPC, {
async function solBalance(address: string): Promise<{ native: string; tokens: Record<string, string> }> {
// 1) Native SOL balance
const nativeRes = await fetchJson(SOL_RPC, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -123,7 +207,48 @@ async function solBalance(address: string): Promise<string> {
params: [address],
}),
});
return String(res.result?.value ?? 0);
const native = String(nativeRes.result?.value ?? 0);
// 2) Все SPL token accounts юзера одним запросом
const tokens: Record<string, string> = {};
const knownMints = new Map(getSolTokens().map((t) => [t.mint, t.symbol]));
// Сразу инициализируем все известные символы нулём (чтобы output был consistent)
for (const [, sym] of knownMints) tokens[sym] = '0';
try {
const splRes = await fetchJson(SOL_RPC, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'getTokenAccountsByOwner',
params: [
address,
{ programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' }, // SPL Token program
{ encoding: 'jsonParsed' },
],
}),
});
const accounts = splRes.result?.value || [];
for (const acc of accounts) {
const info = acc?.account?.data?.parsed?.info;
const mint = info?.mint;
const amount = info?.tokenAmount?.amount;
if (mint && amount && knownMints.has(mint)) {
const symbol = knownMints.get(mint)!;
// Суммируем если несколько token accounts для одного mint
const prev = BigInt(tokens[symbol] || '0');
tokens[symbol] = (prev + BigInt(amount)).toString();
}
}
} catch {
// SOL RPC недоступен — оставляем нули
}
return { native, tokens };
}
// ─────────────────────── TRANSACTIONS ───────────────────────

View File

@@ -14,16 +14,52 @@ import * as bip39 from 'bip39';
import { BIP32Factory } from 'bip32';
import * as ecc from 'tiny-secp256k1';
import * as bitcoin from 'bitcoinjs-lib';
import { Keypair, Connection, PublicKey, SystemProgram, Transaction } from '@solana/web3.js';
import { Keypair, Connection, PublicKey, SystemProgram, Transaction, ComputeBudgetProgram } from '@solana/web3.js';
import { derivePath } from 'ed25519-hd-key';
import { env } from '../config/env';
import { DERIVATION_PATHS, ethAddressToTron } from './wallet-generator.service';
import { getEvmFeeForTier, type FeeTier } from './gas-oracle.service';
import type { ChainCode } from '../lib/address-validators';
const bip32 = BIP32Factory(ecc);
const ETH_RPC = 'https://ethereum-rpc.publicnode.com';
const BSC_RPC = 'https://bsc-dataseed.binance.org';
// H29 — multiple RPC endpoints для failover. Если основной 5xx — переключаемся на следующий.
const ETH_RPCS = [
'https://ethereum-rpc.publicnode.com',
'https://eth.llamarpc.com',
'https://rpc.ankr.com/eth',
];
const BSC_RPCS = [
'https://bsc-dataseed.binance.org',
'https://bsc-dataseed1.binance.org',
'https://bsc-dataseed2.binance.org',
'https://bsc.publicnode.com',
];
// Backward-compat exports (для других модулей которые могут использовать)
const ETH_RPC = ETH_RPCS[0];
const BSC_RPC = BSC_RPCS[0];
/**
* Try RPC providers в order until one succeeds. Returns first-working StaticJsonRpcProvider.
*/
async function pickProvider(rpcs: string[], chainId: number): Promise<ethers.providers.StaticJsonRpcProvider> {
let lastErr: any;
for (const url of rpcs) {
const p = new ethers.providers.StaticJsonRpcProvider(url, chainId);
try {
// Quick aliveness check (3s timeout) — `eth_blockNumber` is cheapest read.
await Promise.race([
p.getBlockNumber(),
new Promise((_, reject) => setTimeout(() => reject(new Error('rpc_alive_timeout')), 3000)),
]);
return p;
} catch (err) {
lastErr = err;
}
}
throw new Error(`All ${rpcs.length} RPC endpoints failed: ${lastErr?.message || lastErr}`);
}
const SOL_RPC = 'https://api.mainnet-beta.solana.com';
const TRONGRID = 'https://api.trongrid.io';
const BLOCKSTREAM = 'https://blockstream.info/api';
@@ -46,6 +82,29 @@ export interface SendParams {
amount: string;
token?: string;
expectedFromAddress: string;
feeTier?: FeeTier;
// default 'normal'. Применяется для:
// ETH/BSC — eth_feeHistory p25/p50/p75 priority
// BTC — blockstream targets 144/6/1 блок
// TRX/SOL — игнорится (TRX = fixed fee_limit cap, SOL = no priority fee)
}
export interface RawEvmTx {
to: string;
data: string;
value: string;
chainId: number;
gas: string;
maxFeePerGas: string;
maxPriorityFeePerGas: string;
}
export interface RawEvmSignParams {
chain: 'ETH' | 'BSC';
mnemonic: string;
expectedFromAddress: string;
tx: RawEvmTx;
feeTier?: FeeTier; // если задан → override maxFee/maxPriority из tx актуальными из feeHistory
}
export async function signAndBroadcast(p: SendParams): Promise<{ txid: string }> {
@@ -58,6 +117,89 @@ export async function signAndBroadcast(p: SendParams): Promise<{ txid: string }>
}
}
/**
* Sign + broadcast ARBITRARY EVM transaction (used for Relay/Swap unsigned tx from /execute).
*
* ⚠️ SECURITY: подписывает arbitrary `to` + arbitrary `data` (calldata) — UI compromise
* может подсунуть `approve(attacker, MAX)` или drain-call. Для test/dev это OK,
* для production надо whitelist'ить `to` против known Relay routers ИЛИ требовать
* Relay attestation (signed quote) от upstream.
*
* Capы: maxFeePerGas, chainId matches chain.
*/
export async function signAndBroadcastRawEvm(p: RawEvmSignParams): Promise<{ txid: string }> {
const expectedChainId = p.chain === 'ETH' ? 1 : 56;
if (p.tx.chainId !== expectedChainId) {
throw new Error(`chainId mismatch: tx.chainId=${p.tx.chainId} but chain=${p.chain} (${expectedChainId})`);
}
const rpcs = p.chain === 'ETH' ? ETH_RPCS : BSC_RPCS;
const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.ETH);
assertAddressMatch(wallet.address, p.expectedFromAddress, p.chain);
// H29 — RPC failover
const provider = await pickProvider(rpcs, expectedChainId);
const signer = wallet.connect(provider);
// Cap fees против rogue/poisoned quote с insanely-high maxFeePerGas.
const capWei = ethers.utils.parseUnits(String(MAX_GAS_PRICE_GWEI), 'gwei');
// H21 — explicit FeeTier validation (защита от internal callers с empty string)
if (p.feeTier !== undefined && p.feeTier !== 'slow' && p.feeTier !== 'normal' && p.feeTier !== 'fast') {
throw new Error(`Invalid feeTier ${JSON.stringify(p.feeTier)} (expected slow|normal|fast)`);
}
// Если клиент задал feeTier — override fees из Relay quote актуальными из feeHistory.
// Иначе используем maxFeePerGas из quote как-есть (legacy путь).
let maxFeePerGas: ethers.BigNumber;
let maxPriorityFeePerGas: ethers.BigNumber;
if (p.feeTier) {
const fee = await getEvmFeeForTier(p.chain, p.feeTier);
maxFeePerGas = ethers.BigNumber.from(fee.maxFeePerGas);
maxPriorityFeePerGas = ethers.BigNumber.from(fee.maxPriorityFeePerGas);
} else {
maxFeePerGas = ethers.BigNumber.from(p.tx.maxFeePerGas);
maxPriorityFeePerGas = ethers.BigNumber.from(p.tx.maxPriorityFeePerGas);
}
// H26 — оба ограничения: maxFee≤cap И priority≤maxFee (иначе invalid EIP-1559)
if (maxFeePerGas.gt(capWei)) {
throw new Error(`maxFeePerGas exceeds cap (${MAX_GAS_PRICE_GWEI} gwei)`);
}
if (maxPriorityFeePerGas.gt(maxFeePerGas)) {
throw new Error('maxPriorityFeePerGas must be ≤ maxFeePerGas (invalid EIP-1559 fee config)');
}
if (maxPriorityFeePerGas.gt(capWei)) {
throw new Error(`maxPriorityFeePerGas exceeds cap (${MAX_GAS_PRICE_GWEI} gwei)`);
}
const nonce = await provider.getTransactionCount(wallet.address, 'pending');
const txRequest: ethers.providers.TransactionRequest = {
to: p.tx.to,
data: p.tx.data,
value: ethers.BigNumber.from(p.tx.value || '0'),
chainId: expectedChainId,
nonce,
gasLimit: ethers.BigNumber.from(p.tx.gas),
type: 2,
maxFeePerGas,
maxPriorityFeePerGas,
};
// H25 — explicit timeout (без него slow RPC stalls Express worker).
const sent = await withTimeout(signer.sendTransaction(txRequest), HTTP_TIMEOUT_MS, 'EVM raw broadcast timed out');
return { txid: sent.hash };
}
/** H25 helper — Promise.race vs timeout. */
function withTimeout<T>(p: Promise<T>, ms: number, msg: string): Promise<T> {
return Promise.race([
p,
new Promise<T>((_, reject) => setTimeout(() => reject(new Error(msg)), ms)),
]);
}
function assertAddressMatch(derived: string, expected: string, chain: ChainCode): void {
const norm = (s: string) =>
chain === 'ETH' || chain === 'BSC' ? s.toLowerCase() : s;
@@ -71,25 +213,41 @@ function assertAddressMatch(derived: string, expected: string, chain: ChainCode)
async function sendEvm(p: SendParams, rpc: string, chainId: number, usdtAddr: string): Promise<{ txid: string }> {
const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.ETH);
assertAddressMatch(wallet.address, p.expectedFromAddress, p.chain);
const provider = new ethers.providers.StaticJsonRpcProvider(rpc, chainId);
// H29 — RPC failover (выбираем working RPC из списка для chain)
const rpcs = chainId === 1 ? ETH_RPCS : BSC_RPCS;
const provider = await pickProvider(rpcs, chainId);
const signer = wallet.connect(provider);
const feeData = await provider.getFeeData();
// Gas из feeHistory (slow/normal/fast tier) — заменяет старый provider.getFeeData() который
// на BSC возвращал inflated values (~1.5 gwei вместо реальных ~0.05-0.1).
const evmChain = p.chain === 'ETH' || p.chain === 'BSC' ? p.chain : null;
if (!evmChain) {
throw new Error(`sendEvm called with non-EVM chain ${p.chain}`);
}
// H21 — explicit tier validation (empty string defensive guard)
if (p.feeTier !== undefined && p.feeTier !== 'slow' && p.feeTier !== 'normal' && p.feeTier !== 'fast') {
throw new Error(`Invalid feeTier ${JSON.stringify(p.feeTier)} (expected slow|normal|fast)`);
}
const tier: FeeTier = p.feeTier ?? 'normal';
const fee = await getEvmFeeForTier(evmChain, tier);
const capWei = ethers.utils.parseUnits(String(MAX_GAS_PRICE_GWEI), 'gwei');
const effectiveGasPrice = feeData.maxFeePerGas ?? feeData.gasPrice;
if (!effectiveGasPrice || effectiveGasPrice.gt(capWei)) {
throw new Error(`Gas price exceeds cap (${MAX_GAS_PRICE_GWEI} gwei)`);
const maxFeePerGas = ethers.BigNumber.from(fee.maxFeePerGas);
const maxPriorityFeePerGas = ethers.BigNumber.from(fee.maxPriorityFeePerGas);
// H26 — both caps + priority ≤ maxFee invariant
if (maxFeePerGas.gt(capWei)) {
throw new Error(`maxFeePerGas exceeds cap (${MAX_GAS_PRICE_GWEI} gwei)`);
}
if (maxPriorityFeePerGas.gt(maxFeePerGas)) {
throw new Error('maxPriorityFeePerGas must be ≤ maxFeePerGas (invalid EIP-1559)');
}
if (maxPriorityFeePerGas.gt(capWei)) {
throw new Error(`maxPriorityFeePerGas exceeds cap (${MAX_GAS_PRICE_GWEI} gwei)`);
}
const nonce = await provider.getTransactionCount(wallet.address, 'pending');
// Принудительно EIP-1559 (type 2). ETH и BSC оба поддерживают с 2021.
// Если feeData не вернул maxFeePerGas — fallback но всё равно type 2 с computed cap.
const maxFeePerGas = feeData.maxFeePerGas ?? effectiveGasPrice;
const maxPriorityFeePerGas = feeData.maxPriorityFeePerGas ?? ethers.BigNumber.from(0);
if (maxFeePerGas.gt(capWei)) {
throw new Error(`maxFeePerGas exceeds cap (${MAX_GAS_PRICE_GWEI} gwei)`);
}
const effectiveGasPrice = maxFeePerGas; // for balance estimation
const feeFields: Partial<ethers.providers.TransactionRequest> = {
type: 2,
maxFeePerGas,
@@ -117,17 +275,31 @@ async function sendEvm(p: SendParams, rpc: string, chainId: number, usdtAddr: st
throw new Error('Insufficient token balance');
}
const nativeBal = await provider.getBalance(wallet.address);
const estGas = ethers.BigNumber.from(80000);
const data = iface.encodeFunctionData('transfer', [p.to, p.amount]);
// H10 — actual estimateGas + 20% safety. Hardcoded 80000 was too low for cold
// storage slots (first transfer to recipient SSTORE costs 81-90k → OOG burn).
let estGas: ethers.BigNumber;
try {
const estimated = await provider.estimateGas({ from: wallet.address, to: usdtAddr, data, value: 0 });
estGas = estimated.mul(120).div(100); // +20%
// Floor 60k (minimum realistic), ceiling 200k (sanity)
const minGas = ethers.BigNumber.from(60000);
const maxGas = ethers.BigNumber.from(200000);
if (estGas.lt(minGas)) estGas = minGas;
if (estGas.gt(maxGas)) estGas = maxGas;
} catch {
estGas = ethers.BigNumber.from(100000); // fallback если RPC estimateGas fails
}
if (nativeBal.lt(effectiveGasPrice.mul(estGas))) {
throw new Error('Insufficient native balance for gas');
}
const data = iface.encodeFunctionData('transfer', [p.to, p.amount]);
tx = { to: usdtAddr, data, value: 0, chainId, nonce, gasLimit: estGas, ...feeFields };
} else {
throw new Error(`Token ${p.token} not supported on chainId ${chainId}`);
}
const sent = await signer.sendTransaction(tx);
// H25 — explicit timeout, иначе slow RPC stalls Express worker indefinitely
const sent = await withTimeout(signer.sendTransaction(tx), HTTP_TIMEOUT_MS, 'EVM send broadcast timed out');
return { txid: sent.hash };
}
@@ -146,8 +318,41 @@ async function sendSol(p: SendParams): Promise<{ txid: string }> {
const keypair = Keypair.fromSeed(key);
assertAddressMatch(keypair.publicKey.toBase58(), p.expectedFromAddress, 'SOL');
const conn = new Connection(SOL_RPC, 'confirmed');
// C10 — lamports precision: @solana/web3.js converts BigInt → Number internally
// (u64 layout). Above 2^53 lamports = silent truncation. Reject early.
const lamports = BigInt(p.amount);
const MAX_SAFE_LAMPORTS = BigInt(Number.MAX_SAFE_INTEGER);
if (lamports > MAX_SAFE_LAMPORTS) {
throw new Error(`SOL amount ${p.amount} lamports exceeds Number precision (max ${MAX_SAFE_LAMPORTS}); split into multiple sends`);
}
if (lamports <= 0n) {
throw new Error('SOL amount must be positive');
}
// H41 — singleton Connection (per-call new() leaks WebSocket subscriptions)
const conn = getSolConnection();
const toPk = new PublicKey(p.to);
// C11 — rent-exempt minimum check. Если recipient — fresh account и amount меньше
// rent-exempt минимума, tx fails ПОСЛЕ broadcast (5000 lamports fee burned, no transfer).
// Pre-check сохраняет fee + user-facing error.
try {
const accountInfo = await conn.getAccountInfo(toPk);
if (accountInfo === null) {
const rentMin = BigInt(await conn.getMinimumBalanceForRentExemption(0));
if (lamports < rentMin) {
throw new Error(`SOL recipient is fresh account; amount ${lamports} lamports < rent-exempt minimum ${rentMin}. Send at least ${rentMin} lamports to create account.`);
}
}
} catch (preErr: any) {
// Network error checking — proceed (broadcast will surface real error)
if (!preErr.message?.includes('rent-exempt')) {
// только network/RPC failures, не наш own throw
} else {
throw preErr;
}
}
const { blockhash, lastValidBlockHeight } = await conn.getLatestBlockhash();
const tx = new Transaction({
@@ -155,26 +360,50 @@ async function sendSol(p: SendParams): Promise<{ txid: string }> {
blockhash,
lastValidBlockHeight,
});
// H40 — compute-unit price для priority fee (tiers slow/normal/fast).
// Без этого tx может dropped в congestion. Default 'normal' = 1_000 microLamports.
const tier = p.feeTier ?? 'normal';
const cuPrice = tier === 'fast' ? 10_000n : tier === 'slow' ? 0n : 1_000n;
if (cuPrice > 0n) {
tx.add(ComputeBudgetProgram.setComputeUnitPrice({ microLamports: cuPrice }));
}
tx.add(
SystemProgram.transfer({
fromPubkey: keypair.publicKey,
toPubkey: toPk,
lamports: BigInt(p.amount),
lamports,
}),
);
tx.sign(keypair);
const sig = await conn.sendRawTransaction(tx.serialize());
// H37 — distinguished error categories
try {
await conn.confirmTransaction({ signature: sig, blockhash, lastValidBlockHeight }, 'confirmed');
} catch (err: any) {
throw new Error(`SOL tx submitted but confirmation timed out (sig=${sig}): ${err.message}`);
const name = err?.name || '';
if (name === 'TransactionExpiredBlockheightExceededError') {
throw new Error(`SOL tx EXPIRED (blockhash invalid, tx will never confirm). sig=${sig}`);
}
if (name === 'TransactionExpiredTimeoutError') {
throw new Error(`SOL tx unconfirmed after timeout (may still land). sig=${sig}`);
}
throw new Error(`SOL confirm error (${name}): ${err.message}. sig=${sig}`);
}
return { txid: sig };
}
// H41 — singleton SOL Connection. Reusing avoids WebSocket leak under load.
let _solConnection: Connection | null = null;
function getSolConnection(): Connection {
if (!_solConnection) {
_solConnection = new Connection(SOL_RPC, 'confirmed');
}
return _solConnection;
}
// ─── BITCOIN ───
async function sendBtc(p: SendParams): Promise<{ txid: string }> {
@@ -199,8 +428,22 @@ async function sendBtc(p: SendParams): Promise<{ txid: string }> {
]);
const utxos = ((utxosRes as any[]) || []).filter((u) => u.status?.confirmed);
const feeMap = feesRes as Record<string, number>;
// Floor 15 sat/vB — иначе tx может реджектиться по min-relay-fee при congestion.
const feeRate = Math.max(Math.ceil(feeMap['1'] ?? feeMap['3'] ?? feeMap['6'] ?? 15), 15);
// Tier-based BTC fee target:
// slow = '144' блоков (~1 сутки) — самый дешёвый
// normal = '6' блоков (~1 час) — DEFAULT, ~5-10× дешевле чем '1'
// fast = '1' блок (~10 мин) — premium
// Floor 2 sat/vB — current bitcoin min-relay-fee на большинстве нод (1 на дефолтных, 2 на mempool.space).
// Раньше floor был 15 sat/vB и target '1' — переплачивали в среднем ×10.
const btcTier = p.feeTier ?? 'normal';
const targetByTier: Record<string, string> = { slow: '144', normal: '6', fast: '1' };
const target = targetByTier[btcTier];
// C15 — feeMap defensive parsing: если значение не number (RPC bug / malicious resp),
// Math.ceil(NaN)=NaN → BigInt(NaN) throws → производственный 500. Coerce + sanity.
const rawCandidate = feeMap[target] ?? feeMap['6'] ?? feeMap['3'] ?? feeMap['1'];
const rawNum = typeof rawCandidate === 'number' && Number.isFinite(rawCandidate) && rawCandidate > 0
? rawCandidate
: 2;
const feeRate = Math.max(Math.ceil(rawNum), 2);
const amountSat = BigInt(p.amount);
if (amountSat > BigInt(Number.MAX_SAFE_INTEGER)) {
@@ -210,6 +453,17 @@ async function sendBtc(p: SendParams): Promise<{ txid: string }> {
utxos.sort((a, b) => b.value - a.value);
const psbt = new bitcoin.Psbt({ network });
// H34 — anti-fee-sniping: locktime=tipHeight предотвращает miner re-org для steal этого fee
// (стандарт Bitcoin Core / Electrum). Best-effort; если /blocks/tip/height down, оставляем 0.
try {
const tipHeightRes = await fetchJson(`${BLOCKSTREAM}/blocks/tip/height`);
const tip = typeof tipHeightRes === 'number' ? tipHeightRes : Number(tipHeightRes);
if (Number.isFinite(tip) && tip > 0) {
psbt.setLocktime(tip);
}
} catch {
// proceed with locktime=0 — degradation, не блокирует send
}
let totalIn = 0n;
const feeFor = (ins: number, outs: number) =>
@@ -230,16 +484,32 @@ async function sendBtc(p: SendParams): Promise<{ txid: string }> {
psbt.addInput({
hash: u.txid,
index: u.vout,
// C12 — RBF (BIP125): sequence ≤ 0xfffffffd позволяет bump fee если tx застрял.
// Без этого tx с низкой fee может dropped из mempool через ~14 days = permanent fund lock.
sequence: 0xfffffffd,
witnessUtxo: { script: payment.output, value: u.value },
});
}
psbt.addOutput({ address: p.to, value: Number(amountSat) });
// C13 — change dust handling. Если change ≤ 294 sat (P2WPKH dust threshold), он
// силtently сжигается в miner fee (без warning). Reject explicitly, чтобы юзер
// знал что надо изменить сумму. Иначе user может терять ~$0.20 per send invisibly.
const fee = feeFor(selectedUtxos.length, 2);
const change = totalIn - amountSat - fee;
if (change > 294n) {
const DUST_THRESHOLD = 294n;
if (change < 0n) {
throw new Error(`BTC insufficient balance (totalIn=${totalIn} sat, amount=${amountSat}, fee=${fee})`);
}
if (change === 0n) {
// Точно равно — no change output needed
} else if (change > DUST_THRESHOLD) {
psbt.addOutput({ address: fromAddr, value: Number(change) });
} else {
// change > 0 но ≤ dust — нельзя добавить как output (network reject)
// и не нужно burning в fee silently. Reject с действенной подсказкой.
throw new Error(`BTC change ${change} sat is below dust threshold (${DUST_THRESHOLD}). Reduce amount by ${change} sat to consolidate, or increase amount to spend full UTXO.`);
}
for (let i = 0; i < selectedUtxos.length; i++) {
@@ -283,6 +553,17 @@ async function sendTrx(p: SendParams): Promise<{ txid: string }> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
// C4 — TRX amount precision: Number(p.amount) silently rounds выше 2^53 sun (~9B TRX).
// BigInt assertion гарантирует что мы не silently dropped digits.
const amountBig = BigInt(p.amount);
const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER); // 9_007_199_254_740_991
if (amountBig > MAX_SAFE_BIGINT) {
throw new Error(`TRX amount ${p.amount} exceeds Number precision (max ${MAX_SAFE_BIGINT} sun = ~9B TRX); split into multiple sends`);
}
if (amountBig <= 0n) {
throw new Error('TRX amount must be positive');
}
let txBody: any;
if (!p.token) {
const built = await fetchJson(`${TRONGRID}/wallet/createtransaction`, {
@@ -291,7 +572,7 @@ async function sendTrx(p: SendParams): Promise<{ txid: string }> {
body: JSON.stringify({
owner_address: fromTronAddr,
to_address: p.to,
amount: Number(p.amount),
amount: Number(amountBig), // safe — checked ≤ MAX_SAFE_INTEGER выше
visible: true,
}),
});
@@ -308,7 +589,9 @@ async function sendTrx(p: SendParams): Promise<{ txid: string }> {
contract_address: USDT_TRC20,
function_selector: 'transfer(address,uint256)',
parameter: param,
fee_limit: 100_000_000,
// 30 TRX cap — реальный USDT transfer обычно жжёт 15-30 TRX без Energy,
// ~0 с Energy. Раньше было 100 TRX — это cap, не actual fee, но завышен.
fee_limit: 30_000_000,
call_value: 0,
visible: true,
}),
@@ -400,22 +683,38 @@ async function sendTrx(p: SendParams): Promise<{ txid: string }> {
// Sign verified txID
const sk = new ethers.utils.SigningKey(wallet.privateKey);
const sig = sk.signDigest('0x' + txBody.txID);
// H42 — recoveryParam должен быть 0 или 1 строго. Undefined fallback на 0 даёт
// подпись recoverable к НЕПРАВИЛЬНОМУ public key → tx подписана но broadcast reject.
if (sig.recoveryParam !== 0 && sig.recoveryParam !== 1) {
throw new Error(`TRX signing: invalid recoveryParam ${sig.recoveryParam} (expected 0 or 1)`);
}
const sigHex =
sig.r.slice(2) +
sig.s.slice(2) +
(sig.recoveryParam ?? 0).toString(16).padStart(2, '0');
sig.recoveryParam.toString(16).padStart(2, '0');
txBody.signature = [sigHex];
// H45 — clean payload to broadcast (не пересылаем upstream-injected лишние поля).
// Это defense-in-depth: компрометированный TronGrid не сможет пропихнуть extra fields
// через broadcast endpoint обратно к самому себе.
const cleanTxBody = {
txID: txBody.txID,
raw_data: txBody.raw_data,
raw_data_hex: txBody.raw_data_hex,
signature: [sigHex],
visible: true,
};
const broadcast = await fetchJson(`${TRONGRID}/wallet/broadcasttransaction`, {
method: 'POST',
headers,
body: JSON.stringify(txBody),
body: JSON.stringify(cleanTxBody),
});
if (!broadcast?.result) {
// H44 — include `code` для operators (DUP_TRANSACTION_ERROR, NOT_ENOUGH_EFFECTIVE_CONNECTION, etc.)
const msg = (broadcast?.message && Buffer.from(broadcast.message, 'hex').toString()) || 'unknown';
throw new Error(`TRX broadcast failed: ${msg.slice(0, 200)}`);
const code = broadcast?.code || 'NO_CODE';
throw new Error(`TRX broadcast failed [${code}]: ${msg.slice(0, 200)}`);
}
return { txid: txBody.txID };
@@ -425,15 +724,65 @@ async function sendTrx(p: SendParams): Promise<{ txid: string }> {
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
/**
* Decode TRON base58check address → 20-byte hex (without 0x41 prefix, without checksum).
*
* C5 fix: правильный base58check decoder с проверкой:
* - length 25 bytes after decode
* - prefix byte = 0x41 (TRON mainnet)
* - SHA256(SHA256(payload))[0:4] === checksum bytes (matches TRON spec)
*
* Если любая проверка failed → throws. Это критично потому что результат используется
* в MITM защите (parameter bit-perfect compare); garbage из этого helper'а silently
* disable защиту.
*/
function tronAddressToHex(address: string): string {
if (typeof address !== 'string' || address.length === 0) {
throw new Error('Invalid TRON address: empty');
}
// Step 1: base58 decode
let num = 0n;
for (const ch of address) {
const i = BASE58_ALPHABET.indexOf(ch);
if (i === -1) throw new Error('Invalid base58 character in TRON address');
num = num * 58n + BigInt(i);
}
const hex = num.toString(16).padStart(50, '0');
return hex.slice(2, 42);
// Step 2: convert BigInt to bytes — account для leading '1's = leading zero bytes
let hex = num.toString(16);
if (hex.length % 2 !== 0) hex = '0' + hex;
let bytes = Buffer.from(hex, 'hex');
// Count leading '1's в base58 = leading zero bytes
let leadingOnes = 0;
for (const ch of address) {
if (ch === '1') leadingOnes++;
else break;
}
if (leadingOnes > 0) {
bytes = Buffer.concat([Buffer.alloc(leadingOnes), bytes]);
}
// Step 3: TRON address = 25 bytes (1 prefix + 20 addr + 4 checksum)
if (bytes.length !== 25) {
throw new Error(`Invalid TRON address length: expected 25 bytes, got ${bytes.length}`);
}
if (bytes[0] !== 0x41) {
throw new Error(`Invalid TRON address prefix: expected 0x41, got 0x${bytes[0].toString(16)}`);
}
// Step 4: verify SHA256d checksum
const payload = bytes.subarray(0, 21);
const expectedChecksum = bytes.subarray(21, 25);
const h1 = createHash('sha256').update(payload).digest();
const h2 = createHash('sha256').update(h1).digest();
if (!h2.subarray(0, 4).equals(expectedChecksum)) {
throw new Error('Invalid TRON address checksum');
}
// Step 5: return 20-byte hex (без 0x41 prefix)
return bytes.subarray(1, 21).toString('hex');
}
async function fetchJson(url: string, init?: RequestInit): Promise<any> {