// 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,040START_HEIGHT (block before deploy)
970039
Requirements
- Bitcoin Core 25 or newer, fully synced, with RPC enabled
ordrunning 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 blocksbrc20_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()