spb/coinexplorer Public MIT
Self-hosted, zero-API-key explorer for stablecoins and major crypto.
Python 60.3%
HTML 23.6%
JavaScript 8.1%
CSS 6.8%
SQL 1%
1# Author: Simon-Pierre Boucher2# Mail: contact@spboucher.ai3"""Tron adapter — TRC-20 stablecoin transfers via the HTTP wallet API.45How a TRC-20 transfer appears on Tron: the tx is a `TriggerSmartContract`6call, and the token contract emits the SAME Transfer event as ERC-207(Tron's TVM is EVM-derived). We read one block's worth of transaction8infos with /wallet/gettransactioninfobyblocknum and filter the `log`9entries by contract address + Transfer topic.1011Addresses: Tron shows Base58Check "T..." addresses; on-chain they are1221 bytes (0x41 prefix + 20-byte EVM-style address). Event topics carry13the bare 20 bytes left-padded to 32 — we convert back to T-addresses so14stored rows use the chain-native format. Base58Check is implemented by15hand below (double-SHA256 checksum) — no dependency.1617Finality: DPoS, ~3s blocks; a block is effectively final once 2/3 of the1827 SRs confirm (≈ 19 blocks) — we trail head by `confirmations` (20).19"""2021import hashlib22import logging23import time2425from .. import db26from ..rpc import AllEndpointsDown, RestError, RestPool2728log = logging.getLogger("tron")2930# keccak256("Transfer(address,address,uint256)") — Tron logs omit the 0x31TRANSFER_TOPIC = "ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"3233_B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"34_B58_INDEX = {c: i for i, c in enumerate(_B58)}353637def _sha256d(b):38 return hashlib.sha256(hashlib.sha256(b).digest()).digest()394041def b58check_decode(s):42 num = 043 for c in s:44 num = num * 58 + _B58_INDEX[c]45 raw = num.to_bytes((num.bit_length() + 7) // 8, "big")46 raw = b"\x00" * (len(s) - len(s.lstrip("1"))) + raw # leading '1's = zero bytes47 payload, checksum = raw[:-4], raw[-4:]48 if _sha256d(payload)[:4] != checksum:49 raise ValueError(f"bad Base58Check checksum for {s}")50 return payload515253def b58check_encode(payload):54 raw = payload + _sha256d(payload)[:4]55 num = int.from_bytes(raw, "big")56 out = ""57 while num:58 num, rem = divmod(num, 58)59 out = _B58[rem] + out60 return "1" * (len(raw) - len(raw.lstrip(b"\x00"))) + out616263def taddr_to_hex20(t_addr):64 """T-address → bare 20-byte hex (what appears in event logs/topics)."""65 payload = b58check_decode(t_addr)66 if len(payload) != 21 or payload[0] != 0x41:67 raise ValueError(f"not a Tron address: {t_addr}")68 return payload[1:].hex()697071def hex20_to_taddr(h):72 """Bare 20-byte hex → Base58Check T-address (0x41 mainnet prefix)."""73 return b58check_encode(b"\x41" + bytes.fromhex(h))747576class TronIndexer:77 def __init__(self, chain, cfg, tokens, db_path):78 self.chain = chain79 self.cfg = cfg80 self.db_path = db_path81 self.conn = None82 self.pool = RestPool(cfg["rpcs"])83 # hex20 (as seen in logs) → token meta84 self.tokens = {taddr_to_hex20(t["id"]): t for t in tokens}85 self.native = cfg.get("native") # {symbol: TRX, min, decimals: 6}8687 # -- API helpers -----------------------------------------------------8889 def head(self):90 blk = self.pool.post("/wallet/getnowblock")91 return blk["block_header"]["raw_data"]["number"]9293 def block_id(self, n):94 blk = self.pool.post("/wallet/getblockbynum", {"num": n})95 return (blk or {}).get("blockID")9697 def block_infos(self, n):98 return self.pool.post("/wallet/gettransactioninfobyblocknum", {"num": n}) or []99100 # -- pipeline ----------------------------------------------------------101102 def process_block(self, n):103 rows = []104 for info in self.block_infos(n):105 receipt = info.get("receipt") or {}106 if receipt.get("result") not in (None, "SUCCESS"):107 continue # reverted contract call — logs are not effective108 for i, lg in enumerate(info.get("log") or []):109 addr = (lg.get("address") or "").lower()[-40:] # strip 41 prefix if present110 meta = self.tokens.get(addr)111 topics = lg.get("topics") or []112 if meta is None or len(topics) != 3 or topics[0].lower() != TRANSFER_TOPIC:113 continue114 data = (lg.get("data") or "").strip() or "0"115 rows.append({116 "chain": self.chain,117 "block": n,118 "block_hash": None,119 "tx_hash": info["id"],120 "log_index": i,121 "timestamp": info.get("blockTimeStamp", 0) // 1000 or None,122 "token": meta["id"], # T-address, chain-native format123 "symbol": meta["symbol"],124 "from": hex20_to_taddr(topics[1][-40:]),125 "to": hex20_to_taddr(topics[2][-40:]),126 "amount": str(int(data, 16)),127 "decimals": meta["decimals"],128 })129 if self.native:130 rows.extend(self.native_rows(n))131 db.insert_transfers(self.conn, rows)132 return len(rows)133134 def native_rows(self, n):135 """Whale-only native TRX: TransferContract txs above the config min.136 Addresses come back as 41-prefixed hex — convert to T-addresses."""137 min_sun = int(self.native["min"] * 10 ** self.native["decimals"])138 blk = self.pool.post("/wallet/getblockbynum", {"num": n}) or {}139 ts = (blk.get("block_header", {}).get("raw_data", {}).get("timestamp", 0)) // 1000 or None140 rows = []141 for tx in blk.get("transactions") or []:142 for ci, c in enumerate(tx.get("raw_data", {}).get("contract") or []):143 if c.get("type") != "TransferContract":144 continue145 v = c.get("parameter", {}).get("value", {})146 amount = v.get("amount", 0)147 if amount < min_sun:148 continue149 def cvt(h):150 h = (h or "").lower()151 return hex20_to_taddr(h[-40:]) if len(h) >= 40 else (h or None)152 rows.append({153 "chain": self.chain, "block": n, "block_hash": None,154 "tx_hash": tx["txID"], "log_index": 100000 + ci,155 "timestamp": ts, "token": "native",156 "symbol": self.native["symbol"],157 "from": cvt(v.get("owner_address")),158 "to": cvt(v.get("to_address")),159 "amount": str(amount), "decimals": self.native["decimals"],160 })161 return rows162163 def reorged(self, cursor_block, cursor_hash):164 if not cursor_hash:165 return False166 bid = self.block_id(cursor_block)167 return bid is not None and bid.lower() != cursor_hash.lower()168169 # -- main loop -----------------------------------------------------170171 def run(self, stop):172 self.conn = db.connect(self.db_path)173 cur = db.get_cursor(self.conn, self.chain)174 confirmations = self.cfg.get("confirmations", 20)175 while not stop.is_set():176 try:177 head = self.head()178 safe = head - confirmations179 if cur is None:180 start = max(0, safe - self.cfg.get("start_offset", 100))181 cur = (start, None, start)182 db.set_cursor(self.conn, self.chain, start, None)183 db.set_backfill(self.conn, self.chain, start)184 log.info("%s: fresh start at block %d (head %d)", self.chain, start, head)185 cursor_block, cursor_hash, _ = cur186187 if self.reorged(cursor_block, cursor_hash):188 to_block = cursor_block - 2 * confirmations189 log.warning("%s: reorg at %d — rolling back to %d",190 self.chain, cursor_block, to_block)191 db.rollback(self.conn, self.chain, to_block)192 cursor_block, cursor_hash = to_block, None193194 moved = 0195 while cursor_block < safe and not stop.is_set():196 n = self.process_block(cursor_block + 1)197 cursor_block += 1198 moved += 1199 if n:200 log.info("%s: %d transfers in block %d (lag %d)",201 self.chain, n, cursor_block, head - cursor_block)202 if moved % 20 == 0: # persist progress during catch-up203 db.set_cursor(self.conn, self.chain, cursor_block, None)204 if moved:205 cursor_hash = self.block_id(cursor_block)206 db.set_cursor(self.conn, self.chain, cursor_block, cursor_hash, head=head)207208 cur = (cursor_block, cursor_hash, None)209 db.save_rpc_health(self.conn, self.chain, self.pool.stats(), int(time.time()))210 stop.wait(max(float(self.cfg.get("block_time", 3)), 2.0))211 except (RestError, AllEndpointsDown, Exception) as e:212 log.error("%s: %s — retrying in 10s", self.chain, e)213 stop.wait(10)214