/** * 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'; 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'а/токена } export interface BalanceResult { chain: ChainCode; address: string; native: FormattedAmount; tokens?: Record; } // Native decimals per chain const NATIVE_DECIMALS: Record = { 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 }; } function fmtTokens( raw: Record, decimalsLookup: Record, ): Record { const out: Record = {}; 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 { const nativeDecimals = NATIVE_DECIMALS[chain]; switch (chain) { case 'BTC': return { chain, address, native: fmt(await btcBalance(address), nativeDecimals), }; case 'TRX': { const { trx, tokens } = await trxBalance(address); const decimalsMap = Object.fromEntries(getTrxTokens().map((t) => [t.symbol, t.decimals])); return { chain, address, native: fmt(trx, nativeDecimals), tokens: fmtTokens(tokens, decimalsMap), }; } 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])); return { chain, address, native: fmt(native, nativeDecimals), tokens: fmtTokens(tokens, decimalsMap), }; } case 'SOL': { const { native, tokens } = await solBalance(address); const decimalsMap = Object.fromEntries(getSolTokens().map((t) => [t.symbol, t.decimals])); return { chain, address, native: fmt(native, nativeDecimals), tokens: fmtTokens(tokens, decimalsMap), }; } } } async function btcBalance(address: string): Promise { 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 }> { const headers: Record = { 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 = {}; 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 }> { 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 = {}; 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 }> { // 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 = {}; 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 { 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 { 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 { 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 { const headers: Record = { 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 { 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 { 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 { 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 { const headers: Record = { '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 { 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 { const { Connection, PublicKey, SystemProgram, Transaction, } = await import('@solana/web3.js'); const conn = new Connection(SOL_RPC, 'confirmed'); let fromPk: InstanceType; let toPk: InstanceType; 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 = { USDT: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', USDC: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', }; return map[symbol] ?? null; } async function deriveAta( mint: any, owner: any, tokenProgramId: any, associatedTokenProgramId: any, ): Promise { 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 { 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(promise: Promise, ms: number, msg: string): Promise { return new Promise((resolve, reject) => { const t = setTimeout(() => reject(new Error(msg)), ms); promise.then( (v) => { clearTimeout(t); resolve(v); }, (e) => { clearTimeout(t); reject(e); }, ); }); }