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%
12.5 KB · 278 lines python
Raw Blame History
1# Author: Simon-Pierre Boucher2# Mail: contact@spboucher.ai3"""Per-chain EVM ingestion loop.45Head tailing:  poll head → compute safe head (head - confirmations) →6verify the chain still links to our cursor (parent-hash check) → sweep7forward in eth_getLogs ranges → decode → insert → advance cursor.89Backfill:      after the head is caught up, grow history BACKWARDS one10slice per cycle (head tailing always has priority) until coverage reaches11`backfill_days` (chain config) / EXPLORER_BACKFILL_DAYS (env). The12`backfill_block` cursor is the lowest indexed block.1314Reorg safety:  two independent checks — (1) each cycle re-fetches the15cursor block and compares its hash; (2) each advance fetches the next16block's header and verifies parentHash == our stored cursor hash. On17mismatch: delete the last 2×confirmations blocks of transfers and rewind.1819Range sizing is adaptive: free RPCs cap getLogs responses, so a semantic20refusal halves the range (down to 2); refusal at minimum range benches the21endpoint via pool.rotate(). Ten clean sweeps grow the range back.22"""2324import logging25import time2627from . import config, db28from .decode import TRANSFER_TOPIC, decode_transfer29from .rpc import RpcError, RpcPool3031log = logging.getLogger("ingest")323334class ChainIndexer:35    def __init__(self, chain, cfg, tokens, db_path):36        self.chain = chain37        self.cfg = cfg38        self.db_path = db_path39        self.conn = None  # opened in run(): sqlite conns are thread-bound40        self.pool = RpcPool(cfg["rpcs"])41        self.tokens = {t["address"].lower(): t for t in tokens}42        self.max_range = cfg.get("max_range", 100)43        self.range = self.max_range44        self.backfill_blocks = config.backfill_blocks(cfg)45        self.native = cfg.get("native")  # {symbol, min, decimals} — whale-only46        self._clean_sweeps = 04748    # -- RPC helpers ---------------------------------------------------4950    def head(self):51        return int(self.pool.call("eth_blockNumber"), 16)5253    def block_header(self, number):54        return self.pool.call("eth_getBlockByNumber", [hex(number), False])5556    def fetch_logs(self, frm, to):57        return self.pool.call(58            "eth_getLogs",59            [{60                "fromBlock": hex(frm),61                "toBlock": hex(to),62                "address": list(self.tokens.keys()),63                "topics": [TRANSFER_TOPIC],64            }],65        )6667    def block_timestamps(self, numbers):68        """Batch-fetch headers just for blocks that actually had transfers."""69        out = {}70        nums = sorted(numbers)71        for i in range(0, len(nums), 20):  # small chunks: free nodes cap batches72            chunk = nums[i : i + 20]73            results = self.pool.batch(74                [("eth_getBlockByNumber", [hex(n), False]) for n in chunk]75            )76            for n, blk in zip(chunk, results):77                if blk:78                    out[n] = int(blk["timestamp"], 16)79        # batches drop entries under rate limits — retry stragglers one by one80        for n in nums:81            if n not in out:82                try:83                    blk = self.block_header(n)84                    if blk:85                        out[n] = int(blk["timestamp"], 16)86                except (RpcError, RuntimeError):87                    pass88        return out8990    # -- pipeline ------------------------------------------------------9192    def process_range(self, frm, to):93        logs = self.fetch_logs(frm, to)94        rows = []95        if logs:96            ts = self.block_timestamps({int(l["blockNumber"], 16) for l in logs})97            for l in logs:98                if l.get("removed"):99                    continue100                meta = self.tokens.get(l["address"].lower())101                if meta is None or len(l.get("topics", [])) != 3:102                    continue  # not one of ours / non-standard Transfer103                rows.append(104                    decode_transfer(self.chain, l, meta, ts.get(int(l["blockNumber"], 16)))105                )106        db.insert_transfers(self.conn, rows)107        return len(rows)108109    def try_range(self, frm, to, stop):110        """One adaptive sweep starting at `frm`. Returns (end, n): the last111        block actually covered (may be < `to` after halving) and row count."""112        while not stop.is_set():113            end = min(frm + self.range - 1, to)114            try:115                n = self.process_range(frm, end)116            except RpcError as e:117                if self.range > 2:118                    self.range = max(2, self.range // 2)119                    self._clean_sweeps = 0120                    log.info("%s: getLogs refused (%s) — range now %d",121                             self.chain, e.message[:80], self.range)122                    continue123                # refused even at minimum range: this provider just won't124                # serve getLogs — bench it and let selection move on125                log.warning("%s: getLogs refused at min range (%s) — rotating "126                            "away from %s", self.chain, e.message[:80],127                            self.pool.current_url)128                self.pool.rotate()129                stop.wait(5)130                continue131            self._clean_sweeps += 1132            if self._clean_sweeps >= 10 and self.range < self.max_range:133                self.range = min(self.max_range, self.range * 2)134                self._clean_sweeps = 0135            return end, n136        return frm - 1, 0137138    # -- reorg checks ----------------------------------------------------139140    def process_native(self, frm, to):141        """Whale-only native-coin transfers: full blocks are expensive, so we142        only capture value >= min (config) and only while head-tailing over a143        bounded range — never during backfill. A skipped stretch is logged."""144        cap = 60145        if to - frm + 1 > cap:146            log.info("%s: native capture skipped for %d..%d (catch-up burst)",147                     self.chain, frm, to - cap)148            frm = to - cap + 1149        min_wei = int(self.native["min"] * 10 ** self.native["decimals"])150        rows = []151        nums = list(range(frm, to + 1))152        for i in range(0, len(nums), 10):153            chunk = nums[i : i + 10]154            blocks = self.pool.batch(155                [("eth_getBlockByNumber", [hex(n), True]) for n in chunk]156            )157            for blk in blocks:158                if not blk:159                    continue160                ts = int(blk["timestamp"], 16)161                for ti, t in enumerate(blk.get("transactions") or []):162                    if not isinstance(t, dict):163                        continue164                    val = int(t.get("value", "0x0"), 16)165                    if val < min_wei:166                        continue167                    rows.append({168                        "chain": self.chain,169                        "block": int(blk["number"], 16),170                        "block_hash": blk["hash"].lower(),171                        "tx_hash": t["hash"].lower(),172                        "log_index": 100000 + ti,  # never collides with log indexes173                        "timestamp": ts,174                        "token": "native",175                        "symbol": self.native["symbol"],176                        "from": (t.get("from") or "").lower() or None,177                        "to": (t.get("to") or "").lower() or None,178                        "amount": str(val),179                        "decimals": self.native["decimals"],180                    })181        db.insert_transfers(self.conn, rows)182        if rows:183            log.info("%s: %d native %s whale transfers in ..%d",184                     self.chain, len(rows), self.native["symbol"], to)185186    def reorged(self, cursor_block, cursor_hash):187        """True if the block we last indexed is no longer canonical."""188        if not cursor_hash:189            return False190        blk = self.block_header(cursor_block)191        return blk is not None and blk["hash"].lower() != cursor_hash.lower()192193    def links_to_cursor(self, cursor_block, cursor_hash):194        """Parent-hash link: next block's parentHash must be our cursor hash."""195        if not cursor_hash:196            return True197        nxt = self.block_header(cursor_block + 1)198        return nxt is None or nxt["parentHash"].lower() == cursor_hash.lower()199200    # -- main loop -----------------------------------------------------201202    def run(self, stop):203        self.conn = db.connect(self.db_path)204        cur = db.get_cursor(self.conn, self.chain)205        confirmations = self.cfg.get("confirmations", 6)206        while not stop.is_set():207            try:208                head = self.head()209                safe = head - confirmations210                if cur is None:211                    start = max(0, safe - self.cfg.get("start_offset", 300))212                    cur = (start, None, start)213                    db.set_cursor(self.conn, self.chain, start, None)214                    db.set_backfill(self.conn, self.chain, start)215                    log.info("%s: fresh start at block %d (head %d)", self.chain, start, head)216                cursor_block, cursor_hash, backfill_block = cur217                if backfill_block is None:  # DB predates backfill support218                    backfill_block = cursor_block219                    db.set_backfill(self.conn, self.chain, backfill_block)220221                if self.reorged(cursor_block, cursor_hash):222                    to_block = cursor_block - 2 * confirmations223                    log.warning("%s: reorg at %d — rolling back to %d",224                                self.chain, cursor_block, to_block)225                    db.rollback(self.conn, self.chain, to_block)226                    cursor_block, cursor_hash = to_block, None227228                # ---- head tailing (always first priority) ----229                while cursor_block < safe and not stop.is_set():230                    if not self.links_to_cursor(cursor_block, cursor_hash):231                        to_block = cursor_block - 2 * confirmations232                        log.warning("%s: parent-hash mismatch after %d — rolling back to %d",233                                    self.chain, cursor_block, to_block)234                        db.rollback(self.conn, self.chain, to_block)235                        cursor_block, cursor_hash = to_block, None236                        continue237                    end, n = self.try_range(cursor_block + 1, safe, stop)238                    if end < cursor_block + 1:239                        break  # stopped mid-sweep240                    if self.native:241                        try:242                            self.process_native(cursor_block + 1, end)243                        except (RpcError, RuntimeError) as e:244                            log.warning("%s: native capture failed (%s) — "245                                        "continuing", self.chain, e)246                    end_blk = self.block_header(end)247                    cursor_block = end248                    cursor_hash = end_blk["hash"].lower() if end_blk else None249                    db.set_cursor(self.conn, self.chain, cursor_block, cursor_hash, head=head)250                    if n:251                        log.info("%s: %d transfers in blocks ..%d (lag %d)",252                                 self.chain, n, end, head - end)253254                # ---- backfill: one slice per cycle, backwards ----255                if self.backfill_blocks and not stop.is_set():256                    target = max(0, safe - self.backfill_blocks)257                    if backfill_block > target:258                        frm = max(target, backfill_block - self.range)259                        covered, total = frm - 1, 0260                        while covered < backfill_block - 1 and not stop.is_set():261                            end, n = self.try_range(covered + 1, backfill_block - 1, stop)262                            if end <= covered:263                                break264                            covered, total = end, total + n265                        if covered >= backfill_block - 1:  # slice fully covered266                            db.set_backfill(self.conn, self.chain, frm)267                            backfill_block = frm268                            if total:269                                log.info("%s: backfill %d transfers, floor now %d (target %d)",270                                         self.chain, total, frm, target)271272                cur = (cursor_block, cursor_hash, backfill_block)273                db.save_rpc_health(self.conn, self.chain, self.pool.stats(), int(time.time()))274                stop.wait(max(float(self.cfg.get("block_time", 12)), 2.0))275            except Exception as e:276                log.error("%s: %s — retrying in 10s", self.chain, e)277                stop.wait(10)278