BSV block header tracking service on Cloudflare Workers. Rust compiled to WASM.
Reimplementation of the Node.js chaintracks-server as a single Cloudflare Worker, replacing a 2× VPS + Docker deployment. Serves block header queries and merkle root validation for SPV consumers.
- Polls the chain every minute (Workers cron) for new block headers through a COURIER LADDER: WhatsOnChain, Arcade's chaintracks v2 and Bitails as each other's fallbacks (see below)
- Stores headers in Cloudflare D1 (~945k rows), serves bulk headers from R2
- Exposes 12 HTTP endpoints matching the original TS server's public API
- Detects and handles reorgs up to 400 blocks deep
- Validates merkle roots for SPV consumers
A same-height competition at 965877 (2026-09-08) left this store 23 minutes behind the network: one courier fed the live path, its refusal of the competitor parent aborted the cron on ? before the announce and the repair, and with observability off nothing recorded why. Since then:
- Three couriers, one ladder (
src/couriers.rs): WhatsOnChain, Arcade's chaintracks v2 (/chaintracks/v2/tip|header/height|header/hash) and Bitails (/network/info,/block/height/{h},/block/{hash}, the raw header hex). The start rung rotates per minute; a rung that faults three times in a tick is skipped for the rest of it and counted;chain_infoasks every rung and follows the HIGHEST tip (a lagging courier never reads as the chain), and that rung is asked first for the tick's headers. The reference (Chaintracks.addLiveHeader→getMissingBlockHeaderacross every live ingestor) does the same for a missing parent. - Availability, not authority. Every answer is bound to the question (the height or the hash asked), to its own bytes (the hash is recomputed from the fields) and to its own proof of work (
BlockHeader::check_pow, the node'sCheckProofOfWork; see the next section). The store's most-work rule still decides the tip. A wrong answer is a faulting rung, never a header. - A poll fault is recorded, never an abort.
sync_state.last_error/last_error_athold the LAST fault (never cleared; judge it by its age);last_seen_height/last_seen_atthe highest tip any courier answered on the last cron with work. The cron's tail (the tip announce, the cumulative-work repair) runs on every pass. /getInfoservesliveLagBlocks(the seen height minus the stored tip; absent until the first cron after migration 0007, and absence is not health),lastSeenHeight,lastSeenAt,lastSyncError,lastSyncErrorAt, andsyncSchemaFaultwhen the health read itself fails (the migration not applied). a private program's fleet launcher refuses a launch on a lag above one block or an unknown lag.- Observability on (
wrangler.toml [observability]): every cron line, every courier fault, the per-tick tally (Cron: couriers: woc ok 3 faults 0 · arcade ok 0 faults 1 (skipped) · …). - Tests (
src/courier_tests.rs, the host harness): the loop-9 shape on one courier (recorded, the cron finishes), the ladder taking the parent from the next rung, the highest tip winning, the per-tick skip, the proof-of-work bind on real mainnet headers (genesis, 965900) and a lying courier, the lag and the fault on/getInfo. Each pin names its mutation.
A header enters the store only through the node's own rules, ported from bitcoin-sv v1.2.3 (src/consensus.rs names each function and line it ports): the compact bits refused when negative, overflowing, zero or above the chain's powLimit, then the hash at most the target (CheckProofOfWork; a bits exponent past 256 bits used to decode to the maximum target); the bits equal to GetNextWorkRequired's answer for the header's parent and time (the DAA on mainnet since 504032, the legacy retarget with the emergency adjustment before it, testnet's 20-minute rule, regtest's constant bits); the checkpoint at its height, and no new header below a checkpoint the store holds (CheckIndexAgainstCheckpoint). The checks run in storage::insert_header and storage::insert_headers_batch, the two writers of header rows, so every path runs them: the live cron, the backfill walk, the read-through grace path, the upstream catch-up, /admin/ingest, /admin/backfill, /admin/bulk-sync. A refusal names the node's reject reason (high-hash, bad-diffbits, checkpoint mismatch, bad-fork-prior-to-checkpoint, prev-blk-not-found) plus ours (bad-hash, bad-genesis, ancestry-missing); the cron records it and runs its tail, the read-through answers unable to verify, the admin routes answer 422.
- Anchors. A store starts from the genesis or a checkpoint: a batch with no stored parent must carry one (its hash vouches for the rows below it, which are the window the rows above it are checked against); a single header with no stored parent on an empty store is refused. On a store that holds a chain it is an orphan, stored inactive, never the tip, and checked when the backfill links it.
CHECKPOINTS(var, optional):"height:hash,..."added to the node's list (a listed height takes the new hash). Malformed, the cron skips and the routes answer 503.- The re-validation after the deploy (migration 0008): a store that held rows when the migration ran is re-checked from the last checkpoint it holds (mainnet's last is 530359), 2000 rows a cron tick, resumed from
sync_state.validated_height/validated_hash. Until it reaches the tip, roots and headers above the cursor are not served (isValidRootForHeightanswers unable to verify, the header routes 404, the tip routes the cursor's header); a refused row stops it (validation_fault) and nothing from that row up is served./admin/revalidate?steps=Ndrives up to 50 chunks a call;/admin/revalidate?restart=1restarts it after a repair./getInfoservesvalidatedHeight,validationComplete,validationFault. Rows at or below the anchor are vouched for by its hash and are not re-read. - Tests:
src/pow_tests.rsandsrc/retarget_tests.rs(RED on the base, each named), the node's own vectors insrc/consensus.rs(setcompact_test,bignum_SetCompact,get_next_work*,retargeting_test,cash_difficulty_test), and real chain runs from Teranode v0.16.0 (src/testdata/README.md). The host fixtures are mined under regtest'spowLimit.
Cloudflare Workers are stateless and request-based, so several TS server features are intentionally dropped or reshaped:
- No event subscriptions. TS exposes
subscribeHeaders()/subscribeReorgs()for live callbacks. Workers cannot hold persistent channels — consumers should poll/getInfoor/currentHeight. - No WebSocket ingestion. TS uses
LiveIngestorWhatsOnChainWsfor push ingest. Workers cannot hold outbound WebSocket connections. Rust uses HTTP polling via 1-minute cron. - Manual bulk header export. TS auto-exports CDN bulk header files every 30 min. Rust exposes
/admin/export-r2as a manual endpoint — trigger it directly or set up an external scheduler to call it. - No in-process watchdog. TS runs a self-check loop that restarts its Docker container on stall. Workers handle restarts at the infra layer; redundant.
- Simpler multi-source fallback. TS reads Babbage CDN + WhatsOnChain with watchdog failover. Rust falls back to a configurable upstream chaintracks URL during catch-up, then direct WhatsOnChain calls.
None of these are missing features — they're architectural trade-offs for serverless. All public HTTP endpoints are at parity with TS.
| Endpoint | Description |
|---|---|
GET / |
Health check (plain text) |
GET /getChain |
Returns "main" or "test" |
GET /getInfo |
Service status (height, header count, sync state) |
GET /currentHeight |
Current chain tip height |
GET /findChainTipHashHex |
Chain tip block hash |
GET /findChainTipHeaderHex |
Chain tip header (JSON object) |
GET /findHeaderHexForHeight?height=N |
Header at height N |
GET /findHeaderHexForBlockHash?hash=H |
Header by block hash (active chain only) |
GET /getHeaders?height=N&count=M |
M headers starting at N (concatenated hex) |
GET /isValidRootForHeight?root=R&height=N |
Validate merkle root at height |
GET /admin/bulk-sync?file=IDX |
Admin: ingest bulk header file from CDN |
GET /admin/export-r2 |
Admin: export D1 headers to R2 bulk files |
Request → lib.rs → routes.rs → storage.rs → D1
Cron 1m → lib.rs (scheduled) → sync.rs → WhatsOnChain → D1
Bulk → R2 bucket (CDN replacement)
- lib.rs — Worker entry:
#[event(fetch)]+#[event(scheduled)] - routes.rs — HTTP routing, 12 endpoints
- storage.rs — D1 read/write operations
- sync.rs — Cron-triggered chain sync, reorg detection
- d1.rs — Parameterized D1 query builder
- types.rs —
BlockHeader,Chain,ChaintracksInfo, 80-byte serialization
| Binding | Type | Purpose |
|---|---|---|
DB |
D1 | Block header storage |
BULK_HEADERS |
R2 | Bulk header binary files |
CHAIN |
Var | "main" or "test" |
WHATSONCHAIN_API_KEY |
Var/Secret | Optional WoC API key |
npm install
npm run dev # local dev (D1 emulated)
worker-build --release # build WASM
npm run deploy # deploy to Cloudflare Workers- Create a Cloudflare account, note your
account_id npx wrangler d1 create rust-chaintracks— record the returneddatabase_idnpx wrangler r2 bucket create rust-chaintracks-headers- Fill
wrangler.tomlwith youraccount_idanddatabase_id - Apply migrations:
npx wrangler d1 migrations apply rust-chaintracks --remote - Deploy:
npm run deploy - (Optional) Set WhatsOnChain key:
echo "<key>" | npx wrangler secret put WHATSONCHAIN_API_KEY
Quality gates — run all five before shipping:
cargo fmt --all
cargo clippy --target wasm32-unknown-unknown -- -D warnings
cargo check --target wasm32-unknown-unknown
cargo test --lib
worker-build --release- Unit tests:
cargo test(217 pass, one extended header run ignored; includes the chain-event witness on the host harness) - Comparison:
tests/e2e/compare.sh(13-test parity check against a reference chaintracks instance)
The versioned chain-event feed, cursor polling, SSE routes, Worker webhooks,
TypeScript compatibility view and client contract are in
docs/CHAIN-EVENTS.md. Apply migration 0009 after 0008;
the events follow P0-4's verification ceiling. Canonical webhook targets opt
in through CHAIN_EVENT_WEBHOOK_URLS and CHAIN_EVENT_WEBHOOK_TOKEN.
Any service implementing the ChainTracker trait from bsv-rs can point at this worker. Known consumers:
rust-wallet-infra— merkle root validationrust-overlay—WorkerChainTrackerfor/findHeaderHexForHeight,/currentHeight
MIT — see LICENSE.