SPB Git

Repository Public

tron.ts 25.3 KB 354 lines typescript

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
Files main
Menu

Theme