Files
cryptowallet/apps/api/src/lib/amount-units.ts
ZOMBIIIIIII 22059373a4 initjnjnj
2026-05-14 19:52:56 +03:00

223 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<ChainCode, number> = {
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);
}