// open-source indexer — phase 1

Run the SBRC-20 indexer

Verify every $SHLD balance yourself. The indexer reads only "p":"sbrc-20" inscriptions, and each block extends a state hash chain — two indexers agree if their hashes match.

Deploy block

970,040

START_HEIGHT (block before deploy)

970039

Requirements

  • Bitcoin Core 25 or newer, fully synced, with RPC enabled
  • ord running its HTTP server against the same node
  • Python 3.9+ (standard library only, nothing to install)

Run it

export BITCOIN_RPC_URL=http://127.0.0.1:8332
export BITCOIN_RPC_USER=youruser
export BITCOIN_RPC_PASS=yourpass
export ORD_URL=http://127.0.0.1:80
export START_HEIGHT=970039
python3 sbrc20_indexer.py

The API runs on http://127.0.0.1:8420:

/statusLatest indexed height, block hash and state hash
/state/<height>State hash at a height, for comparing indexers
/token/<tick>Supply, minted amount and mint rules
/balance/<address>Available and transferable balances
/holders/<tick>?limit=100Top holders
/events/<height>Every mint, transfer and rejection in a block

sbrc20_indexer.py

#!/usr/bin/env python3
"""
SBRC-20 indexer (phase 1: public tokens).

Works like a BRC-20 indexer, with three differences:
  1. It only reads inscriptions whose JSON has "p": "sbrc-20".
  2. A deploy can define a mint window and a mint fee that doubles over time.
  3. Every processed block extends a hash chain of state changes, so two
     independent indexers can compare a single hash to prove they agree.

Data sources:
  - ord (JSON API)    -> which inscriptions were created in each block, and their content
  - Bitcoin Core RPC  -> full block data (outputs, addresses, prevouts) for fee checks
                         and for tracking transfer inscriptions as they move

Standard library only. Python 3.9+, Bitcoin Core 25+ (getblock verbosity 3), ord with
its HTTP server running.
"""

import base64
import hashlib
import json
import os
import re
import sqlite3
import sys
import threading
import time
import urllib.error
import urllib.request
from decimal import Decimal
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse

# ---------------------------------------------------------------- config

CFG = {
    "BITCOIN_RPC_URL": os.environ.get("BITCOIN_RPC_URL", "http://127.0.0.1:8332"),
    "BITCOIN_RPC_USER": os.environ.get("BITCOIN_RPC_USER", ""),
    "BITCOIN_RPC_PASS": os.environ.get("BITCOIN_RPC_PASS", ""),
    "ORD_URL": os.environ.get("ORD_URL", "http://127.0.0.1:80"),
    "DB_PATH": os.environ.get("DB_PATH", "sbrc20.db"),
    # First block to scan. Set it to the block BEFORE the token's deploy inscription.
    "START_HEIGHT": int(os.environ.get("START_HEIGHT", "970039")),
    # Only index blocks with this many confirmations, to stay clear of reorgs.
    "CONFIRMATIONS": int(os.environ.get("CONFIRMATIONS", "6")),
    "API_HOST": os.environ.get("API_HOST", "127.0.0.1"),
    "API_PORT": int(os.environ.get("API_PORT", "8420")),
}

PROTOCOL = "sbrc-20"
TICK_LEN = 4
MAX_DEC = 18
AMOUNT_RE = re.compile(r"^\d+(\.\d+)?$")
ACCEPTED_CONTENT_TYPES = ("text/plain", "application/json")

# ---------------------------------------------------------------- clients


class BitcoinRPC:
    def __init__(self, url, user, password):
        self.url = url
        token = base64.b64encode(f"{user}:{password}".encode()).decode()
        self.headers = {"Content-Type": "application/json", "Authorization": f"Basic {token}"}

    def call(self, method, *params):
        body = json.dumps({"jsonrpc": "1.0", "id": "sbrc20", "method": method, "params": list(params)})
        req = urllib.request.Request(self.url, body.encode(), self.headers)
        with urllib.request.urlopen(req, timeout=120) as r:
            res = json.loads(r.read(), parse_float=Decimal)  # Decimal: no float rounding on BTC values
        if res.get("error"):
            raise RuntimeError(f"RPC {method}: {res['error']}")
        return res["result"]


