/** * Amount unit utilities. * * API контракт исторически — `amount: string` в smallest-units (wei/lamports/sun/satoshi). * Этот файл добавляет ОПЦИОНАЛЬНЫЙ парсинг `amountHuman: "0.01"` через token decimals из * `token-registry`. Старое поле `amount` остаётся 100% backward-compatible. * * Все вычисления — BigInt-based, без float'ов (для finance: precision critical). * * Используется в: * - sendFromChain (body: {amount | amountHuman, token?}) * - quoteSwap / swapOnChain legacy (body: {from/inputMint, amount | amountHuman}) * - relay-proxy /quote preprocessing (body: {originCurrency, amount | amountHuman}) * - cost-estimate endpoints (body: same) */ import type { ChainCode } from './address-validators'; import { getTokenInfo, getEvmTokens, TRX_TOKENS, SOL_TOKENS, } from './token-registry'; /** * Native decimals per chain. Дублирует `NATIVE_DECIMALS` из `wallet-ops.service.ts` * чтобы избежать circular dep (wallet-ops импортирует address-validators которое * импортирует этот файл косвенно). */ export const NATIVE_DECIMALS: Record = { ETH: 18, BSC: 18, BTC: 8, TRX: 6, SOL: 9, }; const SOL_WRAPPED_NATIVE_MINT = 'So11111111111111111111111111111111111111112'; /** * Парсит "0.01" → "10000000" (для SOL 9 decimals) через BigInt. * * Правила: * - "10" + dec=6 → "10000000" (integer, без точки) * - "0.01" + dec=6 → "10000" (1 + 4 zeros) * - "1.5" + dec=6 → "1500000" * - "0.000001" + dec=6 → "1" * - "0.1234567" + dec=6 → "123456" (truncate — не round; consistent с frontend parseAmount) * - "0" + → throw (zero amount = error per existing isValidAmount) * - "-1" / "1e3" + → throw * - "0.1" + dec=0 → throw "no fractional digits for 0-decimal token" */ export function parseHumanAmount(human: string, decimals: number): string { if (typeof human !== 'string') { throw new Error('amountHuman must be a string'); } const s = human.trim(); if (!s) throw new Error('amountHuman is empty'); // Defense-in-depth: длинная строка (например `"1" + "0".repeat(10000)`) форсирует // O(n) парсинг + BigInt round-trip. 64KB body-limit (express.json) — общий gate; // этот check — specific для нового парсера. Legit amount'ы укладываются в 80 chars // (36-decimal fractional + integer + dot). Атакующий не сможет drain CPU. if (s.length > 80) { throw new Error('amountHuman too long (max 80 chars)'); } if (!/^\d+(\.\d+)?$/.test(s)) { throw new Error(`amountHuman invalid format "${s}" (expected "1" or "0.01")`); } if (!Number.isInteger(decimals) || decimals < 0 || decimals > 36) { throw new Error(`Invalid decimals ${decimals}`); } const [whole, frac = ''] = s.split('.'); if (decimals === 0 && frac.length > 0) { throw new Error(`This token has 0 decimals, use integer amount (got "${s}")`); } // Truncate (not round) лишние цифры дробной части. const fracTrunc = frac.slice(0, decimals); const padded = fracTrunc.padEnd(decimals, '0'); // Strip leading zeros чтобы получился чистый BigInt-friendly string. const result = (whole + padded).replace(/^0+/, '') || '0'; if (result === '0') { throw new Error(`amountHuman "${s}" evaluates to 0 smallest units`); } return result; } /** * Inverse of parseHumanAmount. "10000000" + dec=6 → "10". * Используется для логирования / formatted output в cost-estimate. */ export function formatSmallestUnits(smallest: string, decimals: number): string { if (typeof smallest !== 'string' || !/^\d+$/.test(smallest)) { return '0'; } if (decimals === 0) return smallest; if (smallest.length <= decimals) { const padded = smallest.padStart(decimals + 1, '0'); const whole = padded.slice(0, padded.length - decimals); const frac = padded.slice(padded.length - decimals).replace(/0+$/, ''); return frac ? `${whole}.${frac}` : whole; } const whole = smallest.slice(0, smallest.length - decimals); const frac = smallest.slice(smallest.length - decimals).replace(/0+$/, ''); return frac ? `${whole}.${frac}` : whole; } /** * Decimals для send endpoint'а. * - token задан → token-registry lookup (case-insensitive) * - token пуст → native decimals */ export function resolveSendDecimals(chain: ChainCode, token?: string | null): number { if (!token) return NATIVE_DECIMALS[chain]; const info = getTokenInfo(chain, token); if (!info) { throw new Error(`Unknown token "${token}" on ${chain} — cannot resolve decimals`); } return info.decimals; } /** * Decimals для BSC/TRX swap (`from` symbol). * - "BNB" / "TRX" → native (18 / 6) * - "USDT" / etc → registry lookup */ export function resolveSwapDecimalsBscTrx(chain: 'BSC' | 'TRX', symbol: string): number { const upper = symbol.toUpperCase(); if (upper === chain) return NATIVE_DECIMALS[chain]; // BSC→BNB через chain code не сработает, // но BNB→18 и TRX→6 совпадают с NATIVE_DECIMALS, поэтому fallback ниже работает. if (chain === 'BSC' && upper === 'BNB') return NATIVE_DECIMALS.BSC; if (chain === 'TRX' && upper === 'TRX') return NATIVE_DECIMALS.TRX; const info = getTokenInfo(chain, upper); if (!info) { throw new Error(`Unknown ${chain} token "${symbol}" — cannot resolve decimals`); } return info.decimals; } /** * Decimals для SOL swap (`inputMint`). * Wrapped SOL = 9; иначе SOL_TOKENS lookup по mint. */ export function resolveSwapDecimalsSol(mint: string): number { if (mint === SOL_WRAPPED_NATIVE_MINT) return NATIVE_DECIMALS.SOL; const t = SOL_TOKENS.find((x) => x.mint === mint); if (!t) { throw new Error(`Unknown SOL mint "${mint}" — cannot resolve decimals`); } return t.decimals; } /** * Decimals по contract address (для Relay /quote где body содержит * `originCurrency: "0x..."` вместо symbol). Returns null если не найден — * caller решает: 400 vs fallback. */ export function getDecimalsByContract(chain: ChainCode, contractOrMint: string): number | null { const addr = contractOrMint.trim(); if (!addr) return null; // Native sentinels (Relay использует 0xeeee... для native EVM). if (chain === 'SOL' && addr === SOL_WRAPPED_NATIVE_MINT) return NATIVE_DECIMALS.SOL; if ((chain === 'ETH' || chain === 'BSC') && /^0xee+/i.test(addr) || addr === '0x0000000000000000000000000000000000000000') { return NATIVE_DECIMALS[chain]; } if (chain === 'TRX' && (addr === 'TRX' || addr === '0x0000000000000000000000000000000000000000')) { return NATIVE_DECIMALS.TRX; } if (chain === 'ETH' || chain === 'BSC') { const lower = addr.toLowerCase(); const t = getEvmTokens(chain).find((x) => x.contractAddress.toLowerCase() === lower); return t?.decimals ?? null; } if (chain === 'TRX') { const t = TRX_TOKENS.find((x) => x.contractAddress === addr); return t?.decimals ?? null; } if (chain === 'SOL') { const t = SOL_TOKENS.find((x) => x.mint === addr); return t?.decimals ?? null; } return null; } /** * Main dispatcher. Body содержит ровно ОДНО поле из {amount, amountHuman}. * - оба пусты → throw * - оба заданы → throw "use either … not both" (поведение из плана) * - amount задан → возврат as-is (legacy backward-compat) * - amountHuman задан → parseHumanAmount(value, decimals) * * Caller передаёт `decimals` (уже resolved через resolveSendDecimals / resolveSwapDecimals*). */ export function resolveAmountFromBody( body: { amount?: unknown; amountHuman?: unknown }, decimals: number, ): string { const hasAmount = body?.amount !== undefined && body?.amount !== null && body?.amount !== ''; const hasAmountHuman = body?.amountHuman !== undefined && body?.amountHuman !== null && body?.amountHuman !== ''; if (hasAmount && hasAmountHuman) { throw new Error('Use either "amount" (smallest units) OR "amountHuman" (human form), not both'); } if (!hasAmount && !hasAmountHuman) { throw new Error('Either "amount" or "amountHuman" is required'); } if (hasAmount) { const a = String(body.amount); if (!/^\d+$/.test(a) || BigInt(a) <= 0n) { throw new Error('amount must be positive integer string (smallest units)'); } return a; } // amountHuman path return parseHumanAmount(String(body.amountHuman), decimals); }