someone asked for a block explorer in a workshop signal group on a tuesday afternoon. ninety minutes later one was live at ctaz.frontiercompute.cash, source MIT at github.com/Frontier-Compute/ctaz-explorer. this is a writeup of what it took and which choices cost time.
the tldr: about five hundred lines of python and jinja, no build step, no javascript framework, no analytics, no cookies, eighteen routes, server-side svg charts, systemd unit, nginx reverse proxy, lets encrypt cert. the hardest part was a port collision.
the chain
zcash crosslink is a hybrid consensus design. proof of work blocks are produced as usual, and a bft finality gadget stamps them finalized after a delay. it is currently running as an incentivized feature net called ctaz, which is short for crosslink testnet. the protocol page is at shieldedlabs.net/crosslink.
the practical consequence for an explorer is that every block has two states that matter, not one. "this block exists on the chain" and "this block has been finalized by the bft layer" are different questions with different answers. so every surface that shows a block needs to answer both.
there was no public explorer for the ctaz feature net at the time of writing. the protocol had a running node, a json rpc, and a workshop full of engineers staring at raw hex.
stack choices
the choices took about four minutes of deliberation, because the constraint was time and also, do not build anything that needs a sidecar service to render a page.
fastapi + jinja2. python is what the operator already had installed and warm. fastapi gives typed request handling and automatic openapi. jinja2 renders strings on the server. a react or svelte client would have required a build step, a bundler, and a deploy pipeline. on a ninety minute clock those are cost centers, not features.
nginx + certbot. cloudflare in front of a site gives tls and a cdn, and also gives cloudflare a view into every request and every ip. nginx with lets encrypt gives tls and nothing else, which is the correct amount of third party for a tool whose purpose is to let people verify things themselves.
systemd unit. a single python process with a virtualenv did not need a container runtime. the systemd unit is sixteen lines, the environment variable for the rpc port lives inline, and systemctl status ctaz-explorer is shorter to type than the docker equivalent.
no database. every page is rendered from a live rpc call. the rpc node is on the same box, so latency is microseconds. premature persistence is how weekend projects become monorepos.
the rpc dance
this part was not ninety minutes of clean flow. the rpc stack needed debugging.
zebra-crosslink, the monolith fork that runs the bft layer, builds from commit d880b65 tagged "speculative build" by sam smith. it compiles, it syncs, it exposes the standard zebra json rpc surface. the default rpc port is 8232.
the default rpc port was already taken. an unrelated docker zebra-mainnet on the same vps had claimed 8232 months earlier. so the ctaz explorer runs its backing zebra-crosslink on 18232 via an env var override.
# /etc/systemd/system/ctaz-explorer.service
[Service]
Environment=ZEBRAD_RPC_URL=http://127.0.0.1:18232
ExecStart=/opt/ctaz-explorer/.venv/bin/uvicorn main:app --host 127.0.0.1 --port 8088
ZEBRAD_RPC_URL = os.environ.get("ZEBRAD_RPC_URL", "http://127.0.0.1:8232")
async def rpc(method: str, params: list | None = None) -> dict:
async with httpx.AsyncClient(timeout=10.0) as c:
r = await c.post(
ZEBRAD_RPC_URL,
json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params or []},
)
return r.json()["result"]
thirty seconds of code, about twenty minutes to figure out why getblockcount was returning the wrong chain.
finality badges
zebra-crosslink exposes a method called get_tfl_block_finality_from_hash. tfl stands for trailing finality layer. given a block hash it returns one of "Finalized", "NotYetFinalized", or an error. that is almost the entire ux model for a hybrid pow plus bft chain in three string values.
async def finality_for(block_hash: str) -> str:
try:
r = await rpc("get_tfl_block_finality_from_hash", [block_hash])
return r or "Unknown"
except Exception:
return "Unknown"
three css classes, three colors. gold for finalized, blue for seen but not yet finalized, gray for unknown.
.badge-final { background: #d4a017; color: #000; }
.badge-pending { background: #2b6cb0; color: #fff; }
.badge-unknown { background: #4a5568; color: #fff; }
{% if block.finality == "Finalized" %}
<span class="badge badge-final">finalized</span>
{% elif block.finality == "NotYetFinalized" %}
<span class="badge badge-pending">not yet finalized</span>
{% else %}
<span class="badge badge-unknown">unknown</span>
{% endif %}
the badge shows up inline on the homepage block list, on the block detail page, and on every tx page since a tx inherits its block's finality. an honest detail: at the time of writing the finality gap on the ctaz feature net is about eighteen hundred blocks. the explorer displays that accurately. a chain does not need to be perfect for its explorer to be honest about where it is.
pool deltas
every zcash block returned by getblock verbose includes a valuePools array. one entry per shielded pool, with a valueDelta that tells you how much zec moved in or out of that pool in that block. the pools are transparent, sprout, sapling, orchard, and on crosslink there is also a lockbox pool.
no explorer on ctaz was showing per block pool flow. that was a gap worth filling, and the data was already there in the rpc response. so the explorer computes a two hundred block rolling window and renders one svg sparkline per pool, cumulatively, with no javascript involved.
def sparkline_svg(values: list[float], width: int = 280, height: int = 40) -> str:
if not values:
return ""
lo, hi = min(values), max(values)
span = (hi - lo) or 1.0
pts = []
for i, v in enumerate(values):
x = (i / max(len(values) - 1, 1)) * width
y = height - ((v - lo) / span) * height
pts.append(f"{x:.1f},{y:.1f}")
return (
f'<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg">'
f'<polyline fill="none" stroke="currentColor" stroke-width="1.5" '
f'points="{" ".join(pts)}"/></svg>'
)
the polyline string goes straight into the jinja template with the |safe filter. browser renders svg natively. no chart library, no hydration, no skeleton loader. the page ships in one request.
/pool/orchard, /pool/transparent, /pool/sapling, /pool/sprout, /pool/lockbox. each has a matching /api/pool/{id} that returns the raw series as json for anyone who wants to pipe it into their own tooling.
attestation registries
three small json files in the repo hold a registry for each of three protocols that sit on top of zcash: zap1 anchors, shieldedvault custody events, and zeven event streams. the explorer loads them at request time and matches them against the txid being viewed. if there is a match, a typed card renders on the tx page explaining which registry matched and what claim is being made.
REGISTRIES = {
"anchors": Path("data/anchors.json"),
"vaults": Path("data/vaults.json"),
"events": Path("data/events.json"),
}
def registry_matches(txid: str) -> list[dict]:
hits = []
for name, path in REGISTRIES.items():
if not path.exists():
continue
entries = json.loads(path.read_text())
for e in entries:
if e.get("txid") == txid:
hits.append({"registry": name, **e})
return hits
on the ctaz feature net these registries are currently empty, because no anchors or custody events have been published to this chain yet. the /anchors, /vaults, and /events pages say so honestly, with a one line explanation of what will land there when the feature net gets real traffic. fake demo content would have been faster to render and would have been a lie. empty state with a sentence of context costs nothing and keeps the explorer trustworthy.
/verify
one page, one input field, one purpose. paste a txid. get back a json object containing the block, the finality status, the pool deltas affected, and any registry matches. one request, one answer.
@app.get("/api/verify/{txid}")
async def api_verify(txid: str):
tx = await rpc("getrawtransaction", [txid, 1])
block = await rpc("getblock", [tx["blockhash"], 2])
return {
"txid": txid,
"block_height": block["height"],
"block_hash": block["hash"],
"finality": await finality_for(block["hash"]),
"value_pools": block.get("valuePools", []),
"registry_matches": registry_matches(txid),
}
no auth, no api key, no rate limit, curl friendly. the whole point of the tool is that every claim the explorer makes elsewhere is independently verifiable by anyone running their own crosslink node, and /verify is just the shortcut.
curl https://ctaz.frontiercompute.cash/api/verify/<txid> | jq
/why
a link at the top of the homepage labeled "why this exists" goes to a page explaining the design choices above in more detail, with every claim anchored to a file and line in the repo. a block explorer explaining why it renders its own pages is recursively silly, but it answers the most common question a first time visitor has.
what does not work
- the finality gap is large. the explorer shows it accurately but does not speed it up.
- the 200 block sparkline window is a constant in source, not configurable per request.
- no websocket push for new blocks, the homepage refreshes on page load only.
- no search across addresses yet, only direct address page lookup by string match.
- on rpc failure the page returns a 503 json blob, not a friendly error page with a retry hint.
none of those are hard. they are just not ninety minutes of work.
closing
this is not a product. there is no signup, no cta, no dashboard, no email capture. source is mit at github.com/Frontier-Compute/ctaz-explorer. if something looks wrong, open an issue. if something looks right, just use it.
the full route list, for reference:
/ home, five panel dashboard
/block/{x} block detail with finality badge and pool deltas
/tx/{x} tx detail with attestation cards if any match
/address/{x} address page
/finalizers bft finalizer set
/stake stake distribution
/params protocol params
/verify one shot proof tool
/why design notes
/pool/{id} per pool cumulative sparkline page (5 pools)
/anchors zap1 anchor registry
/vaults shieldedvault registry
/events zeven event stream
/api/* json versions of all of the above
eighteen routes, about five hundred lines of python and jinja, one afternoon. the ninety minutes is real but the ninety minutes is also the result of every choice above being the cheap one. react would have been a day. a database would have been a day. cloudflare in front would have been an hour of dns plus a permanent third party. none of those buy anything a feature net explorer needs on day one.
if the next person wants to fork it and point it at a different chain, the rpc url is an env var and the templates are plain jinja. that is the whole porting guide.
Top comments (0)