class Ord:
    def __init__(self, url):
        self.url = url.rstrip("/")

    def _get(self, path, as_json=True):
        headers = {"Accept": "application/json"} if as_json else {}
        req = urllib.request.Request(self.url + path, headers=headers)
        with urllib.request.urlopen(req, timeout=60) as r:
            data = r.read()
        return json.loads(data) if as_json else data

    def block_inscription_ids(self, height):
        ids, page = [], 0
        while True:
            res = self._get(f"/inscriptions/block/{height}/{page}")
            ids.extend(res.get("ids", []))
            if not res.get("more"):
                return ids
            page += 1

    def inscription(self, iid):
        return self._get(f"/inscription/{iid}")

    def content(self, iid):
        return self._get(f"/content/{iid}", as_json=False)


# ---------------------------------------------------------------- database

SCHEMA = """
CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT);
CREATE TABLE IF NOT EXISTS tokens (
  tick TEXT PRIMARY KEY, max TEXT, lim TEXT, dec INTEGER, minted TEXT,
  deployer TEXT, deploy_id TEXT, deploy_height INTEGER,
  fee_sats INTEGER, fee_addr TEXT, start INTEGER, blocks INTEGER, dbl INTEGER
);
CREATE TABLE IF NOT EXISTS balances (
  address TEXT, tick TEXT, available TEXT, transferable TEXT,
  PRIMARY KEY (address, tick)
);
CREATE TABLE IF NOT EXISTS pending_transfers (
  inscription_id TEXT PRIMARY KEY, outpoint TEXT, sat_offset INTEGER,
  sender TEXT, tick TEXT, amt TEXT, height INTEGER
);
CREATE INDEX IF NOT EXISTS pt_outpoint ON pending_transfers(outpoint);
CREATE TABLE IF NOT EXISTS events (
  id INTEGER PRIMARY KEY AUTOINCREMENT, height INTEGER, txid TEXT, inscription_id TEXT,
  op TEXT, tick TEXT, from_addr TEXT, to_addr TEXT, amt TEXT, note TEXT
);
CREATE TABLE IF NOT EXISTS block_hashes (height INTEGER PRIMARY KEY, block_hash TEXT, state_hash TEXT);
"""


class DB:
    def __init__(self, path):
        self.conn = sqlite3.connect(path, check_same_thread=False)
        self.conn.row_factory = sqlite3.Row
        self.conn.executescript(SCHEMA)
        self.lock = threading.Lock()

    def meta(self, k, default=None):
        row = self.conn.execute("SELECT v FROM meta WHERE k=?", (k,)).fetchone()
        return row["v"] if row else default

    def set_meta(self, k, v):
        self.conn.execute("INSERT OR REPLACE INTO meta (k, v) VALUES (?, ?)", (k, str(v)))

    def token(self, tick):
        return self.conn.execute("SELECT * FROM tokens WHERE tick=?", (tick,)).fetchone()

    def balance(self, address, tick):
        row = self.conn.execute(
            "SELECT available, transferable FROM balances WHERE address=? AND tick=?", (address, tick)
        ).fetchone()
        return (int(row["available"]), int(row["transferable"])) if row else (0, 0)

    def set_balance(self, address, tick, available, transferable):
        assert available >= 0 and transferable >= 0, "negative balance: indexer bug"
        self.conn.execute(
            "INSERT OR REPLACE INTO balances (address, tick, available, transferable) VALUES (?,?,?,?)",
            (address, tick, str(available), str(transferable)),
        )


# ---------------------------------------------------------------- helpers


def to_sats(btc):
    return int((Decimal(btc) * 100_000_000).to_integral_value())


def parse_amount(s, dec):
    """'1000' or '12.5' -> integer in base units. Returns None if invalid."""
    if not isinstance(s, str) or not AMOUNT_RE.match(s):
        return None
    whole, _, frac = s.partition(".")
    if len(frac) > dec:
        return None
    value = int(whole) * 10**dec + int((frac or "0").ljust(dec, "0") or "0")
    return value if value > 0 else None


def fmt_amount(units, dec):
    if dec == 0:
        return str(units)
    s = str(units).rjust(dec + 1, "0")
    whole, frac = s[:-dec], s[-dec:].rstrip("0")
    return f"{whole}.{frac}" if frac else whole


def output_address(vout):
    spk = vout.get("scriptPubKey", {})
    return spk.get("address")  # None for OP_RETURN / non-standard


def parse_payload(raw):
    try:
        data = json.loads(raw.decode("utf-8"))
    except (UnicodeDecodeError, json.JSONDecodeError):
        return None
    if not isinstance(data, dict) or data.get("p") != PROTOCOL:
        return None
    return data


# ---------------------------------------------------------------- indexer


