feat: security audit fixes
This commit is contained in:
@@ -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)) {
|
||||
|
||||
125
apps/api/src/services/gas-oracle.service.ts
Normal file
125
apps/api/src/services/gas-oracle.service.ts
Normal 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];
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ───────────────────────
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user