223 lines
8.8 KiB
TypeScript
223 lines
8.8 KiB
TypeScript
/**
|
||
* 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);
|
||
}
|