This commit is contained in:
ZOMBIIIIIII
2026-05-13 00:37:00 +03:00
parent 7d9907be9c
commit 3a890b79ee
5 changed files with 230 additions and 43 deletions

View File

@@ -3,6 +3,7 @@
* 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';
@@ -177,10 +178,11 @@ async function evmBalance(
tokens: { symbol: string; addr: string }[],
): Promise<{ native: string; tokens: Record<string, string> }> {
const provider = new ethers.providers.StaticJsonRpcProvider(rpc);
const native = await withTimeout(provider.getBalance(address), TIMEOUT_MS, 'EVM balance timeout');
// 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.all(
await Promise.allSettled(
tokens.map(async ({ symbol, addr }) => {
try {
const c = new ethers.Contract(addr, ERC20_ABI, provider);
@@ -192,7 +194,8 @@ async function evmBalance(
}),
);
return { native: native.toString(), tokens: tokenBalances };
const native = await nativeP;
return { native, tokens: tokenBalances };
}
async function solBalance(address: string): Promise<{ native: string; tokens: Record<string, string> }> {
@@ -316,18 +319,42 @@ async function scanTransactions(
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 inSelf = tx.vin.some((v: any) => v.prevout?.scriptpubkey_address === address);
const outSelf = tx.vout.some((v: any) => v.scriptpubkey_address === address);
const direction: TxItem['direction'] = inSelf && outSelf ? 'self' : inSelf ? 'out' : 'in';
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(
tx.vout
.filter((v: any) => (direction === 'in' ? v.scriptpubkey_address === address : v.scriptpubkey_address !== address))
.reduce((s: bigint, v: any) => s + BigInt(v.value), 0n),
),
amount: String(amountSat),
};
});
}
@@ -358,7 +385,7 @@ async function trxTransactions(address: string, limit: number): Promise<TxItem[]
}
async function solTransactions(address: string, limit: number): Promise<TxItem[]> {
const res = await fetchJson(SOL_RPC, {
const sigsRes = await fetchJson(SOL_RPC, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -368,11 +395,56 @@ async function solTransactions(address: string, limit: number): Promise<TxItem[]
params: [address, { limit }],
}),
});
return ((res.result as any[]) || []).map((sig) => ({
txid: sig.signature,
timestamp: sig.blockTime ?? null,
direction: 'self' as const, // без deep parsing — направление неизвестно
}));
// 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 ───────────────────────
@@ -584,10 +656,52 @@ function tronAddressToHex(address: string): string {
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 {
// Формат TronGrid: 41xxxx (20 bytes after prefix). Это base58check.
// Для простоты возвращаем как есть hex (можно улучшить на full base58check если нужно).
return hex;
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> {