SPB Git

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%
8.9 KB · 214 lines python
Raw Blame History
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