class Indexer:
    def __init__(self, cfg):
        self.cfg = cfg
        self.rpc = BitcoinRPC(cfg["BITCOIN_RPC_URL"], cfg["BITCOIN_RPC_USER"], cfg["BITCOIN_RPC_PASS"])
        self.ord = Ord(cfg["ORD_URL"])
        self.db = DB(cfg["DB_PATH"])
        self._events = []

    # --- event log + state hash -------------------------------------

    def emit(self, height, txid, iid, op, tick, frm, to, amt, note=""):
        row = (height, txid, iid, op, tick, frm, to, None if amt is None else str(amt), note)
        self.db.conn.execute(
            "INSERT INTO events (height, txid, inscription_id, op, tick, from_addr, to_addr, amt, note)"
            " VALUES (?,?,?,?,?,?,?,?,?)",
            row,
        )
        self._events.append(row)

    def close_block(self, height, block_hash):
        prev = self.db.conn.execute(
            "SELECT state_hash FROM block_hashes WHERE height < ? ORDER BY height DESC LIMIT 1", (height,)
        ).fetchone()
        h = hashlib.sha256((prev["state_hash"] if prev else "").encode())
        for e in self._events:
            h.update(json.dumps(e, separators=(",", ":")).encode())
        self.db.conn.execute(
            "INSERT OR REPLACE INTO block_hashes (height, block_hash, state_hash) VALUES (?,?,?)",
            (height, block_hash, h.hexdigest()),
        )
        self._events = []

    # --- main loop ----------------------------------------------------

    def run_forever(self):
        while True:
            try:
                tip = self.rpc.call("getblockcount")
                target = tip - self.cfg["CONFIRMATIONS"] + 1
                nxt = int(self.db.meta("last_height", self.cfg["START_HEIGHT"] - 1)) + 1
                if nxt > target:
                    time.sleep(20)
                    continue
                for height in range(nxt, target + 1):
                    self.index_block(height)
            except (urllib.error.URLError, RuntimeError, ConnectionError) as e:
                print(f"[warn] {e}; retrying in 15s", file=sys.stderr)
                time.sleep(15)

    def index_block(self, height):
        block_hash = self.rpc.call("getblockhash", height)
        block = self.rpc.call("getblock", block_hash, 3)  # verbosity 3 includes prevouts
        ids = self.ord.block_inscription_ids(height)

        # group this block's inscriptions by reveal txid
        by_tx = {}
        for iid in ids:
            txid, _, idx = iid.rpartition("i")
            by_tx.setdefault(txid, []).append((int(idx), iid))

        with self.db.lock, self.db.conn:  # one atomic transaction per block
            for tx in block["tx"]:
                # 1) movements of pending transfer inscriptions spent by this tx
                self.process_transfer_spends(height, tx)
                # 2) new SBRC-20 inscriptions revealed in this tx
                for idx, iid in sorted(by_tx.get(tx["txid"], [])):
                    if idx != 0:
                        continue  # spec: only the first inscription of a reveal tx counts
                    self.process_inscription(height, tx, iid)
            self.close_block(height, block_hash)
            self.db.set_meta("last_height", height)
        print(f"[ok] block {height}: {len(ids)} inscriptions scanned")

    # --- inscriptions -------------------------------------------------

    def process_inscription(self, height, tx, iid):
        info = self.ord.inscription(iid)
        ctype = (info.get("content_type") or "").split(";")[0].strip()
        if ctype not in ACCEPTED_CONTENT_TYPES:
            return
        data = parse_payload(self.ord.content(iid))
        if data is None:
            return
        owner = output_address(tx["vout"][0]) if tx["vout"] else None
        if owner is None:
            return  # inscribed to OP_RETURN / non-standard output: ignored
        op = data.get("op")
        if op == "deploy":
            self.op_deploy(height, tx, iid, owner, data)
        elif op == "mint":
            self.op_mint(height, tx, iid, owner, data)
        elif op == "transfer":
            self.op_inscribe_transfer(height, tx, iid, owner, data)

    def op_deploy(self, height, tx, iid, owner, d):
        tick = d.get("tick")
        if not isinstance(tick, str) or len(tick.encode("utf-8")) != TICK_LEN:
            return
        tick = tick.lower()
        if self.db.token(tick):
            return  # first valid deploy wins
        try:
            dec = int(d.get("dec", MAX_DEC))
        except (TypeError, ValueError):
            return
        if not 0 <= dec <= MAX_DEC:
            return
        mx = parse_amount(d.get("max"), dec)
        lim = parse_amount(d.get("lim", d.get("max")), dec)
        if mx is None or lim is None or lim > mx:
            return

        # SBRC-20 extensions (all optional): mint fee, fee address, mint window, doubling period
        try:
            fee = int(d.get("fee", 0))
            start = int(d.get("start", height))
            blocks = int(d.get("blocks", 0))  # 0 = no end
            dbl = int(d.get("dbl", 0))  # 0 = fee never doubles
        except (TypeError, ValueError):
            return
        faddr = d.get("faddr")
        if fee < 0 or blocks < 0 or dbl < 0 or start < height:
            return
        if fee > 0 and not isinstance(faddr, str):
            return

        self.db.conn.execute(
            "INSERT INTO tokens VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
            (tick, str(mx), str(lim), dec, "0", owner, iid, height, fee, faddr, start, blocks, dbl),
        )
        self.emit(height, tx["txid"], iid, "deploy", tick, owner, None, mx)

    def mint_fee_due(self, t, height):
        if not t["fee_sats"]:
            return 0
        doublings = (height - t["start"]) // t["dbl"] if t["dbl"] else 0
        return t["fee_sats"] * (2**doublings)

    def op_mint(self, height, tx, iid, owner, d):
        tick = str(d.get("tick", "")).lower()
        t = self.db.token(tick)
        if not t:
            return
        amt = parse_amount(d.get("amt"), t["dec"])
        if amt is None or amt > int(t["lim"]):
            return
        # mint window
        if height < t["start"] or (t["blocks"] and height >= t["start"] + t["blocks"]):
            self.emit(height, tx["txid"], iid, "mint-rejected", tick, None, owner, amt, "outside mint window")
            return
        # mint fee: the reveal transaction must pay at least the current fee to the fee address
        due = self.mint_fee_due(t, height)
        if due:
            paid = sum(to_sats(v["value"]) for v in tx["vout"] if output_address(v) == t["fee_addr"])
            if paid < due:
                self.emit(height, tx["txid"], iid, "mint-rejected", tick, None, owner, amt,
                          f"fee paid {paid} < due {due}")
                return
        remaining = int(t["max"]) - int(t["minted"])
        if remaining <= 0:
            return
        amt = min(amt, remaining)  # BRC-20 rule: last mint gets whatever is left
        self.db.conn.execute("UPDATE tokens SET minted=? WHERE tick=?", (str(int(t["minted"]) + amt), tick))
        av, tr = self.db.balance(owner, tick)
        self.db.set_balance(owner, tick, av + amt, tr)
        self.emit(height, tx["txid"], iid, "mint", tick, None, owner, amt)

    def op_inscribe_transfer(self, height, tx, iid, owner, d):
        """Step 1 of a BRC-20 style transfer: lock amount into a transfer inscription."""
        tick = str(d.get("tick", "")).lower()
        t = self.db.token(tick)
        if not t:
            return
        amt = parse_amount(d.get("amt"), t["dec"])
        if amt is None:
            return
        av, tr = self.db.balance(owner, tick)
        if amt > av:
            return  # insufficient available balance: inscription is inert
        self.db.set_balance(owner, tick, av - amt, tr + amt)
        self.db.conn.execute(
            "INSERT INTO pending_transfers VALUES (?,?,?,?,?,?,?)",
            (iid, f"{tx['txid']}:0", 0, owner, tick, str(amt), height),
        )
        self.emit(height, tx["txid"], iid, "inscribe-transfer", tick, owner, None, amt)

    # --- transfer movements -------------------------------------------

    def process_transfer_spends(self, height, tx):
        """Step 2 of a transfer: the first time a transfer inscription moves, settle it.

        Uses ordinal theory (first-in-first-out sat flow) to find which output
        the inscribed sat lands in.
        """
        if "coinbase" in tx["vin"][0]:
            return
        input_offset = 0
        hits = []
        for vin in tx["vin"]:
            outpoint = f"{vin['txid']}:{vin['vout']}"
            for row in self.db.conn.execute("SELECT * FROM pending_transfers WHERE outpoint=?", (outpoint,)):
                hits.append((input_offset + row["sat_offset"], row))
            input_offset += to_sats(vin["prevout"]["value"])
        if not hits:
            return

        out_ranges, cum = [], 0
        for i, v in enumerate(tx["vout"]):
            val = to_sats(v["value"])
            out_ranges.append((cum, cum + val, i, v))
            cum += val

        for abs_offset, row in hits:
            receiver = None
            for lo, hi, _i, v in out_ranges:
                if lo <= abs_offset < hi:
                    receiver = output_address(v)
                    break
            tick, sender, amt = row["tick"], row["sender"], int(row["amt"])
            s_av, s_tr = self.db.balance(sender, tick)
            if receiver is None:
                # spent as fee, or sent to OP_RETURN / non-standard: amount returns to sender
                self.db.set_balance(sender, tick, s_av + amt, s_tr - amt)
                self.emit(height, tx["txid"], row["inscription_id"], "transfer-returned", tick,
                          sender, sender, amt, "inscription lost to fee or unspendable output")
            else:
                self.db.set_balance(sender, tick, s_av, s_tr - amt)
                r_av, r_tr = self.db.balance(receiver, tick)
                self.db.set_balance(receiver, tick, r_av + amt, r_tr)
                self.emit(height, tx["txid"], row["inscription_id"], "transfer", tick, sender, receiver, amt)
            # only the first movement counts; the inscription is now spent
            self.db.conn.execute("DELETE FROM pending_transfers WHERE inscription_id=?", (row["inscription_id"],))


