934 lines
32 KiB
TypeScript
934 lines
32 KiB
TypeScript
/**
|
||
* 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); },
|
||
);
|
||
});
|
||
}
|