Files
cryptowallet/apps/api/src/services/wallet-ops.service.ts
2026-05-14 16:39:56 +03:00

934 lines
32 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.
/**
* Wallet read-only operations across chains: balance + tx history.
* Server-side signing now lives in `wallet-signer.service.ts` (custodial).
*/
import { ethers } from 'ethers';
import { createHash } from 'crypto';
import { env } from '../config/env';
import { getEvmTokens, getTrxTokens, getSolTokens } from '../lib/token-registry';
import { getPricesBySymbols } from './price-oracle.service';
import { logger } from '../lib/logger';
import { getRedis } from '../config/redis';
export type ChainCode = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC';
const TIMEOUT_MS = 15_000;
// ── External APIs ──
const BLOCKSTREAM = 'https://blockstream.info/api';
const TRONGRID = 'https://api.trongrid.io';
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 ERC20_ABI = [
'function balanceOf(address owner) view returns (uint256)',
'function transfer(address to, uint256 amount) returns (bool)',
'function decimals() view returns (uint8)',
];
// ─────────────────────── BALANCE ───────────────────────
export interface FormattedAmount {
raw: string; // smallest units (string-encoded BigInt — без потери точности)
formatted: string; // human-readable, e.g. "0.003"
decimals: number; // decimals chain'а/токена
/**
* USD price per 1 целая единица (e.g. $67432.12 за 1 BTC).
* `null` если symbol не в реестре с `coingeckoId` или upstream price oracle недоступен.
* Источник: CoinGecko free API, cache 5 мин в KeyDB.
*/
usdPrice: number | null;
/**
* Совокупная USD-стоимость holding'а: `Number(formatted) × usdPrice`.
* Округлено до 8 знаков. `null` если `usdPrice === null` или вычисление дало `Infinity/NaN`.
*/
usdValue: number | null;
}
export interface BalanceResult {
chain: ChainCode;
address: string;
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,
usdPrice: null, // populated post-build via populatePrices()
usdValue: null,
};
}
/**
* Округление USD значения до 8 знаков после запятой (избавляемся от float-мусора).
* S10 — `Infinity`/`NaN` → `null`.
*/
function roundUsd(n: number): number | null {
if (!Number.isFinite(n)) return null;
return Math.round(n * 1e8) / 1e8;
}
/**
* Мутирует BalanceResult, проставляя `usdPrice` и `usdValue` каждому FormattedAmount.
* Никогда не throws — если price oracle упал, поля остаются `null`.
*/
async function populatePrices(result: BalanceResult): Promise<void> {
try {
const pairs: { chain: ChainCode; symbol: string }[] = [
{ chain: result.chain, symbol: result.chain }, // native (BTC/ETH/BSC/TRX/SOL)
];
if (result.tokens) {
for (const sym of Object.keys(result.tokens)) {
pairs.push({ chain: result.chain, symbol: sym });
}
}
const prices = await getPricesBySymbols(pairs);
// Native
const nativeKey = `${result.chain}:${result.chain}`;
const nativePrice = prices.get(nativeKey) ?? null;
result.native.usdPrice = nativePrice;
if (nativePrice != null) {
const formattedNum = Number(result.native.formatted);
result.native.usdValue = Number.isFinite(formattedNum)
? roundUsd(formattedNum * nativePrice)
: null;
}
// Tokens
if (result.tokens) {
for (const [sym, amt] of Object.entries(result.tokens)) {
const key = `${result.chain}:${sym}`;
const p = prices.get(key) ?? null;
amt.usdPrice = p;
if (p != null) {
const fNum = Number(amt.formatted);
amt.usdValue = Number.isFinite(fNum) ? roundUsd(fNum * p) : null;
}
}
}
} catch (err: any) {
// Не валим запрос — balance вернётся без цен.
logger.warn(`populatePrices(${result.chain}) failed: ${err?.message || 'unknown'}`);
}
}
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];
let result: BalanceResult;
switch (chain) {
case 'BTC':
result = {
chain, address,
native: fmt(await btcBalance(address), nativeDecimals),
};
break;
case 'TRX': {
const { trx, tokens } = await trxBalance(address);
const decimalsMap = Object.fromEntries(getTrxTokens().map((t) => [t.symbol, t.decimals]));
result = {
chain, address,
native: fmt(trx, nativeDecimals),
tokens: fmtTokens(tokens, decimalsMap),
};
break;
}
case 'BSC':
case 'ETH': {
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]));
result = {
chain, address,
native: fmt(native, nativeDecimals),
tokens: fmtTokens(tokens, decimalsMap),
};
break;
}
case 'SOL': {
const { native, tokens } = await solBalance(address);
const decimalsMap = Object.fromEntries(getSolTokens().map((t) => [t.symbol, t.decimals]));
result = {
chain, address,
native: fmt(native, nativeDecimals),
tokens: fmtTokens(tokens, decimalsMap),
};
break;
}
}
// Populate USD prices (graceful — never throws, fields stay null on failure).
await populatePrices(result);
return result;
}
// ─────────────────────── PORTFOLIO (aggregate всех 5 сетей) ─────────
export interface ChainPortfolio extends BalanceResult {
/** Сумма usdValue по native + всем токенам chain'а. `null` если все цены недоступны. */
totalUsd: number | null;
/** true = данные из KeyDB cache (RPC chain'а упал в этом запросе). */
stale: boolean;
/** Unix ms когда данные были обновлены (fresh fetch). */
lastUpdated: number;
/** Причина почему stale (только если stale=true). */
error?: string;
}
export interface PortfolioResult {
/** Grand sum по всем сетям. Округлено до 8 знаков. */
totalUsd: number;
/** true если хотя бы одна сеть в stale/error состоянии. */
hasErrors: boolean;
/** Per-chain breakdown. `null` для chain'а если ни fresh ни cache нет. */
perChain: Record<ChainCode, ChainPortfolio | null>;
}
const PORTFOLIO_CACHE_TTL_SEC = 3600; // 1 час stale-fallback
const PORTFOLIO_CACHE_PREFIX = 'portfolio:'; // ключ: portfolio:{userId}:{chain}
function computeChainTotalUsd(b: BalanceResult): number | null {
let total = 0;
let anyValid = false;
const add = (amt: FormattedAmount | undefined): void => {
const v = amt?.usdValue;
if (typeof v === 'number' && Number.isFinite(v)) {
total += v;
anyValid = true;
}
};
add(b.native);
for (const a of Object.values(b.tokens ?? {})) add(a);
return anyValid ? roundUsd(total) : null;
}
/**
* Aggregate balance по всем 5 сетям. Параллельно дёргает `getBalance(chain, address)` для каждой,
* сохраняет успешные ответы в KeyDB (TTL 1 час). При сбое RPC отдельной сети — возвращает
* последний кэшированный snapshot с пометкой `stale:true`, чтобы UI никогда не показывал 0.
*
* Не throws — даже при полной недоступности всех сетей вернёт totalUsd=0 + hasErrors=true.
*/
export async function getPortfolio(
userId: string,
addresses: Record<ChainCode, string>,
): Promise<PortfolioResult> {
const chains: ChainCode[] = ['ETH', 'BSC', 'BTC', 'TRX', 'SOL'];
const settled = await Promise.allSettled(
chains.map((c) => {
const addr = addresses[c];
if (!addr) return Promise.reject(new Error(`No ${c} address for user`));
return getBalance(c, addr);
}),
);
let redis: ReturnType<typeof getRedis> | null = null;
try { redis = getRedis(); } catch { redis = null; }
const perChain: Record<string, ChainPortfolio | null> = {};
let totalUsd = 0;
let hasErrors = false;
const now = Date.now();
for (let i = 0; i < chains.length; i++) {
const chain = chains[i];
const res = settled[i];
const cacheKey = `${PORTFOLIO_CACHE_PREFIX}${userId}:${chain}`;
if (res.status === 'fulfilled') {
const balance = res.value;
const chainTotal = computeChainTotalUsd(balance);
const entry: ChainPortfolio = {
...balance,
totalUsd: chainTotal,
stale: false,
lastUpdated: now,
};
perChain[chain] = entry;
if (typeof chainTotal === 'number') totalUsd += chainTotal;
// Cache fire-and-forget
if (redis) {
redis
.set(cacheKey, JSON.stringify(entry), 'EX', PORTFOLIO_CACHE_TTL_SEC)
.catch((err) => logger.warn(`portfolio cache write ${chain} failed: ${err?.message}`));
}
} else {
hasErrors = true;
const reason = String((res.reason as any)?.message || 'unknown');
// Попробуем cached fallback
let cached: ChainPortfolio | null = null;
if (redis) {
try {
const raw = await redis.get(cacheKey);
if (raw) cached = JSON.parse(raw) as ChainPortfolio;
} catch (err: any) {
logger.warn(`portfolio cache read ${chain} failed: ${err?.message}`);
}
}
if (cached) {
perChain[chain] = { ...cached, stale: true, error: reason };
if (typeof cached.totalUsd === 'number') totalUsd += cached.totalUsd;
} else {
perChain[chain] = null;
}
}
}
return {
totalUsd: roundUsd(totalUsd) ?? 0,
hasErrors,
perChain: perChain as Record<ChainCode, ChainPortfolio | null>,
};
}
async function btcBalance(address: string): Promise<string> {
const res = await fetchJson(`${BLOCKSTREAM}/address/${address}`);
const stats = res.chain_stats;
const sat = BigInt(stats.funded_txo_sum) - BigInt(stats.spent_txo_sum);
return sat.toString();
}
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';
// Parallel TRC20 balanceOf для всех зарегистрированных токенов
const tokens: Record<string, string> = {};
const addrHex = tronAddressToHex(address).padStart(64, '0');
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(
rpc: string,
address: string,
tokens: { symbol: string; addr: string }[],
): Promise<{ native: string; tokens: Record<string, string> }> {
const provider = new ethers.providers.StaticJsonRpcProvider(rpc);
// H52 — Promise.allSettled: одна сбоящая RPC subcall не валит весь endpoint в 502
const nativeP = withTimeout(provider.getBalance(address), TIMEOUT_MS, 'EVM balance timeout').then(b => b.toString()).catch(() => '0');
const tokenBalances: Record<string, string> = {};
await Promise.allSettled(
tokens.map(async ({ symbol, addr }) => {
try {
const c = new ethers.Contract(addr, ERC20_ABI, provider);
const bal: ethers.BigNumber = await withTimeout(c.balanceOf(address), TIMEOUT_MS, `${symbol} balance timeout`);
tokenBalances[symbol] = bal.toString();
} catch {
tokenBalances[symbol] = '0';
}
}),
);
const native = await nativeP;
return { native, tokens: tokenBalances };
}
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({
jsonrpc: '2.0',
id: 1,
method: 'getBalance',
params: [address],
}),
});
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 ───────────────────────
export interface TxItem {
txid: string;
timestamp: number | null; // unix seconds
direction: 'in' | 'out' | 'self';
amount?: string;
token?: string;
to?: string;
from?: string;
}
export async function getTransactions(chain: ChainCode, address: string, limit: number): Promise<TxItem[]> {
switch (chain) {
case 'BTC':
return btcTransactions(address, limit);
case 'TRX':
return trxTransactions(address, limit);
case 'BSC':
return scanTransactions('https://api.bscscan.com/api', env.bscscanApiKey, address, limit);
case 'ETH':
return scanTransactions('https://api.etherscan.io/api', env.etherscanApiKey, address, limit);
case 'SOL':
return solTransactions(address, limit);
}
}
async function scanTransactions(
apiBase: string,
apiKey: string | null,
address: string,
limit: number,
): Promise<TxItem[]> {
if (!apiKey) return [];
const url = new URL(apiBase);
url.searchParams.set('module', 'account');
url.searchParams.set('action', 'txlist');
url.searchParams.set('address', address);
url.searchParams.set('startblock', '0');
url.searchParams.set('endblock', '99999999');
url.searchParams.set('page', '1');
url.searchParams.set('offset', String(Math.min(limit, 100)));
url.searchParams.set('sort', 'desc');
url.searchParams.set('apikey', apiKey);
const res = await fetchJson(url.toString());
if (res.status !== '1' || !Array.isArray(res.result)) return [];
return (res.result as any[]).slice(0, limit).map((tx) => {
const isOut = String(tx.from).toLowerCase() === address.toLowerCase();
return {
txid: tx.hash,
timestamp: tx.timeStamp ? parseInt(tx.timeStamp, 10) : null,
direction: (isOut ? 'out' : 'in') as TxItem['direction'],
amount: tx.value || undefined,
from: tx.from,
to: tx.to,
};
});
}
async function btcTransactions(address: string, limit: number): Promise<TxItem[]> {
const txs = await fetchJson(`${BLOCKSTREAM}/address/${address}/txs`);
return (txs as any[]).slice(0, limit).map((tx) => {
const vin = Array.isArray(tx.vin) ? tx.vin : [];
const vout = Array.isArray(tx.vout) ? tx.vout : [];
const inSelf = vin.some((v: any) => v.prevout?.scriptpubkey_address === address);
const allOutsSelf = vout.length > 0 && vout.every((v: any) => v.scriptpubkey_address === address);
const anyOutsExternal = vout.some((v: any) => v.scriptpubkey_address && v.scriptpubkey_address !== address);
// H49 — корректная direction logic:
// self = consolidation/tx where ВСЕ outs идут обратно к нам (rare; обычно нет change)
// out = мы spend'им (inSelf=true) И есть external recipient
// in = мы получаем (НЕ inSelf, есть out к нам)
let direction: TxItem['direction'];
if (inSelf && allOutsSelf) {
direction = 'self';
} else if (inSelf && anyOutsExternal) {
direction = 'out';
} else {
direction = 'in';
}
// amount: для 'out' — sum external outs; для 'in' — sum outs к нам; для 'self' — 0
let amountSat = 0n;
if (direction === 'in') {
amountSat = vout
.filter((v: any) => v.scriptpubkey_address === address)
.reduce((s: bigint, v: any) => s + BigInt(v.value || 0), 0n);
} else if (direction === 'out') {
amountSat = vout
.filter((v: any) => v.scriptpubkey_address && v.scriptpubkey_address !== address)
.reduce((s: bigint, v: any) => s + BigInt(v.value || 0), 0n);
}
return {
txid: tx.txid,
timestamp: tx.status?.block_time ?? null,
direction,
amount: String(amountSat),
};
});
}
async function trxTransactions(address: string, limit: number): Promise<TxItem[]> {
const headers: Record<string, string> = { Accept: 'application/json' };
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
const res = await fetchJson(
`${TRONGRID}/v1/accounts/${address}/transactions?limit=${limit}`,
{ headers },
);
return ((res.data as any[]) || []).slice(0, limit).map((tx) => {
const contract = tx.raw_data?.contract?.[0];
const value = contract?.parameter?.value;
const fromAddr = value?.owner_address ? hexToTron(value.owner_address) : '';
const toAddr = value?.to_address ? hexToTron(value.to_address) : '';
const isOut = fromAddr === address;
return {
txid: tx.txID,
timestamp: tx.block_timestamp ? Math.floor(tx.block_timestamp / 1000) : null,
direction: (isOut ? 'out' : 'in') as TxItem['direction'],
amount: value?.amount ? String(value.amount) : undefined,
from: fromAddr || undefined,
to: toAddr || undefined,
};
});
}
async function solTransactions(address: string, limit: number): Promise<TxItem[]> {
const sigsRes = await fetchJson(SOL_RPC, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'getSignaturesForAddress',
params: [address, { limit }],
}),
});
// H50 — H18: filter out failed txs + deep-parse selected (limit small concurrency).
const allSigs = ((sigsRes.result as any[]) || []).filter((s) => s.err === null);
// Fetch tx details для balance deltas — batch parallel но небольшим limit'ом
const results: TxItem[] = [];
for (const sig of allSigs.slice(0, limit)) {
try {
const txRes = await fetchJson(SOL_RPC, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'getTransaction',
params: [sig.signature, { maxSupportedTransactionVersion: 0, encoding: 'json' }],
}),
});
const tx = txRes.result;
const accountKeys = tx?.transaction?.message?.accountKeys || [];
const idx = accountKeys.indexOf(address);
const pre = tx?.meta?.preBalances?.[idx];
const post = tx?.meta?.postBalances?.[idx];
let direction: TxItem['direction'] = 'self';
let amount: string | undefined;
if (typeof pre === 'number' && typeof post === 'number') {
const delta = post - pre;
if (delta < 0) {
direction = 'out';
amount = String(-delta);
} else if (delta > 0) {
direction = 'in';
amount = String(delta);
}
}
results.push({
txid: sig.signature,
timestamp: sig.blockTime ?? null,
direction,
amount,
});
} catch {
// Если getTransaction fails — fallback на minimal entry
results.push({
txid: sig.signature,
timestamp: sig.blockTime ?? null,
direction: 'self',
});
}
}
return results;
}
// ─────────────────────── HELPERS ───────────────────────
// (buildSend + chain-specific builders deleted — server signs custodially via wallet-signer.service.ts)
/* deleted-marker-begin
export interface BuildSendParams {
chain: ChainCode;
from: string;
to: string;
amount: string;
token?: string;
}
export type UnsignedTx =
| { kind: 'btc'; from: string; to: string; amountSat: string; utxos: any[]; feeRateSatPerVb: number }
| { kind: 'tron'; transaction: any }
| { kind: 'evm'; to: string; data: string; value: string; chainId: number; gasLimit?: string }
| { kind: 'solana'; instructions: any; recentBlockhash: string };
export async function buildSend(p: BuildSendParams): Promise<UnsignedTx> {
switch (p.chain) {
case 'BTC':
return buildBtcSend(p);
case 'TRX':
return buildTrxSend(p);
case 'BSC':
return buildEvmSend(p, BSC_RPC, 56, USDT_BEP20);
case 'ETH':
return buildEvmSend(p, ETH_RPC, 1, USDT_ERC20);
case 'SOL':
return buildSolSend(p);
}
}
async function buildBtcSend(p: BuildSendParams): Promise<UnsignedTx> {
if (p.token) throw new Error('BTC tokens not supported');
const utxos = await fetchJson(`${BLOCKSTREAM}/address/${p.from}/utxo`);
const fees = await fetchJson(`${BLOCKSTREAM}/fee-estimates`);
const confirmed = ((utxos as any[]) || []).filter((u) => u.status?.confirmed);
return {
kind: 'btc',
from: p.from,
to: p.to,
amountSat: p.amount,
utxos: confirmed.map((u) => ({ txid: u.txid, vout: u.vout, value: u.value })),
feeRateSatPerVb: Math.ceil((fees as any)['3'] ?? (fees as any)['6'] ?? 5),
};
}
async function buildTrxSend(p: BuildSendParams): Promise<UnsignedTx> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
if (!p.token) {
// Native TRX
const res = await fetchJson(`${TRONGRID}/wallet/createtransaction`, {
method: 'POST',
headers,
body: JSON.stringify({ owner_address: p.from, to_address: p.to, amount: Number(p.amount), visible: true }),
});
return { kind: 'tron', transaction: res };
}
if (p.token.toUpperCase() === 'USDT') {
// TRC20 USDT
const param = tronAddressToHex(p.to).padStart(64, '0') + BigInt(p.amount).toString(16).padStart(64, '0');
const res = await fetchJson(`${TRONGRID}/wallet/triggersmartcontract`, {
method: 'POST',
headers,
body: JSON.stringify({
owner_address: p.from,
contract_address: USDT_TRC20,
function_selector: 'transfer(address,uint256)',
parameter: param,
fee_limit: 100_000_000,
call_value: 0,
visible: true,
}),
});
return { kind: 'tron', transaction: res };
}
throw new Error(`Token ${p.token} not supported on TRX`);
}
async function buildEvmSend(p: BuildSendParams, rpc: string, chainId: number, usdtAddr: string): Promise<UnsignedTx> {
if (!ethers.utils.isAddress(p.to)) throw new Error('Invalid recipient address');
if (!p.token) {
return { kind: 'evm', to: p.to, data: '0x', value: ethers.BigNumber.from(p.amount).toHexString(), chainId };
}
if (p.token.toUpperCase() === 'USDT') {
const iface = new ethers.utils.Interface(ERC20_ABI);
const data = iface.encodeFunctionData('transfer', [p.to, p.amount]);
return { kind: 'evm', to: usdtAddr, data, value: '0x0', chainId };
}
throw new Error(`Token ${p.token} not supported on ${chainId === 56 ? 'BSC' : 'ETH'}`);
}
async function buildSolSend(p: BuildSendParams): Promise<UnsignedTx> {
const {
Connection,
PublicKey,
SystemProgram,
Transaction,
} = await import('@solana/web3.js');
const conn = new Connection(SOL_RPC, 'confirmed');
let fromPk: InstanceType<typeof PublicKey>;
let toPk: InstanceType<typeof PublicKey>;
try {
fromPk = new PublicKey(p.from);
toPk = new PublicKey(p.to);
} catch {
throw new Error('Invalid Solana address');
}
const { blockhash, lastValidBlockHeight } = await conn.getLatestBlockhash();
const tx = new Transaction({
feePayer: fromPk,
blockhash,
lastValidBlockHeight,
});
if (!p.token) {
// Native SOL transfer
tx.add(
SystemProgram.transfer({
fromPubkey: fromPk,
toPubkey: toPk,
lamports: BigInt(p.amount),
}),
);
} else {
// SPL token transfer (manual instruction — не тянем @solana/spl-token)
const TOKEN_PROGRAM_ID = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
const ASSOCIATED_TOKEN_PROGRAM_ID = new PublicKey('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL');
const mint = solMintFor(p.token);
if (!mint) throw new Error(`Unsupported SOL token: ${p.token}`);
const fromAta = await deriveAta(new PublicKey(mint), fromPk, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID);
const toAta = await deriveAta(new PublicKey(mint), toPk, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID);
// Transfer instruction (instruction tag = 3 для SPL Token Transfer)
const data = Buffer.alloc(9);
data.writeUInt8(3, 0);
data.writeBigUInt64LE(BigInt(p.amount), 1);
tx.add({
programId: TOKEN_PROGRAM_ID,
keys: [
{ pubkey: fromAta, isSigner: false, isWritable: true },
{ pubkey: toAta, isSigner: false, isWritable: true },
{ pubkey: fromPk, isSigner: true, isWritable: false },
],
data,
});
}
// Сериализуем сообщение (без подписей) для клиента
const serialized = tx.serialize({ requireAllSignatures: false, verifySignatures: false });
return {
kind: 'solana',
instructions: serialized.toString('base64'),
recentBlockhash: blockhash,
};
}
function solMintFor(symbol: string): string | null {
const map: Record<string, string> = {
USDT: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB',
USDC: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
};
return map[symbol] ?? null;
}
async function deriveAta(
mint: any,
owner: any,
tokenProgramId: any,
associatedTokenProgramId: any,
): Promise<any> {
const { PublicKey } = await import('@solana/web3.js');
const [pda] = await PublicKey.findProgramAddress(
[owner.toBuffer(), tokenProgramId.toBuffer(), mint.toBuffer()],
associatedTokenProgramId,
);
return pda;
}
deleted-marker-end */
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
function tronAddressToHex(address: string): string {
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); // 20 bytes без префикса 0x41
}
/**
* Encode TRON address: 20-byte hex (без 0x41 prefix) → base58check 'T...' string.
* H51 — фронтенд получал raw hex `41...` instead of readable `T...`. Fix.
*/
function hexToTron(hex: string): string {
if (!hex) return '';
// Принимаем hex с или без префикса 0x41
let bytesHex = hex.toLowerCase();
if (bytesHex.startsWith('0x')) bytesHex = bytesHex.slice(2);
// Если уже 21 byte (с prefix) — оставляем; если 20 — добавляем 0x41
if (bytesHex.length === 40) {
bytesHex = '41' + bytesHex;
} else if (bytesHex.length !== 42) {
// Unknown length — fail-safe return raw input для backward compat
return hex;
}
if (!/^[0-9a-f]+$/.test(bytesHex)) return hex;
const payload = Buffer.from(bytesHex, 'hex');
// SHA256d checksum (4 bytes)
const h1 = createHash('sha256').update(payload).digest();
const h2 = createHash('sha256').update(h1).digest();
const fullBytes = Buffer.concat([payload, h2.subarray(0, 4)]);
// base58 encode
return base58Encode(fullBytes);
}
const BASE58_ALPHABET_OPS = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
function base58Encode(bytes: Buffer): string {
let num = 0n;
for (const b of bytes) {
num = (num << 8n) + BigInt(b);
}
let s = '';
while (num > 0n) {
s = BASE58_ALPHABET_OPS[Number(num % 58n)] + s;
num /= 58n;
}
// Leading zero bytes → leading '1's
for (const b of bytes) {
if (b === 0) s = '1' + s;
else break;
}
return s;
}
async function fetchJson(url: string, init?: RequestInit): Promise<any> {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const res = await fetch(url, { ...init, signal: controller.signal });
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Upstream ${res.status}: ${body.slice(0, 200)}`);
}
return await res.json();
} finally {
clearTimeout(t);
}
}
function withTimeout<T>(promise: Promise<T>, ms: number, msg: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const t = setTimeout(() => reject(new Error(msg)), ms);
promise.then(
(v) => { clearTimeout(t); resolve(v); },
(e) => { clearTimeout(t); reject(e); },
);
});
}