# ---------------------------------------------------------------- HTTP API


def make_handler(db):
    class Handler(BaseHTTPRequestHandler):
        def _send(self, code, obj):
            body = json.dumps(obj, indent=2).encode()
            self.send_response(code)
            self.send_header("Content-Type", "application/json")
            self.send_header("Access-Control-Allow-Origin", "*")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)

        def log_message(self, *args):
            pass

        def do_GET(self):
            url = urlparse(self.path)
            parts = [p for p in url.path.split("/") if p]
            q = parse_qs(url.query)
            with db.lock:
                try:
                    return self.route(parts, q)
                except Exception as e:  # keep the API up even on a bad request
                    return self._send(500, {"error": str(e)})

        def route(self, parts, q):
            c = db.conn
            if parts == ["status"]:
                last = c.execute("SELECT * FROM block_hashes ORDER BY height DESC LIMIT 1").fetchone()
                return self._send(200, dict(last) if last else {"height": None})
            if len(parts) == 2 and parts[0] == "state":
                row = c.execute("SELECT * FROM block_hashes WHERE height=?", (int(parts[1]),)).fetchone()
                return self._send(200 if row else 404, dict(row) if row else {"error": "not indexed"})
            if len(parts) == 2 and parts[0] == "token":
                t = db.token(parts[1].lower())
                if not t:
                    return self._send(404, {"error": "unknown tick"})
                d = dict(t)
                for k in ("max", "lim", "minted"):
                    d[k] = fmt_amount(int(d[k]), t["dec"])
                return self._send(200, d)
            if len(parts) == 2 and parts[0] == "balance":
                rows = c.execute(
                    "SELECT b.*, t.dec FROM balances b JOIN tokens t ON t.tick=b.tick WHERE b.address=?",
                    (parts[1],),
                ).fetchall()
                return self._send(200, [
                    {"tick": r["tick"], "available": fmt_amount(int(r["available"]), r["dec"]),
                     "transferable": fmt_amount(int(r["transferable"]), r["dec"])} for r in rows
                ])
            if len(parts) == 2 and parts[0] == "holders":
                t = db.token(parts[1].lower())
                if not t:
                    return self._send(404, {"error": "unknown tick"})
                rows = c.execute("SELECT * FROM balances WHERE tick=?", (t["tick"],)).fetchall()
                holders = sorted(
                    ((r["address"], int(r["available"]) + int(r["transferable"])) for r in rows),
                    key=lambda x: -x[1],
                )
                limit = int(q.get("limit", ["100"])[0])
                return self._send(200, [
                    {"address": a, "total": fmt_amount(v, t["dec"])} for a, v in holders[:limit] if v > 0
                ])
            if len(parts) == 2 and parts[0] == "events":
                rows = c.execute(
                    "SELECT * FROM events WHERE height=? ORDER BY id", (int(parts[1]),)
                ).fetchall()
                return self._send(200, [dict(r) for r in rows])
            return self._send(404, {
                "error": "not found",
                "routes": ["/status", "/state/<height>", "/token/<tick>", "/balance/<address>",
                           "/holders/<tick>?limit=100", "/events/<height>"],
            })

    return Handler


def main():
    idx = Indexer(CFG)
    server = ThreadingHTTPServer((CFG["API_HOST"], CFG["API_PORT"]), make_handler(idx.db))
    threading.Thread(target=server.serve_forever, daemon=True).start()
    print(f"[api] listening on http://{CFG['API_HOST']}:{CFG['API_PORT']}")
    idx.run_forever()


if __name__ == "__main__":
    main()