Repository Public
1// TRON adapter: two independent java-tron HTTP APIs (/wallet = head state, /walletsolidity = irreversible state).2// Everything that feeds the signature (reference block, account existence, balances, chain fee parameters, energy)3// is read from both sources and must agree, otherwise prepare() fails closed.4import {5 buildTronTx, formatUnits, getAsset, tronAddressBytes, tronAddressFromBytes, TRON_ACTIVATION_FEE, TRON_BANDWIDTH_PRICE, TRON_CREATE_ACCOUNT_BANDWIDTH_FEE,6 TRON_MAX_FEE_LIMIT, type Intent, type NetworkDef,7} from '@spw/core';8import { UserError } from '../config.ts';9import { hostOf, http, type Adapter, type Deposit, type Health, type Prepared, type Tracked } from './rpc.ts';1011const TRANSFER_TOPIC = 'ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef';12/** Lifetime of the signed transaction from the moment it is prepared (the protocol allows < 24 h). */13const TX_LIFETIME_MS = 240_000;14const APPROVAL_TTL_SEC = 120;15/** Tolerated distance between our clock and the chain's latest irreversible block (which normally trails by ~1 minute). */16const MAX_CLOCK_DRIFT_MS = 5 * 60_000;17/** Safety margin on the simulated energy: an exhausted fee_limit burns the whole limit and moves nothing. */18const ENERGY_MARGIN_NUM = 5n, ENERGY_MARGIN_DEN = 4n;19const ZERO_OWNER = 'T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb'; // 0x41 || 00…00, used as the caller of read-only metadata calls2021/** Integers beyond 2^53 (balances in sun can get there) are kept exact as bigint. */22const parseJson = (text: string): any => JSON.parse(text, ((_k: string, v: unknown, ctx?: { source?: string }) =>23 (typeof v === 'number' && !Number.isSafeInteger(v) && ctx?.source && /^-?\d+$/.test(ctx.source) ? BigInt(ctx.source) : v)) as never);24const big = (v: unknown): bigint => (v === undefined || v === null ? 0n : BigInt(v as number | bigint | string));25const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));26const word = (addr: string) => Buffer.from(tronAddressBytes(addr).subarray(1)).toString('hex').padStart(64, '0');2728/** Keyless public endpoints are rate limited (TronGrid: 3 requests/s, then a 5 s suspension): one request at a time per host, spaced. */29const queues = new Map<string, Promise<unknown>>();30function throttled<T>(base: string, fn: () => Promise<T>): Promise<T> {31 const gap = /trongrid/.test(base) ? 380 : 40;32 const run = (queues.get(base) ?? Promise.resolve()).then(fn);33 queues.set(base, run.then(() => sleep(gap), () => sleep(gap)));34 return run;35}3637interface Src { url: string; post<T = any>(path: string, body?: unknown): Promise<T>; get<T = any>(path: string): Promise<T> }38function source(url: string): Src {39 const call = (path: string, init: RequestInit) => throttled(url, async () => {40 let r = await http(url + path, init);41 if (r.status === 429) { await sleep(5300); r = await http(url + path, init); }42 if (!r.ok) throw new Error(`tron http ${r.status}`);43 const j = parseJson(await r.text());44 if (j && typeof j === 'object' && typeof j.Error === 'string') throw new Error(`tron node error: ${j.Error.slice(0, 120)}`);45 return j;46 });47 return {48 url,49 post: (path, body = {}) => call(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }),50 get: (path) => call(path, { method: 'GET', headers: { accept: 'application/json' } }),51 };52}5354interface Account { exists: boolean; balance: bigint; contract: boolean }55interface Resources { freeNet: bigint; stakedNet: bigint; energy: bigint }56interface BlockRef { number: number; id: string; timestamp: number }57interface ConstantCall { ok: boolean; message: string; result: string; energy: bigint }5859const account = async (s: Src, addr: string, solidity = false): Promise<Account> => {60 const a = await s.post(`/${solidity ? 'walletsolidity' : 'wallet'}/getaccount`, { address: addr, visible: true });61 if (!a || typeof a !== 'object') throw new Error('bad getaccount answer');62 if (a.address === undefined) return { exists: false, balance: 0n, contract: false };63 if (a.address !== addr) throw new Error('getaccount answered for another address');64 return { exists: true, balance: big(a.balance), contract: a.type === 'Contract' };65};66const resources = async (s: Src, addr: string): Promise<Resources> => {67 const r = await s.post('/wallet/getaccountresource', { address: addr, visible: true });68 const left = (limit: unknown, used: unknown) => { const v = big(limit) - big(used); return v > 0n ? v : 0n; };69 return { freeNet: left(r.freeNetLimit, r.freeNetUsed), stakedNet: left(r.NetLimit, r.NetUsed), energy: left(r.EnergyLimit, r.EnergyUsed) };70};71const blockRef = (b: any): BlockRef => {72 const number = Number(b?.block_header?.raw_data?.number ?? 0), id = String(b?.blockID ?? ''), timestamp = Number(b?.block_header?.raw_data?.timestamp ?? 0);73 if (!/^[0-9a-f]{64}$/.test(id) || parseInt(id.slice(0, 16), 16) !== number) throw new Error('bad block answer');74 return { number, id, timestamp };75};76const solidHead = async (s: Src) => blockRef(await s.post('/walletsolidity/getnowblock'));77const blockAt = async (s: Src, num: number) => blockRef(await s.post('/wallet/getblockbynum', { num }));78/** Read-only contract execution against head state. A revert is reported in `ok`/`message`, never thrown. */79const constantCall = async (s: Src, owner: string, contract: string, selector: string, parameter: string): Promise<ConstantCall> => {80 const r = await s.post('/wallet/triggerconstantcontract', { owner_address: owner, contract_address: contract, function_selector: selector, parameter, visible: true });81 if (r?.result?.result !== true) throw new Error(`constant call rejected: ${String(r?.result?.message ?? r?.result?.code ?? 'no result').slice(0, 100)}`);82 const failed = !!r.result.message || r.transaction?.ret?.[0]?.ret === 'FAILED';83 return { ok: !failed, message: String(r.result.message ?? ''), result: String(r.constant_result?.[0] ?? ''), energy: big(r.energy_used) };84};85const PARAM_KEYS = ['getTransactionFee', 'getEnergyFee', 'getCreateAccountFee', 'getCreateNewAccountFeeInSystemContract', 'getCreateNewAccountBandwidthRate', 'getMaxFeeLimit'] as const;86type ChainParams = Record<(typeof PARAM_KEYS)[number], bigint>;87const chainParams = async (s: Src): Promise<ChainParams> => {88 const r = await s.post('/wallet/getchainparameters');89 const m = new Map<string, unknown>((r.chainParameter ?? []).map((p: { key: string; value?: unknown }) => [p.key, p.value]));90 return Object.fromEntries(PARAM_KEYS.map((k) => { if (!m.has(k)) throw new Error(`chain parameter ${k} missing`); return [k, big(m.get(k))]; })) as ChainParams;91};9293export function tronAdapter(n: NetworkDef): Adapter {94 const sources = n.rpc.map(source);95 const any = async <T>(f: (s: Src) => Promise<T>): Promise<T> => { let last: unknown; for (const s of sources) { try { return await f(s); } catch (e) { last = e; } } throw last; };96 /** first source that knows the object (a source that fails or answers "unknown" is skipped) */97 const firstHit = async <T>(f: (s: Src) => Promise<T | null>): Promise<T | null> => {98 let failures = 0, last: unknown;99 for (const s of sources) { try { const v = await f(s); if (v !== null) return v; } catch (e) { failures += 1; last = e; } }100 if (failures === sources.length) throw last;101 return null;102 };103 /** every source must answer */104 const all = async <T>(what: string, f: (s: Src) => Promise<T>): Promise<T[]> => {105 if (sources.length < 2) throw new UserError('rpc_degraded', `${n.name} needs two independent data sources; transfer refused`, 503);106 const rs = await Promise.allSettled(sources.map(f));107 if (rs.some((r) => r.status === 'rejected')) throw new UserError('rpc_degraded', `${n.name} data source unavailable (${what}); transfer refused`, 503);108 return rs.map((r) => (r as PromiseFulfilledResult<T>).value);109 };110 /** every source must answer and give the same value */111 const agree = async <T>(what: string, f: (s: Src) => Promise<T>): Promise<T> => {112 const vs = await all(what, f);113 const key = (v: T) => JSON.stringify(v, (_k, x) => (typeof x === 'bigint' ? x.toString() : x));114 for (const v of vs) if (key(v) !== key(vs[0]!)) throw new UserError('rpc_disagreement', `${n.name} data sources disagree (${what}); transfer refused`, 503);115 return vs[0]!;116 };117 let identity = false;118 const disabled = new Set<string>();119 let tokensChecked = false;120 const tokenBalance = async (s: Src, token: string, owner: string): Promise<bigint> => {121 const c = await constantCall(s, owner, token, 'balanceOf(address)', word(owner));122 if (!c.ok || !/^[0-9a-f]{64}$/.test(c.result)) throw new Error('balanceOf failed');123 return BigInt('0x' + c.result);124 };125 /** the contract itself must report the pinned decimals and symbol, on every source that answers (at least one) */126 async function verifyTokens(): Promise<void> {127 for (const t of n.tokens) {128 const checks = await Promise.allSettled(sources.map(async (s) => {129 const d = await constantCall(s, ZERO_OWNER, t.id, 'decimals()', ''), y = await constantCall(s, ZERO_OWNER, t.id, 'symbol()', '');130 if (!d.ok || !y.ok || !/^[0-9a-f]{64}$/.test(d.result) || y.result.length < 192) return false;131 const len = Number(BigInt('0x' + y.result.slice(64, 128)));132 const sym = Buffer.from(y.result.slice(128, 128 + len * 2), 'hex').toString('utf8');133 return BigInt('0x' + d.result) === BigInt(t.decimals) && sym === (t.onchainSymbol ?? t.symbol);134 }));135 const answers = checks.flatMap((c) => (c.status === 'fulfilled' ? [c.value] : []));136 if (answers.length === 0) throw new Error('token check unavailable');137 if (answers.every(Boolean)) disabled.delete(t.id);138 else { disabled.add(t.id); console.error(`[tron] token ${t.symbol} ${t.id} failed on-chain decimals/symbol check — disabled`); }139 }140 tokensChecked = true;141 }142 const genesisOk = async (s: Src) => (await blockAt(s, 0)).id === n.chainRef;143144 return {145 async health(): Promise<Health> {146 const endpoints = await Promise.all(sources.map(async (s) => {147 const t = Date.now();148 try {149 const h = blockRef(await s.post('/wallet/getnowblock')).number;150 if (!identity && !(await genesisOk(s))) throw new Error('genesis');151 return { url: hostOf(s.url), ok: true, ms: Date.now() - t, h };152 } catch { return { url: hostOf(s.url), ok: false, ms: null, h: null as number | null }; }153 }));154 const ok = endpoints.every((e) => e.ok) && endpoints.length >= 2;155 if (ok) identity = true;156 if (identity && !tokensChecked) await verifyTokens().catch(() => {});157 return { network: n.id, ok: ok && identity, readable: endpoints.some((e) => e.ok), identityVerified: identity, height: Math.max(0, ...endpoints.map((e) => e.h ?? 0)) || null,158 endpoints: endpoints.map(({ h: _h, ...e }) => e), checkedAt: new Date().toISOString() };159 },160161 async balances(addr) {162 const out: Record<string, bigint> = { native: (await any((s) => account(s, addr))).balance };163 for (const t of n.tokens) if (!disabled.has(t.id)) out[t.id] = await any((s) => tokenBalance(s, t.id, addr));164 return out;165 },166 disabledAssets: () => [...disabled],167168 async prepare(d): Promise<Prepared> {169 if (d.memo !== undefined && d.memo !== '') throw new UserError('bad_memo', `${n.name} transfers do not carry a memo`);170 if (!(await agree('genesis block', genesisOk))) throw new UserError('rpc_degraded', 'chain identity mismatch; transfer refused', 503);171 const asset = getAsset(n.id, d.asset), native = asset.id === 'native', warnings: string[] = [];172 if (!tokensChecked) await verifyTokens().catch(() => { throw new UserError('rpc_degraded', 'token identity check unavailable; transfer refused', 503); });173 if (disabled.has(asset.id)) throw new UserError('unsupported_asset', 'this token failed its on-chain identity check and is disabled');174 if (n.tokens.some((t) => t.id === d.to)) throw new UserError('bad_destination', 'destination is a token contract');175176 // protocol fee parameters: the builder pins the bandwidth price, the account-creation fees are pinned here177 const cp = await agree('chain parameters', chainParams);178 if (cp.getTransactionFee !== TRON_BANDWIDTH_PRICE || cp.getCreateNewAccountFeeInSystemContract !== TRON_ACTIVATION_FEE179 || cp.getCreateAccountFee !== TRON_CREATE_ACCOUNT_BANDWIDTH_FEE || cp.getCreateNewAccountBandwidthRate !== 1n || cp.getEnergyFee <= 0n)180 throw new UserError('rpc_degraded', `${n.name} fee parameters changed on-chain; the wallet must be updated before sending`, 503);181182 const src = await agree('source account', (s) => account(s, d.from));183 const dst = await agree('destination account', (s) => account(s, d.to));184 // resource counters recover continuously, so two nodes may differ by a few units: use the lowest185 const res = (await all('account resources', (s) => resources(s, d.from))).reduce((a, b) => ({186 freeNet: a.freeNet < b.freeNet ? a.freeNet : b.freeNet, stakedNet: a.stakedNet < b.stakedNet ? a.stakedNet : b.stakedNet, energy: a.energy < b.energy ? a.energy : b.energy }));187 if (dst.contract) warnings.push('contract_destination');188189 // reference block: the newest block that is irreversible on both sources, with the same id on both190 const heads = await all('irreversible block', solidHead);191 const refNum = Math.min(...heads.map((h) => h.number));192 const ref = await agree('reference block', (s) => blockAt(s, refNum));193 if (ref.number !== refNum) throw new UserError('rpc_degraded', 'reference block unavailable; transfer refused', 503);194 const now = Date.now();195 if (heads.some((h) => Math.abs(now - h.timestamp) > MAX_CLOCK_DRIFT_MS)) throw new UserError('rpc_degraded', `${n.name} data sources are behind (or the server clock is off); transfer refused`, 503);196 const x: Record<string, string> = {197 ref_block_bytes: ref.id.slice(12, 16), ref_block_hash: ref.id.slice(16, 32),198 expiration: String(now + TX_LIFETIME_MS), timestamp: String(now),199 };200201 // size the transaction with the real builder (placeholder fee values: the size does not depend on maxFee)202 const sized = (fee_limit?: bigint) => buildTronTx({ v: 1, proposalId: '00000000-0000-4000-8000-000000000000', keyId: 'size-only', network: n.id, chainRef: n.chainRef,203 asset: asset.id, symbol: asset.symbol, decimals: asset.decimals, amount: d.amount.toString(), from: d.from, to: d.to, maxFee: (TRON_MAX_FEE_LIMIT * 2n).toString(),204 createdAt: Math.floor(now / 1000), expiresAt: Math.floor(now / 1000) + APPROVAL_TTL_SEC, policyVersion: 0,205 x: fee_limit === undefined ? x : { ...x, fee_limit: fee_limit.toString() } } as Intent, n);206 const bandwidth = (bytes: number) => { const b = BigInt(bytes); return res.stakedNet >= b || res.freeNet >= b ? 0n : b * TRON_BANDWIDTH_PRICE; };207208 if (native) {209 const tx = sized();210 let feeEstimate: bigint, maxFee = tx.worstFee;211 if (!dst.exists) {212 // creating the recipient account: 1 TRX, plus 0.1 TRX unless staked bandwidth covers the transaction; no ordinary bandwidth charge213 feeEstimate = TRON_ACTIVATION_FEE + (res.stakedNet >= BigInt(tx.bandwidthBytes) ? 0n : TRON_CREATE_ACCOUNT_BANDWIDTH_FEE);214 const worst = TRON_ACTIVATION_FEE + TRON_CREATE_ACCOUNT_BANDWIDTH_FEE;215 if (worst > maxFee) maxFee = worst;216 warnings.push('activates_account');217 } else feeEstimate = bandwidth(tx.bandwidthBytes);218 if (src.balance < d.amount + feeEstimate) throw new UserError('insufficient_funds', `insufficient ${n.native.symbol} for amount plus network fee${dst.exists ? '' : ' (the recipient address is new: activating it costs about 1.1 TRX)'}`);219 return { section: { x }, maxFee, feeEstimate, warnings, ttlSec: APPROVAL_TTL_SEC };220 }221222 // TRC-20: balance and a full simulation of the exact call on both sources223 const held = await agree(`${asset.symbol} balance`, (s) => tokenBalance(s, asset.id, d.from));224 if (held < d.amount) throw new UserError('insufficient_funds', `insufficient ${asset.symbol} balance`);225 const param = word(d.to) + d.amount.toString(16).padStart(64, '0');226 const sims = await all('transfer simulation', (s) => constantCall(s, d.from, asset.id, 'transfer(address,uint256)', param));227 const bad = sims.find((s) => !s.ok);228 if (bad) throw new UserError('simulation_failed', `simulation failed: ${bad.message.slice(0, 120) || 'reverted'}`);229 if (sims.some((s) => s.energy !== sims[0]!.energy || s.energy <= 0n)) throw new UserError('rpc_disagreement', `${n.name} data sources disagree (energy estimate); transfer refused`, 503);230 const energy = sims[0]!.energy;231 const energyCap = (energy * ENERGY_MARGIN_NUM + ENERGY_MARGIN_DEN - 1n) / ENERGY_MARGIN_DEN;232 const feeLimit = energyCap * cp.getEnergyFee;233 if (feeLimit > cp.getMaxFeeLimit || feeLimit > TRON_MAX_FEE_LIMIT) throw new UserError('fee_too_high', 'energy cost exceeds the protocol fee limit');234 const tx = sized(feeLimit);235 const burnFor = (e: bigint) => (e > res.energy ? (e - res.energy) * cp.getEnergyFee : 0n);236 const feeEstimate = bandwidth(tx.bandwidthBytes) + burnFor(energy);237 // the account must be able to pay for the whole margin, otherwise the call could run out of energy and burn the fee for nothing238 const needed = bandwidth(tx.bandwidthBytes) + burnFor(energyCap);239 if (!src.exists || src.balance < needed) throw new UserError('insufficient_gas', `not enough ${n.native.symbol} to pay for the energy of this ${asset.symbol} transfer (about ${formatUnits(needed, n.native.decimals)} ${n.native.symbol} needed)`);240 return { section: { x: { ...x, fee_limit: feeLimit.toString() } }, maxFee: tx.worstFee, feeEstimate, warnings, ttlSec: APPROVAL_TTL_SEC };241 },242243 async broadcast(raw) {244 if (!/^([0-9a-f]{2})+$/.test(raw)) throw new Error('bad transaction encoding');245 const rs = await Promise.allSettled(sources.map(async (s) => {246 const r = await s.post('/wallet/broadcasthex', { transaction: raw });247 if (r?.result === true || r?.code === 'SUCCESS' || r?.code === 'DUP_TRANSACTION_ERROR') return;248 const msg = typeof r?.message === 'string' && /^([0-9a-f]{2})+$/i.test(r.message) ? Buffer.from(r.message, 'hex').toString('utf8') : String(r?.message ?? '');249 throw new Error(`${r?.code ?? 'rejected'}: ${msg}`.slice(0, 200));250 }));251 if (rs.some((r) => r.status === 'fulfilled')) return;252 throw new Error(String((rs[0] as PromiseRejectedResult).reason?.message));253 },254255 async track(p): Promise<Tracked> {256 const value = p.txid;257 // 1. irreversible state: the only place a transfer is reported finalized or failed-on-chain258 const solid = await firstHit(async (s) => {259 const info = await s.post('/walletsolidity/gettransactioninfobyid', { value });260 if (info?.id !== value) return null;261 const tx = await s.post('/walletsolidity/gettransactionbyid', { value });262 if (tx?.txID !== value) return null;263 return { info, tx };264 });265 if (solid) {266 const blockRef = String(solid.info.blockNumber), ret = solid.tx.ret?.[0]?.contractRet, receipt = solid.info.receipt?.result;267 if (ret !== 'SUCCESS' || solid.info.result === 'FAILED' || (receipt !== undefined && receipt !== 'SUCCESS')) {268 const why = typeof solid.info.resMessage === 'string' ? Buffer.from(solid.info.resMessage, 'hex').toString('utf8') : '';269 return { state: 'failed', confirmations: n.minConf, blockRef, error: `on-chain failure: ${ret ?? receipt ?? 'unknown'}${why ? ` (${why.slice(0, 80)})` : ''}; the network fee was still charged` };270 }271 return { state: 'finalized', confirmations: n.minConf, blockRef };272 }273 // 2. included but not yet irreversible274 const pending = await firstHit(async (s) => { const info = await s.post('/wallet/gettransactioninfobyid', { value }); return info?.id === value ? info : null; });275 if (pending) {276 const head = await any(async (s) => blockRef(await s.post('/wallet/getnowblock')).number);277 return { state: 'confirming', confirmations: Math.max(1, Math.min(n.minConf - 1, head - Number(pending.blockNumber) + 1)), blockRef: String(pending.blockNumber) };278 }279 // 3. not in any block. It can only be declared dead when BOTH sources have irreversible blocks past its expiration and neither knows it.280 const expiration = Number(p.intent?.x?.expiration ?? 0);281 if (expiration > 0) {282 const dead = await Promise.allSettled(sources.map(async (s) => {283 const h = await solidHead(s);284 const info = await s.post('/wallet/gettransactioninfobyid', { value });285 return h.timestamp > expiration + 6000 && info?.id !== value;286 }));287 if (dead.length >= 2 && dead.every((r) => r.status === 'fulfilled' && r.value))288 return { state: 'failed', confirmations: 0, error: 'expired before inclusion; no funds moved' };289 }290 return { state: p.blockRef ? 'reorged' : 'broadcast', confirmations: 0, rebroadcast: true };291 },292293 /**294 * Discovery uses the TronGrid history index (confirmed = irreversible only); every candidate is then re-read from the295 * irreversible state of a full node — the independent second source when it answers — and amounts/recipient/success296 * are taken from the node, never from the index. Cursor = block timestamp (ms) of the newest deposit already seen.297 */298 async deposits(addr, cursor) {299 const finalizedHeight = (await any(solidHead)).number;300 const index = sources.find((s) => /trongrid/.test(s.url));301 const items: Deposit[] = [];302 if (!index) return { items, cursor: cursor ?? 0, finalizedHeight };303 const PAGE = 30;304 const page = cursor === null ? `limit=${PAGE}&order_by=block_timestamp,desc` : `limit=${PAGE}&order_by=block_timestamp,asc&min_timestamp=${cursor + 1}`;305 const found = new Map<string, number>(); // txid -> block timestamp306 // when a listing is cut by the page size, nothing newer than its last full block may be consumed in this pass307 let bound = Number.MAX_SAFE_INTEGER;308 const list = async (path: string) => {309 const r = await index.get(path);310 if (r?.success !== true || !Array.isArray(r.data)) throw new Error('history index unavailable');311 const data = r.data as { block_timestamp: number }[];312 if (cursor !== null && data.length >= PAGE) {313 const lastTs = Number(data[data.length - 1]!.block_timestamp);314 bound = Math.min(bound, data.some((t) => Number(t.block_timestamp) < lastTs) ? lastTs - 1 : lastTs);315 }316 return data as any[];317 };318 for (const t of await list(`/v1/accounts/${addr}/transactions?only_confirmed=true&only_to=true&${page}`))319 if (t?.raw_data?.contract?.[0]?.type === 'TransferContract' && /^[0-9a-f]{64}$/.test(t.txID)) found.set(t.txID, Number(t.block_timestamp));320 for (const tok of n.tokens) {321 if (disabled.has(tok.id)) continue;322 for (const t of await list(`/v1/accounts/${addr}/transactions/trc20?only_confirmed=true&only_to=true&contract_address=${tok.id}&${page}`))323 if (/^[0-9a-f]{64}$/.test(t?.transaction_id)) found.set(t.transaction_id, Number(t.block_timestamp));324 }325 const verifiers = [...sources.filter((s) => s !== index), index];326 const me = word(addr);327 let next = cursor ?? 0;328 // the index treats min_timestamp loosely (the boundary block comes back): keep strictly newer entries only329 const ordered = [...found].filter(([, ts]) => ts <= bound && ts > (cursor ?? -1)).sort((a, b) => a[1] - b[1]);330 for (const [txid] of ordered) {331 const v = await (async () => { let last: unknown; for (const s of verifiers) { try {332 const info = await s.post('/walletsolidity/gettransactioninfobyid', { value: txid });333 const tx = await s.post('/walletsolidity/gettransactionbyid', { value: txid, visible: true });334 if (info?.id !== txid || tx?.txID !== txid) throw new Error('not irreversible on the verifying node yet');335 return { info, tx };336 } catch (e) { last = e; } } throw last; })();337 const block = Number(v.info.blockNumber), time = Math.floor(Number(v.info.blockTimeStamp) / 1000) || null, ts = Number(v.info.blockTimeStamp);338 const c = v.tx.raw_data?.contract?.[0], pv = c?.parameter?.value;339 const success = v.tx.ret?.[0]?.contractRet === 'SUCCESS' && v.info.result !== 'FAILED' && (v.info.receipt?.result ?? 'SUCCESS') === 'SUCCESS';340 if (success && c?.type === 'TransferContract' && pv?.to_address === addr && pv.owner_address !== addr && big(pv.amount) > 0n)341 items.push({ txid, idx: 0, asset: 'native', amount: big(pv.amount), from: String(pv.owner_address), block, time });342 if (success) (v.info.log ?? []).forEach((l: { address: string; topics?: string[]; data?: string }, k: number) => {343 const tok = n.tokens.find((t) => !disabled.has(t.id) && Buffer.from(tronAddressBytes(t.id).subarray(1)).toString('hex') === l.address);344 if (!tok || l.topics?.length !== 3 || l.topics[0] !== TRANSFER_TOPIC || l.topics[2] !== me || !/^[0-9a-f]{64}$/.test(l.data ?? '')) return;345 const amount = BigInt('0x' + l.data), from = tronAddressFromBytes(Uint8Array.from(Buffer.from('41' + l.topics[1]!.slice(24), 'hex')));346 if (amount > 0n && from !== addr) items.push({ txid, idx: 1 + k, asset: tok.id, amount, from, block, time });347 });348 if (ts > next) next = ts;349 }350 return { items, cursor: next, finalizedHeight };351 },352 };353}354