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%
10.2 KB · 341 lines python
Raw Blame History
1# Author: Simon-Pierre Boucher2# Mail: contact@spboucher.ai3"""Storage layer — SQLite (dev, zero-config) or PostgreSQL (production).45Backend selection: DATABASE_URL env set → PostgreSQL via psycopg2;6otherwise SQLite at the given path (WAL mode so indexer threads write7while the API reads).89The DDL below is written in the dialect intersection both engines accept.10amount is TEXT: token amounts are uint256 and overflow both engines'1164-bit integers (e.g. any DAI transfer over ~9.2M). Aggregations CAST to12DOUBLE PRECISION (REAL affinity on SQLite) — fine for dashboards, not13accounting.14"""1516import os17import sqlite31819SCHEMA = """20CREATE TABLE IF NOT EXISTS chains (21    chain    TEXT PRIMARY KEY,22    family   TEXT,23    chain_id BIGINT24);2526CREATE TABLE IF NOT EXISTS transfers (27    chain      TEXT    NOT NULL,28    block      BIGINT  NOT NULL,29    block_hash TEXT,30    tx_hash    TEXT    NOT NULL,31    log_index  INTEGER NOT NULL,32    timestamp  BIGINT,33    token      TEXT    NOT NULL,34    symbol     TEXT,35    "from"     TEXT,36    "to"       TEXT,37    amount     TEXT,38    decimals   INTEGER,39    PRIMARY KEY (chain, tx_hash, log_index)40);41CREATE INDEX IF NOT EXISTS idx_transfers_token_block ON transfers (chain, token, block);42CREATE INDEX IF NOT EXISTS idx_transfers_ts          ON transfers (symbol, timestamp);43CREATE INDEX IF NOT EXISTS idx_transfers_ts_only     ON transfers (timestamp);44CREATE INDEX IF NOT EXISTS idx_transfers_from        ON transfers ("from");45CREATE INDEX IF NOT EXISTS idx_transfers_to          ON transfers ("to");4647-- rolling aggregates, maintained by StatsWorker (query-time scans over the48-- full window don't scale past a few million rows on free-tier hardware)49CREATE TABLE IF NOT EXISTS agg_volume (50    window    TEXT   NOT NULL,51    symbol    TEXT   NOT NULL,52    chain     TEXT   NOT NULL,53    volume    REAL,54    transfers INTEGER,55    senders   INTEGER,56    receivers INTEGER,57    updated   BIGINT,58    PRIMARY KEY (window, symbol, chain)59);6061CREATE TABLE IF NOT EXISTS agg_series (62    window    TEXT   NOT NULL,63    symbol    TEXT   NOT NULL,64    chain     TEXT   NOT NULL,65    t         BIGINT NOT NULL,66    volume    REAL,67    transfers INTEGER,68    updated   BIGINT,69    PRIMARY KEY (window, symbol, chain, t)70);7172-- pre-extracted large transfers (>= WHALE_TABLE_MIN_USD), scanned73-- incrementally so /whales never rescans the transfers table74CREATE TABLE IF NOT EXISTS whale_events (75    chain     TEXT    NOT NULL,76    block     BIGINT,77    tx_hash   TEXT    NOT NULL,78    log_index INTEGER NOT NULL,79    timestamp BIGINT,80    token     TEXT,81    symbol    TEXT,82    "from"    TEXT,83    "to"      TEXT,84    amount    TEXT,85    decimals  INTEGER,86    usd       REAL,87    PRIMARY KEY (chain, tx_hash, log_index)88);89CREATE INDEX IF NOT EXISTS idx_whales_ts  ON whale_events (timestamp);90CREATE INDEX IF NOT EXISTS idx_whales_usd ON whale_events (usd);9192CREATE TABLE IF NOT EXISTS cursors (93    chain          TEXT PRIMARY KEY,94    last_block     BIGINT NOT NULL,95    last_hash      TEXT,96    backfill_block BIGINT,97    head_block     BIGINT98);99100CREATE TABLE IF NOT EXISTS rpc_health (101    chain      TEXT NOT NULL,102    url        TEXT NOT NULL,103    score      REAL,104    ok         INTEGER,105    fail       INTEGER,106    latency_ms REAL,107    cooldown_s REAL,108    updated    BIGINT,109    PRIMARY KEY (chain, url)110);111112CREATE TABLE IF NOT EXISTS tokens (113    chain    TEXT NOT NULL,114    address  TEXT NOT NULL,115    symbol   TEXT,116    decimals INTEGER,117    native   INTEGER,118    PRIMARY KEY (chain, address)119);120121CREATE TABLE IF NOT EXISTS prices (122    symbol  TEXT PRIMARY KEY,123    usd     REAL,124    updated BIGINT125);126127CREATE TABLE IF NOT EXISTS supply_snapshots (128    chain     TEXT   NOT NULL,129    token     TEXT   NOT NULL,130    symbol    TEXT,131    supply    TEXT,132    decimals  INTEGER,133    timestamp BIGINT NOT NULL,134    PRIMARY KEY (chain, token, timestamp)135);136CREATE INDEX IF NOT EXISTS idx_supply_ts ON supply_snapshots (symbol, timestamp);137"""138139COLUMNS = [140    "chain", "block", "block_hash", "tx_hash", "log_index", "timestamp",141    "token", "symbol", "from", "to", "amount", "decimals",142]143144# shared SQL fragments: face-value USD (stablecoins ≈ $1) scaled by the145# latest known price for crypto assets; CASE avoids relying on POW()146USD_EXPR = (147    "CAST(amount AS DOUBLE PRECISION) / (CASE decimals "148    + " ".join(f"WHEN {d} THEN 1e{d}" for d in range(19))149    + " ELSE 1e6 END)"150)151PRICE_JOIN = "LEFT JOIN prices ON prices.symbol = transfers.symbol"152USD_PRICED = f"(({USD_EXPR}) * COALESCE(prices.usd, 1.0))"153154155def is_postgres():156    return bool(os.environ.get("DATABASE_URL"))157158159class _PgConn:160    """Thin psycopg2 wrapper exposing the sqlite3 surface our code uses.161    Translates '?' placeholders to '%s' (no literal '?' appears in queries).162    Rows come from DictCursor: indexable by position AND by column name."""163164    def __init__(self, dsn):165        import psycopg2166        import psycopg2.extras167        self._x = psycopg2.extras168        self.raw = psycopg2.connect(dsn)169170    def execute(self, sql, params=()):171        cur = self.raw.cursor(cursor_factory=self._x.DictCursor)172        cur.execute(sql.replace("?", "%s"), params)173        return cur174175    def executemany(self, sql, seq):176        cur = self.raw.cursor()177        cur.executemany(sql.replace("?", "%s"), list(seq))178        return cur179180    def executescript(self, script):181        cur = self.raw.cursor()182        cur.execute(script)183        self.raw.commit()184185    def commit(self):186        self.raw.commit()187188    def close(self):189        self.raw.close()190191192def connect(path=None, readonly=False):193    dsn = os.environ.get("DATABASE_URL")194    if dsn:195        conn = _PgConn(dsn)196        if not readonly:197            conn.executescript(SCHEMA)198            _migrate(conn)199        return conn200    if readonly:201        conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=10)202        conn.row_factory = sqlite3.Row203        return conn204    conn = sqlite3.connect(path, timeout=30)205    conn.row_factory = sqlite3.Row  # rows usable by name AND position206    conn.execute("PRAGMA journal_mode=WAL")207    conn.execute("PRAGMA synchronous=NORMAL")208    conn.executescript(SCHEMA)209    _migrate(conn)210    return conn211212213def _migrate(conn):214    """Additive column migrations for DBs created by earlier versions."""215    for col in ("backfill_block", "head_block"):216        try:217            conn.execute(f"ALTER TABLE cursors ADD COLUMN {col} BIGINT")218            conn.commit()219        except Exception:220            try:  # psycopg2 aborts the tx on error — reset it221                conn.raw.rollback()222            except AttributeError:223                pass224225226def insert_transfers(conn, rows):227    if not rows:228        return229    cols = ", ".join(f'"{c}"' for c in COLUMNS)230    ph = ", ".join("?" for _ in COLUMNS)231    conn.executemany(232        f"INSERT INTO transfers ({cols}) VALUES ({ph}) ON CONFLICT DO NOTHING",233        [tuple(r[c] for c in COLUMNS) for r in rows],234    )235    conn.commit()236237238def get_cursor(conn, chain):239    row = conn.execute(240        "SELECT last_block, last_hash, backfill_block FROM cursors WHERE chain = ?",241        (chain,),242    ).fetchone()243    return (row[0], row[1], row[2]) if row else None244245246def set_cursor(conn, chain, block, block_hash, head=None):247    conn.execute(248        "INSERT INTO cursors (chain, last_block, last_hash, head_block) VALUES (?, ?, ?, ?) "249        "ON CONFLICT (chain) DO UPDATE SET last_block = ?, last_hash = ?, "250        "head_block = COALESCE(?, cursors.head_block)",251        (chain, block, block_hash, head, block, block_hash, head),252    )253    conn.commit()254255256def set_backfill(conn, chain, block):257    conn.execute(258        "UPDATE cursors SET backfill_block = ? WHERE chain = ?", (block, chain)259    )260    conn.commit()261262263def rollback(conn, chain, to_block):264    """Reorg: drop everything above to_block and rewind the cursor."""265    conn.execute(266        "DELETE FROM transfers WHERE chain = ? AND block > ?", (chain, to_block)267    )268    conn.execute(269        "UPDATE cursors SET last_block = ?, last_hash = NULL WHERE chain = ?",270        (to_block, chain),271    )272    conn.commit()273274275def save_rpc_health(conn, chain, stats, now):276    conn.executemany(277        "INSERT INTO rpc_health (chain, url, score, ok, fail, latency_ms, cooldown_s, updated) "278        "VALUES (?, ?, ?, ?, ?, ?, ?, ?) "279        "ON CONFLICT (chain, url) DO UPDATE SET score = excluded.score, ok = excluded.ok, "280        "fail = excluded.fail, latency_ms = excluded.latency_ms, "281        "cooldown_s = excluded.cooldown_s, updated = excluded.updated",282        [283            (chain, s["url"], s["score"], s["ok"], s["fail"], s["latency_ms"], s["cooldown_s"], now)284            for s in stats285        ],286    )287    conn.commit()288289290def upsert_chain(conn, chain, family, chain_id):291    conn.execute(292        "INSERT INTO chains (chain, family, chain_id) VALUES (?, ?, ?) "293        "ON CONFLICT (chain) DO UPDATE SET family = ?, chain_id = ?",294        (chain, family, chain_id, family, chain_id),295    )296    conn.commit()297298299def upsert_tokens(conn, chain, tokens):300    conn.executemany(301        "INSERT INTO tokens (chain, address, symbol, decimals, native) "302        "VALUES (?, ?, ?, ?, ?) "303        "ON CONFLICT (chain, address) DO UPDATE SET "304        "symbol = excluded.symbol, decimals = excluded.decimals, native = excluded.native",305        [306            (chain, token_key(t), t["symbol"], t.get("decimals"), int(t.get("native", False)))307            for t in tokens308        ],309    )310    conn.commit()311312313def insert_supply(conn, rows):314    if not rows:315        return316    conn.executemany(317        "INSERT INTO supply_snapshots (chain, token, symbol, supply, decimals, timestamp) "318        "VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING",319        rows,320    )321    conn.commit()322323324def upsert_prices(conn, rows):325    """rows: [(symbol, usd, updated)]"""326    if not rows:327        return328    conn.executemany(329        "INSERT INTO prices (symbol, usd, updated) VALUES (?, ?, ?) "330        "ON CONFLICT (symbol) DO UPDATE SET usd = excluded.usd, updated = excluded.updated",331        rows,332    )333    conn.commit()334335336def token_key(t):337    """Canonical stored identifier: EVM addresses lowercase; everything else338    (Base58, coin types, denoms…) is case-sensitive and kept verbatim."""339    key = t.get("address") or t.get("id")340    return key.lower() if key.startswith("0x") else key341