Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

blockwatcher

blockwatcher runs as a long-lived service that operators reconfigure entirely through its REST API: adding networks, contract specs, or delivery targets never requires stopping the process. Under the hood, it turns incoming on-chain data into typed values using the contract specifications you register, then runs your own conditions against that data to decide what counts as a match. Because none of this logic is chain-specific, chain support comes from swappable modules rather than the core itself. It hands off each match to whatever endpoint, script, or log file you’ve wired up.

How to read this wiki

I want to…Start with…
Understand what blockwatcher doesUnderstanding
Get it runningGetting started, Guides
Operate it in productionConcepts, Reference
Contribute or extend itInternals
Embed the engine in another processEmbedding

The whole system in one picture

flowchart LR
    rpc["RPC endpoints<br/>(external chains)"]
    %% Source modules are family-owned; the running list is GET /catalog, not this map.
    sources["Sources<br/>one per network"]
    sinks["Sinks<br/>webhook · script · log"]
    storage["Storage<br/>checkpoints · dead letters · resources"]
    api["REST API"]
    metrics["Metrics"]
    engine["engine<br/>bounded channels · checkpoints"]

    subgraph pipeline["Engine pipeline"]
        direction LR
        decoder["Decoder"]
        matcher["Matcher<br/>predicates"]
        gate["Gate<br/>threshold · max_once"]
        decoder --> matcher
        matcher --> gate
    end

    rpc --> sources
    sources -->|"decode and match"| decoder
    gate --> sinks
    api -->|"manages resources"| storage
    storage <--> engine
    engine -->|"drives"| pipeline
    engine -.->|"reports"| metrics

    classDef module fill:none,stroke:#a9a3e3
    classDef core fill:none,stroke:#8a8d86,stroke-dasharray: 5 5

    class rpc,sources,sinks,decoder,matcher,gate module
    class engine,api,storage,metrics core
click sources "concepts/selectors.html"
click decoder "concepts/chain-agnosticism.html"
click matcher "concepts/predicates.html"
click gate "concepts/gates.html"
click sinks "concepts/delivery.html"
click storage "concepts/resources.html"
click api "reference/http-api.html"
click metrics "reference/observability.html"
click engine "concepts/pipeline.html"

Local development

Build and serve the wiki:

cargo install mdbook mdbook-mermaid --locked
mdbook serve docs/wiki --open

Release notes

Consumer-visible changes are tracked in the root CHANGELOG.md.

Understanding blockwatcher

This part answers what blockwatcher is before any of it needs to run: the problem it exists to solve, the shape of the pipeline it builds for you, and the vocabulary the rest of the wiki assumes you already have. Read it first if you are deciding whether blockwatcher fits your problem, or if a later page uses a term you have not seen defined yet.

What is blockwatcher?

Smart contracts emit events and receive calls constantly: a transfer, an approval, a call to an administrative function, a change of ownership. A team that cares about a handful of these conditions is usually left with two bad options: poll a block explorer’s API on a timer and hope its rate limits and uptime cooperate, or write and operate a bespoke script against an RPC endpoint for every condition, one script per rule, each with its own crash handling and no shared guarantees. Neither approach scales past a few rules, and both leave a team owning infrastructure that has nothing to do with the business rule they actually care about.

blockwatcher is a long-running service built to take that watching-and-deciding work off a team’s hands, for any number of rules across any number of chains it has a module for.

What blockwatcher does

At the center of an blockwatcher deployment sit four kinds of resource, and configuring all four is what makes something happen. A network tells blockwatcher where to look: a chain and the RPC endpoint(s) to pull activity from. A contract spec tells it what a piece of on-chain data means (an ABI, compiled once into a schema the rest of the system reasons about from then on). A monitor ties a network to a spec, names which addresses and which events or functions to pay attention to via its selectors, and optionally narrows that further with a predicate, a condition over the decoded fields. An optional gate can require several such hits, or cap alerts, before anything is delivered. A sink says where a hit should go.

Wire all four together and the loop needs no further attention from you: blockwatcher keeps ingesting from the network and checking every new occurrence against every active monitor, dispatching whatever counts as a match; see How it works for that loop traced step by step. None of this needs a process restart to change: every one of the four resource kinds above is created, edited, and deleted through a REST API while blockwatcher keeps running, and an edit takes effect on the running pipeline within the same request.

What blockwatcher is not

blockwatcher doesn’t keep a queryable history. It has no store of past chain state you can run open-ended historical questions against: it reacts to activity as it arrives (or, for a one-off test run, over a bounded range you specify through the API), and what it retains about anything it has already processed is limited to its own operational bookkeeping: checkpoints, dead letters, counters.

It also isn’t a place to build dashboards or discover trends. There is no aggregation layer, no time-series rollup, no chart: a match either reaches a destination you control or ends up in blockwatcher’s dead-letter store; anything you do with the resulting stream happens downstream, in whatever receives it.

And it doesn’t participate in a chain. blockwatcher holds no key, has no consensus role, and issues no transactions of its own: it only reads, through whatever RPC endpoint a network names, and its own reliability is bounded by that endpoint’s.

The four promises

No silent gaps. A slow sink never causes blockwatcher to drop anything to keep up: the pipeline holds events in its internal queues under backpressure rather than discarding work, so a struggling delivery target slows the whole pipeline down instead of losing data out of it. Recovering from a crash follows the same rule: blockwatcher resumes exactly where its last persisted checkpoint says it left off, which can mean re-sending a match it had already delivered but never means skipping one it hadn’t reached yet. The one place this promise does not hold is a source whose cursor is not a chain position: a restart there can genuinely lose whatever was mid-flight. Full mechanics: Delivery guarantees.

Bad configuration never fires silently. The failure mode this closes is specific: a monitor that looks correctly wired, runs for months, and never once fires because one field name has a typo three levels deep in a predicate: nothing about a monitor like that announces its own mistake. blockwatcher forecloses it by parsing, type-checking, and compiling every predicate and selector against the schema its monitor actually exposes the moment it’s written, rejecting anything it can’t resolve and naming its best guess at the field you meant. Full mechanics: Predicates and the expression language.

No chain knowledge leaks into the core. Every chain family, however it encodes its own data, normalizes into the same canonical value model before anything past the decoder ever touches it (arbitrary-precision integers included, so a 256-bit token amount survives decode to delivery without losing precision). That boundary is enforced, not just assumed: the engine that runs pipelines, tracks checkpoints, and serves the API never imports a chain SDK, an HTTP client, or a storage driver, and a CI gate breaks the build the moment a core crate tries. Full mechanics: Chain-agnosticism.

Swap any stage without touching the rest. How blockwatcher reads a chain, decodes its data, evaluates conditions, gates hits, delivers matches, and persists state are six independent choices, each an interchangeable implementation behind a small port trait, selected in config by name. A new chain family, or a new delivery target, arrives as its own module without the engine changing to accommodate it. Full mechanics: Modules and trade-offs.

How it works in one picture

Every network you register runs its own pipeline: a chain-specific source feeding a chain-agnostic decode-and-match stage, fanning out to whichever sinks the matching monitors name. A separate engine wires those pipelines up from resources managed over the REST API, and owns the bounded queues and checkpoint bookkeeping that make delivery safe to resume after a restart.

flowchart LR
    subgraph pipeline["one pipeline per network"]
        direction LR
        src("Source"):::module
        dec("Decoder"):::module
        mat("Matcher"):::module
        gate("Gate"):::core
        sw("Sink worker<br/>throttle · aggregate"):::core
        wh("Webhook sink"):::module
        scr("Script sink"):::module
        lg("Log sink"):::module
        src --> dec
        dec --> mat
        mat --> gate
        gate --> sw
        sw --> wh
        sw --> scr
        sw --> lg
    end

    api("REST API"):::core -->|writes resources| eng
    sto("Storage port<br/>checkpoints · dead letters"):::module <--> eng
    eng("engine<br/>bounded channels · checkpoint tracker"):::core --> pipeline
    eng -. interest hints .-> src

    classDef module fill:none,stroke:#a9a3e3
    classDef core fill:none,stroke:#8a8d86,stroke-dasharray: 5 5

Boxes drawn with a solid outline are swappable modules: an operator picks a source module by name, webhook, script, or log for each sink, and so on. The engine and the REST API, drawn with a dashed outline, are fixed: every pipeline is built the same way regardless of which modules fill its module slots. Which source and decoder names a build carries is GET /catalog; what each chain family costs is on the family page.

One event’s journey

  1. A source pulls activity. The module the network’s source.module names polls or subscribes according to its own trade-off, and what reaches the next stage is a raw, chain-native payload tagged with a cursor, the source’s own position marker.

  2. The decoder normalizes it. Decoder takes that raw payload and the compiled schema from the relevant contract spec, and turns it into blockwatcher’s canonical value model, the same shape of data regardless of which chain family produced it. Nothing past this point knows or cares which family the source belonged to.

  3. The matcher evaluates every monitor. For each of the network’s active monitors whose selectors apply to this occurrence, Matcher runs the monitor’s compiled predicate (if it has one) against the decoded fields. A monitor with no predicate accepts everything its selectors already narrowed down to.

  4. A gate may stay quiet. If the monitor names a gate, the engine offers this hit and the persisted journal to that module. Retain or Discard mints nothing and does not stall the checkpoint. Emit mints a match or digest and continues into delivery.

  5. A match is fanned out. Every monitor that accepted the occurrence (Emit / passthrough) produces one match with a deterministic id, and the engine routes it to a Sink worker for every sink that monitor’s actions name. A sink with no throttle or aggregate configured delivers that match immediately; a sink with one of those set instead holds it in a window, dropping or bundling matches per the policy, and eventually delivers a plain match or a digest of everything the window collected. Either way delivery is one attempt per sink, independently retried.

  6. The checkpoint waits for the whole event. The engine only advances the network’s checkpoint past this event once every match it produced (across every monitor and every sink) has either been delivered or exhausted its retries and become a dead letter. Quiet gate hits are already done (outstanding 0). An event that produced no matches at all completes immediately; an event with five dispatched deliveries waits for all five.

If the process crashes anywhere in that sequence, it resumes on restart from the last checkpoint that step 6 actually persisted. Anything from an event at or before that checkpoint is done: matches were delivered or dead-lettered before the checkpoint advanced. Anything after it is redone from scratch, meaning a match already delivered right before the crash can be delivered again, but nothing already accepted onto a queue is ever skipped. A duplicate is possible; a gap is not.

Glossary

Every term below is defined by how blockwatcher’s code actually behaves, not by a general blockchain-industry sense of the word. Terms are alphabetical; each links to the others it depends on.

At-least-once delivery

blockwatcher’s delivery guarantee for a source whose cursor is a real chain position: a network’s checkpoint advances past an event only once every sink event that event produced has either reached its sink or been recorded as a dead letter after exhausting retries, never while a delivery is still outstanding. Quiet gate hits are not deliveries. An emitted match after a gate is still at-least-once (replay can duplicate). Both Match and Retracted are at-least-once; a consumer must treat a redelivered duplicate as the same occurrence (idempotent on match_id). Because the checkpoint is what a restart resumes from, the only way delivery can go wrong across a crash is repeating work already sent, never dropping work that was never sent. A source whose cursor is not a chain position is not covered: a crash there can lose whatever was in flight. Which shipped sources that is, and what to deduplicate on, is on the family page.

Backpressure

What happens instead of dropping data when one pipeline stage runs behind another. A pipeline’s stages (source, decode-and-match, per-sink delivery) are connected by fixed-size queues; a stage that produces faster than the next one consumes simply waits for room in that queue before it can hand off more work. A source therefore never sees “buffer full, discard”: it sees its own send call take longer, which is the entire backpressure mechanism.

Canonical value model

The one representation every decoder normalizes chain-native data into, and the only representation a matcher, gate, or sink ever has to understand. It covers null, boolean, arbitrary-precision signed and unsigned integers, byte strings, addresses, strings, arrays, and ordered maps. Arbitrary-precision integers are why a 256-bit token amount compares and serializes exactly rather than losing precision the way a JSON number would; each chain family’s decoder is responsible for encoding its own values into this model without two distinct native values ever colliding on the same canonical one.

Checkpoint

The resume point a network’s pipeline persists: a cursor plus whatever extra state the source that wrote it needs to verify a safe resume (a block hash for reorg linkage, for instance). It advances only past a fully finished prefix of events: one still-outstanding sink event anywhere in that prefix holds the whole checkpoint back, even if later events already finished. A positively detected invalidate is the one case that rewinds it, and only backward to the proven from cursor, only after retracts have finished; a rewind that cannot move the cursor backward (the invalidation’s cursor is not behind the stored checkpoint, no checkpoint is stored, or the post-drain read failed) is refused and counted as blockwatcher_rewinds_refused_total, leaving the stored checkpoint unmoved and an absent one absent rather than fabricated. A gate Retain/Discard does not leave outstanding work; only an emitted match/digest does. Held hits persist in gate_hits without holding the checkpoint. Every checkpoint also records which source module wrote it, and resuming it under a different module is refused outright, because a cursor’s two numbers mean nothing outside the module that produced them.

Contract spec

A resource pairing a chain identifier with that chain’s decode artifact as an opaque payload. The payload shape is decoder-owned; see the family page. Writing a spec triggers its chain’s decoder to compile that artifact once into chain-agnostic event and function schemas; every selector and predicate that references the spec afterward works from those compiled schemas, never from the raw payload again. A spec has no module field of its own: its chain field alone determines which decoder compiles it.

Cursor

A pair of numbers a source uses to mark its own position in its feed, meaningful only inside the module that wrote them. How a family packs those numbers is on the family page. Core code only ever compares and orders cursors: it never interprets what the numbers mean.

Dead letter

The record blockwatcher keeps of a sink event that could not be delivered after its sink’s retry budget ran out. It carries the match’s id, which monitor and sink produced it, the cursor it traces back to, how many attempts were made, why the last one failed, and, for events produced after this field was added, the original SinkEvent payload, which lets a Match or a Digest be replayed through the API. Legacy rows stored as a bare Match object still load as SinkEvent::Match. A retraction payload cannot be replayed. Recording a dead letter, rather than discarding the event outright, is what lets the checkpoint move past work that genuinely could not be delivered without pretending nothing happened.

Decoder

The port that turns a source’s raw payload into the canonical value model, one implementation per chain family. Nothing about the port boundary limits a deployment to one chain family; each compiled-in family registers its decoder through the same catalog. A decoder compiles a contract spec’s payload into reusable schemas once, at write time, and decodes every later occurrence against those schemas rather than re-parsing the original artifact each time.

Delivery journal

The bounded per-network log of Match ids that were delivered or dead-lettered, used to emit Retracted events after an invalidate. Depth is instance config journal_depth (default 1024, in cursor primary units). Rows older than that window are self-pruned on the next write; there is no operator prune API. A successful retract forgets its row; a dead-lettered retract keeps it so a later invalidate can re-offer. See Delivery guarantees § The delivery journal.

Gate

A port that decides, for one monitor, whether a decoded occurrence that already passed selector and predicate becomes a match (or digest), given an engine-owned journal of earlier hits. threshold and max_once key windows on block.timestamp. Omit the monitor’s gate field for passthrough. See Gates. Distinct from sink throttle / aggregate, which run after a match exists.

Invalidate

A typed source outcome, SourceOutcome::Invalidated { from }, meaning everything with cursor > from previously implied by this source is on a dead fork and must not be resumed past from. It is not a SourceError. from is a cursor the source can defend with evidence: a proven fork point, or, when every tracked ancestor is refuted, just below the oldest tracked height (the minimal rewind that evidence permits). A source that holds no such evidence does not return this outcome. Core never names a chain or a reorg; only the source module decides when to return this. The engine drains that network, prunes gate_hits with cursor > from (rows with cursor ≤ from stay), retracts journaled deliveries after from (to that network’s sinks, or after delete to the sinks recorded on its runtime tombstone), rewinds the checkpoint when from is behind it (leaving it unmoved otherwise), then restarts. See Delivery guarantees § Shallow vs deep invalidation.

Match

One occurrence where a monitor’s selectors picked up a decoded event and its predicate, if it has one, accepted it. A match carries a deterministic id derived from the network, the monitor, the decoded event, and an index among the matches that one event produced, so a consumer that receives the same match twice under at-least-once delivery can recognize the duplicate by id, except from a source whose cursor is not a stable chain position, where the same occurrence can carry two different ids across a restart.

Matcher

The port that evaluates a compiled predicate against a decoded event and reports whether it counts as a match. The shipped implementation, expr, is selected once for the whole instance in blockwatcher.toml rather than per monitor, and does not become per-monitor because Gate exists. The port boundary means an alternative predicate engine is a possible module, not a hardcoded choice.

Mempool

The set of transactions a node has received but has not yet included in a mined block. A source that watches that feed trades earlier visibility for a cursor that is not a chain position. Which shipped sources do that is on the family page.

Module

Any of the six swappable behaviors blockwatcher selects by name plus a module-specific config object: source, decoder, matcher, gate, sink, and storage backend. Every module implements exactly one port trait, and a module name that was never registered into the running binary is refused at write or boot time with the list of names that were.

Monitor

A resource that ties everything else together: it names one network to watch, one or more selectors describing what to decode from it, an optional predicate to filter what those selectors decode, an optional gate that decides whether a predicate-true hit becomes a match at all, and the sink ids a resulting match should reach. A monitor is compiled and schema-checked the moment it is written, and edits to it hot-swap into its running pipeline without a process restart.

Network

A resource naming a chain and a source module plus that module’s own config (where blockwatcher looks and how it ingests from there). Exactly one pipeline runs per network, and changing which source module a network uses restarts only that one pipeline, resuming from its persisted checkpoint as long as the checkpoint’s recorded module still matches.

Port

An object-safe Rust trait defining one axis of swappable behavior. blockwatcher defines six: Source, Decoder, Matcher, Gate, Sink, and Storage. Core code depends only on these trait definitions, never on any concrete module’s implementation: the reason a chain family’s code never has to be linked into, or even known by, the engine that drives it.

Predicate

A boolean expression, written in blockwatcher’s own small expression language, evaluated over a decoded event’s fields to decide whether it produces a match. It supports field access by namespace (args.* for decoded event fields; chain-specific namespaces such as tx.* and block.*), comparisons, boolean logic, arithmetic, and chain-native literals like hex addresses and token-decimal numbers. A predicate is type-checked against its monitor’s selector schemas the moment it is written, and at evaluation time a field an occurrence simply doesn’t carry resolves to an explicit “unknown” value rather than raising an error.

Seed

The one-time load of resources (networks, specs, sinks, monitors) from a directory of JSON files into empty storage, run at first boot via a CLI flag. It only ever populates a storage backend that has nothing in it yet; once a backend holds any resource, seeding again is a no-op, and afterward resources are managed exclusively through the REST API.

An optional paused.json at the seed directory’s root names seeded network and monitor ids to pause as they are written, so an example resource is there to look at without polling a chain until someone resumes it. It may only name ids the same seed writes.

Selector

Part of a monitor: which addresses, which contract spec, and which of that spec’s events and/or functions to decode. A monitor’s selector entries are OR’d, and each entry is self-contained, so which spec governs which address is never ambiguous. What a given selector can actually produce depends on the network’s source: a kind the decoder accepts can still be a no-op if that source never supplies the corresponding raw material. The pairing is on the family page.

Sink

The port responsible for delivering one sink event somewhere: a webhook, an operator-run script, or a log line are the modules in the shipped module catalog. Retry behavior on a failed delivery (how many attempts, backoff timing, when to give up and record a dead letter) is owned entirely by the engine and applied identically regardless of which sink module is delivering; a sink module itself never retries on its own. Every sink event variant shares that path. A sink whose SinkDef sets throttle or aggregate holds matches in a window before delivering, rather than delivering each one as it arrives; see Delivery guarantees § Aggregation. A gate digest is the same Digest wire shape aggregation already defined.

Sink event

What a sink receives: Match (apply this occurrence), Retracted { match_id } (undo the occurrence that id named), or Digest { matches } (apply several matches an aggregate window bundled together; see Sink). A closed set, serialized on the wire as tagged JSON (type: match, type: retracted, or type: digest). All three are at-least-once. See Delivery guarantees § What a sink receives.

Source

The port that pulls raw activity into a pipeline, whether by polling an RPC endpoint for blocks and logs or by subscribing to a node’s pending-transaction feed. A source owns the meaning of its own cursor, must emit events in non-decreasing cursor order, and is the only module family configured on the network resource rather than on a resource of its own. Source::run returns a SourceOutcome (Ended or Invalidated { from }) or a SourceError; a positively detected deep invalidate is the outcome, not the error.

Storage

The port responsible for every resource ever written (versioned, with optimistic concurrency), each network’s checkpoint, its dead-letter queue, its delivery journal, and each monitor’s gate_hits / gate_meta. Unlike the other five ports, a deployment picks exactly one storage backend for the whole process rather than one per resource: memory (no persistence, gone on restart) or sqlite (durable, single-writer) are the two in the shipped module catalog.

Getting started

This part gets blockwatcher running on your machine: building or installing it, watching one real pipeline end to end, driving that same pipeline through a browser instead of the command line, or embedding the engine in another process. It is for a reader about to run blockwatcher for the first time, not for one auditing its internals.

Quickstart

Get one blockwatcher pipeline running end to end: watching a live testnet contract and printing every occurrence it matches as JSON. This page assumes you already have a rough sense of what a network, a monitor, and a sink are; see What is blockwatcher? first if not. This page is about getting bytes moving on Ethereum Sepolia; Your first monitor explains each piece in more depth, and the Concepts pages explain why the pieces are shaped the way they are. The Stellar Testnet twin is Monitoring Stellar Testnet [RPC].

The steps below all feed one running pipeline:

flowchart LR
    seed["seed/<br/>network, spec, sink, monitor"] --> check["blockwatcher check ./seed"]
    check -->|"ok: N networks..."| run
    cfg["blockwatcher.toml"] --> run["blockwatcher --config ... --seed ..."]
    seed --> run
    run --> pipe["running pipeline"]
    pipe --> out["stdout: one match per line"]

Prerequisites

  • A Rust toolchain at or above the workspace minimum, rust-version = "1.98". Tagged releases publish a Linux x86_64 archive and GHCR images (see Installation and building); building from source is the path this walkthrough uses.
  • An RPC endpoint URL for an EVM testnet. The example below uses Sepolia; a free key from Infura, Alchemy, or dRPC works.

From a clone of the repository, build the release binary once:

cargo build --release -p blockwatcher

The binary lands at target/release/blockwatcher.

1. Write an instance config

blockwatcher has two separate configuration planes. Instance config (blockwatcher.toml) is read once at process boot and controls process wiring: the API listener, the storage backend, engine tunables. It is not where you say what to watch; that’s a separate plane, managed at runtime (see Configuration later in this wiki). A minimal instance config:

[api]
enabled = true
listen = "127.0.0.1:8080"

[[auth.tokens]]
label = "operator"
scope = "admin"
secret = "env:BLOCKWATCHER_API_TOKEN"

[storage]
module = "sqlite"
config = { path = "blockwatcher.db" }

Each token’s secret is a reference (env:BLOCKWATCHER_API_TOKEN), never a literal token: boot refuses, naming the variable, if it is unset. GET /health answers without a token; everything else requires one.

2. Write a seed directory

blockwatcher loads its resources (what to watch, decode, and deliver) from a directory of JSON files on first boot only. Once the store holds anything at all, seeding is skipped and the resources plane is managed exclusively through the REST API from then on. A minimal seed needs one of each resource kind, one file per resource, sorted into kind subdirectories that the loader recognizes by name:

seed/
├── networks/sepolia.json
├── specs/usdc-erc20.json
├── sinks/log-sink.json
└── monitors/usdc-sepolia-transfers.json

networks/sepolia.json: where to look and how to ingest. start_block is an absolute block number rather than head-relative, so a restart resumes from exactly this point instead of silently skipping whatever passed while the process was down:

{
  "id": "sepolia",
  "chain": "evm",
  "source": {
    "module": "evm-rpc",
    "config": {
      "start_block": 9000000,
      "endpoints": [
        { "name": "primary", "url_secret": "env:SEPOLIA_RPC_URL" }
      ]
    }
  }
}

Set start_block near the chain’s current head: a value from months ago means a long wait before the first match arrives.

specs/usdc-erc20.json: the decode artifact. For "chain": "evm" this is a Solidity ABI fragment list; the decoder compiles it once into a schema everything downstream works from:

{
  "id": "usdc-erc20",
  "chain": "evm",
  "payload": [
    {
      "type": "event",
      "name": "Transfer",
      "anonymous": false,
      "inputs": [
        { "name": "from", "type": "address", "indexed": true },
        { "name": "to", "type": "address", "indexed": true },
        { "name": "value", "type": "uint256", "indexed": false }
      ]
    }
  ]
}

sinks/log-sink.json: one JSON line per match to stdout, no configuration of its own:

{
  "id": "log-sink",
  "module": "log",
  "config": {},
  "retry": { "max_attempts": 1, "initial_backoff_ms": 100, "max_backoff_ms": 1000 }
}

monitors/usdc-sepolia-transfers.json: ties the others together: watch this address on sepolia, decode Transfer against usdc-erc20, send every match to log-sink:

{
  "id": "usdc-sepolia-transfers",
  "network": "sepolia",
  "selectors": [
    {
      "addresses": ["0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"],
      "spec": "usdc-erc20",
      "events": ["Transfer"]
    }
  ],
  "predicate": "args.value > 0",
  "actions": ["log-sink"]
}

3. Validate offline, then run

export SEPOLIA_RPC_URL=https://sepolia.infura.io/v3/YOUR_KEY

./target/release/blockwatcher check ./seed

check takes only a directory (it never reads blockwatcher.toml), but it constructs every module the seed references, including resolving each url_secret, so SEPOLIA_RPC_URL has to be set (to a real http/https URL: the check inspects its scheme, it does not need the endpoint to be reachable). BLOCKWATCHER_API_TOKEN isn’t needed yet; nothing about check touches the instance config that reads it.

A passing check prints a one-line summary to stdout and exits 0:

ok: 1 networks, 1 specs, 1 sinks, 1 monitors

Now export the API token and run for real:

export BLOCKWATCHER_API_TOKEN=quickstart-token

./target/release/blockwatcher --config ./blockwatcher.toml --seed ./seed

--seed only loads resources into a store that has nothing in it yet; once it has run once, blockwatcher.db is authoritative and the seed directory is ignored on every later boot.

What success looks like

Startup lines and warnings go to stderr; stdout carries nothing but tagged sink-event JSON ("type": "match", "type": "retracted", or "type": "digest" for a sink configured with aggregate), one line per delivery: nothing else is ever mixed in, so a consumer piping stdout into a parser never has to filter anything out. Within a few seconds of a matching Transfer on Sepolia, a line like this appears:

{
  "type": "match",
  "id": "a3f8b21c9d...",
  "monitor": "usdc-sepolia-transfers",
  "network": "sepolia",
  "event": {
    "kind": "event",
    "name": "Transfer",
    "fields": {
      "map": {
        "args": {
          "map": {
            "from": { "address": "0xff30fb28e1794bb91d5bceb7d66b731d0c61af8e" },
            "to": { "address": "0x7a3f2b16924f0e5c8f6a1c3d9e0b5a2f8c4d7e1a" },
            "value": { "uint": "13000000" }
          }
        },
        "tx": {
          "map": {
            "hash": { "bytes": "0x5f71ab..." },
            "index": { "uint": "26" },
            "status": { "uint": "1" }
          }
        },
        "block": {
          "map": {
            "number": { "uint": "11424039" },
            "hash": { "bytes": "0x9ab2cd..." },
            "timestamp": { "uint": "1785929472" }
          }
        },
        "log": {
          "map": {
            "address": { "address": "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238" },
            "index": { "uint": "54" }
          }
        }
      }
    },
    "cursor": { "primary": 11424039, "secondary": 54 }
  }
}

Every scalar is tagged with its own type (address, uint, bytes), which is why a 256-bit integer like value survives as an exact decimal string rather than a lossy JSON number. USDC has 6 decimals, so 13000000 is 13 USDC. The top-level id is deterministic: the same occurrence always derives the same id, which is what lets a consumer recognize a redelivered match after a restart as a duplicate rather than as new activity.

Next: Your first monitor walks through this same shape of deployment file by file, and shows how to narrow what it matches.

Installation and building

A vX.Y.Z tag publishes a Linux x86_64 archive to GitHub Releases and two images to GHCR (ghcr.io/thethirdorigin/blockwatcher and ghcr.io/thethirdorigin/blockwatcher-ui). Building from source, or building the Docker image locally, remain the paths for anything that archive does not cover. The tag-triggered process is in CONTRIBUTING.md.

GitHub Release archive

gh release download vX.Y.Z --pattern "blockwatcher-*.tar.gz"
tar -xzf blockwatcher-X.Y.Z-x86_64-unknown-linux-gnu.tar.gz
./blockwatcher --version

The matching images are ghcr.io/thethirdorigin/blockwatcher:X.Y.Z and ghcr.io/thethirdorigin/blockwatcher-ui:X.Y.Z (also tagged vX.Y.Z). The UI image tag is the release it shipped with, not ui/server’s own crate version. Building the same images locally is Running with Docker.

Building from source

The workspace’s minimum Rust version is 1.98. Build the whole workspace:

cargo build --workspace

or just the binary crate:

cargo build --release -p blockwatcher

The resulting binary is target/release/blockwatcher (or target/debug/blockwatcher without --release).

Every commit is expected to pass the same gates CI runs on Linux:

cargo fmt --all --check
cargo check --workspace --locked
cargo clippy --workspace --all-features --all-targets -- -D warnings
cargo test --workspace --all-features
./scripts/check-dep-graph.sh
./scripts/check-release-version.test.sh
./scripts/changelog-release-notes.test.sh

CI additionally checks that every pairwise combination of the blockwatcher binary’s feature flags still builds:

cargo hack check --workspace --feature-powerset --depth 2 --locked

Worth knowing before reaching for a custom feature combination in production: the combinations that ship are the ones that are actually tested.

Feature flags

The blockwatcher binary crate defines optional features, all on by default:

[features]
default = ["evm", "stellar", "expr", "sinks"]
evm = ["blockwatcher-embed/evm"]
stellar = ["blockwatcher-embed/stellar"]
expr = ["blockwatcher-embed/expr"]
sinks = ["blockwatcher-embed/sinks"]

Each forwards onto the matching blockwatcher-embed feature, which is what actually links that crate. A family that isn’t linked can never be selected by name in config, however the config is written. Storage (memory, sqlite) is not behind a feature: blockwatcher-storage is a plain, non-optional dependency, so both storage modules are always present.

Chain-family flags each fold that family’s modules; what turning one off removes is on the family page. The flags are independent. A build with no chain-family feature registers no source and no decoder.

FeatureOff removes
exprThe expr matcher, the predicate engine that ships with blockwatcher. A network can still be configured, but the engine’s [engine].matcher has nothing to select: booting refuses, naming expr as unavailable.
sinksAll three sink modules (webhook, script, log). A monitor’s actions can name a sink id, but the sink resource itself can never construct, so nothing can ever be delivered anywhere.

Building a custom binary that carries only what an operator’s deployment needs uses Cargo’s usual --no-default-features --features combination. Name the chain-family feature from that family’s page together with expr and sinks (or whichever of those the deployment needs):

cargo build --release -p blockwatcher --no-default-features --features <family>,expr,sinks

A binary built this way still needs some matcher configured in [engine].matcher, and boot refuses at startup if the named module isn’t one this build actually carries: the refusal names what is available, not just what was asked for.

The check command and its exit codes

check runs the same construction and compilation path a real boot would, without ever starting a pipeline:

flowchart LR
    dir["seed directory"] --> load["load every JSON file"]
    load --> construct["construct every module<br/>resolves env: secrets"]
    construct --> compile["type-check every<br/>selector and predicate"]
    compile -->|"ok"| pass["print ok: N counts<br/>exit 0"]
    compile -->|"refused"| fail["print reason to stderr<br/>exit 1"]
    construct -->|"refused"| fail

blockwatcher check <dir> validates a seed directory offline: it loads every JSON file the directory holds, then runs the exact same construction and compilation path a real boot would run (every module gets built, every selector and predicate gets type-checked against its spec’s schema) without starting a pipeline or opening a listener. It takes only the directory argument; it never reads an instance config file, so nothing in blockwatcher.toml (the API token, the storage backend) plays any part in whether a check passes.

Because check constructs every module, any secret a seed’s configs reference through env:NAME must already be present in check’s own environment: a sink whose config resolves a secret at construction fails the same way it would at boot.

A pass prints a summary to stdout and returns 0:

ok: 3 networks, 5 specs, 2 sinks, 8 monitors

A failure prints the refusal to stderr and returns 1, naming the file and the reason: a malformed resource, a duplicate id within one kind, a selector referencing a spec that isn’t in the seed, a url_secret pointing at an unset variable.

The full set of exit codes the binary can return, useful for a supervisor’s restart policy:

CodeMeaning
0A clean drain (or a stop signal that arrived before boot finished), or a check that passed.
1A configuration, seed, or boot failure, or a check that refused.
2Shutdown reached its drain deadline with at least one pipeline still running work, and aborted it.
64A command line this binary could not parse (unrecognized flag, missing value).

check itself only ever returns 0 or 1: codes 2 and 64 belong to blockwatcher’s normal run mode and to argument parsing, respectively, neither of which check goes through.

Docker

docker/Dockerfile.blockwatcher builds the binary in a rust:1.98-bookworm stage (cargo build --release -p blockwatcher, with the default feature set: the Dockerfile passes no --features flags) and copies only the resulting binary into a debian:bookworm-slim runtime stage alongside ca-certificates and curl (the latter for the container healthcheck). The entrypoint is the binary itself, so container arguments are blockwatcher’s own CLI arguments:

docker build -f docker/Dockerfile.blockwatcher -t blockwatcher .
docker run --rm -p 127.0.0.1:8080:8080 \
  -e BLOCKWATCHER_API_TOKEN=change-me -e SEPOLIA_RPC_URL=https://sepolia.infura.io/v3/YOUR_KEY \
  -v "$(pwd)/blockwatcher.toml:/etc/blockwatcher/blockwatcher.toml:ro" \
  blockwatcher --config /etc/blockwatcher/blockwatcher.toml

(SEPOLIA_RPC_URL is only needed if the instance’s seeded or API-created networks reference it via url_secret: the binary itself doesn’t know that name, only whichever resources you give it do. The compose demo also names STELLAR_RPC_URL.)

For a full local stack (blockwatcher plus the companion dashboard), docker compose from the repository root is the supported path; see The dashboard.

Your first monitor

Quickstart got a pipeline running from files typed by hand. This page walks the same ground more slowly, using the runnable example shipped in the repository at examples/source-evm-rpc-monitor/, and ends by changing what the monitor matches and watching the behavior change. The Concepts pages explain why each resource is shaped the way it is; this one is about doing it once, end to end.

What’s in the example

Eight tracked files (.env isn’t one of them: you create it from .env.example in a moment, and it’s git-ignored so your key never gets committed):

examples/source-evm-rpc-monitor/
├── .env.example
├── README.md
├── blockwatcher.toml
├── setup.sh
└── resources/
    ├── monitors/usdc-sepolia-transfers.json
    ├── networks/sepolia.json
    ├── sinks/log-sink.json
    └── specs/usdc-erc20.json

Two of these aren’t blockwatcher resources at all: blockwatcher.toml is the instance config covered in Quickstart, and setup.sh is a one-off helper this page runs below: neither is read by check or seeded into storage. The other four are exactly the seed directory’s four resource kinds, one file each. Below, they’re discussed in the order they reference each other (network, then spec, then sink, then the monitor that ties all three together) rather than the tree’s alphabetical order.

The four files wire together into one running pipeline like this:

flowchart LR
    net["network<br/>sepolia.json"] --> mon
    spec["spec<br/>usdc-erc20.json"] --> mon
    sink["sink<br/>log-sink.json"] --> mon
    mon["monitor<br/>usdc-sepolia-transfers.json"] --> pipe["running pipeline<br/>on sepolia"]
    net --> pipe

resources/networks/sepolia.json names the evm-rpc source and tunes it beyond the defaults: 12 confirmations before a block is trusted, a starting eth_getLogs window of 1000 blocks growing to 5000, and a 30-second head-probe interval. None of these are required fields (the quickstart network file left every one of them at its default and still worked), but they’re realistic values for a testnet feed, not just illustration.

resources/specs/usdc-erc20.json is a Solidity ABI fragment list covering both Transfer and Approval events. Only Transfer is selected by the monitor below, but nothing stops a spec from carrying more than one monitor ever asks it to decode: the fragments a monitor doesn’t reference are simply unused.

resources/sinks/log-sink.json selects the log sink with a max_attempts of 1: one delivery attempt, dead-letter on failure, no retries. That’s a reasonable choice for a sink with nothing to fail on other than a broken stdout pipe.

resources/monitors/usdc-sepolia-transfers.json is the monitor: it watches that one address on the sepolia network, decodes only Transfer against usdc-erc20, keeps every occurrence where args.value > 0 (which is to say, everything a Transfer event can carry: a zero-value transfer is legal but rare), and sends every match to log-sink.

Running it

From the repository root:

cd examples/source-evm-rpc-monitor
cp .env.example .env

Edit .env and replace YOUR_API_KEY in SEPOLIA_RPC_URL with a real Sepolia endpoint (a free key from Infura, Alchemy, or dRPC works). The key never gets written into any resource file: the network resource only names the SEPOLIA_RPC_URL variable, and blockwatcher resolves it from its own environment at construction time.

./setup.sh
set -a; . ./.env; set +a

setup.sh reads the current chain head over eth_blockNumber and rewrites resources/networks/sepolia.json’s start_block to a value just behind it. start_block is deliberately absolute, with no default: a head-relative start would derive a different block on every restart and silently skip whatever passed in between, which a monitor must never do. The trade-off is that a fresh network needs a block number named for it, and naming one from months ago means a long catching_up wait, visible on the GET /status endpoint, before anything is delivered.

cargo run --bin blockwatcher -- check ./resources
cargo run --bin blockwatcher -- --config ./blockwatcher.toml --seed ./resources

check constructs every module the seed references (including the evm-rpc source, which resolves SEPOLIA_RPC_URL), so it only passes with the environment loaded. A pass prints:

ok: 1 networks, 1 specs, 1 sinks, 1 monitors

The run command boots the engine, seeds resources/ into the empty blockwatcher.db it just created, and starts the pipeline. Diagnostics (the boot line, warnings) go to stderr; stdout carries nothing but match JSON, one line per delivery. Within a few seconds, matching transfers start arriving:

{"id":"a3f8b21c9d...","monitor":"usdc-sepolia-transfers","network":"sepolia","event":{"kind":"event","name":"Transfer","fields":{"map":{"args":{"map":{"from":{"address":"0xff30fb28e1794bb91d5bceb7d66b731d0c61af8e"},"to":{"address":"0x7a3f2b16924f0e5c8f6a1c3d9e0b5a2f8c4d7e1a"},"value":{"uint":"20000000"}}},"tx":{"map":{"hash":{"bytes":"0x5f71ab..."},"index":{"uint":"26"},"status":{"uint":"1"}}},"block":{"map":{"number":{"uint":"11424039"},"hash":{"bytes":"0x9ab2cd..."},"timestamp":{"uint":"1785929472"}}},"log":{"map":{"address":{"address":"0x1c7d4b196cb0c7b01d743fbc6116a902379c7238"},"index":{"uint":"54"}}}}},"cursor":{"primary":11424039,"secondary":54}}}

Pipe through jq to read it comfortably, or reduce to one line per transfer:

cargo run --bin blockwatcher -- --config ./blockwatcher.toml --seed ./resources \
  | jq -r '.event.fields.map.args.map | "\(.from.address) → \(.to.address)  \(.value.uint)"'

Narrowing what it matches

The seeded monitor’s predicate, args.value > 0, accepts every nonzero transfer. Suppose you only care about large ones, say, anything over 1000 USDC (six decimals: 1_000e6). The monitor is already running and its store already holds it, so re-seeding won’t touch it: --seed only loads into a store that has nothing in it yet. The way to change a live monitor is the same way you’d change any resource (the REST API), and the change takes effect on the running pipeline immediately, no restart.

Get the monitor’s current version (ETag), then PUT it back with the predicate changed:

export TOKEN=$BLOCKWATCHER_API_TOKEN   # from .env

etag=$(curl -s -o /dev/null -w '%header{etag}' \
  localhost:8080/monitors/usdc-sepolia-transfers \
  -H "Authorization: Bearer $TOKEN")

curl -s -X PUT localhost:8080/monitors/usdc-sepolia-transfers \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -H "If-Match: $etag" \
  -d '{
    "id": "usdc-sepolia-transfers",
    "network": "sepolia",
    "selectors": [{
      "addresses": ["0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"],
      "spec": "usdc-erc20",
      "events": ["Transfer"]
    }],
    "predicate": "args.value > 1_000e6",
    "actions": ["log-sink"]
  }'

If-Match carries the resource’s current version so the API can refuse a write that would clobber a change made since you last read it; omitting it on a PUT to a resource that already exists returns 409 Conflict (already_exists), and a stale If-Match value returns 412 Precondition Failed (version_conflict). 1_000e6 is blockwatcher’s token-decimal literal syntax (one thousand at six decimals), evaluated against args.value as an arbitrary-precision integer, so the comparison is exact even though value itself arrived as a decimal string, not a JSON number.

The behavior change is immediate: transfers under 1000 USDC that used to print a line stop appearing entirely, while nothing about the pipeline restarts or re-scans: the predicate is recompiled and hot-swapped into the running monitor set, and only events decoded from here on are evaluated against the new one. Widen the predicate back, or remove it entirely (an absent predicate matches everything the selector decodes), the same way: another PUT with a fresh If-Match.

Next: The dashboard covers a web UI that does the same resource CRUD and log-watching this page just did by hand, and Concepts covers what the predicate language can express beyond a threshold. To require N transfers in a block-time window before alerting, see Gates. The same USDC Transfer monitor polling a deployed logs subgraph instead of eth_getLogs lives at examples/source-evm-subgraph-monitor/ and is annotated in Monitoring ERC-20 transfers [Subgraph]. The same shape of seed on Stellar Testnet — classic Payment, native SAC transfer events, and transfer invocations — lives at examples/source-stellar-rpc-monitor/ and is annotated in Monitoring Stellar Testnet [RPC].

The dashboard

Everything so far has driven blockwatcher by hand: editing JSON files, calling the REST API with curl. blockwatcher ships an optional local web UI that does the same operations (resource CRUD, watching matches arrive) through a browser instead.

What it shows

  • A monitors fleet table: every monitor, active or paused, with a live summary of its selectors and uptime.
  • Per-monitor stats: pause/resume, a dry-run against the live decoder and matcher (either raw test payloads or a bounded history scan), and stat cards for the monitor’s counters.
  • A virtualised match/event log per monitor, built to stay responsive even once a monitor has produced a large number of matches. Opening a match translates Stellar flavored hex addresses to G/C/M StrKeys and tx.hash bytes to an unprefixed hash, and links both to StellarExpert (testnet when the network id contains testnet). EVM matches link to Etherscan when the network id names a known host (sepolia, mainnet).
  • CRUD for every resource kind: networks, specs, sinks, and monitors, as either a structured form (for monitors, a selector editor with a schema helper) or raw JSON.
  • A networks page with per-network operational state: source status, lag, queue depth, cursor, pause/resume, skip (tip, or a block/ledger number), and dead letters. Skip-to-tip is 422 on evm-mempool and stellar-rpc (those sources cannot report a confirmed tip); skip to a ledger/block number still works while paused.

Two services, not one

The dashboard is not a feature you turn on inside the blockwatcher binary: it’s a second, independent process that sits in front of it. blockwatcher itself keeps running exactly as described everywhere else in this wiki: same REST API, same pipelines, same SQLite store, blockwatcher.db. What changes is that nothing outside its own container ever talks to it directly: its listen port isn’t published on the host at all. A second binary, blockwatcher-ui-server, built from Rust (Axum) and serving a compiled React single-page app, is the only thing that binds a host-reachable port (127.0.0.1:8080), and it holds its own SQLite database, ui.db, entirely separate from blockwatcher’s.

That server does two unrelated jobs behind the one port: it forwards every API call the browser makes through to the real blockwatcher API and relays the answer back, and it exposes an ingest endpoint that blockwatcher itself calls into to hand over matches. The first job is why a browser never needs to know blockwatcher’s address; the second is covered next.

flowchart LR
    browser["Browser"]

    subgraph ui_svc["ui service (127.0.0.1:8080, host-reachable)"]
        spa["React SPA"]
        srv["Companion server (Axum)"]
        uidb[("ui.db")]
    end

    subgraph ob_svc["blockwatcher service (internal only)"]
        api["REST API"]
        eng["Engine · pipelines"]
        obdb[("blockwatcher.db")]
    end

    browser --> spa
    spa --> srv
    srv -->|"proxy /api/blockwatcher/..."| api
    api --- eng
    eng -->|"webhook POST /ingest"| srv
    srv --> uidb
    eng --> obdb

Compose never publishes the engine on the host. Without Docker the engine still only accepts the companion as a client, but it does listen on loopback (127.0.0.1:9080) so the companion can reach it; Vite on :5173 is a third process that proxies the browser to the companion.

Two consequences follow from that shape:

  • Every API call the browser makes is really two hops. GET /api/blockwatcher/networks from the browser reaches the companion server first, which forwards it to blockwatcher’s real GET /networks and relays the response back: blockwatcher is never addressable directly.
  • Matches reach the dashboard the same way any other consumer would: as a sink. The companion server registers a webhook sink named ui-ingest in blockwatcher automatically on boot. Any monitor whose actions include ui-ingest has its matches delivered (over HTTP, retried and dead-lettered by the engine exactly like any other webhook sink) to the companion’s /ingest endpoint, which stores them in ui.db for the log view. A monitor that omits ui-ingest from its actions runs and delivers to its other sinks as normal, but never shows up in the dashboard’s log.

Running it

Two topologies. Compose hides the engine and serves the built SPA on 127.0.0.1:8080. A cargo run example needs three local processes: the engine on 9080, the companion on 8080, Vite on 5173. The ui/README.md local-development section keeps the same commands next to the UI crate.

With Docker

From the repository root, copy and fill in the compose environment file:

cp docker/.env.example docker/.env

Edit docker/.env: set BLOCKWATCHER_API_TOKEN, UI_OPERATOR_SECRET, and UI_INGEST_SECRET (the last two must not be the same value). The compose seed (examples/compose-demo) names SEPOLIA_RPC_URL and STELLAR_RPC_URL; the latter’s example value is the public Stellar Testnet node. Every url_secret a network resource names must resolve to a variable present here, because blockwatcher resolves secrets inside its own container.

Stamp recent cursors into both seeded networks, then start the stack. --seed only loads into an empty store, so down -v is required when this volume previously booted a different seed:

./examples/compose-demo/setup.sh
docker compose -f docker/compose.yaml down -v
docker compose -f docker/compose.yaml up --build

The first run builds two images: one for blockwatcher itself, one for the companion server plus the built SPA. The ui container waits for blockwatcher’s health check (GET /health, no token) before serving. Open http://127.0.0.1:8080 and sign in with UI_OPERATOR_SECRET.

The seed already created sepolia and stellar-testnet, with monitors that include ui-ingest under Actions, so matches appear in the log within a few seconds of qualifying on-chain activity. Pause either network on Networks if you only want one pipeline. Create further networks, specs, or monitors under Resources the same way you would outside Docker.

Alongside a local example (no Docker)

The in-tree examples/source-*-monitor trees listen on 8080 and deliver to log-sink. The companion also wants 8080. Point the engine at 9080, put UI_INGEST_URL in that process (blockwatcher resolves env:… from its own environment), then start the companion and Vite.

Export these in every terminal:

export BLOCKWATCHER_API_TOKEN=dev
export UI_OPERATOR_SECRET=dev-operator
export UI_INGEST_SECRET=dev-ingest   # must differ from UI_OPERATOR_SECRET
export UI_INGEST_URL=http://127.0.0.1:8080/ingest

Also export whatever the network JSON names (SEPOLIA_SUBGRAPH_URL, SEPOLIA_RPC_URL, …).

Terminal 1 — engine. Shipped examples listen on 8080. The companion needs that port. Change [api] listen before cargo run. Leave it at 8080 and GET /login is JSON a bearer token is required from the engine, not a sign-in form.

[api]
listen = "127.0.0.1:9080"
cd examples/source-evm-subgraph-monitor   # or any source-*-monitor example
grep 'listen = "127.0.0.1:9080"' blockwatcher.toml
./setup.sh                                # first time / empty db
cargo run --bin blockwatcher -- --config ./blockwatcher.toml --seed ./resources
# later boots: drop --seed; the sqlite store is authoritative

The engine log must show addr=127.0.0.1:9080.

Terminal 2 — companion.

cd ui/server
export BLOCKWATCHER_API_URL=http://127.0.0.1:9080
cargo run

Wait for listening on 127.0.0.1:8080. That log line means the port accepts connections; it does not wait for ui-ingest to register. Until that sink exists, /login answers 503 with a waiting page (Retry-After: 3) rather than resetting the handshake. Address already in use means the engine still owns 8080 — do not start Vite. A 422 retry loop in the companion log means UI_INGEST_URL is unset in the engine — restart terminal 1 with it set. Once the sink is registered, /login is the sign-in page:

curl -sS http://127.0.0.1:8080/login | head
# waiting: HTML "blockwatcher UI is starting"
# ready: HTML containing "Sign in — blockwatcher"
# JSON unauthorized → still the engine

Sign in at http://127.0.0.1:8080/login with UI_OPERATOR_SECRET. That is the dashboard. Vite on :5173 is only for hot-reloading the React app, and its /login proxy is the companion only after this check passes.

Terminal 3 — SPA (optional).

cd ui/web
npm install
npm run dev

Networks and status work immediately. Matches appear in the log only after that monitor’s Actions include ui-ingest (the examples ship log-sink only; keep both if you still want stdout). Set it in the monitor form, or GET the monitor, add the action, and PUT with If-Match.

What the companion does and does not do

Operators sign in at /login with UI_OPERATOR_SECRET and hold an HttpOnly; SameSite=Strict session. The engine’s webhook authenticates with UI_INGEST_SECRET on x-blockwatcher-ingest; the two secrets must not be the same value. Compose still binds the dashboard to 127.0.0.1 because loopback is what keeps the login page off the wider network; re-point that bind to 0.0.0.0 only behind TLS (UI_COOKIE_SECURE=true) and a deployment story that is not “the operator’s laptop”. The engine API’s GET /health remains unauthenticated so Compose can probe it. The trust boundary is written out in the repository’s docs/threat-model.md.

Where the data actually lives

Two SQLite files back the two services. In Compose they live in named volumes rather than bind mounts so container recreation doesn’t lose them: blockwatcher’s own blockwatcher.db (the blockwatcher-data volume) holds resources, checkpoints, and dead letters, and pause state, exactly as it would outside Docker; the companion’s ui.db (the ui-data volume) holds only what it has ingested: match history. Without Docker those are ordinary files: blockwatcher.db next to the example’s blockwatcher.toml, and ui.db in whatever directory you launched ui/server from. Pause state lives entirely in blockwatcher.db; the companion reads it from /status rather than keeping its own copy. Neither service reads the other’s file; the only thing that crosses between them is whatever the ui-ingest webhook forwards. docker compose down leaves both volumes in place for the next up; only down -v removes them, and that removal is permanent: every resource, checkpoint, and logged match is gone, with no confirmation beyond the flag itself. Because match bodies can carry on-chain data an operator considers sensitive, that risk applies to ui.db the same way it applies to any consumer that stores what a webhook sink delivers.

Embedding blockwatcher in a host process

The blockwatcher binary is one way to run the engine: it owns the CLI, instance config, HTTP control plane, metrics listener, and SIGTERM/SIGINT drain. A host that already has a process — a ledger-sync worker, a service that applies matches to its own store — can run the same engine in-process without standing any of that up.

That path is blockwatcher-embed. It is the one place compiled-in module families are registered (crates/blockwatcher-embed/src/catalog.rs), and it re-exports the engine types a host needs to boot and stop. The binary is a client of the same crate for catalog construction; it does not keep a second registrar.

flowchart LR
    subgraph host["host process"]
        embed["blockwatcher-embed<br/>build_catalog · Engine::start"]
        ports["host port impls<br/>Sink · Storage · Source<br/>Decoder · Matcher · Gate"]
        stop["host-owned shutdown"]
        embed --> ports
        stop --> embed
    end

    subgraph bin["blockwatcher binary"]
        embed2["blockwatcher-embed<br/>(same catalog + start)"]
        extra["CLI · config · seed<br/>API · metrics · SIGTERM"]
        extra --> embed2
    end

Key takeaways

  • Depend on blockwatcher-embed, not on the blockwatcher binary crate, to run the engine in-process.
  • build_catalog is the one registration path; Engine::start boots from a catalog, a storage backend, and an EngineConfig.
  • A host can supply an implementation of any of the six ports, not just Sink: register it on the catalog before Engine::start, or, for storage, hand it straight to EngineDeps.
  • A host sink implements Sink::deliver(&SinkEvent) in-process and must treat both Match and Retracted as at-least-once (idempotent on match_id).
  • Each port’s own obligations are the same in-process as in a shipped module; Extending blockwatcher § What each port asks of a module states them per port.
  • The host owns shutdown: call Engine::shutdown. Embed does not wire SIGTERM or SIGINT.
  • Embed does not serve the REST API or the Prometheus scrape endpoint. Those stay process concerns of the binary.

The host recipe

Add blockwatcher-embed to the host’s Cargo.toml. Default features mirror the binary: they fold the same module families into the catalog. Storage (memory, sqlite) and gate modules (threshold, max_once) are always registered. Chain-family flags and what they remove are on the family page. A host-supplied gate uses the same compile/on_hit contract and still must not open sqlite. A host that implements its own Sink also depends on blockwatcher-ports and blockwatcher-types.

Boot is four steps: build the catalog, construct storage, start the engine, stop it yourself.

#![allow(unused)]
fn main() {
use blockwatcher_embed::{build_catalog, with_spec_importers, Engine, EngineConfig, EngineDeps};

let catalog = build_catalog()?;
let storage_factory = catalog.storage("memory")?;
let storage = storage_factory(serde_json::json!({}))
    .await
    .expect("memory storage constructs from empty config");

let config: EngineConfig = serde_json::from_value(serde_json::json!({
    "matcher": { "module": "expr", "config": {} }
}))?;

let engine = Engine::start(with_spec_importers(EngineDeps::new(
    storage,
    catalog,
    config,
)))
.await?;

// Host-owned shutdown: embed does not install signal handlers.
let _report = engine.shutdown().await;
}

EngineConfig has no overall default because it refuses to guess a matcher module. Deserializing a JSON object that names expr (when that feature is on) fills every other tunable from core’s own defaults, including journal_depth = 1024. See Configuration reference § [engine].

Resources (networks, specs, sinks, monitors) still live in storage. A host either writes them through ControlHandle (re-exported from embed) or persists them into the backend before Engine::start, the same shapes the binary’s seed path writes. Embed does not load a seed directory of its own. with_spec_importers installs this build’s compiled-in spec importers so ControlHandle::import_spec_from_chain (and POST /specs/{id}/from-chain, if the host serves the API) can fetch a payload; without it the importer map is empty.

Any of the six ports can be the host’s own

A sink is the common case, not the only one. build_catalog returns a catalog the host still owns until EngineDeps takes it by value, and every register_* method on it is public, so a host can add its own module to any port family before start:

PortRegister withThen selected by
Sourcecatalog.register_source(SourceModule::of::<MyRegistry>())a network resource’s source.module
Decodercatalog.register_decoder(name, factory)nothing: automatic, by the ChainKind the decoder’s chain() returns
Matchercatalog.register_matcher(name, factory)EngineConfig.matcher.module
Gatecatalog.register_gate(name, factory)a monitor resource’s gate.module
Sinkcatalog.register_sink(name, factory)a sink resource’s module
StorageEngineDeps.storage, or catalog.register_storagehanded in directly; the name matters only to the binary’s [storage]

Two things do not change by being in-process. Each port’s contract is identical to a shipped module’s, so an in-process source still owes the engine non-decreasing cursor order and an in-process matcher still owes it a bounded compile; Extending blockwatcher § What each port asks of a module states those obligations per port, and they are worth reading before writing the impl rather than after. And a duplicate name within one family is EngineError::DuplicateModule from register_*, reported rather than panicked precisely so a host with no main of its own can recover.

A host that supplies its own module for a family usually also wants to stop linking the shipped one. blockwatcher-embed’s default features include a flag per chain family plus expr and sinks; turning them off with default-features = false sheds those families, leaving storage (always registered) and whatever the host registers itself. What a build can select is then exactly what it linked, so a name the host never registered is refused at write or boot time listing only the names actually present.

An in-process sink

Shipped sink modules (webhook, script, log) still work: register a sink resource that names one of them, the same as in the binary. A host that wants to apply and undo by MatchId in the same process implements the Sink port itself and registers it on the catalog before start.

#![allow(unused)]
fn main() {
use async_trait::async_trait;
use blockwatcher_ports::{Sink, SinkError};
use blockwatcher_types::SinkEvent;

struct LedgerSink;

#[async_trait]
impl Sink for LedgerSink {
    async fn deliver(&self, event: &SinkEvent) -> Result<(), SinkError> {
        match event {
            SinkEvent::Match(m) => {
                // Apply by m.id. At-least-once: a duplicate id is the same
                // occurrence, not a new one.
                let _ = m;
                Ok(())
            }
            SinkEvent::Retracted { match_id } => {
                // Undo by match_id. Also at-least-once: must be idempotent.
                let _ = match_id;
                Ok(())
            }
        }
    }
}
}

(Sink is one method, crates/blockwatcher-ports/src/sink.rs. The engine owns retry, backoff, and dead-lettering; a sink that retries internally is a bug, the same contract as every shipped module.)

Retracted is how a consumer undoes work that a deep invalidate proved was on a dead fork. Register the impl on the catalog before start, under a name a sink resource can select:

#![allow(unused)]
fn main() {
let mut catalog = build_catalog()?;
catalog.register_sink("ledger", |_config| {
    Box::pin(async move { Ok(Arc::new(LedgerSink) as Arc<dyn Sink>) })
})?;
}

A stored sink resource with "module": "ledger" then constructs this type the same way a catalog sink does. Delivery guarantees covers when retracts fire, the bounded journal they read from, and the ordering that every retract for a sink finishes (delivered or dead-lettered) before any post-restart Match from that network is offered. A host that ignores Retracted will keep orphan rows it can no longer identify by id.

Classify failures through SinkError::Delivery { message, class: ErrorClass::… } exactly as a shipped sink does. The engine’s retry loop reads that class; it does not know this sink is in-process.

Host-owned storage

Storage is the one port a host does not have to register at all. EngineDeps.storage is an Arc<dyn Storage>, so a host that already has a database hands its own backend straight in, and every resource, checkpoint, dead letter, and journal row lands in the host’s store instead of a sqlite file beside the process:

#![allow(unused)]
fn main() {
use std::sync::Arc;
use blockwatcher_ports::Storage;

let storage: Arc<dyn Storage> = Arc::new(LedgerStore::new(pool));

let engine = Engine::start(EngineDeps::new(
    storage,
    build_catalog()?,
    config,
))
.await?;
}

catalog.register_storage exists for symmetry, but nothing in the engine resolves storage by name; only the binary does, from [storage] in blockwatcher.toml. A host registers a storage module only if its own configuration should be able to name one.

Storage is also the widest port and the only one with a shared behavioural contract. Take blockwatcher-testkit as a dev-dependency and run exercise_storage_contract against the host backend: it is the same sequence memory and sqlite pass, so passing it is what makes the host’s store equivalent to them rather than merely compiling (blockwatcher-testkit § The storage contract). The rules it checks, optimistic concurrency on every write, unpaginated resource listings, and pruning the journal inside record_delivery, are stated in blockwatcher-storage § The Storage port contract.

A host-owned source

A host that already streams a chain, or that reads from an internal bus a shipped source knows nothing about, implements Source and registers it under a name a network resource can select:

#![allow(unused)]
fn main() {
use blockwatcher_ports::{
    ModuleRegistry, Source, SourceError, SourceFactory, SourceModule, SourceModuleFacts,
};

struct LedgerFeedRegistry;

impl ModuleRegistry for LedgerFeedRegistry {
    const NAME: &'static str = "ledger-feed";
    type Factory = SourceFactory;
    fn factory() -> Self::Factory {
        |config| {
            Box::pin(async move {
                let config: FeedConfig =
                    serde_json::from_value(config).map_err(|e| SourceError::InvalidConfig {
                        message: format!("invalid ledger-feed config: {e}"),
                    })?;
                Ok(Arc::new(LedgerFeed::new(config)) as Arc<dyn Source>)
            })
        }
    }
}

impl SourceModuleFacts for LedgerFeedRegistry {}

catalog.register_source(SourceModule::of::<LedgerFeedRegistry>())?;
}

A stored network resource with "source": { "module": "ledger-feed", … } then gets one instance per network, exactly as a shipped source would. The obligations are unchanged by the source being local: events go out through ctx.events in non-decreasing cursor order from a single loop, ctx.cancel means return SourceOutcome::Ended, a detected rewind is SourceOutcome::Invalidated { from } rather than an error, and the cursor mapping is the host’s to choose and document. scan and confirmed_tip default to Unsupported, so a streaming-only feed implements neither. SourceModuleFacts defaults refuse the same way: a module with no confirmed tip and no cold start writes nothing, and a module with either states it beside its own registry.

blockwatcher-testkit’s recv and stop are worth pulling in as a dev-dependency here too: they drive the source under a deadline and fail a run that ignores its cancellation token instead of hanging the host’s test suite.

A host-owned decoder

A decoder is the one module a host registers but never names. The engine constructs every registered decoder at boot with an empty config object and indexes them by the ChainKind each chain() returns, so a host decoder becomes reachable purely by storing Spec resources whose chain matches:

#![allow(unused)]
fn main() {
use blockwatcher_ports::{Decoder, SpecError};

catalog.register_decoder("ledger-wire", |_config| {
    Box::pin(async move { Ok(Arc::new(LedgerDecoder) as Arc<dyn Decoder>) })
})?;
}

Two registered decoders claiming the same ChainKind is EngineError::DuplicateChainDecoder at boot, so a host adding a decoder for a chain the build already carries should drop that family’s feature first. The compile-time and hot-path obligations, resolving only the specs a selector names, matching declared field names to the shapes actually decoded, and keeping decode output order deterministic, are where a host decoder most often goes quietly wrong; Extending blockwatcher § Decoder states each with the failure it produces.

A host-owned matcher

A host with its own predicate language implements Matcher, registers it, and names it in EngineConfig instead of expr:

#![allow(unused)]
fn main() {
catalog.register_matcher("host-lang", |_config| {
    Box::pin(async move { Ok(Arc::new(HostMatcher) as Arc<dyn Matcher>) })
})?;

let config: EngineConfig = serde_json::from_value(serde_json::json!({
    "matcher": { "module": "host-lang", "config": {} }
}))?;
}

One matcher runs per process, which is why EngineConfig has no overall default and refuses to guess one. A host that replaces expr should build embed without the expr feature; DEFAULT_MATCHER exists only under that feature, and nothing else fills the field. The obligation most easily missed is that compile takes operator-controlled source: the engine bounds the length it passes in, but the module must bound the work and memory one call can consume, because a compile that can be driven to exhaust a stack or heap is a write-path denial of service.

What embed is not

  • Not a second engine API. Engine::start, Engine::shutdown, and ControlHandle are the same types the binary calls. Embed does not wrap them.
  • Not a signal handler. The host decides when to drain. Engine::shutdown is the same deadline-bounded drain the binary maps onto its exit codes; without a host call, nothing asks the engine to stop.
  • Not the HTTP or metrics stack. blockwatcher-api and blockwatcher-metrics are not dependencies of embed. A host that wants those listeners still runs the binary, or wires those crates itself.
  • Not a second registration path for workspace modules. A module that ships in the workspace still becomes selectable through one get_all() line in its own crate, which build_catalog folds, and the binary must not grow its own catalog beside embed’s. A host registering a type it owns is the separate case above: it adds to the catalog build_catalog returned rather than replacing how that catalog is built.

Extending blockwatcher walks a shipped module through registration step by step and states what each of the six ports asks of the module behind it, in § What each port asks of a module; its § Extending to a new network covers the chain-family case, which a host reaches by registering a Source and a Decoder together rather than by adding a crate to the workspace. A write-time SpecImporter is optional on that same host; without one, from-chain is chain_not_supported for that family’s specs. blockwatcher-embed is the crate page: features, allowlist, and what it re-exports.

Concepts

This part opens up the model underneath the pipeline: what a resource is, how a predicate turns a decoded occurrence into a yes-or-no answer, what blockwatcher guarantees about delivery, and where the boundary sits that keeps the engine from ever needing to know which chain it is watching. It is for a reader who has already run blockwatcher and now wants to understand why it behaves the way it does.

flowchart LR
    rpc["RPC endpoints<br/>(external chains)"]
    %% Source modules are family-owned; the running list is GET /catalog, not this map.
    sources["Sources<br/>one per network"]
    sinks["Sinks<br/>webhook · script · log"]
    storage["Storage<br/>checkpoints · dead letters · resources"]
    api["REST API"]
    metrics["Metrics"]
    engine["engine<br/>bounded channels · checkpoints"]

    subgraph pipeline["Engine pipeline"]
        direction LR
        decoder["Decoder"]
        matcher["Matcher<br/>predicates"]
        gate["Gate<br/>threshold · max_once"]
        decoder --> matcher
        matcher --> gate
    end

    rpc --> sources
    sources -->|"decode and match"| decoder
    gate --> sinks
    api -->|"manages resources"| storage
    storage <--> engine
    engine -->|"drives"| pipeline
    engine -.->|"reports"| metrics

    classDef module fill:none,stroke:#a9a3e3
    classDef core fill:none,stroke:#8a8d86,stroke-dasharray: 5 5

    class rpc,sources,sinks,decoder,matcher,gate module
    class engine,api,storage,metrics core
classDef dim fill:none,stroke:#999999,color:#999999,opacity:0.35
classDef focus fill:#ffd43b,stroke:#d9480f,stroke-width:3px,color:#1a1a1a
class rpc,sources,sinks,storage,api,metrics dim
class engine,decoder,matcher,gate focus
click sources "selectors.html"
click decoder "chain-agnosticism.html"
click matcher "predicates.html"
click gate "gates.html"
click sinks "delivery.html"
click storage "resources.html"
click api "../reference/http-api.html"
click metrics "../reference/observability.html"
click engine "pipeline.html"

Key takeaways

  • Concepts pages explain the model behind the pipeline, not how to run it: resources, predicates, gates, delivery, and the chain-agnostic boundary.

  • The pipeline page covers which task does what and where the bounded queues sit; the resources page covers the resource kinds blockwatcher watches and delivers to.

  • Predicates, gates, selectors, and delivery guarantees together describe how a decoded event becomes a match and reaches a sink.

  • Chain-agnosticism and modules describe the boundaries that keep the engine chain-unaware and every axis of behavior swappable.

  • The pipeline: which task does what, and where the bounded queues sit.

  • Resources: the resource kinds blockwatcher watches and delivers to.

  • Predicates and the expression language: how one decoded occurrence becomes a match.

  • Gates: when a predicate-true hit becomes a delivery, across other hits of the same monitor.

  • Selectors: how a monitor points at a contract spec.

  • Chain families: selector keys, spec payloads, and source trade-offs that belong to one decoder and the sources that feed it.

  • Delivery guarantees: what “done” means, and what happens when delivery fails.

  • Chain-agnosticism: the boundary that keeps the engine chain-unaware.

  • Modules and trade-offs: the swappable pieces behind each port, and what each choice costs.

The pipeline

How it works in one picture sketched a single pipeline. This page opens it up: which task does what, which crate implements it, where the bounded queues sit, and (the part that only reading crates/blockwatcher-core/src/pipeline/ settles) exactly what is shared across a deployment and what is private to one network.

flowchart LR
    rpc["RPC endpoints<br/>(external chains)"]
    %% Source modules are family-owned; the running list is GET /catalog, not this map.
    sources["Sources<br/>one per network"]
    sinks["Sinks<br/>webhook · script · log"]
    storage["Storage<br/>checkpoints · dead letters · resources"]
    api["REST API"]
    metrics["Metrics"]
    engine["engine<br/>bounded channels · checkpoints"]

    subgraph pipeline["Engine pipeline"]
        direction LR
        decoder["Decoder"]
        matcher["Matcher<br/>predicates"]
        gate["Gate<br/>threshold · max_once"]
        decoder --> matcher
        matcher --> gate
    end

    rpc --> sources
    sources -->|"decode and match"| decoder
    gate --> sinks
    api -->|"manages resources"| storage
    storage <--> engine
    engine -->|"drives"| pipeline
    engine -.->|"reports"| metrics

    classDef module fill:none,stroke:#a9a3e3
    classDef core fill:none,stroke:#8a8d86,stroke-dasharray: 5 5

    class rpc,sources,sinks,decoder,matcher,gate module
    class engine,api,storage,metrics core
classDef dim fill:none,stroke:#999999,color:#999999,opacity:0.35
classDef focus fill:#ffd43b,stroke:#d9480f,stroke-width:3px,color:#1a1a1a
class rpc,sources,sinks,storage,api,metrics,decoder,matcher,gate dim
class engine focus
linkStyle 6,7,8 stroke:#d9480f,stroke-width:3px
click sources "selectors.html"
click decoder "chain-agnosticism.html"
click matcher "predicates.html"
click gate "gates.html"
click sinks "delivery.html"
click storage "resources.html"
click api "../reference/http-api.html"
click metrics "../reference/observability.html"
click engine "pipeline.html"

Key takeaways

  • spawn turns one network’s PipelineSpec into four task kinds (source, processor, sink worker, checkpoint writer) linked end to end by channels.
  • The decoder is shared per chain and the matcher is one instance for the whole engine; only the source, monitor set, and sink workers are per-network.
  • The two bounded channels never drop a message: a full channel stalls the pipeline at the point of congestion rather than losing anything.
  • A raw event registers with the progress tracker exactly once, after dispatch, for however many matches it produced, even zero.
  • After a predicate-true decode, an optional per-monitor gate may Retain or Discard the hit with outstanding 0, or Emit a match/digest. Holding a hit does not stall the checkpoint.
  • A cursor only ever persists once every completion guard the event handed out, across every monitor and sink it touched, has completed.

One pipeline per network

Engine::start builds one PipelineSpec per stored network and spawns it once, and every later network write goes through the same spawn call by way of a restart (crates/blockwatcher-core/src/pipeline/mod.rs). A PipelineSpec bundles that network’s already-constructed source, its chain’s decoder, the engine’s one matcher instance, the network’s compiled monitor set, and every sink any of those monitors names (crates/blockwatcher-core/src/pipeline/mod.rs). spawn turns that bundle into the task kinds below, wired together by channels it owns end to end.

flowchart LR
    src["Source task"] -->|"events channel<br/>(bounded)"| proc
    proc["Processor task<br/>decode + match + gate"]
    proc -->|"sink channel<br/>(bounded, per sink)"| sw["Sink worker task<br/>blockwatcher-core"]
    proc -.->|register cursor| prog["Progress tracker<br/>blockwatcher-core"]
    sw -.->|complete / dead-letter| prog
    prog -->|checkpoint| cpw["Checkpoint writer task<br/>blockwatcher-core"]
    sw -->|on exhaustion| dl[("dead letters<br/>blockwatcher-storage")]
    cpw --> store[("checkpoint<br/>blockwatcher-storage")]

    classDef task fill:none,stroke:#a9a3e3
    class src,proc,sw,cpw task

Each task kind, and the crate doing the real work inside it:

  • Source task: one per network, running whatever module the network’s source.module names. It owns its own notion of position, its cursor, and must hand events to the next stage in non-decreasing cursor order, the contract Processor::run states for its receiver; nothing downstream reorders what it reads (crates/blockwatcher-core/src/pipeline/processor.rs).
  • Processor task: one per network, decoding, matching, and gating in the same loop. It calls the network chain’s Decoder (the decoder registered for that network’s chain) once per active, unpaused monitor’s selector, then evaluates that monitor’s compiled predicate through the engine’s single Matcher instance (blockwatcher-expr’s expr matcher, unless a deployment swaps it); see Processor::process_one (crates/blockwatcher-core/src/pipeline/processor.rs). Gate arithmetic lives in pipeline/gate.rs, not inlined in processor.rs.
  • Sink worker task: one per sink a network’s monitors reference, running the sink module (webhook, script, or log) named by that sink’s SinkDef. It owns delivery retry and backoff and dead-lettering, identical regardless of which module is attached, per SinkWorker’s own doc comment (crates/blockwatcher-core/src/pipeline/sink_worker.rs).
  • Checkpoint writer task: one per network, persisting whatever the progress tracker publishes and refusing to persist anything older than what it already wrote this run, in CheckpointWriter’s persist method (crates/blockwatcher-core/src/pipeline/checkpoint_writer.rs).

The Progress tracker (crates/blockwatcher-core/src/progress.rs) is not a task; it is a shared, lock-protected structure the processor registers events into and sink workers complete guards against, read by the checkpoint writer. It is covered in full on Delivery guarantees; this page treats it as the thing that turns “every dispatched match done” into “safe to persist this cursor.”

What is shared, and what is per-network

The pipeline diagram makes every task look network-scoped, but two of the module families it touches — the decoder and the matcher — are actually shared engine-wide, constructed once at boot and reused rather than rebuilt per network or per pipeline restart:

ComponentScopeWhere it’s built
Source instance & taskOne per network, from that network’s source module selection.build_pipeline_spec, crates/blockwatcher-core/src/engine/control.rs (constructed fresh on every restart)
Decoder instanceOne per chain, shared by every network on that chain.validate_and_build, crates/blockwatcher-core/src/engine/boot.rs (built once at boot, kept in Engine.decoders)
Matcher instanceOne for the whole engine: the module named in blockwatcher.toml’s matcher field, never per network or per monitor.validate_and_build, crates/blockwatcher-core/src/engine/boot.rs (built once at boot, kept in Engine.matcher)
Monitor setOne MonitorSet per network, holding every monitor whose network field names it.build_pipeline_spec, crates/blockwatcher-core/src/engine/control.rs
Sink module instanceConstructed fresh from the stored SinkDef every time a network’s pipeline is (re)built, not retained across a restart.build_pipeline_spec, crates/blockwatcher-core/src/engine/control.rs
Sink worker task, its channel, and its retry loopOne per (network, sink) pair: a sink two networks both reference gets two independent workers, two independent channels, two independent retry loops.spawn, crates/blockwatcher-core/src/pipeline/mod.rs
Events channel (source → processor)One per network.spawn, crates/blockwatcher-core/src/pipeline/mod.rs
CheckpointOne per network, keyed by NetworkId.CheckpointWriter’s network field, crates/blockwatcher-core/src/pipeline/checkpoint_writer.rs

The two easiest to get wrong: the decoder is per chain, not per network (two networks on the same chain decode through the exact same Arc<dyn Decoder>), and the matcher is a single engine-wide instance, not something a monitor or a network picks; blockwatcher.toml names it once for the entire process, as Engine’s matcher field doc comment states (crates/blockwatcher-core/src/engine.rs). A sink’s module instance, by contrast, is rebuilt every time a pipeline is (re)constructed: it is not shared even across two restarts of the same network, though this is invisible in practice because a sink module carries no state beyond its config.

Backpressure: two bounded channels, no drops

Between source and processor sits one tokio::sync::mpsc channel of capacity event_channel_capacity (default 256); between processor and each sink worker sits one of capacity sink_channel_capacity (default 64), both configured once in blockwatcher.toml’s engine section and applied to every pipeline alike, EngineConfig’s fields, read by spawn (crates/blockwatcher-core/src/config.rs, crates/blockwatcher-core/src/pipeline/mod.rs). Neither channel ever drops a message when full. A source’s send and the processor’s dispatch are plain awaited .send() calls, in Processor::process_one (crates/blockwatcher-core/src/pipeline/processor.rs); when a channel is at capacity, that await simply doesn’t resolve until a slot opens, which means a slow sink worker’s channel filling up stalls the processor’s dispatch loop, which in turn stalls the source’s own send: the whole pipeline pauses at the first point of congestion rather than losing anything. The two channels close only when their sending side is dropped, which is what a graceful shutdown or restart uses to drain: cancel the source, its sender drops, the processor’s receive loop ends and drops the sink senders, and each sink worker drains whatever is already queued before it exits, the cascade spawn’s own doc comment states (crates/blockwatcher-core/src/pipeline/mod.rs).

Escalation, abort, and the storage fence

Every drain (a whole-engine shutdown, and a single network’s stop or restart through the control plane) is bounded by drain_deadline_ms. Missing that deadline escalates: a hard_cancel signal fires, tasks that are still running get a brief grace window to exit on their own, and anything still not done after that is stopped with a forced abort() (crates/blockwatcher-core/src/engine/drain.rs). An escalation like this is always loud (a warning naming the network) and always counted (blockwatcher_pipelines_aborted_total).

An abort() can leave more behind than a stopped task: a storage backend whose operations run independent of the caller that started them (sqlite’s own connection lives behind a tokio::task::spawn_blocking call, for example) keeps running whatever it was asked to do at the moment of the abort, and that call still lands later, on its own schedule. Without accounting for it, a pipeline restarted right after such an escalation could read its resume point, or its gate journal, before that residual write lands, then start delivering against what it read, only for the residual write to land afterward and overwrite what the restart’s own work just persisted: a gap, not a duplicate.

Storage::quiesce closes that window: after any escalated drain, the engine awaits it, bounded by its own budget, before treating the network as safely stopped. It resolves once every storage operation an aborted task had in flight has actually completed. One function, stop_pipeline, is where every single-network stop and restart drains through, so this quiescing, its budget, and its counting all live in that one place regardless of which control-plane path called it. Three paths read what it decides before touching storage themselves, and all three refuse in the same shape when it times out, leaving the network abandoned (visible in /status the same way a failed crash-restart already is) until an operator retries or the process restarts:

  • a restart, before re-reading the checkpoint and gate outbox it would otherwise redeliver or overwrite against;
  • a gate envelope change or a gated monitor’s delete, before dropping that monitor’s held journal, which a residual write could otherwise resurrect after the drop; and
  • a reorg invalidation, before pruning gate holds, retracting journaled deliveries, or rewinding the checkpoint, any of which a residual write could otherwise race.

Every one of these refusals is counted under blockwatcher_pipelines_quiesce_timeout_total exactly once, at stop_pipeline itself, no matter which of the three paths called it or how many times. A timeout is not forgiven just because a later attempt on the same network finds no pipeline left running to escalate: the engine remembers the debt and re-attempts the same quiesce, under the same budget, on that later attempt too, refusing again for as long as the residual write stays outstanding and clearing the debt only once some attempt actually waits it out.

A whole-engine shutdown facing the same timeout only warns and counts it instead of refusing anything, since the process is exiting either way and a later process starts with its own storage connections, which the residual write from this one cannot reach.

From one raw event to zero or more deliveries

Inside Processor::process_one, run once per raw event (crates/blockwatcher-core/src/pipeline/processor.rs):

  1. An event addressed to a different network is dropped and counted (misrouted) rather than processed: a pipeline never touches an event that isn’t its own, because attributing it would corrupt this pipeline’s own cursor ordering.
  2. The current monitor set is snapshotted once for the whole event, not once per monitor, so a hot-swapped monitor set landing mid-event can never split that event’s processing between an old and a new set.
  3. For every active, unpaused monitor, the decoder runs against that monitor’s compiled selector. Zero, one, or several decoded events can result (one raw payload can decode to several occurrences); each one that decodes is evaluated against the monitor’s predicate (or accepted outright if it has none).
  4. Every monitor that accepts a decoded event offers that occurrence to its gate (or, with gate omitted, treats it as Emit of this hit). Retain / Discard mint nothing. Emit mints one match per chosen journal index (one SinkEvent::Match, or one Digest if two or more) and queues one work item per sink that monitor’s actions name.
  5. Once every pair from this one raw event is known, the event is registered with the progress tracker for exactly that many outstanding completions: registered exactly once per event, after dispatch, never before and never twice. A gate that retained or discarded adds zero to that count for that monitor (crates/blockwatcher-core/src/pipeline/processor.rs).

An event that matched nothing (no selector, or predicate false, or every gate quiet) still gets registered, with zero outstanding, which is what lets its cursor advance immediately rather than wait on deliveries that were never dispatched.

Delivery, retry, and the checkpoint’s dependency on it

Each sink worker pulls one work item at a time and calls deliver_with_retry (crates/blockwatcher-core/src/pipeline/delivery.rs): a transient or rate-limited failure sleeps a doubling backoff and tries again, up to the sink’s configured max_attempts; a permanent or range-narrower failure skips straight to giving up. Giving up means recording a dead letter in storage (retried forever on its own backoff if that write itself fails), because a checkpoint may only move past a loss storage has durably recorded, never past one it merely intends to record, the ordering SinkWorker::dead_letter’s own doc comment states (crates/blockwatcher-core/src/pipeline/sink_worker.rs). Only once delivery succeeds or the dead letter is durably written does the sink worker complete its guard. The checkpoint writer, in turn, only ever persists a cursor once every guard the processor handed out for it (across every monitor and every sink that event’s matches touched) has completed, via Progress and the CompletionGuard it hands out (crates/blockwatcher-core/src/progress.rs). Delivery guarantees covers this mechanism itself in depth, along with sources whose cursor is not a chain position and so cannot make the same promise across a crash.

Resources

Everything blockwatcher watches and everywhere it delivers to is one of its resource kinds, each its own Rust struct in crates/blockwatcher-types/src/resource.rs, each rejecting an unrecognized field at write time (#[serde(deny_unknown_fields)], one exception noted below). This page covers what each kind actually contains, how a monitor references the others, how a resource gets into storage in the first place, and exactly what gets checked before a write is allowed to land. The field-level tables (every key, its type, its default, and the exact refusal a bad value gets) live in the Resource reference, one page per kind.

flowchart LR
    rpc["RPC endpoints<br/>(external chains)"]
    %% Source modules are family-owned; the running list is GET /catalog, not this map.
    sources["Sources<br/>one per network"]
    sinks["Sinks<br/>webhook · script · log"]
    storage["Storage<br/>checkpoints · dead letters · resources"]
    api["REST API"]
    metrics["Metrics"]
    engine["engine<br/>bounded channels · checkpoints"]

    subgraph pipeline["Engine pipeline"]
        direction LR
        decoder["Decoder"]
        matcher["Matcher<br/>predicates"]
        gate["Gate<br/>threshold · max_once"]
        decoder --> matcher
        matcher --> gate
    end

    rpc --> sources
    sources -->|"decode and match"| decoder
    gate --> sinks
    api -->|"manages resources"| storage
    storage <--> engine
    engine -->|"drives"| pipeline
    engine -.->|"reports"| metrics

    classDef module fill:none,stroke:#a9a3e3
    classDef core fill:none,stroke:#8a8d86,stroke-dasharray: 5 5

    class rpc,sources,sinks,decoder,matcher,gate module
    class engine,api,storage,metrics core
classDef dim fill:none,stroke:#999999,color:#999999,opacity:0.35
classDef focus fill:#ffd43b,stroke:#d9480f,stroke-width:3px,color:#1a1a1a
class rpc,sources,sinks,metrics,engine,decoder,matcher,gate dim
class storage,api focus
click sources "selectors.html"
click decoder "chain-agnosticism.html"
click matcher "predicates.html"
click gate "gates.html"
click sinks "delivery.html"
click storage "resources.html"
click api "../reference/http-api.html"
click metrics "../reference/observability.html"
click engine "pipeline.html"

Key takeaways

  • blockwatcher’s resource kinds are exactly Network, Spec, SinkDef, and Monitor, each rejecting an unrecognized field except Monitor’s selectors, checked later by the decoder.
  • A monitor is the only resource that references any other: it names one network, one spec per selector, and one or more sinks.
  • Every resource is a versioned record, written through the REST API’s ETag/If-Match concurrency or, once, through a first-boot seed directory.
  • What restarts on a write differs by kind: a monitor write hot-swaps in place, a network write always restarts, and a spec or sink write restarts every network it affects.
  • Every write validates before it reaches storage, and every validation rejection surfaces on the wire as 422.

The kinds and their fields

Network (Network, crates/blockwatcher-types/src/resource.rs): an id, a chain tag, and a source module selection (ModuleSel: a module name plus that module’s own opaque config object). Nothing else. It is the only resource whose module family (Source) is configured on the resource itself rather than referenced from elsewhere.

Spec (Spec): an id, a chain tag, and a payload that core never interprets. The payload shape is decoder-owned; see the family page. The write itself is what runs that chain’s compile_spec, turning the artifact into a SchemaSet and a selector-keyed dispatch table exactly once; a selector or predicate written against this spec afterward resolves against that already-built result, so re-parsing the artifact is a write-time cost this resource pays only once, not something every later selector compile repeats.

SinkDef (SinkDef): an id, a module name, that module’s config, an optional retry policy (DeliveryRetry { max_attempts, initial_backoff_ms, max_backoff_ms }), an optional throttle policy (Throttle { max_deliveries, window_ms }), and an optional aggregate policy (Aggregate { window_ms, max_batch }). All of them are siblings of config rather than nested inside it: a sink module never sees or interprets its own retry, throttle, or aggregate policy, the engine owns retry, dead-lettering, throttling, and aggregation identically for every module. A SinkDef with no retry falls back to the engine-wide default from blockwatcher.toml; a SinkDef with no throttle is not throttled at all, since throttling is opt-in; a SinkDef whose throttle field is present fills any field left out of it with the default, 60 deliveries per 60,000ms; a SinkDef with no aggregate delivers every match on its own, unbatched. See Delivery policies for what enforcing each one means, and for a complete SinkDef JSON example carrying all three beside a webhook module config.

Monitor (Monitor): an id, the network it watches, one or more selectors, an optional predicate, an optional gate (ModuleSel: module + config; omitted means passthrough), and actions (a list of sink ids). gate is this monitor’s decision rule (like predicate), not a shared resource id (like actions: SinkId[]). Journals are per (pipeline, monitor). RawSelector is deliberately the one resource shape without deny_unknown_fields: it carries a core-readable spec field plus a #[serde(flatten)] bag of decoder-owned keys, and serde cannot combine flatten with deny_unknown_fields on the same struct. An unrecognized selector key is still rejected, just later, by the chain’s decoder at compile time rather than by serde at parse time. Which keys a family admits is on the family page.

How they reference each other

A monitor is the only resource that names any of the others, and it names all of them:

erDiagram
    Network {
        NetworkId id
        ChainKind chain
        ModuleSel source
    }
    Spec {
        SpecId id
        ChainKind chain
        Json payload
    }
    SinkDef {
        SinkId id
        string module
        DeliveryRetry retry
        Throttle throttle
        Aggregate aggregate
    }
    Monitor {
        MonitorId id
        NetworkId network
        RawSelector[] selectors
        string predicate
        ModuleSel gate
        SinkId[] actions
    }
    Monitor }o--|| Network : "network"
    Monitor }o--o{ Spec : "selectors[].spec"
    Monitor }o--o{ SinkDef : "actions"

Monitor.gate is optional; mermaid cannot show Option cleanly, so omitted means passthrough, as Gates describes. Monitor.network names exactly one Network; each entry in Monitor.selectors names exactly one Spec through its spec field; and Monitor.actions names one or more SinkDefs. Network, Spec, and SinkDef never reference each other or point back at a monitor: the reference graph is a star with Monitor at the center, one level deep. A Spec and the Networks that use it are connected only indirectly, through whichever monitors select that spec on that network; spec_set_for_chain (crates/blockwatcher-core/src/compile.rs) compiles every spec sharing a network’s chain together, which is why a spec edit can affect a network that no monitor explicitly ties to it by name (see the write-time checks below).

Lifecycle: how a record is created, versioned, and reloaded by kind

Every resource is a VersionedRecord in storage: an id, a version for optimistic concurrency, and the JSON value (crates/blockwatcher-types/src/resource.rs). A record gets there by exactly the routes below, and by no other:

  • The REST API, PUT /{kind}/{id}. Without an If-Match header the write is a create: it fails with 409 Conflict if the id already exists. With If-Match: "<version>" it is an update: it fails with 412 Precondition Failed (naming the actual current version) if the version doesn’t match, or 404 if the record is gone, per Storage::put’s contract and the write handler’s call to if_match (crates/blockwatcher-ports/src/storage.rs, crates/blockwatcher-api/src/routes/resources.rs). A successful write’s response carries the resulting version in its own ETag, which is what the next conditional write is expected to send back as If-Match.
  • A seed directory, read once at boot via the binary’s --seed <dir> flag. It expects exactly one subdirectory per kind (networks/, specs/, sinks/, monitors/), one JSON file per resource, and it is strictly first-boot: seeding runs the whole bundle through the same validation Engine::start itself would run, then writes it with create-only semantics, and once storage holds any resource of any kind, seeding is refused as a no-op on every later boot, per validate and store_is_empty (crates/blockwatcher/src/seed.rs). After that first boot, every resource in that deployment is managed exclusively through the API.

What happens to a running pipeline differs by kind, and this is the one place “hot-reloaded” oversimplifies:

  • Writing a Monitor usually restarts nothing. It recompiles that monitor’s network’s whole MonitorSet from storage and publishes it to the already-running pipeline through a watch channel: the same source instance keeps running, its checkpoint keeps advancing straight through the swap, in ControlHandle::put_monitor (crates/blockwatcher-core/src/control/writes.rs). Two things turn that swap into a restart of the one network:

    • Changing gate (module or config) is a new compiled artifact and drops that monitor’s gate journal. Holds belong to one envelope, and a processor keeps the journal of every gated monitor it is running in memory, so the drop may only happen with nothing processing for that network: the pipeline is stopped first, the holds dropped, and the pipeline brought back, in wipe_gate_state_while_stopped (crates/blockwatcher-core/src/control/mod.rs). Deleting a gated monitor takes the same path, per delete_monitor (crates/blockwatcher-core/src/control/deletes.rs). The wipe drops holds (undecided accumulation), never committed emissions: an outbox row the old envelope emitted but had not yet delivered survives and is delivered (or dead-lettered) when the pipeline comes back, per the gate delivery guarantee.
    • If the recompiled set now names a sink the running pipeline never spawned a worker for, the write escalates to a full restart, because a sink worker cannot be added to a pipeline after the fact, per hot_swap_monitors’s own doc comment (crates/blockwatcher-core/src/engine/control.rs).

    Both cost only what any restart costs: the source resumes from the checkpoint the drain left behind. A network with no running pipeline is never brought up by either: there is nothing to protect, and starting one would undo whatever stopped it.

  • Writing a Network always restarts that network’s pipeline: the old one is drained, a fresh one is spawned, and it resumes from whatever checkpoint the drain left behind, in put_network (crates/blockwatcher-core/src/control/writes.rs). A restart is cheap specifically because the checkpoint survives it; it is not a re-scan from the beginning.

  • Writing a SinkDef restarts every network whose stored monitors currently name that sink id, not every network in the deployment, per put_sink (crates/blockwatcher-core/src/control/writes.rs).

  • Writing a Spec restarts every network on either the spec’s new chain or (on a reassignment) its prior chain, because spec_set_for_chain compiles every spec sharing a chain together: a network that never named this spec by id can still be running a compiled set built partly from it, per put_spec (crates/blockwatcher-core/src/control/writes.rs).

A restart that fails part-way through a multi-network fan-out (a SinkDef or Spec write affecting several networks) does not fail the request or stop the ones after it: the resource is already durably stored either way, and each network’s own restart failure is only logged, in restart_affected (crates/blockwatcher-core/src/control/writes.rs).

Exception. A gate envelope change, or a gated monitor’s delete, that cannot bring the pipeline back after stopping it returns that failure even when the persist has already landed. /status then reports the network as abandoned; answering 2xx would tell the operator the network is fine.

Whatever triggers it, a restart has the same observable side effects: a network write, a spec or sink write, a gate-envelope change, a gated monitor’s delete, and reorg recovery all go through this same replacement. Each one resets the /status counter snapshot for that network and rebuilds its sink workers, so in-memory throttle and aggregate windows start fresh; the Prometheus counters, checkpoints, journals, and outbox are unaffected, since none of them live in the pipeline instance a restart replaces.

Write-time validation

Every write validates before it ever reaches storage, and every rejection is 422 Unprocessable Entity on the wire: classify, in crates/blockwatcher-api/src/error.rs, classifies exactly this set of engine refusals that way, distinguishing them from a version conflict (412), a missing reference lookup (404), or a genuine server fault (500, and never with internal detail on the wire). What gets checked differs by kind:

  • Monitor: its network must exist; every id in actions must name an existing sink; every selector’s spec must exist and share the network’s chain; and the whole monitor (selectors, predicate, and gate) must compile against the live decoder, matcher, and gate catalog before the write is persisted, in put_monitor (crates/blockwatcher-core/src/control/writes.rs). An unknown gate module, unknown config keys, out-of-range window_ms/count, or missing block.timestamp is 422: gate requires 'block.timestamp'; this monitor's selectors do not expose it.
  • Network: its chain must have a loaded decoder, and its source module must actually construct with the given config, not merely be a name the catalog recognizes. Updating an existing network additionally recompiles every monitor already stored for it against the incoming chain, refusing a chain reassignment that would strand them, in put_network (writes.rs).
  • SinkDef: its module must actually construct with the given config. retry, throttle, and aggregate bounds are the same 422 invalid_resource as any other write-time InvalidResource: a zero max_deliveries / max_batch / window_ms, a window over one day, a max_batch over 10_000, or initial_backoff_ms above max_backoff_ms. Nothing about existing monitors is re-validated: a sink id, once it exists, is a stable dependency by name, per put_sink (writes.rs).
  • Spec: its chain must have a loaded decoder, and the payload must compile standalone; then every monitor on every network that would be affected (per the chain-sharing rule above) is recompiled against a hypothetical spec set with this write already applied, so a reassignment that would break one of them is refused before it is stored, in put_spec (writes.rs).
  • Delete, for Network/Spec/SinkDef, refuses outright while any stored monitor still references the id (checked before storage is touched at all), because a monitor left pointing at nothing would not fail on its own; it would fail the next boot for the whole deployment, per refuse_if_referenced (crates/blockwatcher-core/src/control/deletes.rs).

The did-you-mean suggestion

A 422 for an unresolvable name (an event or function a selector names that the spec doesn’t declare) carries a suggestion when one is available. For a selector’s events/functions list, the suggestion is simply the first event or function of that kind the spec declares (kind_suggestion in the family’s decoder); it is not an edit-distance match. A predicate’s unknown field or namespace, covered on Predicates, gets a real bounded edit-distance suggestion instead: the mechanisms are deliberately different, one per crate that owns the vocabulary being checked. Either way the message shape is the same: SelectorError::UnknownField and PredicateError::UnknownField both render as unknown field '{field}', followed by a did you mean '{suggestion}'? suffix when one exists (crates/blockwatcher-ports/src/error.rs); for example, naming "Transfr" in a selector against a spec that declares Transfer and Approval rejects with unknown field 'Transfr' plus did you mean 'Approval'? (the suggestion is the spec’s first declared event of that kind, not the closest by spelling).

Predicates and the expression language

Every predicate a monitor carries reduces one decoded occurrence to a single yes-or-no answer, deciding whether it becomes a match. The whole language it’s written in lives in crates/blockwatcher-expr: a lexer, a recursive-descent parser, a type-checker that runs at write time against a monitor’s selector schemas, and a total evaluator that runs at decode time and can never error. This page enumerates the grammar from the parser itself, explains the type system it checks against, and walks compilation and evaluation end to end. Every predicate shown below was checked against the parser and type-checker described here, against a schema this page defines in The example schema below.

flowchart LR
    rpc["RPC endpoints<br/>(external chains)"]
    %% Source modules are family-owned; the running list is GET /catalog, not this map.
    sources["Sources<br/>one per network"]
    sinks["Sinks<br/>webhook · script · log"]
    storage["Storage<br/>checkpoints · dead letters · resources"]
    api["REST API"]
    metrics["Metrics"]
    engine["engine<br/>bounded channels · checkpoints"]

    subgraph pipeline["Engine pipeline"]
        direction LR
        decoder["Decoder"]
        matcher["Matcher<br/>predicates"]
        gate["Gate<br/>threshold · max_once"]
        decoder --> matcher
        matcher --> gate
    end

    rpc --> sources
    sources -->|"decode and match"| decoder
    gate --> sinks
    api -->|"manages resources"| storage
    storage <--> engine
    engine -->|"drives"| pipeline
    engine -.->|"reports"| metrics

    classDef module fill:none,stroke:#a9a3e3
    classDef core fill:none,stroke:#8a8d86,stroke-dasharray: 5 5

    class rpc,sources,sinks,decoder,matcher,gate module
    class engine,api,storage,metrics core
classDef dim fill:none,stroke:#999999,color:#999999,opacity:0.35
classDef focus fill:#ffd43b,stroke:#d9480f,stroke-width:3px,color:#1a1a1a
class rpc,sources,sinks,storage,api,metrics,engine,decoder,gate dim
class matcher focus
click sources "selectors.html"
click decoder "chain-agnosticism.html"
click matcher "predicates.html"
click gate "gates.html"
click sinks "delivery.html"
click storage "resources.html"
click api "../reference/http-api.html"
click metrics "../reference/observability.html"
click engine "pipeline.html"

Key takeaways

  • A predicate compiles once, at write time, into an AST checked against the monitor’s schema; evaluation at decode time is total and can never error.
  • The type system groups values into families (Int, Str, address/bytes, Bool) rather than exact types, so a heterogeneous field can carry more than one family at once.
  • Evaluation is three-valued (True, False, Unknown) with Kleene logic for &&/||, and a predicate treats Unknown the same as False.
  • An unknown field or namespace gets a bounded edit-distance suggestion within distance 3; beyond that there is no suggestion at all.
  • expr is the one matcher this repository ships, selected once for the whole process rather than per monitor.
  • A predicate is per-event. Counting, windows, and “already alerted” are a gate, not an expression.

Compilation: what happens when a monitor is written

Compilation happens once, at write time; evaluation happens on every decoded event, against the artifact compilation already produced:

flowchart LR
    subgraph write["write time, once"]
        text["predicate text"] --> parse["parser::parse"] --> ast["Ast"]
        ast --> check["typecheck::check<br/>against SchemaSet"]
        check --> compiled["compiled Predicate"]
    end
    subgraph hot["decode time, every event"]
        compiled --> eval["eval::evaluate(ast, event)"]
        eval --> truth["True / False / Unknown"]
    end

Predicate::compile (crates/blockwatcher-expr/src/lib.rs) does exactly two things, in order: parser::parse turns the source text into an Ast, and typecheck::check walks that tree against the monitor’s SchemaSet, resolving every field path and admitting or rejecting every operator’s operand types. Either stage can fail with a PredicateError, and either failure is what a monitor write rejects with. A predicate never reaches storage unparsed or untyped. A Predicate that compiled successfully cannot fail to evaluate: matches (lib.rs) is eval::evaluate(&self.ast, event) == Truth::True, a total function over the type-checked tree. This is also why the same text can mean two different, unrelated things depending on which monitor it’s attached to: the schema it type-checks against comes entirely from that monitor’s own selectors, never from a shared global vocabulary. The expr module (crates/blockwatcher-expr/src/matcher.rs) is the one Matcher this repository ships, and blockwatcher.toml’s matcher field names it for the entire process at boot: one choice for every network and every monitor the deployment runs, not a per-monitor setting. Nothing about the port boundary itself limits a deployment to this one engine; it is simply the only implementation in the module catalog blockwatcher ships (see Modules). The pipeline covers where in a running pipeline this compiled predicate actually runs.

The example schema

Every predicate example on this page type-checks against one small schema: a spec declaring two ERC-20-style events plus one made-up event for string and array examples, alongside the tx/block/log namespaces the evm decoder’s namespaces function attaches to every compiled spec (crates/blockwatcher-evm/src/decoder/compile.rs):

args (from the selected event)
  Transfer:  from address, to address, value uint256
  Approval:  owner address, spender address, value uint256
  Registered: owner address, name string, tags string[]
tx:    hash bytes, index uint, status uint, from address, to address, value uint
block: number uint, hash bytes, timestamp uint
log:   address address, index uint

args.* is always derived from whichever event or function the monitor’s selectors decoded: it is never declared separately. tx.*, block.*, and log.* are the same three namespaces on every evm spec, and which of their fields an occurrence actually carries depends on the selector that produced it: a log-decoded event’s tx.status is always the constant 1 (a log exists only in a transaction that succeeded) and it never carries tx.from/tx.to/tx.value, while a function-call-decoded occurrence carries those but only carries tx.status/tx.index/block.* once the transaction is mined, never on evm-mempool. A predicate reading a field this occurrence doesn’t carry resolves Unknown, covered under Evaluation below.

Syntax by example

args.value > 1_000_000e6

args.value is declared uint256, so it’s an integer; 1_000_000e6 is token-decimal notation: one million at six decimals, expanded exactly to 1000000000000 at parse time by expand_token_decimal (crates/blockwatcher-expr/src/lexer.rs). This reads “more than one million whole units of a six-decimal token.”

tx.from != 0x0000000000000000000000000000000000000000 && args.value > 0

tx.from is declared address; the hex literal is a byte string compared byte-for-byte, regardless of length: lex_hex (lexer.rs) doesn’t require 20 bytes or any other specific length, only an even digit count. && requires both sides to admit Bool, which both comparisons do.

"promo" in args.tags

args.tags is declared string[]; in’s left operand is a scalar, its right operand here is a path resolving to an array whose element type (Str) intersects the left operand’s family.

args.name contains "USDC" || args.name starts_with "test-"

Both contains and starts_with require Str on both sides; args.name is declared string.

args.value % 1_000_000 == 0

% requires both operands to admit Int; 1_000_000 is a plain integer literal (no exponent), and dividing or taking the modulus of a literal zero is rejected at write time by bin_type_arithmetic (crates/blockwatcher-expr/src/typecheck/mod.rs). A runtime zero divisor, by contrast, resolves to Unknown rather than panicking (covered below).

One rejection worth showing alongside the working examples, because it’s the language’s one surprising parse rule: comparison operators don’t chain.

1 < args.value < 100

This is refused at parse time with comparisons do not chain; parenthesize (crates/blockwatcher-expr/src/parser.rs:262-282) rather than silently reading as either (1 < args.value) && (args.value < 100) or the mathematically different (1 < args.value) < 100. Write it as args.value > 1 && args.value < 100 instead.

Grammar and precedence

The parser is recursive descent, one function per precedence level, from loosest binding to tightest (crates/blockwatcher-expr/src/parser.rs):

  1. ||
  2. &&
  3. ! (prefix; its operand is itself parsed at this level, so ! chains, and it wraps an entire comparison rather than binding inside one: !a.x == 1 parses as !(a.x == 1), not (!a.x) == 1)
  4. Comparisons: == != < <= > >= in starts_with ends_with contains (non-chaining, exactly as shown above)
  5. + -
  6. * / %
  7. Unary -
  8. Atoms: integer/hex/string/boolean literals, field paths, (...)

in’s right-hand side is special-cased in the grammar itself, inside parse_in_rhs (parser.rs): it accepts only a literal list ([...], elements literals-only, one level of nesting so an address allowlist of any width never counts as “deep”) or a bare field path, never an arbitrary expression, so args.x in [1 + 2] is rejected as a list-syntax error before type-checking ever runs.

Operators and functions

CategorySpellingOperand requirementResult
Boolean|| && !BoolBool
Equality== !=both sides’ scalar families intersect (IntInt, StrStr, bytes↔bytes, BoolBool)Bool
Ordering< <= > >=both sides IntBool (non-chaining)
Membershipinleft: a scalar; right: a literal list, or a path resolving to an array whose element family intersects the left’sBool
Stringstarts_with ends_with containsboth sides StrBool
Arithmetic+ - * / %both sides IntInt (/, % truncate toward zero; a literal-zero right side is a compile-time rejection)
Unary- (negation)IntInt

This table is exhaustive against crates/blockwatcher-expr/src/parser.rs’s BinOp enum (Or, And, Eq, Ne, Lt, Le, Gt, Ge, In, StartsWith, EndsWith, Contains, Add, Sub, Mul, Div, Mod) plus the unary node kinds, Not and Neg. There is no function call syntax, no user-defined name, and no loop construct anywhere in the grammar. starts_with/ends_with/contains/in are keywords lex_ident recognizes, not calls (lexer.rs), which is why the language has no general extensibility surface beyond what the parser hard-codes.

Literal forms

FormExampleNotes
Integer1_000_000arbitrary precision; _ separators anywhere in the digit run, may repeat or trail (1__0, 1_ both lex to 10 and 1)
Token-decimal1_000_000e6, 1.5e18exact integer expansion: mantissa (optionally with a fractional part) times 10^exponent; the fraction must fully resolve (1.5e0 is rejected: not an integer); exponent capped at 100
Hex bytes0xA0b8even, non-zero digit count; any length, not just 20-byte addresses
String"USDC"double-quoted; only \" and \\ are recognized escapes
Booleantrue / false
List[1, 2, 0xA0]literals only (optionally negated integers); appears only as in’s right-hand side

Field paths

A path is namespace.field, optionally followed by more .segments, parsed by parse_path (parser.rs). Two things resolve past the first dot, in order, inside SchemaSet::resolve (crates/blockwatcher-types/src/schema.rs):

  • A dotted flattened name, matched whole. A decoder that flattens a nested ABI parameter into order.maker is what makes args.order.maker resolve: the whole remainder after the namespace is one field name, checked as a unit, not traversed component by component.
  • A trailing run of digit segments, derived through a declared array type. args.tags.0 needs no declaration of its own; it resolves through tags’s declared Array(Str) to Str, one array layer unwrapped per digit segment. A digit segment too large to fit a usize is rejected: such an index could never be read back at evaluation time, so admitting it would compile a predicate that resolves to Unknown forever.

A flat declaration always wins over derivation when both would apply to the same spelling: a decoder’s explicit word for what it emits takes priority.

The type system: families, not exact types

The type-checker’s own vocabulary, the Family enum (crates/blockwatcher-expr/src/typecheck/mod.rs), is coarser than the canonical value model: Int and Uint collapse into one family, Int, because every arithmetic and ordering operator treats them identically; Address and Bytes collapse into one family too, because a predicate only ever compares them as raw bytes. A path can carry more than one family at once when heterogeneous selectors declare the same name at different types: a monitor with two selectors, one where args.id is a uint256 and another where it’s a bytes32, type-checks an operation against args.id if either declared type admits it.

Two consequences worth knowing:

  • A negative literal against an all-Uint field never gets as far as running. args.value == -1 against a field declared only uint256 fails to compile with 'args.value' is unsigned and can never satisfy this comparison (checked in crates/blockwatcher-expr/src/typecheck/mod.rs:182-204, the message itself built by diagnostics.rs:282-293). An unsigned value can never equal or be less than a negative number, so the predicate would never fire, and that’s caught before it’s ever stored rather than discovered later as a monitor that silently never matches. The opposite direction (args.value > -1, always true for a Uint) compiles: it’s loud in a different way, matching everything, which is visible, unlike a predicate that matches nothing.
  • A type error names the schema field it’s about whenever one side of the failure is a path, and otherwise names the computed families on both sides. args.name + 1 (a Str field used in arithmetic) reports 'args.name' is declared as [Str], which does not support arithmetic; 1 > "x" (neither side a field) reports ordering requires an integer, but a string was given.

Evaluation: three-valued, not two

Evaluation (crates/blockwatcher-expr/src/eval.rs) never errors and never panics. Every subexpression reduces to one of three truth values (True, False, or Unknown), and Unknown has exactly three sources: a field the occurrence doesn’t carry at all, an explicit null, or a runtime value that the operation in question cannot digest (possible only through a heterogeneous field, since the type-checker already confirmed some declared type admits the operation). matches() collapses Unknown the same way it collapses False (no match), which is what makes a predicate over a field only some of a monitor’s selectors produce safe to write: an event that never carries the field simply never satisfies that clause, on either side of a negation.

&& and || follow the standard three-valued (Kleene) tables rather than treating Unknown as either extreme:

aba && ba || b
TrueUnknownUnknownTrue
FalseUnknownFalseUnknown
UnknownUnknownUnknownUnknown

The practical effect: False && Unknown is False (nothing on the unresolved side could rescue a conjunct that already failed), but True || Unknown is True for the same reason in reverse. Neither of those outcomes requires evaluating the Unknown side at all, so evaluate short circuits exactly where the table already has an answer (eval.rs).

Integer comparisons are exact at every width the canonical value model carries: Value::Int/Value::Uint wrap num-bigint’s arbitrary-precision types, so a comparison against a number one above u128::MAX is not approximated: it is pinned directly in blockwatcher-expr’s own test suite (comparisons_are_exact_past_u128, eval.rs), and Int and Uint compare mathematically regardless of which one a decoder happened to spell a value as. Division and modulus truncate toward zero; a runtime zero divisor (reachable only through a heterogeneous field or an arithmetic expression whose value depends on the event) resolves to Unknown, never a panic.

Errors and the did-you-mean suggestion

Every PredicateError variant carries an operator-facing message, and two (UnknownField and UnknownNamespace) carry a suggestion when one exists. The mechanism is suggestion’s bounded edit-distance search (crates/blockwatcher-expr/src/typecheck/diagnostics.rs): every field the resolved namespace actually declares is compared against what was typed, a two-row dynamic-programming pass that bails out the moment a candidate provably can’t come in under the threshold, and the closest candidate within distance 3 is offered. Ties keep whichever declared name comes first. Given this page’s example schema, args.vlaue > 100 (a five-character transposition, edit distance 2 from args.value) is rejected at write time with unknown field 'args.vlaue' — did you mean 'args.value'? (the em-dash is part of the literal error text, verbatim from the code that emits it). Beyond distance 3 there is no suggestion at all: a wildly wrong name gets a plain rejection rather than a misleading nudge. A namespace typo (argss.value) gets the list of every namespace this monitor’s schema actually declares instead of a single guess, since there’s no declared spelling to measure distance against: the operator wrote a namespace, not a typo of a field.

This is a different mechanism from the one Resources describes for an unknown event or function name in a selector: that suggestion is simply the spec’s first declared name of the right kind, not an edit-distance match. The two live in different crates (blockwatcher-expr for predicates, blockwatcher-evm for selectors) because they check different vocabularies against different failure shapes.

Gates

A predicate answers a per-event yes-or-no. A gate answers a question that depends on other hits of the same monitor: did Transfer occur three times in the last hour of event time? Has this monitor already alerted in this window? The engine asks that question after selector and predicate succeed, and before a match exists.

Omit gate and the engine behaves as it does today: every predicate-true decode is a match. Name a gate module the same way a sink names its module ({ "module": "threshold", "config": { … } }), and the engine offers each hit to that compiled module together with an engine-owned journal of earlier holds.

Delivery guarantees still describe what happens after a match (or digest) is minted. This page is the seam before that mint. Sink throttle and aggregate are the wrong layer for “count three Transfers”: they run after a Match already exists, and aggregate stalls the checkpoint for the whole window.

flowchart LR
    rpc["RPC endpoints<br/>(external chains)"]
    %% Source modules are family-owned; the running list is GET /catalog, not this map.
    sources["Sources<br/>one per network"]
    sinks["Sinks<br/>webhook · script · log"]
    storage["Storage<br/>checkpoints · dead letters · resources"]
    api["REST API"]
    metrics["Metrics"]
    engine["engine<br/>bounded channels · checkpoints"]

    subgraph pipeline["Engine pipeline"]
        direction LR
        decoder["Decoder"]
        matcher["Matcher<br/>predicates"]
        gate["Gate<br/>threshold · max_once"]
        decoder --> matcher
        matcher --> gate
    end

    rpc --> sources
    sources -->|"decode and match"| decoder
    gate --> sinks
    api -->|"manages resources"| storage
    storage <--> engine
    engine -->|"drives"| pipeline
    engine -.->|"reports"| metrics

    classDef module fill:none,stroke:#a9a3e3
    classDef core fill:none,stroke:#8a8d86,stroke-dasharray: 5 5

    class rpc,sources,sinks,decoder,matcher,gate module
    class engine,api,storage,metrics core
classDef dim fill:none,stroke:#999999,color:#999999,opacity:0.35
classDef focus fill:#ffd43b,stroke:#d9480f,stroke-width:3px,color:#1a1a1a
class rpc,sources,sinks,storage,api,metrics,engine,decoder,matcher dim
class gate focus
click sources "selectors.html"
click decoder "chain-agnosticism.html"
click matcher "predicates.html"
click gate "gates.html"
click sinks "delivery.html"
click storage "resources.html"
click api "../reference/http-api.html"
click metrics "../reference/observability.html"
click engine "pipeline.html"

Key takeaways

  • A gate is a sixth port: one compiled decision per monitor over an engine-owned hit journal. The module never opens sqlite.
  • Retain / Discard complete with zero outstanding: holding a hit does not stall the checkpoint. Only an Emit journals a delivery.
  • Window membership uses decoded block.timestamp (unix seconds), never wall-clock time. Catch-up does not dump history into “now.”
  • On invalidate the engine prunes journal rows with cursor strictly after from. It does not drain the whole bag. A tip reorg must not wipe holds from before the fork.
  • One gate per monitor. Compose behaviours with two monitors. Per-event filters stay in the predicate.

Assumptions (what the operator is agreeing to)

AssumptionPrecise meaning
Event time, never wall timeWindow membership uses decoded block.timestamp (unix seconds in Value). Catch-up, a paused pipeline, and tokio::time::Instant do not move a window.
One gate per monitorThe resource field is gate, a single { "module", "config" } envelope. There is no gates: []. Two behaviours means two monitors (same selectors, different gates, different actions if needed).
Omit = passthroughNo gate field means the engine emits this hit as today. No passthrough module is constructed at runtime.
After predicate, before a Match existsA gate never sees a decoded event the predicate rejected. A Retain/Discard is not a Match, not a dead letter, not a sink event.
Decoder vocabularyConfig paths (block.timestamp, later args.from) are the same schema the predicate uses. The gate crate does not parse ABI, logs, or RPC.
block.timestamp required for time-window gatesA monitor whose compiled schemas do not expose block.timestamp as unsigned / non-negative int cannot name threshold or max_once. The write is 422, not a runtime skip.
Mempool / no header timeA source that never puts block.timestamp in the tree cannot use a time-window gate. That is a decoder/schema fact, not a special case in core.
Engine owns the journalModules do not open sqlite, do not hold a HashMap of hits across calls as the source of truth, and do not flush “all RAM” on invalidate.
Hot-swap does not migrate holdsChanging module or config, or deleting the monitor, deletes that monitor’s gate_hits / gate_meta. Old holds are not rewritten into the new compiled gate.
Dry-run is inertPOST /monitors/{id}/test uses a fresh empty journal, does not read stored holds, does not persist, does not call sinks, does not move a checkpoint.
No clock without a hitNo on_tick. No absence, debounce, or “flush the hour.” Quiet is only a decision on a predicate-true hit.
Matcher stays process-wideGate selection is per monitor, like a sink. The matcher language is still one engine-wide module.

Guarantees (what the engine actually promises)

GuaranteePrecise meaning
Quiet hits do not stall the checkpointRetain and Discard register zero outstanding for this monitor’s sinks. Other monitors on the same raw event still add their own outstanding.
Persist-before-completeThe resulting journal (+ meta) is persisted before Progress::begin for an Emit, and before completing a quiet hit.
Persist failure stalls, never skipsStorage put failed: begin(cursor, 1) and the guard is not completed (same stall as a closed sink channel). The engine does not skip begin (that would let a later cursor strand this one).
Prune-by-cursor on invalidateAfter drain, DELETE hold rows with cursor > from for that pipeline. Rows with cursor ≤ from stay. Then today’s retract pass for already-emitted Matches.
Tip reorg does not wipe pre-fork holdsDrain-all / “clear the bag on any Invalidated” is forbidden. Replay only re-feeds events after from; wiping earlier holds would silently under-count.
Replay is at-least-onceAn Emit that already happened may happen again after rewind; Match ids are deterministic. Consumers dedupe as they do today.
max_once discards are not dead lettersAn in-window later hit is dropped with no sink event and no dead-letter row. Throttle’s “suppressed but replayable” contract does not apply here.
threshold session resetAfter an Emit of N hits, those rows leave the journal. Leftovers stay. T4–T6 after a 3-fire can form a second digest without waiting out the original hour.
Engine mints idsThe module returns indices into the journal. Core calls existing Match::new. One index → SinkEvent::Match; two or more → SinkEvent::Digest.
Cap is visibleMore than 10_000 hold rows for one monitor: drop oldest, count blockwatcher_gate_hits_dropped_total. Not silent.
Untimestamped at runtime is counted, not stalledA predicate-true hit whose block.timestamp is missing or unusable is not inserted, outstanding 0, counted blockwatcher_gate_untimestamped_total. Write-time schema check is the closed door; this counter is a decoder/runtime hole.
Invalid on_hit after compile does not stallTreat as Discard of this hit only, count blockwatcher_gate_decision_invalid_total. Never a third outcome that wedges Progress.
Chain-agnosticCore and gate crates do not match ChainKind, import SDKs, or parse RPC payloads.

Contrast operators will get wrong

WantUseDo not use
Fire only when N predicate-true hits span ≤ W (event time); then start a new sessionMonitor gate.module = thresholdSink aggregate (holds the checkpoint until the window closes; batches Matches that already exist)
At most one alert per event-time window; later hits vanishMonitor gate.module = max_onceSink throttle (caps deliveries after a Match exists; refusals are dead letters you can replay)
Drop this event because it is odd / before noon UTCpredicate (block.number % 2, block.timestamp % 86400)A gate. Per-event filters are not stateful.
Rate-limit a noisy webhook without losing matchesSink throttlemax_once (those hits are gone, not replayable)
Batch already-fired matches into fewer HTTP postsSink aggregatethreshold (threshold decides whether a Match exists)

Where it sits

flowchart LR
    src[Source] --> dec[Decode]
    dec --> pred[Predicate]
    pred --> gate[Gate]
    gate --> sink[Sink]

Inside Processor::process_one (crates/blockwatcher-core/src/pipeline/processor.rs), after a monitor accepts a decoded event:

  1. If the monitor has no gate, mint a match as today.
  2. If it has a gate, load that monitor’s persisted hits, append this hit, call Gate::on_hit, persist the resulting journal, then either stay quiet or mint SinkEvent::Match / Digest from the indices the module returned.
  3. Quiet (Retain or Discard) registers outstanding 0 for this monitor’s sinks. Other monitors on the same raw event still add theirs.
  4. The raw event still registers with Progress exactly once, after every monitor has been considered, for the total outstanding across all of them, including zero.

A hit the gate kept is not a match. Sinks never see it. Dead-letter replay cannot resurrect it. The only durable trace is the gate_hits row (and the metrics on Observability).

Resource shape

On Monitor, sibling of predicate (crates/blockwatcher-types/src/resource.rs):

{
  "id": "usdc-burst",
  "network": "eth-mainnet",
  "selectors": [{ "spec": "erc20", "events": ["Transfer"] }],
  "predicate": "args.value > 0",
  "gate": {
    "module": "threshold",
    "config": { "count": 3, "window_ms": 3600000 }
  },
  "actions": ["alerts"]
}
"gate": { "module": "max_once", "config": { "window_ms": 3600000 } }

gate is this monitor’s decision rule (like predicate), not a shared resource id (like actions: SinkId[]). Journals are per (pipeline, monitor). The envelope is ModuleSel-shaped: module plus config object, deny_unknown_fields on the envelope. The named module validates config at write (Modules, P9). An unknown module name is 422, listing catalog names, the same as an unknown sink module.

gate omitted: no module is constructed; the engine emits the hit.

There is no gates array. Order of stacked gates would be a footgun (max_once before threshold never reaches count 3). Two monitors with the same selectors and different gates are how an operator composes behaviours.

Decisions: Retain, Discard, Emit

Gate::on_hit (crates/blockwatcher-ports/src/gate.rs) is total after a successful compile, the same way Matcher::matches is total after compile. It sees the journal including this hit as the last element and returns exactly one of:

DecisionJournalDeliveryProgress for this monitor
RetainKeep the whole bag, including this hitNoneoutstanding 0, after persist
Discard { indices }Drop those rows; no sinkNoneoutstanding 0
Emit { indices }Remove those rowsMatch if one index, Digest if two or more; engine mints idsoutstanding = this monitor’s action count

The module returns indices, never a payload it invented. Core calls existing Match::new on each chosen DecodedEvent.

A buggy on_hit after compile is counted (blockwatcher_gate_decision_invalid_total) and treated as Discard of this hit only. It does not stall the pipeline.

Event time

Time-window gates require block.timestamp in the compiled schema (unsigned or non-negative int). Otherwise the write refuses:

gate requires 'block.timestamp'; this monitor's selectors do not expose it

Window membership:

(newer_ts.saturating_sub(older_ts) as u128) * 1000 <= window_ms

window_ms is 1..=86_400_000 (one day, inclusive). threshold.count is 2..=10_000.

A predicate-true hit that still has no usable timestamp at runtime is not inserted, does not stall, and increments blockwatcher_gate_untimestamped_total. That counter is a decoder hole the write-time check is supposed to have closed; it is not a third way to configure a gate.

Shipped modules

Both crates depend on types + ports only. Catalog family gate, same register path as sinks (build_catalog in crates/blockwatcher-embed/src/catalog.rs).

threshold: session digest

Config: { "count": u32, "window_ms": u64 }.

On a hit, drop a prefix until the remaining span fits window_ms (those drops are internal, not operator-visible, not dead letters). If len >= count, Emit the oldest count indices. Else Retain.

Worked example, count: 3, one-hour window:

  • T1@5m, T2@20m, T3@30m → one digest [1, 2, 3]. Journal empty of them.
  • T4@45m, T5@50m, T6@55m → second digest [4, 5, 6].

That is reset-on-fire, not “wait out the original hour,” and not a cooldown that would drop T4–T6. Leftover hits that were not part of the emitted N stay in the journal.

max_once: first hit per window

Config: { "window_ms": u64 }.

If last_emit_ts is none, or this event_ts is outside the window from last_emit_ts, Emit([this]) and record last_emit_ts. Else Discard([this]). The bag does not grow.

The payload is the first hit as a Match, not a digest of everything seen in the hour. Later in-window hits are gone: not dead-lettered, not replayable. If the operator needs those hits later, that is a different product (follow-up), not throttle.

Passthrough

Omit gate. Tests also ship an in-memory fake that always Emit([this]); it is not an operator-facing module in a default binary the way threshold is.

Persistence and crash

Storage (sqlite schema v4 and every Storage impl, including memory and the ports fake) keeps:

  • gate_hits: (pipeline, monitor, cursor_primary, cursor_secondary, event_index) plus event_ts and the decoded event JSON.
  • gate_meta: (pipeline, monitor)last_emit_ts, last_emit_cursor, optional opaque aux blob.

Identity is those columns, never a string-joined key.

After insert, if that monitor has more than 10_000 rows, the oldest are dropped and blockwatcher_gate_hits_dropped_total ticks.

Persist the new journal before completing the event. An Emit writes the journal and meta in one storage transaction so a crash cannot drop the cooldown after the emitted rows are already gone. A persist failure begins Progress with outstanding 1 and does not complete the guard: the checkpoint stays on this cursor, same as a closed sink channel. Skipping begin would let a later cursor complete and strand this one behind it.

Restart: sqlite still has the holds; the next hit sees them. Memory storage does not survive a process exit, matching its own contract.

Delete or rewrite a monitor (including changing gate.module or config): delete that monitor’s gate_hits and gate_meta. Holds are not migrated across compiled artifacts.

Invalidate

After drain, before rewind/restart, for that pipeline:

  1. Delete gate_hits rows whose cursor is strictly after from.
  2. Clear last_emit_ts / last_emit_cursor only when last_emit_cursor is strictly after from. An empty journal is not stale: after Emit those rows are gone on purpose, and a tip reorg must not reset max_once. A reorg of the emitted hit itself (cursor after from) does clear the cooldown so the replacement on the new fork can fire.
  3. Existing delivery-journal retract: Retracted for already-emitted Matches after from, as Delivery guarantees already describes.

Replay from from feeds on_hit again. Duplicate Emit is at-least-once; Match ids are deterministic.

Forbidden: DELETE FROM gate_hits WHERE pipeline = ? with no cursor predicate. A two-block tip reorg must not wipe holds with cursor ≤ from. Replay only re-emits after from; those earlier holds would never come back, and a later threshold would under-fire.

Gate::on_invalidate exists for follow-up aux (e.g. forever-distinct keys). threshold and max_once do not need it. The engine has already pruned holds before that hook runs; a module must not use the hook to drain the journal.

Dry-run

POST /monitors/{id}/test stays inert: no persist, no sink, no checkpoint. It constructs a fresh empty journal for the request and feeds payloads or fetch events in order. It does not read stored gate_hits. A three-payload body can fire a threshold digest. A single payload against threshold with count: 3 reports a retained hit and an empty matched list.

TestReport reports flattened emitted Matches (existing clients still see the events that would have been delivered) plus held (Retain decisions) and dropped (Discard decisions) without emitting.

What a gate is not

  • Not a predicate. “Odd block number” / “before noon UTC” belong in predicate (block.number % 2, block.timestamp % 86400).
  • Not sink throttle. Throttle caps deliveries after a Match exists and dead-letters the rest (replayable). max_once drops later hits.
  • Not sink aggregate. Aggregate batches Matches and stalls the checkpoint until the window closes. A 60-minute aggregate window is a 60-minute checkpoint stall. A 60-minute threshold window is not.
  • Not on_tick. A gate cannot fire because “an hour passed with no hit.” That needs a timer-driven path and a checkpoint-without-cursor spec, which this version does not have.
  • Not module-owned storage. A gate crate that opens sqlite, or that clears all RAM on any reorg, is not this port.

Chain-agnosticism

The gate sees Cursor and canonical Value only. event_ts is the block.timestamp path the decoder already put in the tree. Chain-agnosticism is unchanged: core does not learn a chain to “do windows.” A decoder that does not expose block.timestamp cannot use a time-window gate; the write fails closed.

Sources must provide event timestamps

A time-window gate windows on decoded block.timestamp, which presumes a block: a feed of not-yet-mined data has none to stamp. Each source module declares SourceCaps { event_timestamps: bool } (crates/blockwatcher-ports/src/source.rs). Which shipped sources declare true or false is on the family page.

PUT /monitors/{id} checks the target network’s source caps against what the compiled gate needs, the same 422 compile_failed class every other compile refusal in this document uses. A gated monitor is invalid configuration on a network whose source does not declare event_timestamps, not a monitor that silently never fires. The same check runs the other way: PUT /networks/{id} refuses to swap a network’s source module out from under an existing gated monitor, because doing so would leave that monitor pointed at a feed its gate can no longer window against. Neither route lets the pairing exist for even one write.

Delivery guarantee

An emission’s durability starts inside the same storage transaction that consumes the journal members it was built from: Emit does not exist as a decision without also existing as a row a sink can still be delivered from. The sink worker deletes that row only once the delivery outcome itself is durable: landed in the delivery journal, or recorded as a dead letter, never before. A crash between those two points redelivers on the next start rather than losing the emission: duplicates are possible, gaps are not. A pending emission outlives the monitor that minted it: deleting the monitor leaves its outbox row in place, and the row is still delivered, or dead-lettered when its sink no longer has a worker, on the next start. Deleting the emission’s network dead-letters the row right away instead, because a deleted id gets no next start for the drain to run at: each contained match becomes a dead letter for the pipeline id, and the dead-letter surface keeps answering for any id that still holds letters, so they stay listable, replayable, and discardable while the network stays deleted. Whatever that delete-time sweep could not finish (a storage fence that did not resolve, a lapsed budget, a failed write) is dead-lettered by the next boot’s orphan sweep, which also covers rows stranded before the sweep existed. Every pipeline’s pending rows are counted by /status as gate_outbox_depth, read fresh from storage, so an emission parked behind a paused network stays visible until a resume delivers it. See Delivery guarantees for the outbox’s shape and the startup drain that sweeps whatever a crash left behind. Invalidate reaches into a pending emission’s row the same way it reaches into the hit journal: reorged-out members of the row are pruned at invalidation, and their canonical replacements re-accumulate through the gate on replay.

Selectors

Every entry in a monitor’s selectors list is a selector: a reference to one contract spec, an optional address restriction, and a choice of what that spec has on offer to actually decode. Selector keys are decoder-owned. An unrecognized key is rejected by name by that family’s compiler, not by a shared schema. Which keys a family admits, and what each kind decodes, lives on the family page.

What a selector can produce also depends on which source module happens to be feeding its network. A kind the decoder accepts can still be a no-op if that source never supplies the corresponding raw material; that pairing is family-owned too.

flowchart LR
    rpc["RPC endpoints<br/>(external chains)"]
    %% Source modules are family-owned; the running list is GET /catalog, not this map.
    sources["Sources<br/>one per network"]
    sinks["Sinks<br/>webhook · script · log"]
    storage["Storage<br/>checkpoints · dead letters · resources"]
    api["REST API"]
    metrics["Metrics"]
    engine["engine<br/>bounded channels · checkpoints"]

    subgraph pipeline["Engine pipeline"]
        direction LR
        decoder["Decoder"]
        matcher["Matcher<br/>predicates"]
        gate["Gate<br/>threshold · max_once"]
        decoder --> matcher
        matcher --> gate
    end

    rpc --> sources
    sources -->|"decode and match"| decoder
    gate --> sinks
    api -->|"manages resources"| storage
    storage <--> engine
    engine -->|"drives"| pipeline
    engine -.->|"reports"| metrics

    classDef module fill:none,stroke:#a9a3e3
    classDef core fill:none,stroke:#8a8d86,stroke-dasharray: 5 5

    class rpc,sources,sinks,decoder,matcher,gate module
    class engine,api,storage,metrics core
classDef dim fill:none,stroke:#999999,color:#999999,opacity:0.35
classDef focus fill:#ffd43b,stroke:#d9480f,stroke-width:3px,color:#1a1a1a
class rpc,sinks,storage,api,metrics,engine,decoder,matcher,gate dim
class sources focus
click sources "selectors.html"
click decoder "chain-agnosticism.html"
click matcher "predicates.html"
click gate "gates.html"
click sinks "delivery.html"
click storage "resources.html"
click api "../reference/http-api.html"
click metrics "../reference/observability.html"
click engine "pipeline.html"

Key takeaways

  • Selector keys are decoder-owned. The family’s compiler rejects an unrecognized key by name.
  • A selector’s kind keys are independent dispatch tables; leaving every kind key unset selects everything the referenced spec declares. An explicit empty array for a kind list is invalid, not “select nothing.”
  • What a given kind actually fires on depends on the network’s source. The family page states that pairing.
  • A source whose cursor is not a chain position cannot make at-least-once promises. That constraint is Delivery guarantees; which shipped sources it applies to is on the family page.

Predicates covers how a predicate reads decoded data; The pipeline covers where decoding and matching run.

Chain families

A chain family is one decoder plus the sources that feed it. Selector keys, spec payloads, cursor units, source.config, and source trade-offs are owned by that family. Shared concept pages describe ports and catalogs; they do not enumerate families.

Each family has one page in this directory. Adding a family adds a page here, a crate under Workspace map, and a feature flag on embed and the binary. It does not rewrite Selectors, Modules, Delivery, or the glossary. Extending to a new network is the contributor checklist; this directory is what an operator reads once the family exists. A write-time SpecImporter is optional per family and is documented on that family’s page when it exists.

The running binary’s module list is GET /catalog, not this directory. What a given family’s modules cost, which selector keys they admit, what source.config they accept, and what turning their feature off removes, is on the family’s page.

EVM

The EVM family is three sources, evm-rpc, evm-mempool, and evm-subgraph, and the evm decoder all three feed (crates/blockwatcher-evm). An operator selects one source per network; they are never combined in one pipeline.

The crate page maps the same facts onto file layout. This page is the operator surface: modules, selector keys, spec payload, and what the evm feature folds.

Key takeaways

  • Selector keys are events and functions. Naming neither selects every event and function the referenced spec declares; naming either selects only what it names.
  • events matches a decoded log; functions matches decoded calldata. Both kinds compile the same way regardless of source; what differs is the raw material a source ever supplies.
  • evm-rpc can satisfy both kinds in one poll cycle. evm-subgraph can too, for whatever the deployed subgraph already indexed: interest filters only narrow GraphQL _in fetching, never matching, so a monitor for an address the mapping never stored never fires. evm-mempool only ever produces functions, and only unmined fields. Its cursor is a per-run arrival counter, so it sits outside at-least-once delivery.
  • A spec payload is a Solidity JSON ABI. Network source.config is the family’s RPC, mempool, or subgraph shape, not a shared schema.

Modules

FamilyModuleRegistered inTrade-off
sourceevm-rpccrates/blockwatcher-evm/src/registry.rsWaits for an event to be mined and positioned before reporting it, which is exactly what lets at-least-once delivery work; the trade is RPC calls spent polling and the wait for confirmation. Reports a confirmed tip and cold-starts from start_block.
sourceevm-mempoolcrates/blockwatcher-evm/src/registry.rsReports a transaction the moment it’s pending, ahead of mining and with no guarantee it’s ever mined; because its cursor is just an arrival counter that a restart can’t recover, at-least-once delivery guarantees don’t extend to it. Reports neither a confirmed tip nor a cold start.
sourceevm-subgraphcrates/blockwatcher-evm/src/registry.rsPolls a documented GraphQL logs schema and emits the same confirmed-block envelopes evm-rpc does, on the same kinded cursor packing; completeness is whatever that subgraph indexed, not the chain, which is the cost of a handful of HTTP round trips over an already-built index. Reports a confirmed tip and cold-starts from start_block.
decoderevmcrates/blockwatcher-evm/src/registry.rsCompiles a Solidity ABI into reusable schemas once per spec write; chain-specific decode logic for EVM lives in this crate, not in the core ring.

The evm Cargo feature on blockwatcher-embed and the blockwatcher binary folds those four modules. Turning it off removes them from build_catalog; turning it off leaves every other family’s modules in place.

evm-rpc and evm-subgraph both declare SourceCaps { event_timestamps: true }: every occurrence they emit carries a mined block’s timestamp, so a time-window gate can compile against either. evm-mempool declares false: a pending transaction has not been included in a block, so a time-window gate cannot compile against a network that uses it.

evm-rpc and evm-subgraph report a confirmed tip and cold-start from start_block. evm-mempool reports neither.

Spec payload

A spec whose chain is evm carries a Solidity JSON ABI array as payload. The write runs EvmDecoder::compile_spec (crates/blockwatcher-evm/src/decoder/mod.rs). Worked JSON lives on Spec and Your first monitor.

Selector keys

A selector body carries optional keys events, functions, and addresses, and nothing else; an unrecognized key is rejected by name in compile (crates/blockwatcher-evm/src/decoder/selector.rs). What actually gets selected turns on one boolean the compiler computes once per entry, select_all, which is true exactly when neither events nor functions appears in the body at all (selector.rs). With select_all false, each key resolves independently against whatever names it carries: writing only events: ["Transfer"] builds a dispatch table with that one event and zero functions; it never falls back to “every function” for the side you didn’t mention. With select_all true, both tables are filled from the entire vocabulary the spec exposes: nothing past that one spec’s boundary, so two specs on the same chain never bleed into each other’s “select everything,” which is why adding a new declaration to a spec silently widens an existing catch-all monitor’s reach the moment the deployment next recompiles it, without anyone touching the monitor. One spelling is refused outright rather than given either meaning: a key present but pointed at an empty array, rejected in name_list (selector.rs).

Naming an event or function the referenced spec doesn’t declare rejects with a suggestion from kind_suggestion: the spec’s first declared name of that kind, not an edit-distance match (selector.rs; see Resources for why that differs from a predicate’s unknown-field suggestion). events decodes against a log’s topic0; functions decodes against a transaction’s first four bytes of calldata: two independent dispatch tables on the Entry struct inside one compiled selector (selector.rs), which is what lets one selector entry watch both at once.

Monitor shows a complete resource that uses these keys. An address must be a 0x-prefixed 40-hex-digit string. A mixed-case address claims an EIP-55 checksum and is held to it; an all-lowercase or all-uppercase spelling makes no checksum claim. Shared selector contracts (spec reference, OR across entries) stay on Selectors.

What each kind decodes

events matches a decoded log. Its data reaches a predicate as:

  • args.*: the event’s own declared parameters.
  • tx.hash, tx.index: always present; every log carries its transaction’s envelope.
  • tx.status: always present and always 1: a log exists only inside a transaction that succeeded, so this is a constant stamped at decode time by assemble_fields, never a receipt fetch (crates/blockwatcher-evm/src/decoder/decode.rs).
  • tx.from, tx.to, tx.value: never present on a log-decoded occurrence; nothing in a log’s own envelope carries them, and fetching them would mean a receipt/transaction lookup the log-only path is designed to avoid, as documented on namespaces (crates/blockwatcher-evm/src/decoder/compile.rs).
  • block.number, block.hash, block.timestamp, log.address, log.index: always present; every log’s own envelope carries all five.

functions matches a decoded transaction’s calldata. Its data reaches a predicate as:

  • args.*: the function’s own declared parameters, decoded regardless of whether the call reverted: calldata decodes independent of the receipt.
  • tx.hash, tx.from, tx.value: always present; every transaction carries them by definition.
  • tx.to: present unless the transaction is a contract creation, which carries no to at all.
  • tx.index, tx.status, and every block.* field: present only once the transaction is mined. A pending transaction has none of them yet, since they are TxEnvelope’s optional fields, assembled by assemble_call_fields (crates/blockwatcher-evm/src/decoder/decode.rs), and a predicate reading one before that resolves Unknown, never an error, never a fabricated value.
  • No log.* namespace at all: nothing about a function-call decode came from a log, as documented on assemble_call_fields (decode.rs).

Because a log only exists when its transaction succeeded, an events selector has no way to notice a revert at all: the occurrence it would decode is never emitted in the first place. A functions selector sees the call regardless of how it ended, since decoding calldata never touches the receipt; only its tx.status field carries the outcome, and only once the transaction is mined. Watching for failed calls is therefore a functions concern exclusively.

flowchart TD
    start{"selector kind"} -->|"events"| ev{"source?"}
    start -->|"functions"| fnq{"source?"}

    ev -->|"evm-rpc"| evlog["decodes a log<br/>full args, tx, block, log"]
    ev -->|"evm-subgraph"| evsub["decodes a log the subgraph indexed<br/>same envelope as evm-rpc"]
    ev -->|"evm-mempool"| evnone["no-op: never fires,<br/>this source has no logs"]

    fnq -->|"evm-rpc"| fnmined["decodes a mined tx<br/>tx/block fields once mined"]
    fnq -->|"evm-subgraph"| fnsub["decodes a mined tx the subgraph indexed<br/>same envelope as evm-rpc"]
    fnq -->|"evm-mempool"| fnpending["decodes a pending tx<br/>no tx.status, no block.*"]

    evlog --> pred["predicate evaluates"]
    evsub --> pred
    fnmined --> pred
    fnsub --> pred
    fnpending --> pred

Sources

Both selector kinds compile the same way regardless of source. What differs is what a given source ever calls decode with, and decode itself dispatches purely on the shape of that payload: an object carrying topics is a log, one carrying input (and no topics) is a transaction, and nothing ever carries both (crates/blockwatcher-evm/src/decoder/decode.rs).

evm-rpc scans confirmed blocks: it fetches full block bodies (headers plus every transaction) and eth_getLogs results in the same poll cycle, so one network running this source can satisfy both selector kinds at once: whatever a selector’s events half names comes from that cycle’s logs, whatever its functions half names comes from that cycle’s mined transactions, carried on one non-decreasing cursor stream, packed by pack_secondary and emitted by emit_verified_leaf, that always walks a block’s transaction list to completion before touching that same block’s logs (crates/blockwatcher-evm/src/source/rpc/emit.rs). A selector written with only one of the two keys simply gets fed from only the matching half of that cycle. Whether the source bothers fetching full transaction bodies at all is itself interest-driven: a pipeline with no monitor watching any functions selector never asks for them, in scan_range (crates/blockwatcher-evm/src/source/rpc/emit.rs, mirrored in the streaming path).

Cursor: primary is block number; secondary packs (kind << 32) | index so a block’s transactions order ahead of its logs (crates/blockwatcher-types/src/cursor.rs is compared, never interpreted, by core). journal_depth is denominated in those primary units.

evm-subgraph polls a hosted GraphQL logs schema (logs / transactions / _meta) and emits the same 0x decoder envelopes evm-rpc does, on the same kinded cursor packing: transactions then logs, pack_secondary identical (crates/blockwatcher-evm/src/source/cursor.rs). Completeness is whatever the deployed subgraph’s mapping already stored. InterestSet only narrows GraphQL _in filters (address_in / topic0_in / to_in / selector_in); empty sets omit the key rather than sending [], because some hosts treat an empty _in as match-nothing. Interest never changes matching: a monitor for an address the subgraph never indexed never fires, and a stored log outside the current interest is still fetched when interest is empty. Transactions are queried only while some monitor watches functions (EvmInterest). confirmations defaults to 0: the head this source reads from _meta is already the subgraph’s indexed tip, trailing the chain by the mapping’s own delay, so a second wait on top is usually pure latency.

A schema that cannot answer logs, transactions, or _meta (Cannot query field) is a named Degraded, not an empty successful window. Hash disagreement against a height this run already sent through ctx.events returns SourceOutcome::Invalidated { from } with rpc’s end-of-block secondary (the last still-good height). In-window disagreement against unsent work retries in place. The tracker is (number, hash) only — a GraphQL Log has no parentHash, so this is not rpc RecentChain.

evm-mempool watches a single node’s stream of not-yet-mined transactions and never sees a log in its entire lifetime: mining is the event that produces a receipt, and a log lives inside one, so an occurrence this source hands the decoder is calldata or nothing. That means the only payload shape it ever produces is one decode routes down the functions path, as documented on EvmMempoolSource (crates/blockwatcher-evm/src/source/mempool/run.rs). A selector’s events half compiles cleanly against this source’s network too (selection is checked against the spec, not the source that will feed it), but can never contribute a single match, because there is nothing here for it to decode. Only the functions half of any selector on such a network ever does anything, and if the spec behind it has no functions in it to begin with, that selector on this source is a complete no-op, matching nothing ever. The source itself checks whether any monitor on its pipeline watches functions before paying for a lookup; with no function interest published, it skips the eth_getTransactionByHash round trip entirely and forwards nothing, in run (source/mempool/run.rs).

Why evm-mempool cannot make the same promises

Every other part of a compiled selector is source-independent: the same schema, the same dispatch table, the same predicate. What genuinely differs between evm-rpc and evm-mempool is what each one’s cursor is even counting. evm-rpc answers a question a mined chain can always answer, “where in the chain is this?”, with a block number and an in-block ordering bit that puts every transaction ahead of every log it shares a block with. evm-mempool has no such question to answer: nothing pending has a place in the chain yet, so its cursor counts something else entirely: how many occurrences this one process has forwarded since it started, tracked by run’s arrival counter (crates/blockwatcher-evm/src/source/mempool/run.rs).

This is not a detail that stays contained inside the source. Because a checkpoint is exactly that cursor plus enough state to verify a resume (crates/blockwatcher-core/src/pipeline/checkpoint_writer.rs), evm-mempool’s persisted checkpoint is a dedupe watermark for this one process’s lifetime, never a replayable position: whatever was pending in the node’s mempool while the process was down is simply gone on restart, and nothing resumes it, unlike evm-rpc’s checkpoint, which always names a real block to continue scanning from. scan and confirmed_tip are therefore unsupported on this source outright: there is no history to fetch and no confirmed tip to report (source/mempool/run.rs).

That instability reaches all the way into how a match is identified. Restart this source and its arrival counter starts over from wherever the fresh checkpoint left off, so the identical pending call, seen again after the gap, is minted a different number and therefore a different match id. There is no way to prevent this, because nothing about a hash-derived position can be made to behave monotonically across a process boundary, which is exactly what the Source port requires of a cursor. The same drift can happen inside one run, with no restart at all: a transaction the subscription already announced can get mined while its hydration lookup is still outstanding, and the copy that comes back then carries a real transactionIndex it didn’t have a moment before, changing the decoded fields (and the id derived from them) out from under it, as documented on EvmMempoolSource (crates/blockwatcher-evm/src/source/mempool/run.rs). The fix lives with whatever consumes these matches, not with the source itself: read the transaction’s own hash back out of the delivered payload and deduplicate on that, since it is the one thing two sightings of the same pending call are guaranteed to agree on. It’s exactly this instability that keeps evm-mempool outside the reach of at-least-once delivery: every source whose cursor is a chain position can promise it, this one cannot.

fires ondata available to a predicateposition / delivery guarantee
events (any source)a decoded logargs.*; tx.hash/index/status(=1); no tx.from/to/value; full block.*; full log.*inherits whichever source produced the log
functions on evm-rpca decoded transaction, minedargs.*; tx.hash/from/value always, tx.to unless a creation, tx.index/tx.status/block.* once mined; no log.*chain position; at-least-once
events / functions on evm-subgrapha decoded log or mined transaction the subgraph indexedsame fields as the evm-rpc rows abovechain position; at-least-once; completeness is the indexed set, not the chain
functions on evm-mempoola decoded pending transactionsame fields as above; tx.status and block.* are always absent: this source never fetches a receipt and never injects a block timestamp, so block.* can never fully assemble regardless of mining. tx.index is usually absent too, but not always: it comes straight off the same lookup response, and a call mined between notification and hydration comes back with a real onearrival counter only; not replayable across a restart; dedupe by tx.hash, not match id
events on evm-mempoolnothingnonethis source never produces a log

Delivery guarantees states the engine-side consequence: a source whose cursor is not a resumable chain position cannot make at-least-once. This page is that source.

Source config

The network resource envelope (id, chain, source.module) is on Network. The tables below are this family’s source.config.

Endpoints: the shared pool vocabulary

evm-rpc and evm-mempool name a pool of HTTP JSON-RPC endpoints with the same shape (EndpointDef, crates/blockwatcher-evm/src/source/endpoint.rs). evm-subgraph uses a sibling row (SubgraphEndpointDef, source/subgraph/config.rs) that adds header_secrets rather than grafting that field onto EndpointDef. Each JSON-RPC endpoint object:

KeyTypeDefaultMeaning
namestringnone (required)Labels every metric and log line for this endpoint. A repeat refuses: endpoint name 'primary' is used by more than one endpoint; endpoint names must be unique.
url_secretstringnone (required)An env:NAME reference to where the URL lives, never the URL itself. A value that is not a reference refuses with endpoint '<name>' url_secret is not a secret reference: ..., deliberately never echoing what was written: a provider URL routinely carries an API key.
prioritystring: high or low"high"The selection tier; high endpoints are always tried before low.
rate_limitobject { "rps": u32 }noneA preemptive request rate enforced before a call leaves the pool. A zero refuses: endpoint '<name>' rate_limit.rps is 0; it must be at least 1.
weightu321Ring slots in the tier’s rotation; meaningful only under round_robin selection, inert under ordered (and therefore always inert on evm-mempool, whose pool is fixed to ordered and exposes no selection knob). A zero refuses: endpoint '<name>' weight is 0; it must be at least 1. Above the pool’s cap of 100 (blockwatcher_rpc::MAX_WEIGHT) refuses: endpoint '<name>' weight (101) exceeds the maximum (100).

evm-rpc

Polls confirmed blocks over HTTP JSON-RPC: events via eth_getLogs, and, only while some monitor watches functions, full transaction bodies too. Its config (EvmRpcConfig, crates/blockwatcher-evm/src/source/rpc/config.rs) rejects unrecognized fields, including a network key: which network a source’s events belong to is the engine’s to supply, never the config’s.

The annotated example above is the module’s complete example config. The tunables:

KeyTypeDefaultMeaning
endpointsarraynone (required)The pool, per the table above. An empty array refuses: evm-rpc requires at least one endpoint.
selectionstring: ordered or round_robin"ordered"How the pool orders a tier’s endpoints when no window pin dictates the choice: ordered concentrates calls on the first admissible endpoint in configuration order; round_robin spreads windows across the tier, weighted by each endpoint’s weight. A misspelling is refused by serde naming both accepted spellings.
start_blocku64none (required)Where a run with no persisted checkpoint begins scanning. Deliberately absolute and without a default: a head-relative start would re-derive a different block on every restart and silently skip the gap.
confirmationsu6412How deep a block must age before it is emitted; trades delivery latency for reorg safety.
max_lag_blocksu643How far behind the pool’s most current head an endpoint may report before it is excluded from serving.
poll_interval_msu643000How often, while caught up, one eth_blockNumber head check advances the emission barrier.
logs_windowobject{ "initial": 512, "max": 2048 }How wide an eth_getLogs scan window starts and how far it may grow, per the table below.
full_block_windowu648Ceiling on a range fetched with full transaction bodies, applied only while some monitor watches functions. A zero refuses: full_block_window (0) must be at least 1.
probe_interval_msu6410000How often every non-open endpoint is probed for health changes, bypassing rate limits.
retry_backoff_max_msu6430000Cap on the doubling backoff for a window the run loop cannot fetch; the schedule starts at one poll interval. A cap below poll_interval_ms refuses: retry_backoff_max_ms (2999) is below poll_interval_ms (3000); the retry schedule starts at one poll interval, so a smaller cap silently disables the backoff.
receiptsstring: always or when_read"always"When a matching transaction spends an eth_getTransactionReceipt, which buys exactly tx.status. always keeps the derived match id a function of chain content alone; when_read saves the round trip when no predicate reads tx.status, at the cost of a payload (and match id) that changes with the monitor set. A misspelling is refused by serde naming both accepted values.
receipt_concurrencyu644How many eth_getTransactionReceipt calls may be in flight at once for one leaf’s matching transactions. Receipts are unpinned consensus reads, so concurrency changes pacing alone and never consistency; the per-endpoint rate limiter and breaker still gate every call. Raising it shortens function-heavy leaves at the cost of burstier provider load. A zero refuses: receipt_concurrency (0) must be at least 1.
header_batchu6420How many eth_getBlockByNumber requests ride one JSON-RPC batch, so a window of w blocks costs ceil(w / header_batch) header round trips. A batch is one HTTP request against one endpoint, so it inherits the window pin exactly as single calls do. 1 sends classic single calls, which is the setting for a provider that rejects batch arrays: such a provider fails its first window visibly and the run loop retries it under Degraded. A zero refuses: header_batch (0) must be at least 1.
bloom_screenbooltrueWhether a window may skip its eth_getLogs call when every fetched header’s logsBloom proves no monitored address or topic0 can be present. Against a protocol-conforming node the skip is lossless, since a bloom is a superset of its own block’s logs. Enabling it nonetheless adds a dependency the unscreened path does not carry: correctness rests on the bloom as well as on the logs response, so an endpoint or caching proxy that serves correct logs behind a zeroed or otherwise-inaccurate bloom loses those logs silently. Disable it for endpoints whose blooms are not trusted. A broad filter, carrying neither addresses nor topic0s, is never screened, and a header without a readable bloom is never screened regardless of this setting.

logs_window (LogsWindow, same file):

KeyTypeDefaultMeaning
initialu64512Starting scan width. A zero refuses: logs_window.initial (0) must be at least 1. A value above max refuses: logs_window.initial (2048) exceeds logs_window.max (512).
maxu642048How far the width may grow back after a provider forces it narrower. At runtime the first width a provider refuses drops the run’s effective ceiling pool-wide, so one narrow-limited endpoint caps every endpoint until the source next starts.

evm-subgraph

Polls a documented GraphQL logs schema over HTTP POST: logs, and only while some monitor watches functions, transactions, with head from _meta { block { number hash } }. Its config (EvmSubgraphConfig, crates/blockwatcher-evm/src/source/subgraph/config.rs) rejects unrecognized fields, including a network key and every rpc-only knob (receipts, receipt_concurrency, header_batch, bloom_screen, full_block_window): which network a source’s events belong to is the engine’s to supply, and those rpc fields have no GraphQL equivalent. A worked seed of this module — including the deployable subgraph/ schema and mapping — is Monitoring ERC-20 transfers [Subgraph].

The module’s complete example config, registry_examples/evm_subgraph.json from crates/blockwatcher-evm/src/, verbatim, the same file the crate’s family-completeness test constructs:

{
  "start_block": 18000000,
  "endpoints": [
    {
      "name": "goldsky",
      "url_secret": "env:BLOCKWATCHER_EXAMPLE_EVM_SUBGRAPH_URL",
      "priority": "high",
      "rate_limit": { "rps": 25 },
      "header_secrets": {
        "authorization": "env:BLOCKWATCHER_EXAMPLE_EVM_SUBGRAPH_TOKEN"
      }
    }
  ]
}
KeyTypeDefaultMeaning
endpointsarraynone (required)The GraphQL pool. An empty array refuses: evm-subgraph requires at least one endpoint. Each row is SubgraphEndpointDef, not EndpointDef: the shared keys (name, url_secret, priority, rate_limit, weight) mean the same as the JSON-RPC table above, plus header_secrets below.
selectionstring: ordered or round_robin"ordered"How the pool orders a tier’s endpoints when no window pin dictates the choice, same as evm-rpc.
start_blocku64none (required)Where a run with no persisted checkpoint begins scanning. Deliberately absolute and without a default: a head-relative start would re-derive a different block on every restart and silently skip the gap.
confirmationsu640How deep a block must age past the subgraph head before it is emitted. The default is 0 because _meta already trails the chain by the mapping’s indexing delay; raise it on a fast-reorging chain.
max_lag_blocksu643How far behind the pool’s most current head an endpoint may report before it is excluded from serving.
poll_interval_msu643000How often, while caught up, one _meta head check advances the emission barrier.
logs_windowobject{ "initial": 512, "max": 2048 }How wide a GraphQL logs query’s block range starts and how far it may grow, per the table below. Field names match evm-rpc; this width is the from/to on the GraphQL query, not an eth_getLogs range. A host that refuses a width as too expensive (RetryNarrower: complexity, too many, too large, payload, query timeout) halves it and retries the same next block — lossless, no blocks skipped. HTTP 429 is RateLimited and does not shrink the range.
probe_interval_msu6410000How often every non-open endpoint is probed for health changes, bypassing rate limits.
retry_backoff_max_msu6430000Cap on the doubling backoff for a window the run loop cannot fetch; the schedule starts at one poll interval. A cap below poll_interval_ms refuses: retry_backoff_max_ms (2999) is below poll_interval_ms (3000); the retry schedule starts at one poll interval, so a smaller cap silently disables the backoff.
entity_page_sizeu641000How many entities each logs / transactions page asks for (first). Validate 1..=1000: 0 would fetch nothing, and 1000 is the common hosted-subgraph page cap.

Each subgraph endpoint may also set:

KeyTypeDefaultMeaning
header_secretsobject, header name → env:VAR{}Extra request headers whose values are secret references. Always combined with Content-Type: application/json, set at construct. Duplicate names after HTTP-header normalization are refused: endpoint '<name>' header '<key>' is set more than once in 'header_secrets'; remove one — a single header has no defined winner. A value that is not a reference refuses without echoing what was written. Hosted subgraphs (Goldsky, The Graph) typically put authorization here.

logs_window (LogsWindow in source/subgraph/config.rs, a sibling of rpc’s type so GraphQL rustdoc does not talk about eth_getLogs):

KeyTypeDefaultMeaning
initialu64512Starting block-range width. A zero refuses: logs_window.initial (0) must be at least 1. A value above max refuses: logs_window.initial (2048) exceeds logs_window.max (512).
maxu642048Operator ceiling: initial must be <= max, and behind > logs_window.max is the CatchingUp threshold. Width only shrinks this run; it does not grow back, and a refused width does not cap every endpoint.

Host schema

The deployment must implement this contract. A 200 with errors[] naming Cannot query field for logs, transactions, or _meta publishes Degraded whose reason starts subgraph schema does not implement Log/Transaction/_meta: — it is not treated as an empty successful window. Fix the subgraph (or point at one that implements the schema); changing logs_window cannot create missing fields.

Log: id, address, topic0, topics, data, blockNumber, blockHash, blockTimestamp, transactionHash, transactionIndex, logIndex.

Transaction: id, hash, from, to (null on create), input, selector, value, status, plus the same block fields. Transactions with input shorter than 4 bytes are not stored.

Head: _meta { block { number hash } }. No Block entity.

id padding: decimal ASCII, 12-digit block, 5-digit txIndex, 5-digit logIndex. Example: 000018000000-00003-00012. Padded ids make id_gt lexicographic chain order.

Queries: logs / transactions; address_in / topic0_in / to_in / selector_in when those InterestSet sets are non-empty; omit _in when empty; blockNumber_gte / lte; id_gt; first: entity_page_size; orderBy: id, asc. Query transactions only if EvmInterest is present. The source translates GraphQL Bytes / BigInt into the decoder’s 0x envelope at this boundary; the decoder never sees GraphQL types, id, or selector.

evm-mempool

Subscribes to pending transaction hashes over WebSocket and hydrates each against an HTTP pool: a notification carries a hash and nothing else, so every candidate costs one eth_getTransactionByHash. Its config (EvmMempoolConfig, crates/blockwatcher-evm/src/source/mempool/config.rs) has no start_block and no confirmations: a pending stream has no history to begin from and no reorg barrier to honor, and a start_block copied over from an evm-rpc network is refused by name as an unknown field rather than silently ignored. What that difference means for delivery guarantees is on Why evm-mempool cannot make the same promises.

The module’s complete example config, registry_examples/evm_mempool.json from crates/blockwatcher-evm/src/, verbatim, the same file the crate’s family-completeness test constructs:

{
  "ws_url_secret": "env:BLOCKWATCHER_EXAMPLE_EVM_MEMPOOL_WS_URL",
  "endpoints": [
    {
      "name": "primary",
      "url_secret": "env:BLOCKWATCHER_EXAMPLE_EVM_RPC_URL"
    }
  ]
}
KeyTypeDefaultMeaning
ws_url_secretstringnone (required)An env:NAME reference naming where the ws:// or wss:// subscription endpoint lives. A value that is not a reference refuses with ws_url_secret is not a secret reference: ..., never echoing what was written.
endpointsarraynone (required)The HTTP hydration pool, per the shared endpoint table above. An empty array refuses: evm-mempool requires at least one endpoint.
reconnect_msu641000How long to wait after a dropped subscription before dialing again; a fixed interval with no jitter and no backoff. A zero refuses: reconnect_ms is 0; it must be at least 1.
idle_policyobject{ "ping_after_ms": 30000, "pong_deadline_ms": 10000 }When to suspect the subscription half-open, and how long to wait for proof before redialing, per the table below.

idle_policy (IdlePolicyDef, same file, the wire form of ws::IdlePolicy):

KeyTypeDefaultMeaning
ping_after_msu6430000How long a subscription may sit silent before an idle WebSocket ping goes out. A zero refuses: idle_policy.ping_after_ms is 0; it must be at least 1.
pong_deadline_msu6410000How long to wait for any frame after an idle ping before presuming the connection half-open and redialing. A zero refuses: idle_policy.pong_deadline_ms is 0; it must be at least 1.

Stellar

The Stellar family is one source, stellar-rpc, and the stellar decoder it feeds (crates/blockwatcher-stellar).

The crate page maps the same facts onto file layout. This page is the operator surface: modules, selector keys, spec payloads, source config, and what the stellar feature folds.

Key takeaways

  • Selector keys are events, functions, and operations. Naming any of them selects only what it names; naming none selects every declaration the referenced spec exposes of each kind.
  • InvokeHostFunction is a function_call, named under functions. So are UploadContractWasm, CreateContract, and CreateContractV2. Classic operations such as Payment are named under operations.
  • A classic spec payload is {"catalog":"classic"}. A Stellar Asset Contract spec is {"catalog":"stellar-asset"}. A Soroban spec is a WASM blob or XDR-JSON of ScSpecEntry.
  • stellar-rpc walks closed ledgers inside the node’s RPC history window. The first health read refuses a configured start_ledger below oldestLedger. A resume whose checkpoint sits below the window clamps to the new oldest ledger with an explicit gap signal, the same as if retention advances during a run. Closed ledgers are not rewound. On the first successful getHealth, every non-open endpoint is probed with getNetwork; disagreeing passphrases refuse the run by endpoint name. Event interest spends getEvents; operation or function interest spends getTransactions.
  • A classic Payment and a CAP-67 transfer event on the same ledger are two decoded occurrences when both are selected; the decoder does not merge them. Selecting events: ["transfer"] compiles both the scalar transfer plan (bare i128 data) and MuxedTransfer (map {to_muxed_id, amount}) when the spec declares both.
  • Network source.config is this family’s stellar-rpc shape, not a shared schema.

Modules

FamilyModuleRegistered inTrade-off
sourcestellar-rpccrates/blockwatcher-stellar/src/registry.rsWalks closed ledgers inside the node’s RPC history window; misses nothing the node still retains, and spends RPC quota on getEvents/getTransactions proportional to interest and shard count. Closed ledgers are not rewound. Cold-starts from start_ledger and reports no confirmed tip.
decoderstellarcrates/blockwatcher-stellar/src/registry.rsCompiles a classic operation catalog, the baked Stellar Asset Contract catalog, or a Soroban contract spec (WASM or XDR-JSON) into schemas once per spec write; selector keys are events, functions, and operations.

The stellar Cargo feature on blockwatcher-embed and the blockwatcher binary folds those two modules. Turning it off removes them from build_catalog; turning it off leaves every other family’s modules in place.

stellar-rpc declares event timestamps: block.timestamp is envelope.close_time (unix seconds), so a time-window gate can compile against it. Skip { "to": "tip" } is 422 invalid_resource with "this source cannot report a confirmed tip" because this source has no confirmed_tip, the same as evm-mempool. Skip to a ledger/block number still works while paused.

Spec payload

A spec whose chain is stellar is one of:

  • {"catalog":"classic"} — every classic OperationType, compiled in decoder/classic.rs. InvokeHostFunction becomes a function_call; every other classic type is an operation. Named host functions UploadContractWasm, CreateContract, and CreateContractV2 are also function_call schemas on this catalog.
  • {"catalog":"stellar-asset"} — the baked Stellar Asset Contract spec (decoder/stellar_asset.rs), including Transfer and TransferMuxed.
  • {"wasm":"<base64>"} — the contract’s contractspecv0 custom section, compiled in decoder/soroban.rs.
  • XDR-JSON of an ScSpecEntry object or array — the same Soroban compiler, without WASM.

The decoder does not fetch a spec from chain. Operators supply a payload on PUT /specs/{id} or in a seed file, or they produce one at write time with POST /specs/{id}/from-chain or blockwatcher spec-import. Those paths call getLedgerEntries, then materialize_spec: a Stellar Asset Contract instance becomes { "catalog": "stellar-asset" }; a WASM contract becomes { "wasm": "<base64>" } that compile_spec accepts. Account (G) addresses are refused as invalid. A JSON-RPC permanent refusal (-32602..=-32600) is invalid. Other RPC failures are unavailable and name endpoint names, never a URL. After import, the stored spec is the same {id, chain, payload} shape as a hand-written one. blockwatcher_stellar::materialize_spec still turns already-fetched getLedgerEntries JSON into that payload without performing RPC itself.

Addresses in a selector are StrKey at the door and become prefixed canonical bytes on the value tree (decoder/address.rs), encoded as 0x hex. Shared address rules stay on Chain-agnosticism. The dashboard’s match JSON keeps that hex and adds an explorer caption: flavor 0x00/0x01/0x02 becomes a G/C/M StrKey link on StellarExpert, and tx.hash opens the transaction by unprefixed hash.

Selector keys

The compiler lives in crates/blockwatcher-stellar/src/decoder/selector.rs. A selector body admits events, functions, operations, and addresses; any other key is rejected by name. The three kind keys share one presence matrix: naming any of them selects only what it names; naming none selects every declaration the referenced spec exposes of each kind. An explicit empty array for a kind list or for addresses is rejected rather than treated as absence.

InvokeHostFunction is a function_call. So are UploadContractWasm, CreateContract, and CreateContractV2. A selector that wants those invocations names them under functions, not under operations. Classic operations such as Payment are named under operations.

Shared selector contracts (spec reference, OR across entries, unknown keys rejected by the family’s compiler) stay on Selectors.

Source

stellar-rpc polls stellar-rpc for closed ledgers inside oldestLedger..=latestLedger from getHealth. On the first health read, a configured start_ledger below oldestLedger is refused. A checkpoint below that window clamps to the new oldest ledger with a retention gap. If oldestLedger advances past the next ledger during a run, the source reports the skipped range as a retention gap and clamps the next fetch to the new oldest ledger. Catch-up walks event ranges in chunks of at most 10,000 ledgers. Transaction interest narrows each chunk to at most 512 ledgers so returned envelopes remain bounded in memory. Cursor primary is ledger sequence; secondary orders invocation, then operation, then event within the ledger (source/cursor.rs, source/emit.rs). journal_depth is denominated in those primary units.

The source chooses RPC methods from decoder interest: event interest spends getEvents (packed at five filters of five contract IDs; an empty address set sends {type: "contract"}); operation or function interest spends getTransactions (source/run.rs). On the first successful getHealth, every non-open endpoint is probed with getNetwork; disagreeing passphrases refuse the run by endpoint name, never echoing a URL. The envelope on the wire is source-owned JSON wrapping base64 XDR (decoder/envelope.rs). Decoded occurrences carry tx.status (SUCCESS or FAILED from getTransactions; contract events from getEvents carry SUCCESS). Predicates filter; the source does not drop FAILED. Malformed XDR is undecodable; a foreign compiled selector or a non-stellar chain is no_match.

Source config

The network resource envelope (id, chain, source.module) is on Network. The tables below are this family’s source.config.

Endpoints: the pool vocabulary

stellar-rpc names a pool of HTTP JSON-RPC endpoints (EndpointDef, crates/blockwatcher-stellar/src/source/config.rs). There is no priority key; every endpoint is constructed as the pool’s high tier (registry.rs). Each endpoint object:

KeyTypeDefaultMeaning
namestring"primary"Labels every metric and log line for this endpoint. A repeat refuses: endpoint name 'primary' is used by more than one endpoint; endpoint names must be unique. Two endpoints that omit name both become primary and refuse for that reason.
url_secretstringnone (required)An env:NAME reference to where the URL lives, never the URL itself. A value that is not a reference refuses with endpoint '<name>' url_secret is not a secret reference: ..., deliberately never echoing what was written: a provider URL routinely carries an API key. The resolved value must be http or https; any other scheme is refused at write, naming the scheme and never the URL.
rate_limitobject { "rps": u32 }noneA preemptive request rate enforced before a call leaves the pool. A zero refuses: endpoint '<name>' rate_limit.rps is 0; it must be at least 1.
weightu321Ring slots in the pool’s rotation; meaningful only under round_robin selection, inert under ordered. A zero refuses: endpoint '<name>' weight is 0; it must be at least 1. Above the pool’s cap of 100 (blockwatcher_rpc::MAX_WEIGHT) refuses: endpoint '<name>' weight (101) exceeds the maximum (100).

stellar-rpc

Polls closed ledgers over HTTP JSON-RPC: events via getEvents, and, while some monitor watches operations or functions, transactions via getTransactions. Its config (StellarRpcConfig, crates/blockwatcher-stellar/src/source/config.rs) rejects unrecognized fields, including a network key: which network a source’s events belong to is the engine’s to supply, never the config’s. There is no confirmations key: closed ledgers are not rewound, so this source never returns Invalidated for confirmation depth (source/run.rs).

The module’s complete example config, registry_examples/stellar_rpc.json from crates/blockwatcher-stellar/, verbatim, the same file the crate’s family-completeness test constructs:

{
  "start_ledger": 1,
  "poll_interval_ms": 2000,
  "endpoints": [
    { "url_secret": "env:STELLAR_RPC_URL", "weight": 1 }
  ]
}

start_ledger: 1 is a valid construct-time value — the floor the schema accepts, and the number the family-completeness test constructs. A public node’s oldestLedger sits far above 1, so this same JSON refuses at the first getHealth unless the operator stamps a ledger inside oldestLedger..=latestLedger. The runnable seed under examples/source-stellar-rpc-monitor does that from getHealth; the file-by-file pass is Monitoring Stellar Testnet [RPC].

The tunables:

KeyTypeDefaultMeaning
endpointsarraynone (required)The pool, per the table above. An empty array refuses: stellar-rpc requires at least one endpoint.
selectionstring: ordered or round_robin"ordered"How the pool orders endpoints when no window pin dictates the choice: ordered concentrates calls on the first admissible endpoint in configuration order; round_robin spreads windows across the pool, weighted by each endpoint’s weight. A misspelling is refused by serde naming both accepted spellings.
start_ledgeru32requiredWhere a run with no persisted checkpoint begins scanning. Deliberately absolute: a head-relative start would re-derive a different ledger on every restart and silently skip the gap. A missing key refuses at construct. A zero refuses: start_ledger (0) must be at least 1. If this value is below oldestLedger on the first health read, the run refuses it and names oldestLedger and latestLedger. A checkpoint below that window clamps and records a gap instead. If retention advances past the next ledger after that read, the source records the skipped range as a gap and continues from the new oldestLedger.
poll_interval_msu642000How often, while caught up, one getHealth check advances the emission barrier. The same interval paces a retry after a failed getHealth or window fetch. A zero refuses: poll_interval_ms (0) must be at least 1.
request_timeout_msu6410000The per-attempt HTTP timeout the pool enforces. A zero refuses: request_timeout_ms (0) must be at least 1.

Delivery guarantees

The pipeline named the Progress tracker as the thing that turns “every dispatched match done” into “safe to persist this cursor.” This page opens that mechanism up: what “done” means precisely, what happens when it never becomes true, and what a crash actually costs a consumer versus what it never costs them. Delivery scenarios is this page’s companion for a different question: given a monitor, a sink, and whatever policies are configured on it, what actually happens in the situation in front of you right now, walked as flow diagrams rather than field-by-field prose.

flowchart LR
    rpc["RPC endpoints<br/>(external chains)"]
    %% Source modules are family-owned; the running list is GET /catalog, not this map.
    sources["Sources<br/>one per network"]
    sinks["Sinks<br/>webhook · script · log"]
    storage["Storage<br/>checkpoints · dead letters · resources"]
    api["REST API"]
    metrics["Metrics"]
    engine["engine<br/>bounded channels · checkpoints"]

    subgraph pipeline["Engine pipeline"]
        direction LR
        decoder["Decoder"]
        matcher["Matcher<br/>predicates"]
        gate["Gate<br/>threshold · max_once"]
        decoder --> matcher
        matcher --> gate
    end

    rpc --> sources
    sources -->|"decode and match"| decoder
    gate --> sinks
    api -->|"manages resources"| storage
    storage <--> engine
    engine -->|"drives"| pipeline
    engine -.->|"reports"| metrics

    classDef module fill:none,stroke:#a9a3e3
    classDef core fill:none,stroke:#8a8d86,stroke-dasharray: 5 5

    class rpc,sources,sinks,decoder,matcher,gate module
    class engine,api,storage,metrics core
classDef dim fill:none,stroke:#999999,color:#999999,opacity:0.35
classDef focus fill:#ffd43b,stroke:#d9480f,stroke-width:3px,color:#1a1a1a
class rpc,sources,api,metrics,engine,decoder,matcher,gate dim
class sinks,storage focus
click sources "selectors.html"
click decoder "chain-agnosticism.html"
click matcher "predicates.html"
click gate "gates.html"
click sinks "delivery.html"
click storage "resources.html"
click api "../reference/http-api.html"
click metrics "../reference/observability.html"
click engine "pipeline.html"

Key takeaways

  • A checkpoint only ever advances past the longest contiguous run of fully-done events from the front; one stuck event holds back everything behind it.
  • Overload is backpressure, not data loss: bounded channels stall the pipeline rather than dropping a message.
  • A crash can only repeat work, never skip it, because a cursor only advances once every match tied to it is finished.
  • Deterministic match ids let a consumer treat a redelivered duplicate as the same occurrence rather than a new one.
  • A delivery that exhausts its retry budget becomes a durably recorded dead letter, and the guard only completes once that write lands.
  • Sinks consume a closed SinkEvent set: Match to apply, Retracted to undo by match_id, or Digest to apply several matches bundled by aggregation at once. All three are at-least-once; retracts and digests share the same retry and dead-letter path as a plain match.
  • A deep invalidate is a typed source outcome, not a failure: the engine drains, retracts journaled ids, rewinds, then restarts. Retracts for a sink finish before any replacement Match from that network.
  • A monitor gate decides whether a predicate-true hit becomes a match at all. Quiet gate hits are not deliveries, not dead letters, and do not hold the checkpoint. Sink throttle / aggregate still apply only after a match exists.

The contract, precisely

A network’s checkpoint is a cursor plus whatever the source needs to verify a resume. Progress (crates/blockwatcher-core/src/progress.rs) is the structure that decides when a cursor is safe to hand the checkpoint writer: it tracks every registered event as a Slot in arrival order, each carrying a remaining count of outstanding matches, and only ever publishes the cursor of the longest contiguous run of fully-done slots starting from the front, in advance_locked (progress.rs). “Fully done” means every match dispatched from that event has either reached its sink or been recorded as a dead letter. There is no third outcome. One still-open slot anywhere in that prefix holds every slot behind it back, even ones that finished long ago (CompletionGuard::complete, progress.rs, exercised directly in middle_event_finishing_last_gates_the_prefix, progress.rs).

Three consequences follow directly from that rule:

  • Checkpoints only ever move forward past finished work. A CompletionGuard dropped without calling complete() (a worker task dying mid-delivery, say) permanently stalls the prefix at that slot rather than letting it expire or time out (progress.rs; see a_dropped_guard_stalls_the_checkpoint, progress.rs). A stall is visible in the status API as in_flight_events; nothing here manufactures a false “done.”
  • Overload is backpressure, not data loss. The channels between source, processor, and each sink worker are bounded and never drop a message on the full side: a slow sink fills its own channel, which stalls the processor’s dispatch loop, which stalls the source’s own send. Nothing downstream of a full queue is asked to discard anything; the entire pipeline just waits.
  • A crash can only repeat work, never skip it. Resuming means reading the last persisted checkpoint and continuing from there. Anything that cursor already covers is gone for good from the source’s perspective, but by the rule above that only happens once every match tied to it is finished, so the only way a crash surfaces to a consumer is by re-delivering an event whose checkpoint write hadn’t landed yet, never by silently dropping one.

Deterministic match IDs are what make that repetition harmless. MatchId::derive hashes the network, monitor, cursor, an index within the event, and a structural walk of the decoded event’s own field tree (crates/blockwatcher-types/src/id.rs): nothing about it depends on wall-clock time, retry count, or which run produced it. Re-processing the same on-chain event after a restart decodes the same bytes at the same slot and derives the exact same id (match_id_re_derivation_from_the_same_content_is_stable, id.rs), which is what lets a consumer treat two deliveries of the same id as one occurrence rather than two.

Happy path

One raw event, one match, one sink, nothing goes wrong:

sequenceDiagram
    participant Source as Source task
    participant Processor as Processor task
    participant Progress as Progress tracker
    participant Sink as Sink worker task
    participant CPW as Checkpoint writer task
    participant Store as checkpoint storage

    Source->>Processor: event at cursor C
    Processor->>Processor: decode + match (1 match)
    Processor->>Progress: begin(C, outstanding: 1)
    Processor->>Sink: dispatch match M
    Sink->>Sink: deliver Match(M) (success)
    Sink->>Progress: guard.complete()
    Progress->>Progress: prefix fully done, watermark = C
    Progress->>CPW: publish checkpoint(C)
    CPW->>Store: persist checkpoint(C)

An event with zero matches is registered too, with zero outstanding: it completes the moment begin runs, which is why an event that matched nothing still lets the cursor advance immediately rather than wait on deliveries that were never dispatched. An event whose monitors all retained or discarded at the gate is registered with zero outstanding the same way.

Crash after partial delivery, then resume

Two matches from one event, going to two different sinks. One delivery lands before the crash; the other is still retrying when the process dies. Because the checkpoint requires both guards, cursor C never got persisted. The last durable checkpoint is still whatever preceded it:

sequenceDiagram
    participant Source as Source task
    participant Processor as Processor task
    participant Progress as Progress tracker
    participant SinkA as Sink worker task A
    participant SinkB as Sink worker task B
    participant Store as checkpoint storage

    Note over Store: persisted checkpoint = C-1
    Source->>Processor: event at cursor C
    Processor->>Progress: begin(C, outstanding: 2)
    Processor->>SinkA: dispatch match M1
    Processor->>SinkB: dispatch match M2
    SinkA->>SinkA: deliver M1 (success)
    SinkA->>Progress: guard.complete() (M1)
    Note over Progress: M2 still outstanding, watermark holds at C-1
    Note over SinkB: process crashes mid-retry of M2
    Note over Store: restart resumes from C-1 (never advanced)

    Source->>Processor: re-emit event at cursor C
    Processor->>Progress: begin(C, outstanding: 2)
    Processor->>SinkA: dispatch match M1 (same MatchId as before)
    Processor->>SinkB: dispatch match M2 (same MatchId as before)
    SinkA->>SinkA: deliver M1 again (duplicate)
    Note over SinkA: consumer recognizes the duplicate by M1's id
    SinkA->>Progress: guard.complete() (M1)
    SinkB->>SinkB: deliver M2 (success this time)
    SinkB->>Progress: guard.complete() (M2)
    Progress->>Progress: prefix fully done, watermark = C
    Progress->>Store: checkpoint(C) persisted

Nothing about which sink had already succeeded survives the crash: the in-memory Progress state is gone, and the only source of truth on restart is the last persisted checkpoint. Re-reading from C-1 means the whole event at C is decoded and matched again, M1 included, which is exactly the duplicate a consumer’s dedup-by-id exists for.

Dead letters

When deliver_with_retry (crates/blockwatcher-core/src/pipeline/delivery.rs) exhausts a sink’s retry budget (every attempt spent on a transient or rate-limited failure, or a permanent/retry-narrower error that skips retry entirely), the sink worker does not drop the event. It builds a DeadLetter carrying the event’s match id, which monitor and sink produced it, the cursor it traces back to, the attempt count, a reason string, and the original SinkEvent payload, and writes it through the network’s storage backend, in dead_letter (crates/blockwatcher-core/src/pipeline/sink_worker.rs). Only after that write durably lands, and, for a Match, only after the delivery journal row is written, does the worker complete its guard, in dead_letter’s success branch (sink_worker.rs); if the write itself fails, the worker retries the write forever on its own doubling backoff rather than completing early, in dead_letter’s retry loop (sink_worker.rs): the same “never advance past an unrecorded loss” rule as everywhere else in this contract, just applied one layer further out. A dead-lettered Match stays in the journal so a later invalidate can still retract it; a dead-lettered Retracted does not.

An operator interacts with a network’s dead-letter queue through these routes, in router (crates/blockwatcher-api/src/routes/networks_ops.rs):

  • GET /networks/{id}/dead-letters pages the queue in insertion order: a pure storage read, in list_dead_letters (crates/blockwatcher-core/src/control/dead_letters.rs).
  • POST /networks/{id}/dead-letters/{match_id}/replay re-runs the same deliver_with_retry path against the sink the letter names, rebuilt fresh from its stored config. Success deletes the letter (204); exhaustion bumps its attempts and reason in place and leaves it queued, in replay_dead_letter (dead_letters.rs). A letter recorded before payload storage was added has no payload to replay and is refused outright rather than silently no-op’d (dead_letters.rs, message NO_REPLAY_PAYLOAD). A letter whose payload is SinkEvent::Retracted is likewise refused (NO_REPLAY_RETRACTED): only match events can be replayed.
  • DELETE /networks/{id}/dead-letters/{match_id} and DELETE /networks/{id}/dead-letters (the latter with optional sink/monitor filters, returning the count discarded) discard letters outright, without attempting delivery, in discard_dead_letter/discard_dead_letters (dead_letters.rs). Replaying is the only route that resends a letter; discarding is permanent.

The sqlite storage backend is what makes a dead-letter queue survive a restart at all; the memory backend does not, matching its own no-persistence contract.

Delivery policies

Everything on this page is configured per sink, on the SinkDef resource (a JSON document written through PUT /sinks/{id} or a seed directory, hot-reloaded like every resource): nothing here is hardcoded or instance-config. One complete sink showing every policy field beside the module’s own config:

{
  "id": "alerts",
  "module": "webhook",
  "config": {
    "url_secret": "env:ALERTS_WEBHOOK_URL",
    "headers": { "x-source": "blockwatcher" },
    "timeout_ms": 10000,
    "body_template": "{% if type == \"digest\" %}{{ matches | length }} matches{% else %}{{ type }}: {{ monitor }}{% endif %}"
  },
  "retry": { "max_attempts": 8, "initial_backoff_ms": 200, "max_backoff_ms": 30000 },
  "throttle": { "max_deliveries": 60, "window_ms": 60000 },
  "aggregate": { "window_ms": 30000, "max_batch": 100 }
}

config belongs to the named module (here webhook; its fields, including the optional body_template renderer, are that module’s own documentation). retry, throttle, and aggregate are the engine’s, identical for every module, each optional: omit retry for the engine-wide default, omit throttle for no throttling, omit aggregate for unbatched delivery. The values shown are each field’s defaults.

A retry whose initial_backoff_ms sits above its own max_backoff_ms is refused by the same validate call throttle and aggregate go through, at the same two points described below for aggregation: put_sink refuses the write, validate_and_build refuses to boot a stored row that fails the same check. DeliveryRetry::backoff_bounds (crates/blockwatcher-types/src/resource.rs) is what every retry loop reads its starting backoff and ceiling from, deliver_with_retry and the sink worker’s journal, dead-letter, and forget retries alike, so the floor against an out-of-range pair lives in one place rather than at each of those call sites.

A sink’s throttle field (SinkDef.throttle: Option<Throttle>, crates/blockwatcher-types/src/resource.rs) caps how often that sink admits successful deliveries, independent of the retry and dead-letter policy above. The fields that define it:

  • max_deliveries: the number of successful Match deliveries the window admits before it starts refusing more, default 60.
  • window_ms: the window’s length in milliseconds, default 60_000.

A zero in either field is refused at write time, the same “validate before constructing anything” discipline every resource write goes through: a sink’s throttle is either absent or fully valid, never partially so.

At runtime, SinkWorker (crates/blockwatcher-core/src/pipeline/sink_worker.rs) holds a fixed admission window per sink, anchored at the first admitted delivery and reset once window_ms has elapsed. The window belongs to one network’s pipeline, not to the sink id globally: a sink named by monitors on several networks gets one independent window per network, each with its own budget, rather than one shared budget across all of them. A Match that arrives inside the current window’s budget is delivered normally; a Match that would exceed it never reaches the sink at all. It is dead-lettered instead, through the same dead_letter path an exhausted retry budget uses, with a reason naming the policy that suppressed it:

permanent: throttled: policy allows 60 deliveries per 60000ms; replay this dead letter to deliver it

The permanent: class prefix is the same one every dead-letter reason carries; nothing about the throttle path is a distinct class of dead letter. A throttled match is exactly as listable and replayable as any other dead letter, through the same GET /networks/{id}/dead-letters and POST /networks/{id}/dead-letters/{match_id}/replay routes described above: replaying one delivers it directly, without re-checking the window it was suppressed under.

Only a delivered Match spends budget. A failed attempt already pays for itself through the ordinary retry-then-dead-letter path and must not also consume quota meant for the next real delivery. Retracted events skip the throttle check entirely, at the same call site that would otherwise apply it: a correctness signal outranks a fatigue policy, so an undo is never the thing a noisy sink drops on the floor.

The window is runtime-only state, held in memory beside the sink worker rather than persisted: a pipeline restart opens a fresh window with zero deliveries counted. An operator relying on the throttle to bound a sustained rate should not expect the count to survive a restart.

Aggregation

A sink’s aggregate field (SinkDef.aggregate: Option<Aggregate>, crates/blockwatcher-types/src/resource.rs) batches several matches into one digest delivery instead of sending each on its own. The fields that define it:

  • window_ms: how long the batch stays open once the first match joins it, default 30_000, capped at 86_400_000 (one day).
  • max_batch: the number of matches that closes the batch immediately, default 100, capped at 10_000.

A zero in either field is refused the same way a zero throttle field is, and so is a value above either cap: the caps bound what one open window can cost, since every match in it is held in memory and the checkpoint stays behind all of them until the window closes. max_batch prices that worst case in matches, window_ms prices it in time. Each route runs a SinkDef’s policy through the same validate call, but they enforce it at different points. put_sink (crates/blockwatcher-core/src/control/writes.rs) refuses to persist a SinkDef whose policy fails validation. validate_and_build (crates/blockwatcher-core/src/engine/boot.rs) runs the identical check against rows already in storage, a restored backup or a hand-edited row among them, and refuses to start the engine rather than refusing a write. A sink’s aggregation policy is either absent or fully valid, never partially so.

At runtime, AggregateBuffer (crates/blockwatcher-core/src/pipeline/aggregate.rs) holds one batch per sink worker, and only a Match ever joins it; the buffer flushes, delivering whatever it holds as a single event, on any of:

  • the batch reaches max_batch;
  • window_ms elapses since the first match joined the batch, not the most recent one, so a steady trickle still reaches the sink once per window instead of waiting forever for a batch that never fills;
  • the sink worker’s channel closes, which is what a cooperative drain looks like from this worker’s side: a partial batch still in progress is flushed rather than dropped;
  • a Retracted event arrives. The buffer flushes first and the retract is delivered after, so a retract can never overtake the match it retracts: per-sink delivery order is the only evidence a sink has of which of the two came last. Retracted events are never buffered themselves and never count toward max_batch, the same correctness-outranks-fatigue rule that exempts them from the throttle above.

A flush of exactly one match delivers a plain SinkEvent::Match: a quiet sink whose matches never overlap inside one window sees the identical wire format it would see with no aggregate field set at all. A flush of two or more delivers a single SinkEvent::Digest { matches }, rendered by canonical_body as:

{
  "type": "digest",
  "matches": [
    { "id": "…", "monitor": "usdc-sepolia-transfers", "network": "sepolia", "event": { } },
    { "id": "…", "monitor": "usdc-sepolia-transfers", "network": "sepolia", "event": { } }
  ]
}

Every buffered match holds its CompletionGuard uncompleted for as long as it sits in the batch, exactly as an in-flight delivery does. A buffered window stalls the checkpoint by design: the batch’s oldest match cannot pass the checkpoint until the whole batch it ends up part of is delivered or dead-lettered, even though nothing has been sent to the sink yet. A hard-cancel or crash drops those guards uncompleted rather than flushing them, the same safe-by-default direction a mid-delivery crash already takes elsewhere in this contract: on replay, the source re-emits the events those matches came from, they are re-matched, and re-buffered, and eventually re-delivered, deduplicated by MatchId exactly as any other redelivered match is. There is no persisted window state to resume from; the window itself is runtime-only, the same stance the throttle above takes.

On a successful digest delivery, every contained match is journaled at its own cursor, in arrival order, before any guard completes, the same journal-then-complete ordering a lone match’s delivery follows. On retry exhaustion, the digest does not dead-letter as one row: each contained match is dead-lettered individually, carrying the digest’s own failure reason, one match at a time and in arrival order. Per match, the record order is the same as a lone dead-lettered match’s: the dead-letter row is written first, then that match is journaled, then the next match in the digest follows the same two steps. This is the replay caveat to carry forward: a dead-lettered digest member replays as a single match, never as the digest it originally arrived in, because POST /networks/{id}/dead-letters/{match_id}/replay only ever knows how to resend one match at a time.

The delivery policies compose rather than interact specially: a digest counts as exactly one delivery against the sink’s throttle window, the same one unit a lone match would have spent, so aggregating a burst into fewer deliveries is one way an operator keeps a bursty sink under a tight throttle budget. A digest the throttle refuses is suppressed exactly like a suppressed lone match would be, except it dead-letters every contained match individually rather than one row, matching how a delivered digest is journaled.

Monitor gates versus sink policies

throttle and aggregate are fields on SinkDef. A gate is a field on Monitor. They are not interchangeable:

WantUseDo not use
Fire only when N predicate-true hits span ≤ W (event time); then start a new sessionMonitor gate.module = thresholdSink aggregate (holds the checkpoint until the window closes; batches Matches that already exist)
At most one alert per event-time window; later hits vanishMonitor gate.module = max_onceSink throttle (caps deliveries after a Match exists; refusals are dead letters you can replay)
Drop this event because it is odd / before noon UTCpredicate (block.number % 2, block.timestamp % 86400)A gate. Per-event filters are not stateful.
Rate-limit a noisy webhook without losing matchesSink throttlemax_once (those hits are gone, not replayable)
Batch already-fired matches into fewer HTTP postsSink aggregatethreshold (threshold decides whether a Match exists)
  • threshold gate: N predicate-true hits whose block.timestamp values span ≤ window_ms mint one digest of those hits, then reset the session. Held hits persist in gate_hits and complete with outstanding 0, so a 60-minute threshold does not stall the checkpoint for 60 minutes.
  • aggregate sink: already-minted Matches sit in memory and the checkpoint stays behind all of them until the window flushes. That is why a long aggregate window is operationally expensive.
  • max_once gate: at most one alert per event-time window; later hits are discarded with no dead letter.
  • throttle sink: later Matches are dead-lettered and remain replayable. Only successful deliveries spend budget. Restarts reset the in-memory window.

Full gate contract: Gates.

Sources without a chain-position cursor

Everything above assumes a source whose cursor is a real position in a feed it can resume from. A source whose cursor is not that — a per-run arrival counter, for example — cannot make the same promise: its checkpoint is only a dedupe watermark for the current process’s lifetime, a restart cannot replay whatever was in flight, and match identity is not stable across that gap. Consumers of such a source deduplicate on a payload field the family documents, not on MatchId. Which shipped sources this applies to is on the family page.

What a sink receives: SinkEvent

Sink::deliver takes a SinkEvent, not a bare match (crates/blockwatcher-ports/src/sink.rs):

#![allow(unused)]
fn main() {
pub enum SinkEvent {
    Match(Match),
    Retracted { match_id: MatchId },
    Digest { matches: Vec<Match> },
}
}

(crates/blockwatcher-types/src/event.rs)

That set is closed: core reasons about every variant exhaustively, the same way it reasons about the canonical value model. Digest is what aggregation delivers in place of several separate Match events; it only ever reaches a sink whose SinkDef sets aggregate. A sink module that only handles Match will silently keep work a later Retracted asked it to undo, or drop every match a later Digest bundled together.

Every variant travels the same deliver_with_retry path (crates/blockwatcher-core/src/pipeline/delivery.rs): the same retry budget, the same backoff, the same dead-letter write when the budget runs out, and the same CompletionGuard (one per contained match, for a Digest). A failed undo can stall the checkpoint or land in the dead-letter queue; it is never dropped on the floor. Consumers must treat a redelivered Retracted as idempotent on match_id, just as they already treat a redelivered Match as idempotent on Match.id, and a redelivered Digest’s contents as idempotent per contained Match.id for the same reason.

Built-in sinks (webhook, script, log) render the event as tagged JSON through one helper, canonical_body (crates/blockwatcher-sinks/src/lib.rs):

{ "type": "match", "id": "…", "monitor": "…", "network": "…", "event": { } }
{ "type": "retracted", "match_id": "…" }
{
  "type": "digest",
  "matches": [
    { "id": "…", "monitor": "…", "network": "…", "event": { } },
    { "id": "…", "monitor": "…", "network": "…", "event": { } }
  ]
}

That tagging is a wire break against a bare Match object. A DeadLetter’s payload is Option<SinkEvent>: a row written before this shape existed, or written as a legacy Match object, still loads as SinkEvent::Match. Replay through the API only accepts a Match payload; a retraction payload is refused rather than re-sent (NO_REPLAY_RETRACTED, crates/blockwatcher-core/src/control/dead_letters.rs).

Shallow vs deep invalidation

A source whose cursor is a real chain position can see its feed fork. Whether sinks hear about that fork depends on which side of the confirmation barrier moved:

──── already emitted / checkpointed ────|──── unconfirmed window ────| head
                                        ^ confirmations barrier
  • Shallow. Only the unconfirmed side moves. Those cursors were not durably offered to sinks (or a mid-scan race is retried in place, inside the source). No Retracted leaves the module.
  • Deep. The fork cuts into work already emitted. Restarting the source alone would offer the new fork as new Matches (often with new MatchIds) and leave the consumer holding the orphan with no undo signal.

Core never names a chain, a reorg, or a confirmation depth. A source that has positively detected a deep invalidate returns SourceOutcome::Invalidated { from } from Source::run (crates/blockwatcher-ports/src/source.rs): a typed non-success, not a SourceError. The from cursor is the proven point; everything with cursor > from previously implied by this source is on a dead fork. A source whose entire tracked history is refuted (no tracked ancestor matches the live chain at all) proves divergence only at or below the oldest height it can still vouch for, bounded by the tracker’s own depth rather than any named fork block; when that depth reaches back to block 0, the bound is genesis, but that is the tracker’s own limit speaking, not a claim the fork itself sits there. A source that watches a reorging chain is what translates a break against already-emitted work into Invalidated at the module boundary: a walk that finds a still-matching ancestor invalidates from that proven fork (beyond_confirmations); a walk that finds none invalidates from just below the oldest tracked height (beyond_window); journaled deliveries above it are retracted the same as any other invalidate (the journal-depth caveat below still applies), and the source restarts from the rewound checkpoint. Both are the same on a mid-run break as on resume. A source whose cursor is not a chain position never produces it.

The engine supervisor handles Invalidated as a control path, not a crash (crates/blockwatcher-core/src/engine/invalidate.rs):

  1. Cooperative cancel and drain of that network’s pipeline (the same deadline drain any restart uses; no abort on this path unless the deadline is missed).
  2. Prune gate_hits for this pipeline where cursor > from. Do not delete rows with cursor ≤ from.
  3. list_deliveries_after(network, from) against the bounded delivery journal.
  4. One-shot retract pass: a SinkEvent::Retracted { match_id } for each retained id, through the same deliver_with_retry live traffic uses.
  5. Wait until every retract is delivered or dead-lettered. An unrecovered retract leaves the checkpoint unrewound and does not restart.
  6. Rewind the checkpoint to from, unless no write could move the cursor strictly backward: from is not behind the stored checkpoint, no checkpoint is stored at all (writing one would fabricate delivery progress), or the post-drain checkpoint read failed. A rewind must move the cursor backward or not at all, so in each of those cases the write is refused instead and counted as blockwatcher_rewinds_refused_total.
  7. Re-read the persisted checkpoint, then restart the source so replacement Matches can flow.

In-flight work on the old pipeline is dropped with it: it was never checkpointed, so it is not a silent skip. For a given sink, every Retracted from an invalidate finishes before any post-restart Match from that network is offered. Retracts go to the sinks that network’s pipeline was spawned with. After the network resource is deleted, those ids live on a runtime-only tombstone so a late Invalidated still notifies only those sinks, not every other network’s consumers. Sibling networks are not touched. One network’s invalidate does not abort the others.

sequenceDiagram
    participant Src as Source
    participant Sup as Supervisor
    participant Drain as Pipeline drain
    participant Journal as Delivery journal
    participant Sink as Sink worker
    participant Store as Checkpoint

    Src->>Sup: Invalidated { from }
    Sup->>Drain: cooperative cancel + drain
    Drain-->>Sup: outgoing work finished
    Sup->>Journal: list_deliveries_after(from)
    Journal-->>Sup: retained (cursor, match_id) rows
    loop each retained id, per sink
        Sup->>Sink: Retracted { match_id }
        Sink->>Sink: deliver_with_retry
        Note over Sink: success forgets the row,<br/>dead-letter keeps it
    end
    Note over Sink: all retracts delivered or dead-lettered
    Sup->>Store: rewind checkpoint to from
    Sup->>Src: restart (replacement Matches)

The delivery journal

A Match that is delivered or dead-lettered is recorded in a per-network journal before its completion guard completes (journal_delivery, crates/blockwatcher-core/src/pipeline/sink_worker.rs). That ordering is load-bearing: completing first would let the checkpoint pass a match the journal never durably saw, so a later invalidate could not retract it. Retracted is not journaled as a positive fact; a successful retract forgets the row. A retract that exhausts to a dead letter keeps the journal row so a later invalidate can re-offer it.

The journal is bounded by instance config journal_depth (default 1024, measured in cursor primary units: whatever the source uses as cursor.primary). On each record_delivery, rows whose cursor.primary is strictly behind recorded.primary.saturating_sub(journal_depth) are dropped as part of the same write. There is no operator prune API and no disable switch; the depth is meant to sit well above the source’s confirmation window.

When an invalidate’s from plus journal_depth does not cover the checkpoint high-water mark, rewind and restart still happen, but only ids still retained are retracted. The gap is counted (blockwatcher_journal_gap_total) and logged at error, naming the network, the invalidate cursor, the checkpoint, and the configured depth, never silent. See Observability and Troubleshooting. Deliveries at or below the invalidate’s own from bound are never retracted even when the true fork lies deeper than the source could prove: block from.primary itself is re-emitted in full on restart, so any already-delivered match inside it arrives again as a byte-identical duplicate under the at-least-once contract, not a gap.

A gate’s Emit carries the same durability shape one layer earlier: it rides an engine-owned outbox row per sink, written in the same storage transaction that consumes the hits its journal is built from, and held until that sink’s delivery outcome is itself durable. A crash between those two points loses nothing: the row survives to be drained and redelivered, at the cost of a possible duplicate, never a gap, the same trade this page’s dead-letter and journal contracts already make. Downstream consumers dedupe on Match.id, deterministic for the same reason a redelivered plain match already is. See Gates for the emission-to-outcome path in full.

Delivery scenarios

Delivery guarantees is the field-by-field reference: what each policy’s config keys do, what a Progress slot tracks, exactly which route replays a dead letter. This page is its companion for a different question: given a monitor, a sink, and whatever policies are configured on it, what actually happens, end to end, in the situation an operator is looking at right now? Every diagram here traces a real code path already described on that page; nothing here is a new mechanism, only a new view of the ones that exist.

Picking a scenario

SituationJump to
Nothing has gone wrong yet: what does normal delivery look like?The full path, one picture
A delivery attempt failed: what happens before it’s given up on?Retry and backoff
A sink is receiving more matches than it should have to handleThrottle: admission and suppression
Several matches should arrive as one message, not one eachAggregation: hold and flush
I want an alert only after N Transfers in an hour of block time, without stalling the checkpointThreshold gate, not aggregate
Two policies are configured on the same sink at onceComposing policies
The chain forked, or might haveReorg and invalidation
“Will I ever get a duplicate? Will I ever miss one?”What the guarantee actually is, by scenario
“What should I set for my use case?”Configuration cookbook

The full path, one picture

Every match takes the same route from decode to checkpoint, whether or not any policy below is configured. Policies insert themselves as named branch points on this one path; they never create a second path:

flowchart TD
    A[Event decoded, matched] --> B["Progress::begin: guard created,<br/>cursor stalls until it completes"]
    B --> C{Sink has<br/>throttle?}
    C -->|admitted| D{Sink has<br/>aggregate?}
    C -->|refused| DL1["Dead letter<br/>(reason: throttled)"]
    D -->|no| E[Deliver attempt]
    D -->|yes| F["Buffer holds the match<br/>(guard stays uncompleted)"]
    F -->|flush trigger fires| G{Flush held<br/>one match or many?}
    G -->|one| E
    G -->|many| H[Deliver as one Digest]
    E --> I{Attempt<br/>result?}
    H --> I
    I -->|success| J[Journal the delivery]
    I -->|exhausted retries| K["Dead letter<br/>(per contained match, if a digest)"]
    J --> L["guard.complete()"]
    K --> L
    DL1 --> L
    L --> M["Progress advances the checkpoint<br/>once every guard in the prefix is done"]

Two things this picture makes visible that are easy to miss reading the policies one at a time: every exit except the throttle refusal passes through a delivery attempt first (aggregation defers when an attempt happens; it never skips one), and every exit reaches guard.complete(). There is no branch that drops a match without either delivering it, dead-lettering it, or leaving its guard open (which stalls the checkpoint rather than silently passing it). See The contract, precisely for why that third option doesn’t exist.

Retry and backoff

One delivery attempt, from dispatch to either success or exhaustion. This is the branch under “Deliver attempt” in the picture above:

flowchart TD
    A[Attempt delivery] --> B{Result?}
    B -->|success| Z[Journal + complete guard]
    B -->|classified error| C{Class?}
    C -->|Permanent| D[Skip remaining attempts]
    C -->|Transient / RateLimited| E{Attempts<br/>remaining?}
    C -->|RetryNarrower| E
    E -->|yes| F[Wait backoff, doubling<br/>from initial_backoff_ms<br/>up to max_backoff_ms]
    F --> A
    E -->|no| D
    D --> G["Dead letter<br/>(reason names the class)"]

A Permanent error (a config the sink will never accept, a 4xx a retry can’t fix) skips straight to dead-lettering rather than spending the rest of the attempt budget on a result that cannot change; see Dead letters for the exact field a dead letter carries and the API routes (GET .../dead-letters, POST .../dead-letters/{id}/replay, and the DELETE routes that discard one or many) that read, replay, or discard it. retry’s fields (max_attempts, initial_backoff_ms, max_backoff_ms) are what this diagram’s boxes read from; omitting retry on a SinkDef uses the engine-wide default from blockwatcher.toml, not “no retry at all.”

Throttle: admission and suppression

The branch under “Sink has throttle?” above. A window opens on the first admitted delivery and holds for window_ms:

flowchart TD
    A[Match arrives at the sink worker] --> B{Window still open,<br/>and budget left?}
    B -->|no window open yet, or window_ms elapsed| C[Open a fresh window]
    C --> D[Admit, spend one unit]
    B -->|open and budget remains| D
    D --> E[Deliver normally]
    B -->|open and budget exhausted| F["Dead letter<br/>(reason: throttled, attempts: 0)"]

Only a delivered match spends budget: a failed attempt already paid for itself through the retry path above and must not also cost throttle quota meant for the next real delivery. A throttled match is not silently lost: it’s dead-lettered through the exact same path an exhausted retry uses, so it’s listed and replayable identically (replaying delivers it directly, without re-checking the window it was suppressed under). See Delivery policies for the two config fields and their defaults.

Aggregation: hold and flush

The branch under “Sink has aggregate?” above. Unlike throttle, a held match is not resolved yet: it sits with its guard open until one of four things flushes the buffer:

flowchart TD
    A[Match arrives, sink has aggregate] --> B[Add to the open buffer]
    B --> C{Which flush trigger<br/>fires first?}
    C -->|buffer reaches max_batch| D[Flush now]
    C -->|window_ms since the FIRST<br/>held match elapses| D
    C -->|sink worker's channel closes<br/>= a cooperative drain| D
    C -->|a Retracted event arrives<br/>for this sink| E[Flush the buffer,<br/>THEN deliver the Retracted]
    D --> F{One match held,<br/>or several?}
    F -->|one| G[Deliver as a plain Match]
    F -->|several| H[Deliver as one Digest]

The window is anchored to the first match that joined it, not the most recent: a steady trickle of matches still reaches the sink once per window rather than waiting forever for a batch that never fills. A Retracted is never buffered and never counts toward max_batch: it flushes whatever is already held first, so a retract can never overtake the match it retracts (per-sink delivery order is a consumer’s only evidence of which came last). See Aggregation for the digest wire shape, the exhaustion-dead-letters-each-match-individually behavior, and why a dead-lettered digest member replays as a single match, never as the digest it arrived in.

The one behavior this diagram can’t show, because it’s an absence, not a branch: a hard-cancel or crash while matches are held drops those guards uncompleted rather than flushing them. Nothing here reads that as “lost”: the checkpoint simply doesn’t advance past them, and on restart the source re-emits the events those matches came from, which re-match, re-buffer, and eventually re-deliver, deduplicated by MatchId like any other redelivery. There’s no persisted window to resume from; the window is runtime-only, same as throttle’s.

Threshold gate, not aggregate

The branch sits before Progress::begin for a match. Three hits spanning ≤ window_ms produce one digest and complete outstanding for that emit; two hits complete with outstanding 0 and persist holds. Contrast the aggregate diagram on this page: every buffered match there holds a guard open. Full contract: Gates.

Composing policies

The two flow diagrams above never run in isolation on a sink that configures both. Four compositions are worth naming because each answers a question an operator will actually ask:

  • A digest costs one throttle unit, not one per contained match. Aggregating a bursty sink into fewer, larger deliveries is a direct way to keep it under a tight throttle budget: ten matches held into one digest spend the same one unit of window budget a single match would.
  • A throttled digest still dead-letters every contained match individually. The throttle refusal happens at the delivery-attempt boundary, after aggregation has already flushed a Digest; suppressing it doesn’t collapse it back into one dead-letter row, since replay still needs to resend each match on its own.
  • Retract bypasses both policies, at the same call site each time. A Retracted skips the throttle check entirely and flushes (rather than joins) an aggregate buffer. This is deliberate in both cases: a correctness signal (undoing a match a consumer may have already acted on) outranks a fatigue policy meant for noisy matches, so an undo is never the thing a loud sink drops on the floor.
  • A reorg’s retract pass ignores both policies too, for the same reason: see the sequence diagram in the next section. The retracts an invalidate sends ride the same deliver_with_retry path live traffic does, but they are never throttled or buffered; only the ordinary retry budget applies to them.

Reorg and invalidation

Whether a fork produces a Retracted at all depends on which side of the confirmation barrier it cuts into. This is a three-way branch, not two:

flowchart TD
    A[Source detects its tracked chain<br/>no longer matches the live head] --> B{Where does the<br/>fork land?}
    B -->|only in the unconfirmed window,<br/>never durably offered| C["Shallow: retried in place<br/>inside the source. No Retracted."]
    B -->|cuts into already-emitted work,<br/>a tracked ancestor still matches| D["Deep, proven fork:<br/>Invalidated { from: fork point }"]
    B -->|cuts deeper than the tracker<br/>can prove: no tracked ancestor matches| E["Deep, unproven depth:<br/>Invalidated { from: just below<br/>oldest tracked height }"]
    D --> F[Engine invalidate sequence]
    E --> F

The tracker’s own memory bounds how deep a fork can be proven: it keeps clamp(2 × confirmations, 8, 64) entries, so a reorg deeper than 64 blocks is a real limit of the design, not an oversight; the source still invalidates, just from the deepest point it can still vouch for rather than the true fork block. Both deep branches converge on the same sequence once Invalidated { from } is raised: this is the existing diagram from Delivery guarantees, repeated here because it’s the scenario this page’s readers are most often looking for:

sequenceDiagram
    participant Src as Source
    participant Sup as Supervisor
    participant Drain as Pipeline drain
    participant Journal as Delivery journal
    participant Sink as Sink worker
    participant Store as Checkpoint

    Src->>Sup: Invalidated { from }
    Sup->>Drain: cooperative cancel + drain
    Drain-->>Sup: outgoing work finished
    Sup->>Journal: list_deliveries_after(from)
    Journal-->>Sup: retained (cursor, match_id) rows
    loop each retained id, per sink
        Sup->>Sink: Retracted { match_id }
        Sink->>Sink: deliver_with_retry
        Note over Sink: success forgets the row,<br/>dead-letter keeps it
    end
    Note over Sink: all retracts delivered or dead-lettered
    Sup->>Store: rewind checkpoint to from
    Sup->>Src: restart (replacement Matches)

One gap worth knowing about explicitly: if from plus journal_depth doesn’t reach back far enough to cover the checkpoint’s high-water mark, the rewind and restart still happen, but only the ids the journal actually retained get a Retracted. That gap is never silent: it’s counted (blockwatcher_journal_gap_total) and logged naming the network, the invalidate cursor, the checkpoint, and the configured depth. See The delivery journal.

A source whose cursor is not a chain position never produces Invalidated at all: there is no fork to detect. See Sources without a chain-position cursor.

What the guarantee actually is, by scenario

“At-least-once” is the contract everywhere, but what that means in practice (duplicates, gaps, and whether a consumer hears about it) differs by scenario:

ScenarioDuplicates possible?Gaps possible?Consumer notified?Operator action
Happy pathNoNoNoNone
Crash before checkpoint persistsYes (dedupe by MatchId)NoNoNone
Retry exhaustedNoNoYes (dead letter)Inspect and replay, or accept the loss
Throttle suppresses a matchNoNo (it’s a dead letter, not dropped)Yes (dead letter)Replay, or widen the window/budget
Aggregate buffer, clean flushNoNoNo (delivered as one Digest)None
Aggregate buffer, hard-cancel/crashYes (re-buffered, re-delivered)NoNoNone
Shallow reorgNoNoNo (retried in place)None
Deep reorg, proven forkYes (blocks at/below from re-emit)NoYes (Retracted per retained id)None, unless replay is desired
Deep reorg, unproven depth (>64 blocks)YesOnly for ids the journal no longer retainsPartial (only retained ids get Retracted)Check blockwatcher_journal_gap_total; treat un-retracted matches as suspect
Source without a chain-position cursor, restartNo (arrival counter resets)Yes (whatever was in flight is gone)NoNone available; this source has no resume guarantee by design

“Gaps possible” only ever appears where the journal’s bounded depth couldn’t reach far enough, or on the one source that never promised a resumable position in the first place. Every other row’s answer is “no” because of the same one rule: a checkpoint only advances once every match tied to it has either reached its sink or become a durably recorded dead letter, so anything a crash or a restart repeats was, by construction, never durably finished.

Configuration cookbook

Named starting points, not the only correct answer for every case: each maps a goal to the fields that get you there, with the reasoning so you can move off the suggested numbers with the right things in mind.

“I don’t want duplicate delivery noise from one burst of activity.”

Set aggregate with a window_ms wide enough to catch a typical burst and a max_batch above your usual burst size, so bursts land as one digest instead of many separate matches — HTTP batching of matches that already fired. Widening window_ms trades delivery latency for fewer messages; a buffered match holds the checkpoint for the whole window, so an unreasonably wide window (the cap is one day, 86_400_000 ms) is a real cost, not a free knob. If the burst should not fire at all until N predicate-true hits, that is the threshold-gate recipe below, not sink aggregate.

{
  "id": "alerts",
  "module": "webhook",
  "config": { "url_secret": "env:ALERTS_WEBHOOK_URL" },
  "aggregate": { "window_ms": 30000, "max_batch": 100 }
}

“Notify only if Transfer occurred more than 3 times in the last 60 minutes of block time, then start counting again.”

Set the monitor’s gate to threshold with count: 3 and window_ms: 3600000. Do not use sink aggregate for this: aggregate would mint a Match per Transfer and stall the checkpoint for the whole window.

{
  "gate": {
    "module": "threshold",
    "config": { "count": 3, "window_ms": 3600000 }
  }
}

“At most one webhook per hour of block time; I do not want the rest queued.”

Set gate to max_once. Do not use throttle if you intend those hits to vanish; throttle dead-letters them for replay.

{
  "gate": { "module": "max_once", "config": { "window_ms": 3600000 } }
}

“I want to bound how often a flaky or rate-limited downstream gets hit, without losing any match.”

Set throttle to the rate your downstream can actually sustain. Nothing is lost: everything the window refuses becomes a replayable dead letter, so you’re trading “deliver immediately” for “deliver on demand, in bulk, via POST /dead-letters/{id}/replay” rather than trading away completeness.

{
  "id": "alerts",
  "module": "webhook",
  "config": { "url_secret": "env:ALERTS_WEBHOOK_URL" },
  "throttle": { "max_deliveries": 60, "window_ms": 60000 }
}

“I need to know the alert reached the sink even through a real chain reorg, not just that it was attempted once.”

This is a confirmations and journal_depth question, not a sink-policy one. confirmations (default 12) is a latency-for-safety trade: a deeper value makes an already-emitted match’s retraction by a reorg less likely, at the cost of how quickly it reaches the sink at all. Because the tracker that proves a fork’s exact depth caps at 64 blocks regardless of confirmations, a journal_depth (default 1024, measured in cursor-primary units) that comfortably exceeds your confirmations value is what makes a proven-fork retract actually reach every affected match; a shallower journal risks the “unproven depth” row above. Confirmation depth is family-owned source.config; see the family page.

[engine]
journal_depth = 1024

“This monitor watches a source whose cursor is not a chain position and I need to understand what I’m giving up.”

That source has no resumable cursor and no reorg signal: a restart loses whatever was in flight, and identity is only stable within one process’s lifetime, so consumers must deduplicate on a payload field the family documents rather than MatchId. If the use case needs delivery guarantees, pick a source whose cursor is a chain position. See Sources without a chain-position cursor and the family page for which shipped sources that is.

“I want every dropped or suppressed event to show up somewhere I can alert on.”

Nothing here is ever silently dropped, and that’s true without any config, but the observable surface differs by scenario: throttle and retry-exhaustion both produce dead letters (poll GET /networks/{id}/dead-letters or alert on its growth); a deep reorg’s retracts are visible in the Retracted events your sinks receive; a journal gap is a named metric (blockwatcher_journal_gap_total). See Observability for the full metric list.

Chain-agnosticism

Every other concept page so far describes blockwatcher’s engine in terms that never mention a specific chain: cursors, matches, selectors, checkpoints. That is not a writing choice; it reflects an actual boundary drawn in the dependency graph and enforced by tooling, not by convention. This page covers the two halves of that boundary: the shape everything normalizes into on the way past a decoder, and the mechanism that keeps a chain SDK from ever crossing into the crates that don’t need one.

flowchart LR
    rpc["RPC endpoints<br/>(external chains)"]
    %% Source modules are family-owned; the running list is GET /catalog, not this map.
    sources["Sources<br/>one per network"]
    sinks["Sinks<br/>webhook · script · log"]
    storage["Storage<br/>checkpoints · dead letters · resources"]
    api["REST API"]
    metrics["Metrics"]
    engine["engine<br/>bounded channels · checkpoints"]

    subgraph pipeline["Engine pipeline"]
        direction LR
        decoder["Decoder"]
        matcher["Matcher<br/>predicates"]
        gate["Gate<br/>threshold · max_once"]
        decoder --> matcher
        matcher --> gate
    end

    rpc --> sources
    sources -->|"decode and match"| decoder
    gate --> sinks
    api -->|"manages resources"| storage
    storage <--> engine
    engine -->|"drives"| pipeline
    engine -.->|"reports"| metrics

    classDef module fill:none,stroke:#a9a3e3
    classDef core fill:none,stroke:#8a8d86,stroke-dasharray: 5 5

    class rpc,sources,sinks,decoder,matcher,gate module
    class engine,api,storage,metrics core
classDef dim fill:none,stroke:#999999,color:#999999,opacity:0.35
classDef focus fill:#ffd43b,stroke:#d9480f,stroke-width:3px,color:#1a1a1a
class rpc,sources,sinks,storage,api,metrics,engine,matcher,gate dim
class decoder focus
click sources "selectors.html"
click decoder "chain-agnosticism.html"
click matcher "predicates.html"
click gate "gates.html"
click sinks "delivery.html"
click storage "resources.html"
click api "../reference/http-api.html"
click metrics "../reference/observability.html"
click engine "pipeline.html"

Key takeaways

  • Every decoder must normalize chain-specific data into one canonical Value enum; nothing past the decoder (a matcher, a gate, a sink) ever needs to know which chain produced it.
  • Int/Uint are arbitrary-precision and Address is canonical bytes, so no chain’s values lose precision or collide with another chain’s.
  • blockwatcher-core depends only on the port trait signatures (Source, Decoder, Matcher, Gate, Sink, Storage); it never imports a chain SDK, RPC client, or storage driver directly.
  • check-dep-graph.sh enforces that boundary two ways: a per-crate allowlist of direct dependencies, and a transitive-tree denylist of chain/infra crate families.
  • A new chain family arrives as a module implementing Decoder and Source; every existing predicate, matcher, and sink works against it unmodified. Write-time spec fetch (SpecImporter) is optional and is not a seventh port.

One value model, every chain

crates/blockwatcher-types/src/value.rs defines the enum that a decoder is required to normalize into and that nothing past it (a matcher, a gate, a sink) ever has to unlearn:

#![allow(unused)]
fn main() {
pub enum Value {
    Null,
    Bool(bool),
    Int(BigInt),
    Uint(BigUint),
    Bytes(Vec<u8>),
    Address(Vec<u8>),
    Str(String),
    Array(Vec<Value>),
    Map(IndexMap<String, Value>),
}
}

(crates/blockwatcher-types/src/value.rs:30-48). Two design choices here carry the whole point of this page:

  • Int and Uint hold arbitrary-precision integers, not a fixed-width native type. A 256-bit token amount off an EVM chain has nowhere to go in a JSON number or an i64 without losing bits; a BigInt/BigUint has no such ceiling, so the value that leaves a decoder is bit-for-bit the value a sink eventually serializes, all the way out to the wire as a decimal string rather than a JSON number, per Value’s own doc comment and the decimal module’s serialization rules (value.rs). This is also why match ids hash Value trees directly, in MatchId::derive’s eat_value step (crates/blockwatcher-types/src/id.rs) rather than some chain-specific encoding of them: one representation, used identically by every consumer of a decoded event.
  • Everything else a chain family might invent still has to land in one of the shapes above. Address is canonical bytes rather than a chain’s native address type, and the decoder that produced those bytes is on the hook for keeping distinct address flavors from ever comparing equal: the doc comment on Value::Address calls out Stellar account versus contract addresses as the concrete case where two genuinely different values are the same byte width and would collide unless the decoder tags them apart (value.rs). The same discipline applies to Map keys: they are always String, so a chain whose native map keys aren’t strings needs an injective rendering rule from the decoder, because two distinct native keys landing on the same canonical string would make a predicate silently address the wrong field (value.rs). Canonicalization is squarely the decoder’s job: the model itself has no chain awareness to lean on.

block.timestamp is that same decoder vocabulary: unix seconds at that path on the canonical tree. A Stellar or Solana decoder maps close_time / blockTime onto it. A chain that cannot expose unix time cannot use time-window gates; the write is 422. The path is not an EVM-only leak.

Value::from_json (value.rs) is the one general-purpose constructor in this file, and it demonstrates part of the same rule from the other direction: JSON numbers that fit in u64/i64 become Uint/Int, and JSON null becomes Value::Null rather than being dropped or coerced into an empty string. A JSON number can’t carry a 256-bit token amount in the first place, so a decoder never gets there through from_json at all: it builds Value::Uint/Int directly from the chain’s own encoding (hex, decimal string, whatever the RPC returns) and reserves from_json for plain JSON-RPC payloads whose numbers already fit a native width. Numbers outside that range or non-integers passed to from_json fall back to Value::Str holding the literal text, so even that path never rounds through a lossy f64.

Core must not know chains

The value model is the shape of the boundary; the rule that nothing past the decoder needs to know which chain produced a value is the reason the boundary exists. blockwatcher states that rule as trait definitions (Source, Decoder, Matcher, Gate, Sink, Storage), all living in blockwatcher-ports, and blockwatcher-core, the crate that actually runs pipelines, tracks checkpoints, and serves the control API, depends on nothing but those trait signatures. It never imports an RPC client, a chain SDK, an ABI library, or a storage driver directly; every one of those lives one layer out, in a module crate that implements one port. crates/blockwatcher-evm is where an EVM-specific type ever appears in this codebase at all: nothing named alloy or ethers is reachable from blockwatcher-core’s own dependency list. crates/blockwatcher-stellar is the matching bound for Stellar: stellar-xdr and stellar-strkey live there, and nowhere in the core ring.

That separation is what a chain-agnostic decoder boundary actually buys: a chain family is a crate implementing Decoder and Source, translating its own wire format into the same Value tree above, and every existing predicate, matcher, gate, and sink works against it unmodified, because none of them were ever written against one chain’s types to begin with.

The direct edge a chain SDK would need to reach the core ring simply does not exist; the only path in is through a module’s decoder, onto the canonical value tree:

flowchart LR
    sdk["chain sdk<br/>e.g. alloy, stellar-xdr"] --> mod["module crate<br/>blockwatcher-evm, blockwatcher-stellar"]
    mod -->|"decode: normalize"| val["value tree<br/>blockwatcher-types"]
    val --> core["core ring<br/>blockwatcher-core, blockwatcher-ports, blockwatcher-api"]
    mod -.->|"forbidden edge,<br/>blocked by check-dep-graph.sh"| core

The CI gate that proves it

Stating a rule in a crate’s Cargo.toml dependency list is easy to state and easy to quietly violate one dependency at a time. scripts/check-dep-graph.sh exists because “blockwatcher-core has no chain code” is a claim worth re-checking on every commit rather than trusting once at review time, and it checks it with two independent mechanisms layered on top of each other rather than one.

The first mechanism is a per-crate allowlist of direct dependencies: every workspace crate has a hand-maintained list of exactly what it may declare in [dependencies], and anything a crate’s manifest names that isn’t on its own list fails the check outright (allowlist_for and check_direct in check-dep-graph.sh). blockwatcher-core’s list, for instance, names blockwatcher-types, blockwatcher-ports, serde, tokio, tracing, and a small handful more: no RPC crate, no storage driver, nothing chain-specific (ALLOW_BLOCKWATCHER_CORE in check-dep-graph.sh). Because the list is explicit rather than inferred, adding any new direct dependency to a core crate is a decision someone has to make in the script itself, not a side effect of running cargo add.

The second mechanism runs over the whole transitive dependency tree (cargo tree, not just the manifest) and checks it against a denylist of family-name prefixes that are never allowed to appear anywhere underneath a core crate: alloy, reqwest, sqlx, rusqlite, several chain SDKs, and more (CHAIN_AND_INFRA and check_transitive in check-dep-graph.sh). This is the check that catches what the allowlist alone cannot: a dependency the manifest never names directly, pulled in indirectly by something the allowlist did approve. A handful of module and binary crates that legitimately need one of these families get a narrow, per-crate, per-family exemption from this second net only: blockwatcher-evm is allowed to carry alloy because it is an EVM module and that is its entire purpose, blockwatcher-stellar is allowed to carry stellar-xdr for the same reason, but every family either crate wasn’t exempted for still applies in full, and the first net (the direct-dependency allowlist) is never relaxed by an exemption at all.

Running both nets together is deliberate, not redundant. Blocking specific names is inherently reactive: a maintainer has to have heard of a chain SDK before they can add it to a denylist, so anything released after the list was last updated walks straight through. Requiring explicit approval flips that around: a crate is rejected by default the moment it shows up unlisted, regardless of whether anyone had ever heard of it, chain-related or not. Layering the transitive scan on top closes the one gap an allowlist alone leaves: a dependency an approved crate quietly starts pulling in one level down, which a scan of direct manifests never sees. Together they mean a core crate cannot gain a chain (or storage, or HTTP) dependency by any route without the gate failing the build, whether that route is a manifest edit or a transitive version bump three crates away.

Modules and trade-offs

Every other concept page treats one specific behavior as swappable: a source in The pipeline, a matcher in Predicates. This page steps back and treats “swappable” itself as the organizing idea: what a module actually is, how one gets into a running binary, and what it means for an operator that every axis of behavior in blockwatcher is one.

flowchart LR
    rpc["RPC endpoints<br/>(external chains)"]
    %% Source modules are family-owned; the running list is GET /catalog, not this map.
    sources["Sources<br/>one per network"]
    sinks["Sinks<br/>webhook · script · log"]
    storage["Storage<br/>checkpoints · dead letters · resources"]
    api["REST API"]
    metrics["Metrics"]
    engine["engine<br/>bounded channels · checkpoints"]

    subgraph pipeline["Engine pipeline"]
        direction LR
        decoder["Decoder"]
        matcher["Matcher<br/>predicates"]
        gate["Gate<br/>threshold · max_once"]
        decoder --> matcher
        matcher --> gate
    end

    rpc --> sources
    sources -->|"decode and match"| decoder
    gate --> sinks
    api -->|"manages resources"| storage
    storage <--> engine
    engine -->|"drives"| pipeline
    engine -.->|"reports"| metrics

    classDef module fill:none,stroke:#a9a3e3
    classDef core fill:none,stroke:#8a8d86,stroke-dasharray: 5 5

    class rpc,sources,sinks,decoder,matcher,gate module
    class engine,api,storage,metrics core
classDef dim fill:none,stroke:#999999,color:#999999,opacity:0.35
classDef focus fill:#ffd43b,stroke:#d9480f,stroke-width:3px,color:#1a1a1a
class rpc,api,metrics,engine dim
class sources,decoder,matcher,gate,sinks,storage focus
click sources "selectors.html"
click decoder "chain-agnosticism.html"
click matcher "predicates.html"
click gate "gates.html"
click sinks "delivery.html"
click storage "resources.html"
click api "../reference/http-api.html"
click metrics "../reference/observability.html"
click engine "pipeline.html"

Key takeaways

  • blockwatcher has exactly six ports (Source, Decoder, Matcher, Gate, Sink, Storage), and a module is one concrete implementation of exactly one of them.
  • A module never straddles two ports, even when several modules live in the same crate, and configuration resolves a name against the running binary’s own module catalog.
  • Two modules behind the same port can trade off completeness against something else. That trade-off is a property of the module, documented on the module’s catalog row — chain-family rows live on the family page.
  • Switching modules is the operator’s actual lever for a trade-off: changing one module leaves every other port’s behavior untouched, since none of them are written against one module’s assumptions.

Everything an operator composes is a module

An blockwatcher deployment has exactly six axes along which behavior can differ: how it reads a chain, how it turns raw bytes into blockwatcher’s canonical shape, how it decides whether a decoded occurrence counts, when a predicate-true hit becomes a delivery, where a match goes, and where resources and operational state persist. Those six axes are the six ports blockwatcher defines (Source, Decoder, Matcher, Gate, Sink, Storage), and a module is, by definition, one concrete implementation of exactly one of them. Nothing in blockwatcher is configured by writing code against a bespoke integration point; every one of these six choices is made the same way, by naming a module in configuration and giving it whatever config object that module expects.

The six ports look like this. Chain-family sources and decoders are registered the same way; they are named on each family page, not here.

flowchart TD
    source["Source"]
    decoder["Decoder"]
    matcher["Matcher"] --> expr["expr"]
    gate["Gate"] --> threshold["threshold"]
    gate --> maxonce["max_once"]
    sink["Sink"] --> webhook["webhook"]
    sink --> script["script"]
    sink --> log["log"]
    storage["Storage"] --> memory["memory"]
    storage --> sqlite["sqlite"]
    storage --> postgres["postgres"]

    %% Layout only, no meaning: these invisible edges wrap the six ports
    %% into bands. Without them every port sits on one row, which renders
    %% too wide for the content column and shrinks the labels. Keep them.
    source ~~~ sink
    decoder ~~~ storage
    expr ~~~ maxonce

ModuleCatalog (crates/blockwatcher-core/src/catalog.rs) is where that naming resolves: one name-keyed map per port family, each holding factory functions folded in from every compiled-in module’s own get_all() enumeration. A config that names a module absent from the catalog (a typo, or a module simply not compiled into this build) fails at boot or at write time with EngineError::UnknownModule, listing every alternative that actually is registered, via unknown and the family! macro’s lookup arm (catalog.rs), rather than panicking or silently no-op’ing that pipeline stage. Because the list comes from the catalog itself, the message always names what this particular binary carries, never a superset the workspace merely contains somewhere.

One module, one port, one name

A module never straddles two ports. A chain-family crate ships one Decoder and one or more Source implementations as separate modules, each registered under its own name and each satisfying exactly one port’s trait, never blending source and decode logic into a single type. Both kinds of module fold through build_catalog (crates/blockwatcher-embed/src/catalog.rs), each get_all() enumeration separately. Which module handles which resource is itself fixed by the port: a network names its Source module, a spec’s chain determines its Decoder, a sink resource names its Sink module, blockwatcher.toml names the one Matcher and one Storage backend for the whole process. An operator never picks a module without also picking, structurally, which port it fills.

Trade-offs are a property of the module, not a setting

Because two modules behind the same port are interchangeable at the trait level, they are free to differ arbitrarily in the trade-offs they make. blockwatcher leans on that rather than trying to expose every axis as a tunable knob on one do-everything implementation. The clearest illustration is two sources behind Source: one that only reports activity once it has a fixed chain position (and can therefore resume after a crash), against one that reports earlier at the cost of a cursor that is not a chain position. That pair, and every other chain-family trade-off, is documented on the family page, not as a setting on a shared source. evm-subgraph is a third acquisition module on the EVM family page, not a second registration path and not a knob on that rpc/mempool pair: it polls a documented GraphQL logs schema and emits the same confirmed-block envelopes evm-rpc does, covering only what that subgraph indexed.

The same pattern of “trade-off lives in which module you picked, not in a shared config surface” carries across the other five ports as well: the webhook sink trades a network dependency for delivering anywhere HTTP reaches; the log sink trades reach for having none; sqlite storage trades a single-writer constraint for surviving a restart, memory storage trades restart survival for zero setup; threshold trades per-hit delivery for a session digest that does not stall the checkpoint; max_once trades later in-window hits for a single alert.

The module catalog

Every row in the matcher, gate, sink, and storage table below is a module registered into ModuleCatalog by one of the get_all() calls build_catalog (crates/blockwatcher-embed/src/catalog.rs) folds together. Chain-family sources and decoders live on the family page. The running binary’s full list is GET /catalog.

Each source module’s family-page row states two facts: whether it reports a confirmed tip, and which cold-start field it moves (if any). GET /catalog advertises the tip half as caps.confirmed_tip on each source object.

FamilyModuleConfirmed tipCold start
sourceevm-rpcyesstart_block
sourceevm-subgraphyesstart_block
sourceevm-mempoolnonone
sourcestellar-rpcnostart_ledger

The table below is exhaustive for matcher, gate, sink, and storage modules: every such module compiled into this binary appears here, and every row names something a real registration call actually produces.

FamilyModuleRegistered inTrade-off
matcherexprcrates/blockwatcher-expr/src/matcher.rsThe only predicate engine shipped; selected once for the whole process rather than per monitor, trading per-monitor flexibility for one well-tested evaluation path.
gatethresholdcrates/blockwatcher-gates/Session digest: N hits spanning ≤ window_ms of event time fire once, then the bag resets. Holds persist without stalling the checkpoint. Requires block.timestamp.
gatemax_oncecrates/blockwatcher-gates/At most one alert per event-time window; later hits discarded, not dead-lettered. Requires block.timestamp.
sinkwebhookcrates/blockwatcher-sinks/src/webhook.rs (enumerated crates/blockwatcher-sinks/src/registry.rs)Reaches anywhere HTTP does, at the cost of a network dependency and a target that must itself stay reachable and fast enough not to trip the engine’s retry/dead-letter policy. url_secret and, for a header such as Authorization, header_secrets carry destination credentials as env:NAME references resolved per delivery rather than stored plaintext; headers stays for values that are not secret, and one header name cannot appear in both maps.
sinkscriptcrates/blockwatcher-sinks/src/script.rs (enumerated registry.rs)Hands a match to an operator-authored program over stdin (arbitrary local logic), at the cost of owning that program’s own reliability and timeout behavior.
sinklogcrates/blockwatcher-sinks/src/log.rs (enumerated registry.rs)Zero external dependency, one JSON line per match to stdout: the simplest possible delivery target, useful for piping and testing rather than production reach.
storagememorycrates/blockwatcher-storage/src/memory.rs (enumerated crates/blockwatcher-storage/src/registry.rs)No persistence at all: trades restart survival for zero setup, since checkpoints, dead letters, and resources are all gone the moment the process exits.
storagesqlitecrates/blockwatcher-storage/src/sqlite.rs (enumerated registry.rs)Single-file, single-writer, one process: survives a restart, at the cost of the concurrent-writer scaling a networked database would offer instead.
storagepostgrescrates/blockwatcher-storage/src/postgres/ (enumerated registry.rs, feature postgres)Networked, multi-writer durable state so an engine can restart or idle without a local file, at the cost of an external server and a feature-gated binary (off in the archive default).

Omit-gate is engine passthrough, not a catalog row an operator must name. Matcher is still one module for the whole process. Gate is per monitor, like sink.

Module selection is by name in configuration; a name the running binary never registered fails loudly, with the list of names it actually did register, rather than silently doing nothing.

Why this is the operator’s actual lever

Because a trade-off lives inside the module rather than in a shared knob, “choose your trade-offs” and “choose your modules” are the same action for an operator. Wanting a different point on a family’s latency-vs-completeness trade-off means naming a different source module on the network, not flipping a setting on one source that tries to serve both goals at once. The six-port boundary is what makes that swap safe to make at all: switching sources changes nothing about how the decoder, the matcher, a gate, or any configured sink behaves, because none of them were ever written against one source’s assumptions to begin with.

Guides & reference

This part covers running and operating a deployed instance: the configuration surface, the HTTP API and observability endpoints, a Docker deployment, four fully worked examples, a symptom-first troubleshooting index, and a benchmark baseline. It is for an operator or integrator working against a running blockwatcher, not for someone deciding whether to adopt it.

flowchart LR
    rpc["RPC endpoints<br/>(external chains)"]
    %% Source modules are family-owned; the running list is GET /catalog, not this map.
    sources["Sources<br/>one per network"]
    sinks["Sinks<br/>webhook · script · log"]
    storage["Storage<br/>checkpoints · dead letters · resources"]
    api["REST API"]
    metrics["Metrics"]
    engine["engine<br/>bounded channels · checkpoints"]

    subgraph pipeline["Engine pipeline"]
        direction LR
        decoder["Decoder"]
        matcher["Matcher<br/>predicates"]
        gate["Gate<br/>threshold · max_once"]
        decoder --> matcher
        matcher --> gate
    end

    rpc --> sources
    sources -->|"decode and match"| decoder
    gate --> sinks
    api -->|"manages resources"| storage
    storage <--> engine
    engine -->|"drives"| pipeline
    engine -.->|"reports"| metrics

    classDef module fill:none,stroke:#a9a3e3
    classDef core fill:none,stroke:#8a8d86,stroke-dasharray: 5 5

    class rpc,sources,sinks,decoder,matcher,gate module
    class engine,api,storage,metrics core
classDef dim fill:none,stroke:#999999,color:#999999,opacity:0.35
classDef focus fill:#ffd43b,stroke:#d9480f,stroke-width:3px,color:#1a1a1a
class rpc,sources,sinks,storage,engine,decoder,matcher,gate dim
class api,metrics focus
click sources "../concepts/selectors.html"
click decoder "../concepts/chain-agnosticism.html"
click matcher "../concepts/predicates.html"
click gate "../concepts/gates.html"
click sinks "../concepts/delivery.html"
click storage "../concepts/resources.html"
click api "http-api.html"
click metrics "observability.html"
click engine "../concepts/pipeline.html"

Key takeaways

  • This part is for operating a running deployment, not for deciding whether to adopt blockwatcher.

  • Configuration and the HTTP API cover how an instance is configured and controlled; observability covers how to watch it run.

  • The EVM worked examples build the same monitor against confirmed blocks over JSON-RPC, against a deployed logs subgraph, and against pending transactions. The Stellar example watches classic payments and native SAC transfers on Testnet.

  • Troubleshooting indexes failure modes by symptom rather than by component.

  • Configuration: the instance configuration file and its environment overrides.

  • Resource reference: every resource kind’s fields, defaults, and write-time refusals, one page per kind.

  • HTTP API: the REST control plane.

  • Observability: the status endpoint and the Prometheus metrics.

  • Running with Docker: the Compose stack and its own configuration.

  • Monitoring ERC-20 transfers [RPC]: a worked example against confirmed blocks.

  • Monitoring ERC-20 transfers [Mempool]: the same example against pending transactions instead.

  • Monitoring ERC-20 transfers [Subgraph]: the same confirmed Transfer monitor, polling a GraphQL logs subgraph instead of eth_getLogs.

  • Monitoring Stellar Testnet [RPC]: classic native XLM payments, native SAC transfer events, and transfer invocations against closed ledgers.

  • Troubleshooting: a symptom-first index into failure modes and their fixes.

  • Benchmarks: what’s measured, how to reproduce it, and the baseline results.

Configuration reference

blockwatcher boots from exactly one instance configuration: an optional TOML file layered with BLOCKWATCHER_* environment overrides, read once at process start and immutable for the rest of the run (crates/blockwatcher/src/config.rs). It covers only the process’s own plumbing: the API listener, the metrics listener, the storage backend, and the engine’s runtime tunables. Everything else (networks, specs, sinks, and monitors) is resource configuration: storage-backed, mutated through the HTTP API or loaded once from a seed directory, and never read from blockwatcher.toml.

InstanceConfig (crates/blockwatcher/src/config.rs) and every section inside it reject an unrecognized field (#[serde(deny_unknown_fields)]), so a typo’d key fails the boot rather than being silently ignored. Every section also defaults, so an empty file (or no file at all, given at least one recognized environment override) boots a working instance: an in-memory store, no API, no metrics.

Loading a configuration

blockwatcher [--config <path>] [--seed <dir>]

Three layers, each one able to override the layer before it:

flowchart LR
    def["InstanceConfig::default()"] --> file["--config file<br/>TOML, if given"]
    file --> env["BLOCKWATCHER_* env vars<br/>win over both"]
    env --> final["final InstanceConfig"]

load (config.rs) builds the configuration in three layers:

  1. InstanceConfig::default(): every section’s own default.
  2. The TOML file at --config <path>, if given. A file that fails to read or parse refuses the boot naming the path, never the offending value.
  3. BLOCKWATCHER_* environment variables, which win over both of the above.

Nested keys use a double underscore: BLOCKWATCHER_API__LISTEN, BLOCKWATCHER_STORAGE__MODULE. Only variables whose name contains __ after the BLOCKWATCHER_ prefix are read as overrides (env_provider, config.rs): a variable such as BLOCKWATCHER_API_TOKEN, the environment variable a token’s secret = "env:BLOCKWATCHER_API_TOKEN" reference names, has no __ in it and is never mistaken for a config key. The token table itself is file-based: Figment numbered env keys cannot spell a sequence, so [[auth.tokens]] rows are not overridden from the environment.

Two aliases exist for one deprecation cycle: BLOCKWATCHER_API_LISTEN and BLOCKWATCHER_METRICS_LISTEN, equivalent to BLOCKWATCHER_API__LISTEN and BLOCKWATCHER_METRICS__LISTEN respectively, applied after everything else so they still win when both an alias and its nested form are set (apply_listen_aliases, config.rs).

Neither a --config path nor a single recognized BLOCKWATCHER_* override is optional: at least one must be present, or the boot refuses with:

no configuration source: provide --config <path> or set BLOCKWATCHER_* variables
(e.g. BLOCKWATCHER_STORAGE__MODULE, BLOCKWATCHER_API__LISTEN)

This is deliberate: an empty file and “no configuration at all” would otherwise both boot the same all-defaults instance, and the second of those is far more likely to be a mistake than an intent.

[api]

The REST control plane. Disabled by default: a listener that appears without an operator having asked for one is a listener nobody remembered to put a token on.

KeyTypeDefaultMeaning
enabledboolfalseServes the HTTP API on listen when true.
listenstring (socket address)"127.0.0.1:8080"Address the API listener binds. Parsed at boot; a value that doesn’t parse as a socket address refuses naming [api].listen.

GET /health on this listener is the only path that answers without a bearer token. Compose healthchecks curl it with no header. Every other path requires a token from [auth].

[auth]

The labelled bearer table. Required (and must be non-empty) when [api].enabled is true. Each row is one credential: a label audit records name, a scope (read, operate, or admin), and a secret that is an env:NAME reference, never a literal. Boot refuses an empty table, two rows that resolve to the same secret (naming the labels, not the value), an unresolvable reference, or a secret that is not of the env:NAME form.

read < operate < admin. The minimum is attached per MethodRouter, not by path prefix: GET of a resource is read; pause/resume/replay are operate; PUT/DELETE of networks, specs, sinks, and monitors are admin. Creating a script sink is therefore not the same privilege as reading the dashboard.

KeyTypeDefaultMeaning
tokensarray of { label, scope, secret }[]The table. Empty when the API is disabled; refused when the API is enabled.

[metrics]

The Prometheus scrape listener. Disabled by default for the same reason as [api].

KeyTypeDefaultMeaning
enabledboolfalseServes /metrics on listen when true.
listenstring (socket address)"127.0.0.1:9090"Address the metrics listener binds. Parsed the same way as [api].listen.

[storage]

Which storage module backs every resource and every checkpoint and dead letter. This section is a ModuleSel (a module name plus that module’s own opaque config object), the same envelope every other module family uses (crates/blockwatcher-types/src/resource.rs).

KeyTypeDefaultMeaning
modulestring"memory"Which storage module to construct.
configobject{}The named module’s own config. Shape depends on module.

blockwatcher ships memory, sqlite, and postgres storage modules. memory takes no config at all: it persists nothing, which is the floor that lets an instance boot with zero setup and never a durable deployment.

sqlite

Persists to a single file. One writer at a time; the lock is held by the process, so a second engine pointed at the same file blocks on writes.

KeyTypeDefaultMeaning
pathstringnone (required)The database file, or the literal ":memory:" for a database that dies with the process (fine for tests, not for anything meant to survive a restart).
busy_timeout_msu645000How long a call waits on a lock an external reader holds before giving up.

postgres

Persists to a PostgreSQL database rather than to local disk, which is what lets an engine be restarted, replaced, or scaled without losing recorded state. Several engines may share one database provided they own disjoint pipelines; against a second engine writing the same rows, the only guarantee this backend holds is the resource version check. Requires a binary built with the postgres feature, which is off by default: the published blockwatcher image is built with it, while the release archives stay on default features, so an archive binary refuses module = "postgres" at boot with unknown storage module 'postgres'; available: [memory, sqlite].

KeyTypeDefaultMeaning
url_secretstringnone (required)A secret reference (env:NAME) naming the variable that holds the libpq connection URL, never the URL itself: it carries the database password. The URL takes the form postgres://user:pass@host:port/database?options=-csearch_path%3Dschema, whose options=-csearch_path%3D<name> parameter selects the schema; omit that parameter to use public. A literal URL here is refused at boot with the reference form, and the refusal never echoes what was configured.
max_connectionsu3210Connection pool size.

The tables are created automatically on first connect by an embedded migration. The schema is not: nothing in blockwatcher issues CREATE SCHEMA, so a named schema must already exist before an engine points at it. Against a schema that does not, the first connect fails with no schema has been selected to create in — create it (CREATE SCHEMA <name>) as part of provisioning the deployment, before starting the engine.

The connection URL selects the schema through its options=-csearch_path%3D<name> parameter (the %3D is a URL-encoded =), so two engines pointed at the same URL share one set of tables. Separating them requires separate schemas or separate databases. One URL per deployed environment is the expected topology.

TLS is opt-in and unverified by default. The parameters below belong on the URL the referenced variable holds. An unadorned URL gets sqlx’s sslmode=prefer, which attempts TLS, falls back to plaintext without complaint if the server declines, and does not verify the certificate when it succeeds. Encryption needs sslmode=require; an authenticated server needs sslmode=verify-full, which is the only mode that both verifies the chain and checks the hostname.

Verification trusts the Mozilla root set compiled into the binary (webpki-roots), not the operating system’s. Adding a CA to the host trust store therefore has no effect: a server signed by a private CA — Amazon RDS among them, whose CAs are not in the Mozilla set — needs that CA named on the URL as sslrootcert=/path/to/ca.pem, which is added to the built-in roots rather than replacing them.

postgres://user:pass@host:5432/db?sslmode=verify-full&sslrootcert=/etc/ssl/rds-ca.pem

[engine]

The core pipeline’s own tunables, as an operator writes them (EngineSection, crates/blockwatcher/src/config.rs). Every field here is optional; an unset one is left out of the value core’s own deserializer sees (engine_config, config.rs), so the default that applies is whatever blockwatcher-core’s EngineConfig itself defaults to. This binary never restates a number core owns. matcher is the one field this rule doesn’t quite cover: core has no sane default matcher to fall back to on its own, so when this key is unset the binary substitutes the matcher module it was compiled with (expr, when the expr feature is on) rather than leaving the boot to fail on a genuinely absent value.

KeyTypeConfig default (unset)Meaning
event_channel_capacityusize256Bound on the channel between a network’s source and its decode-and-match stage.
sink_channel_capacityusize64Bound on the channel feeding each sink worker.
drain_deadline_msu6410000How long a shutdown waits for in-flight matches to finish delivering before aborting the pipeline (exit code 2).
journal_depthu641024How many cursor-primary units of delivered Match ids the delivery journal retains. Always on; there is no disable switch. Must sit well above the source’s confirmation window so a deep invalidate can still retract by id. Gaps past the window are counted, never silent.
dead_letter_retentionu64, optionalunset (None)How many dead letters one network keeps. Omitting the key keeps every letter, which grows without bound against a sink that keeps failing. A positive value is the cap: recording a letter drops the oldest beyond it in the same write, counted as blockwatcher_dead_letters_pruned_total and logged — a dropped letter can no longer be listed or replayed. 0 is refused at boot: zero is not a spelling of unbounded. A cap above i64::MAX is also refused.
default_retrytable, see belowsee belowDelivery retry policy used by any sink that doesn’t set its own retry.
source_restarttable, see belowsee belowBackoff policy for restarting a pipeline whose source exits on its own.
matchertable ({ module, config })compiled-in matcherWhich matcher module evaluates every predicate. Selected once for the whole instance, never per monitor.

default_retry is the same DeliveryRetry shape a SinkDef’s own retry field carries, validated at boot the same way a sink’s own retry override is validated at write time and at boot:

KeyTypeDefaultMeaning
max_attemptsu328Total delivery attempts, not retries after the first: 1 delivers once and dead-letters on failure; 0 is treated as 1.
initial_backoff_msu64200Delay before the second attempt. Above max_backoff_ms refuses: default_retry.initial_backoff_ms (30001) must be at most default_retry.max_backoff_ms (30000).
max_backoff_msu6430000Cap on the doubling backoff between attempts.

source_restart governs the supervisor that restarts a pipeline whose source task exits before its own cancellation fires, validated at boot on the same terms:

KeyTypeDefaultMeaning
initial_backoff_msu64500Delay before the first restart attempt. Above max_backoff_ms refuses: source_restart.initial_backoff_ms (60001) must be at most source_restart.max_backoff_ms (60000).
max_backoff_msu6460000Cap on the doubling backoff between consecutive restarts.
reset_after_msu64300000How long a network must run without exiting again before its consecutive-exit count resets to zero.

An invalid default_retry, source_restart, or dead_letter_retention fails Engine::start with InvalidEngineConfig, naming the offending field: process config, not a stored resource, so it carries no resource kind or id the way a rejected sink policy does.

matcher is a ModuleSel like [storage]: a module name and that module’s own config object. The shipped expr module takes no config.

Secrets: the env: indirection

A token’s secret is never a literal token: it is a reference of the form "env:NAME", parsed by SecretRef (crates/blockwatcher-types/src/secret.rs) and resolved by reading the named environment variable at the moment it’s needed, never cached and never logged. A value that isn’t of that form refuses the boot naming the required shape and never echoing what was actually written, because the likeliest mistake feeding this key is pasting the real token where a reference belongs. AuthTokenEntry’s own Debug implementation is hand-written for the same reason: it renders secret as <redacted> whenever one is set, so a panic message or a boot-failure log line can never carry it (config.rs). When the API is enabled, boot also refuses a label that is empty, a label used by more than one row, and a reference that resolves to an empty string: audit names the label, and an empty secret would start a listener that can never authenticate.

This indirection isn’t unique to the token table: it’s the general shape blockwatcher uses anywhere a secret has to live in a config object: a webhook sink’s url_secret field, and a network’s endpoint URLs, are all env:NAME references resolved the same way, per use, by the same SecretRef type. The instance config surfaces those fields on [[auth.tokens]], because they are the secrets this file itself carries; every other env: reference lives in resource configuration (network, sink), set through the API or a seed directory rather than blockwatcher.toml.

CLI flags

Parsed by hand, not through a flag-parsing crate, in parse (crates/blockwatcher/src/cli.rs):

FlagApplies toMeaning
--config <path>blockwatcher [run], prune-checkpointsInstance configuration TOML. Optional for the default run command when BLOCKWATCHER_* env vars supply the whole instance config; required for prune-checkpoints.
--seed <dir>blockwatcher [run]Seeds resources from <dir> on a first boot only: a store that already holds any resource is left untouched.
--dry-runprune-checkpointsLists orphaned checkpoint rows without deleting them.
--network <path>spec-importNetwork JSON whose chain selects the importer.
--address <addr>spec-importContract C-strkey to fetch.
--id <id>spec-importId written on the resulting spec.
--out <path>spec-importWrite spec JSON to <path> instead of stdout.

Four commands, no explicit subcommand name for the default one:

blockwatcher [--config <path>] [--seed <dir>]      # boot and run
blockwatcher check <dir>                            # validate a seed directory
blockwatcher prune-checkpoints --config <path> [--dry-run]
blockwatcher spec-import --network <path> --address <C...> --id <id> [--out <path>]
blockwatcher --help      # or -h
blockwatcher --version   # or -V

check <dir> validates a seed directory against the modules actually compiled into the binary and constructs every one of them, so every secret a seed’s configs reference through env:NAME must be present in check’s own environment, exactly as it would need to be at a real boot. prune-checkpoints deletes checkpoint rows whose network resource is gone; deleting a network deliberately leaves its checkpoint behind so a re-created id can resume, and this command is the offline sweep for orphans that will never come back. spec-import fetches a spec payload from the network file’s chain and writes a normal {id, chain, payload} spec JSON that check would accept. It does not start the monitor. --out omitted prints that JSON on stdout; diagnostics stay on stderr. A build without the stellar feature still parses the command; at run it reads the network file and refuses with unsupported chain '…'; loaded: [] when that chain has no importer.

Exit codes (cli.rs):

CodeMeaning
0A clean drain (or a stop during boot), or a check that passed.
1A configuration, seed, or boot failure, or a check that refused.
2A shutdown that aborted at least one pipeline at the drain deadline.
64A command line this binary could not parse.

RUST_LOG sets the log filter; unset, it defaults to info rather than silence, so the shutdown report, seeding warnings, and restart notices are still visible by default (init_tracing, crates/blockwatcher/src/lib.rs). Every diagnostic goes to stderr, never stdout. Stdout, when running with no [api] at all, is reserved for sink-event JSON (tagged type: match or type: retracted).

Full annotated example

# blockwatcher instance configuration. Resources (networks, specs, sinks,
# monitors) are managed through the HTTP API or a --seed directory, never
# through this file.

[api]
enabled = true
listen = "0.0.0.0:8080"

[[auth.tokens]]
label = "operator"
scope = "admin"
# A reference, never the token itself: resolved from the environment at
# each request's auth check.
secret = "env:BLOCKWATCHER_API_TOKEN"

[metrics]
enabled = true
listen = "0.0.0.0:9090"

[storage]
module = "sqlite"
config = { path = "/data/blockwatcher.db", busy_timeout_ms = 5000 }
# or for postgres (requires the postgres feature enabled at build time):
# module = "postgres"
# config = { url_secret = "env:DATABASE_URL", max_connections = 10 }

[engine]
event_channel_capacity = 256
sink_channel_capacity = 64
drain_deadline_ms = 10000
journal_depth = 1024
# omit dead_letter_retention to keep every letter; a positive value is the cap
default_retry = { max_attempts = 8, initial_backoff_ms = 200, max_backoff_ms = 30000 }
source_restart = { initial_backoff_ms = 500, max_backoff_ms = 60000, reset_after_ms = 300000 }
matcher = { module = "expr", config = {} }

Every value shown above under [engine] is also that key’s own default: the section could be omitted entirely and the instance would boot identically, apart from matcher, which still resolves to expr as long as the binary was built with that feature.

Resource reference

Everything blockwatcher watches and everywhere it delivers to is one of four resource kinds, each its own Rust struct in crates/blockwatcher-types/src/resource.rs. This section is the field-level reference for an operator writing one: every key, its type, its default, and the exact refusal a bad value gets. What the kinds mean and how they relate lives on Resources; this section owns the tables.

One page per kind:

  • Network: a feed to watch. Source config is module-owned; see the family page.
  • Spec: a chain-tagged decode artifact. Payload shape is decoder-owned; see the family page.
  • Sink: a delivery destination and its engine-owned policies, including the webhook, script, and log module configs.
  • Monitor: the rule tying the other three together, optionally gated.

The two configuration planes

blockwatcher splits configuration by lifetime, not by topic:

  • Instance configuration (blockwatcher.toml plus BLOCKWATCHER_* environment overrides): the process’s own plumbing, read once at boot and immutable for the run. See the Configuration reference.
  • Resource configuration (this section): networks, specs, sinks, and monitors, stored in the storage backend, mutated through the HTTP API or loaded once from a seed directory, and never read from blockwatcher.toml.

Write routes

Every kind answers on the identical CRUD shape (crates/blockwatcher-api/src/routes/resources.rs):

MethodPathMeaning
PUT/networks/{id}, /specs/{id}, /sinks/{id}, /monitors/{id}Create (no If-Match) or update (If-Match: "<version>").
GETsame item paths, plus the bare collection pathsRead one (with its version in ETag) or list all.
DELETEsame item pathsDelete; If-Match is mandatory.

Optimistic concurrency runs on ETag / If-Match:

  • A PUT with no If-Match is a create: 201 with the new version in ETag, or 409 already_exists if the id is already there.
  • A PUT with If-Match: "<version>" is a conditional update: 200 on success, 412 version_conflict (the body carries actual_version) if the version has moved on, 404 not_found if the record is gone.
  • A DELETE without If-Match is 428 precondition_required.

The full route contract, including error bodies and the If-Match format, is on the HTTP API reference.

Seed directories

The binary’s --seed <dir> flag loads resources once, at first boot only (crates/blockwatcher/src/seed.rs):

  • The directory holds exactly one subdirectory per kind: networks/, specs/, sinks/, monitors/, one JSON file per resource.
  • The bundle runs through the same validation Engine::start itself runs, then writes with create-only semantics.
  • Once storage holds any resource of any kind, seeding is refused as a no-op on every later boot: after the first boot, resources are managed exclusively through the API.
  • blockwatcher check <dir> validates a seed directory offline, against the modules compiled into the binary, constructing every one of them; every env:NAME secret the configs reference must be present in check’s own environment.

What a write does to a running pipeline

Hot-reload granularity differs by kind, per crates/blockwatcher-core/src/control/writes.rs and, for the delete noted below, crates/blockwatcher-core/src/control/deletes.rs:

KindEffect of a write
MonitorHot-swap: the running pipeline receives a recompiled monitor set with no restart. Two exceptions restart that one network: a write that changes gate, whose previous envelope’s holds are dropped with the pipeline stopped and the pipeline then brought back (a delete of a gated monitor likewise), and a monitor that names a sink the pipeline never spawned a worker for.
NetworkAlways restarts that network’s pipeline; it resumes from the checkpoint the drain left behind.
SinkRestarts every network whose stored monitors name that sink id.
SpecRestarts every network on the spec’s chain, and on a chain reassignment its prior chain too, because every spec sharing a chain compiles together.

The mechanics, including what a failed fan-out restart does, are on Resources § Lifecycle.

Validation and deletes

Every write validates before it reaches storage, and every validation rejection surfaces on the wire as 422 Unprocessable Entity; the per-kind checks are on Resources § Write-time validation. The refusal messages quoted throughout this section are those checks’ actual output.

A DELETE of a network, spec, or sink refuses outright while any stored monitor still references the id, per refuse_if_referenced (crates/blockwatcher-core/src/control/deletes.rs): delete the monitors first.

Network

A network names one feed to watch: an id, a chain tag, and a source module selection (Network, crates/blockwatcher-types/src/resource.rs). It is written through PUT /networks/{id} on the HTTP API or as one JSON file under a seed directory’s networks/ subdirectory. Writing a network always restarts that network’s pipeline; the restart is cheap because the checkpoint survives it, per Resources § Lifecycle.

A complete network resource. The example below is an EVM evm-rpc network; other families’ source.config shapes are on the family page. The source.config object is registry_examples/evm_rpc.json from crates/blockwatcher-evm/src/, copied verbatim from the same file the crate’s family-completeness test constructs (the resource envelope around it precludes a literal include); any change to that file updates this example in the same change, per the wiki-parity rule:

{
  "id": "eth-mainnet",
  "chain": "evm",
  "source": {
    "module": "evm-rpc",
    "config": {
      "start_block": 18000000,
      "endpoints": [
        {
          "name": "alchemy",
          "url_secret": "env:BLOCKWATCHER_EXAMPLE_EVM_RPC_URL",
          "priority": "high",
          "rate_limit": { "rps": 25 }
        },
        {
          "name": "public",
          "url_secret": "env:BLOCKWATCHER_EXAMPLE_EVM_RPC_URL_FALLBACK",
          "priority": "low"
        }
      ],
      "confirmations": 12
    }
  }
}

Fields

Network rejects an unrecognized field (#[serde(deny_unknown_fields)]), so a typo’d key fails the write rather than being silently ignored.

KeyTypeDefaultMeaning
idstringnone (required)The network’s name, referenced by every monitor’s network field.
chainstringnone (required)The chain family tag. The write refuses a chain with no loaded decoder.
sourceobjectnone (required)A ModuleSel: which source module feeds this network, plus that module’s own config.

source is the universal module envelope (ModuleSel, resource.rs):

KeyTypeDefaultMeaning
modulestringnone (required)The source module’s name, as listed by GET /catalog.
configobjectnone (required)The named module’s own config. Its shape is family-owned; see the family page.

Write-time validation constructs the named module with the given config, so every refusal quoted below is raised at the write (as a 422) or at seed validation, never discovered later at runtime. Updating an existing network additionally recompiles every monitor already stored for it against the incoming chain, refusing a chain reassignment that would strand them, per put_network (crates/blockwatcher-core/src/control/writes.rs).

Family-owned source.config field tables, including each family’s endpoint vocabulary, live on that family’s page under Chain families.

Spec

A spec is a chain-tagged decode artifact: an id, a chain tag, and a payload that core never interprets (Spec, crates/blockwatcher-types/src/resource.rs). It is written through PUT /specs/{id} on the HTTP API, as one JSON file under a seed directory’s specs/ subdirectory, or — for a stellar chain — produced at write time by POST /specs/{id}/from-chain or blockwatcher spec-import (see How a stellar spec is produced). The write itself compiles the payload (compile_spec on the decoder that claims the spec’s chain), so every refusal quoted below is raised at that write, as a 422, never later; the same compilation also reruns at boot and on pipeline restart, against payloads the write already proved valid. Writing a spec restarts every network on the spec’s chain, and on a chain reassignment its prior chain too, because every spec sharing a chain compiles together, per Resources § Lifecycle.

A complete spec resource. The example below is an EVM Solidity ABI; other families’ payload shapes are on the family page. The payload is the two-declaration ERC-20 ABI the decoder’s own tests compile (erc20_spec_with_function, crates/blockwatcher-evm/src/decoder/mod.rs):

{
  "id": "erc20",
  "chain": "evm",
  "payload": [
    {
      "type": "event",
      "name": "Transfer",
      "anonymous": false,
      "inputs": [
        { "name": "from", "type": "address", "indexed": true },
        { "name": "to", "type": "address", "indexed": true },
        { "name": "value", "type": "uint256", "indexed": false }
      ]
    },
    {
      "type": "function",
      "name": "transfer",
      "stateMutability": "nonpayable",
      "inputs": [
        { "name": "to", "type": "address" },
        { "name": "amount", "type": "uint256" }
      ],
      "outputs": [{ "name": "", "type": "bool" }]
    }
  ]
}

Fields

Spec rejects an unrecognized field (#[serde(deny_unknown_fields)]).

KeyTypeDefaultMeaning
idstringnone (required)The spec’s name, referenced by a monitor selector’s spec field.
chainstringnone (required)The chain family whose decoder compiles the payload. The write refuses a chain with no loaded decoder; shipped builds load evm and stellar.
payloadJSONnone (required)The chain’s own decode artifact, opaque to core. For evm: a Solidity JSON ABI array, per the contract below. For stellar: a catalog object, a WASM blob, or XDR-JSON, per Stellar.

How a stellar spec is produced

A stellar spec payload is one of { "catalog": "classic" }, { "catalog": "stellar-asset" }, { "wasm": "<base64>" }, or XDR-JSON of ScSpecEntry. Operators can write that payload themselves on PUT /specs/{id} or in a seed file. They can also fetch it at write time:

  • POST /specs/{id}/from-chain with { "network", "address" } — Admin, same If-Match / ETag rules as PUT, empty body on success; GET reads the stored pin. See HTTP API.
  • blockwatcher spec-import --network <path> --address <C...> --id <id> [--out <path>] — writes a {id, chain, payload} JSON file check would accept. --out omitted prints that JSON on stdout.

Both paths use the network’s chain and the network’s stellar-rpc source config. A Stellar Asset Contract instance materializes to { "catalog": "stellar-asset" }; a WASM contract materializes to { "wasm": "<base64>" } that compile_spec accepts. Account (G) addresses are refused. An RPC failure names endpoint names, never a URL, and writes nothing. The decoder does not fetch.

The evm payload

The evm decoder parses payload as a Solidity JSON ABI array. A payload that does not parse refuses with spec '<id>': payload is not a JSON ABI array: ..., and a spec whose chain does not match the decoder is refused as an unsupported chain.

What the ABI’s declarations become:

  • Events: every named event overload becomes a schema and a decode plan keyed by the keccak256 hash of its signature (the log’s first topic).
  • Functions: every function overload becomes a schema and a decode plan keyed by the 4-byte selector its calldata leads with.

A monitor’s selector then names these declarations by name; see Monitor for the selector keys and Selectors for what each kind decodes.

What the decoder rejects

All of these refuse the write before a CompiledSpec is ever constructed:

  • An anonymous event: spec '<id>' event '<name>' is anonymous: this decoder identifies events by the keccak256 hash of their signature in the log's first topic, which an anonymous event's log never carries, so it could never be matched at decode time.
  • Two events whose selectors collide: spec '<id>': events '<a>' and '<b>' both hash to the same selector; a decoder cannot tell them apart at decode time, so only one may be declared. Two colliding functions refuse with the same message shape.
  • A payload declaring nothing: spec '<id>' declares zero events and zero functions, so no selector against it could ever match anything.
  • A tuple parameter with no components: tuple field '<name>' has no components, so it would disappear from the schema instead of declaring anything; give it at least one component or remove it from the ABI.
  • Two components of one tuple resolving to the same name: tuple field '<name>': the component at position <n> is named '<x>', which another component of the same tuple already carries; give each component in the tuple a distinct name. When the colliding component is unnamed, the message instead reads ... the unnamed component at position <n> would default to '_<n>', which another component of the same tuple already carries; rename that other field to something other than '_<n>'.

The dotted-name flattening contract

Field names in the compiled schema come from the ABI’s parameters, flattened per flatten_field (crates/blockwatcher-evm/src/decoder/compile.rs):

  • A bare tuple parameter flattens into one field per leaf, dotted by component name (name.component, recursively through nested tuples).
  • An unnamed tuple component is named _<position> (_0, _1, …).
  • Every other shape is one field. An array of tuples (tuple[]) never flattens: it becomes a single array-of-map field, so a predicate can address it as a whole but never a named component inside one element.

Each leaf’s ABI type maps to a canonical value family (address, unsigned or signed integer, bytes, bool, string, array, map); how a predicate types against those families is on Predicates § The type system.

The namespaces every evm spec carries

Alongside its declared events, every compiled evm spec carries the same three predicate namespaces (namespaces, crates/blockwatcher-evm/src/decoder/compile.rs). A field a particular occurrence does not carry (tx.from on a log-decoded event, tx.status on a pending transaction) resolves Unknown at predicate time, never an error:

NamespaceFields
txhash (bytes), index (uint), status (uint), from (address), to (address), value (uint)
blocknumber (uint), hash (bytes), timestamp (uint)
logaddress (address), index (uint)

Sink

A sink is a delivery destination: an id, a module name, that module’s config, and optional engine-owned policies (SinkDef, crates/blockwatcher-types/src/resource.rs). It is written through PUT /sinks/{id} on the HTTP API or as one JSON file under a seed directory’s sinks/ subdirectory. Writing a sink restarts every network whose stored monitors name that sink id, per Resources § Lifecycle.

A complete sink carrying every policy field beside a webhook module config, the same example Delivery guarantees § Delivery policies walks through (the policy values shown are each field’s defaults):

{
  "id": "alerts",
  "module": "webhook",
  "config": {
    "url_secret": "env:ALERTS_WEBHOOK_URL",
    "headers": { "x-source": "blockwatcher" },
    "timeout_ms": 10000,
    "body_template": "{% if type == \"digest\" %}{{ matches | length }} matches{% else %}{{ type }}: {{ monitor }}{% endif %}"
  },
  "retry": { "max_attempts": 8, "initial_backoff_ms": 200, "max_backoff_ms": 30000 },
  "throttle": { "max_deliveries": 60, "window_ms": 60000 },
  "aggregate": { "window_ms": 30000, "max_batch": 100 }
}

Fields

SinkDef rejects an unrecognized field (#[serde(deny_unknown_fields)]). retry, throttle, and aggregate are siblings of config, never nested inside it: a sink module never sees or interprets its own policies, so enforcement is identical for every module. These policies run after a match exists. Counting hits or capping alerts before a match is minted is a monitor gate, not a sink field. throttle dead-letters refused matches (replayable); max_once discards them. aggregate stalls the checkpoint for the window; threshold does not. What enforcing each policy means at runtime is documented on Delivery guarantees § Delivery policies; this page owns the fields and their constraints.

KeyTypeDefaultMeaning
idstringnone (required)The sink’s name, referenced by a monitor’s actions list.
modulestringnone (required)Which sink module delivers: webhook, script, or log in shipped builds.
configobjectnone (required)The named module’s own config, documented per module below. Write-time validation constructs the module with it, so a bad config refuses the write as a 422.
retryobjectabsentDelivery retry policy. Absent falls back to the engine-wide [engine].default_retry from the instance configuration.
throttleobjectabsentDelivery admission cap. Absent means not throttled at all: throttling is opt-in.
aggregateobjectabsentDigest batching. Absent delivers every match on its own, unbatched.

retry, throttle, and aggregate are each validated by the same validate call at two points: put_sink (crates/blockwatcher-core/src/control/writes.rs) refuses the write, and validate_and_build (crates/blockwatcher-core/src/engine/boot.rs) runs the identical check against rows already in storage at boot, refusing to start the engine instead. A policy is either absent or fully valid, never partially so. A write-time refusal is 422 invalid_resource on the wire, never 500 internal.

retry (DeliveryRetry)

KeyTypeDefaultMeaning
max_attemptsu328Total delivery attempts, not retries after the first: 1 delivers once and dead-letters on failure, and 0 is treated as 1, because a delivery that was never attempted cannot be honestly recorded as given up on.
initial_backoff_msu64200Delay before the second attempt. Backoff doubles per attempt; the multiplier is fixed at 2. Above max_backoff_ms refuses: retry.initial_backoff_ms (30001) must be at most retry.max_backoff_ms (30000).
max_backoff_msu6430000Cap on the doubling backoff between attempts.

throttle (Throttle)

KeyTypeDefaultMeaning
max_deliveriesu3260Successful deliveries the window admits before suppressing more. A zero refuses: throttle.max_deliveries (0) must be at least 1.
window_msu6460000The window’s length. A zero refuses: throttle.window_ms (0) must be at least 1. Above one day refuses: throttle.window_ms (86400001) must be at most 86400000; the cap itself is a legal window.

aggregate (Aggregate)

KeyTypeDefaultMeaning
window_msu6430000How long a batch stays open once the first match joins it. A zero refuses: aggregate.window_ms (0) must be at least 1. Above one day refuses: aggregate.window_ms (86400001) must be at most 86400000.
max_batchu32100The batch size that closes the batch immediately. A zero refuses: aggregate.max_batch (0) must be at least 1. Above the cap of 10,000 refuses: aggregate.max_batch (20000) must be at most 10000; the cap itself is a legal batch size.

The caps bound what one open window can cost: every buffered match is held in memory and the checkpoint stays behind all of them until the window closes.

Sink modules

Each module’s example below is copied verbatim from that crate’s registry_examples/*.json file, the same file the crate’s family-completeness test constructs; any change to those files updates these examples in the same change, per the wiki-parity rule.

webhook

POSTs each sink event as JSON to a secret-referenced URL (crates/blockwatcher-sinks/src/webhook.rs). Redirects are never followed (a 3xx fails permanently). Header values come from two maps: headers is plain configuration, and header_secrets names secret references (env:NAME) resolved fresh at each delivery, the same way url_secret is — so a secret Authorization header is supported without ever holding a resolved copy past the request that carries it. A header named in both maps has no defined winner, so the write refuses rather than guessing one. The example config, registry_examples/webhook.json:

{
  "url_secret": "env:BLOCKWATCHER_EXAMPLE_WEBHOOK_URL",
  "headers": { "x-blockwatcher-monitor": "treasury" },
  "timeout_ms": 10000
}
KeyTypeDefaultMeaning
url_secretstringnone (required)An env:NAME reference to where the URL lives, resolved at each delivery. The write refuses a value that is not a reference, and refuses a reference whose variable is absent or does not hold a URL: 'env:NAME' resolves to a value that is not a URL.
headersobject of string to string{}Extra request headers, plain values. A name or value that does not parse refuses with invalid header name '<name>': ... or invalid value for header '<name>': .... A configured content-type replaces the default application/json.
header_secretsobject of string to string{}Extra request headers whose values are env:NAME secret references, resolved at each delivery like url_secret — a resolved value is held no longer than the request that carries it. A name that does not parse refuses the same way headers does. A name that also appears in headers refuses: header '<name>' is set in both 'headers' and 'header_secrets'; ....
timeout_msu6410000Bound on one delivery attempt. A zero refuses: 'timeout_ms' must be greater than 0; 0 makes every attempt time out immediately and dead-letter.
body_templatestringabsentA minijinja template for the request body; absent keeps the canonical sink-event JSON unchanged. Validated at write time by rendering against synthetic match, retracted, and digest events, so a syntax error or a missing filter refuses with invalid webhook body_template: ... rather than dead-lettering the first real delivery. A field the template reads that a real event lacks renders empty. The template semantics, context shape, and the digest type guard are on blockwatcher-sinks § webhook.

script

Runs an operator-authored program per event with the canonical sink-event JSON on stdin (crates/blockwatcher-sinks/src/script.rs). The example config, registry_examples/script.json:

{
  "command": "/usr/local/bin/blockwatcher-notify",
  "args": ["--channel", "ops"],
  "timeout_ms": 30000
}
KeyTypeDefaultMeaning
commandstringnone (required)The program to run. An empty string refuses: script command must not be empty. The path’s existence is checked only at delivery, not at the write: the file may legitimately appear after boot.
argsarray of strings[]Arguments, passed to the program verbatim.
timeout_msu6430000Bounds the stdin write and the exit wait together. A zero refuses with the same message as the webhook’s timeout_ms.
body_templatestringabsentA minijinja template for the bytes piped to the script’s stdin; absent keeps the canonical sink-event JSON unchanged. Validated at write time by rendering against synthetic match, retracted, and digest events, so a syntax error or a missing filter refuses with invalid script body_template: ... rather than dead-lettering the first real delivery. A field the template reads that a real event lacks renders empty. Shares its template semantics, context shape, and the digest type guard with webhook’s body_template, documented on blockwatcher-sinks § script.

The exit-status contract (sysexits):

  • 0: delivered. A script that exits 0 without reading stdin has still delivered; the exit status is the acknowledgement.
  • 75 (sysexits EX_TEMPFAIL): transient, retried by the engine under the sink’s retry policy.
  • Any other exit code: permanent, straight to the dead-letter queue.
  • Death by signal, and the module’s own timeout: transient.

The last 4 KiB of the script’s stderr ride the error message; stdout is ignored.

log

Writes one line of canonical sink-event JSON to the process’s stdout per delivery (crates/blockwatcher-sinks/src/log.rs). It takes no required configuration; the example config, registry_examples/log.json, is the empty object, and any key other than body_template refuses the write:

{}
KeyTypeDefaultMeaning
body_templatestringabsentA minijinja template for the emitted line, rendered before the trailing newline; absent keeps the canonical sink-event JSON unchanged. Validated at write time by rendering against synthetic match, retracted, and digest events, so a syntax error or a missing filter refuses with invalid log body_template: ... rather than dead-lettering the first real delivery. A field the template reads that a real event lacks renders empty. Shares its template semantics, context shape, and the digest type guard with webhook’s body_template, documented on blockwatcher-sinks § webhook.

Monitor

A monitor is the rule that ties the other three kinds together: an id, the network it watches, one or more selectors, an optional predicate, an optional gate, and actions naming the sinks it delivers to (Monitor, crates/blockwatcher-types/src/resource.rs). It is written through PUT /monitors/{id} on the HTTP API or as one JSON file under a seed directory’s monitors/ subdirectory. Writing a monitor usually restarts nothing: the running pipeline hot-swaps the recompiled monitor set in place. Two things restart that one network instead: changing gate, whose previous envelope’s holds are dropped with the pipeline stopped and the pipeline then brought back (deleting a gated monitor takes the same path), and naming a sink the pipeline never spawned a worker for, per Resources § Lifecycle. Dropping holds never drops an already-emitted digest: an emission the old envelope committed but had not yet delivered survives the wipe and is delivered (or dead-lettered) when the pipeline comes back, see Gates § Delivery guarantee.

Monitor
  id         MonitorId
  network    NetworkId
  selectors  RawSelector[]
  predicate  string?          // this monitor's filter, not a shared id
  gate       ModuleSel?       // this monitor's decision rule, like predicate
  actions    SinkId[]

A complete monitor resource, selecting against the erc20 spec from the Spec page (an EVM example; other families’ selector keys are on the family page):

{
  "id": "usdc-transfers",
  "network": "eth-mainnet",
  "selectors": [
    {
      "spec": "erc20",
      "events": ["Transfer"],
      "addresses": ["0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"]
    }
  ],
  "predicate": "args.value > 1000000",
  "actions": ["alerts"]
}

The same monitor with a threshold gate; omitting gate (the first example) remains valid passthrough:

{
  "id": "usdc-burst",
  "network": "eth-mainnet",
  "selectors": [
    {
      "spec": "erc20",
      "events": ["Transfer"],
      "addresses": ["0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"]
    }
  ],
  "predicate": "args.value > 1000000",
  "gate": {
    "module": "threshold",
    "config": { "count": 3, "window_ms": 3600000 }
  },
  "actions": ["alerts"]
}

Fields

Monitor rejects an unrecognized top-level field (#[serde(deny_unknown_fields)]); a selector entry’s keys are the one exception, checked later by the chain’s decoder rather than by serde, per the selector table below.

KeyTypeDefaultMeaning
idstringnone (required)The monitor’s name; every delivered match carries it.
networkstringnone (required)The network this monitor watches. The write refuses an id that does not exist. Fixed at creation: an update naming a different network is refused (422); delete and recreate the monitor to move it.
selectorsarray of objectsnone (required)What to decode and match, OR’d across entries. An empty array refuses: a monitor needs at least one selector.
predicatestringabsentAn expression over the decoded event; absent matches everything the selectors decode.
gateobjectabsent{ "module", "config" }. Absent = passthrough. See Gates.
actionsarray of stringsnone (required)The sinks to deliver each match to, one or more. The write refuses an id that does not name an existing sink.

The whole monitor (selectors, predicate, and gate) must compile against the live decoder, matcher, and gate catalog before the write is persisted, so every refusal quoted below surfaces as a 422 on the write, per Resources § Write-time validation.

Selector keys

Each selector entry carries a core-readable spec reference plus decoder-owned keys. spec is required: a missing key refuses selector requires a 'spec' reference. The spec must exist and share the network’s chain. Every other key is admitted or refused by that chain’s decoder, by name, at compile time. Which keys a family admits, the absent-vs-empty rule for kind lists, and address spelling, are on the family page.

What a selected kind actually decodes to, and which raw material reaches the selector per source, is also on the family page. Shared contracts (entries are OR’d, unknown keys fail closed) stay on Selectors.

Predicate

predicate is one expression string, compiled at write time against the schemas the monitor’s selectors produce; a field or namespace it names that no selected schema declares refuses the write, with a bounded edit-distance suggestion. The language (syntax, operators, the three-valued evaluation, and the type families) is documented on Predicates and the expression language.

Gate

gate compiles at write against the same schemas as the predicate. Refusals (all 422):

  • unknown field on the envelope or inside config (deny_unknown_fields)
  • unknown module (message lists catalog gate names)
  • window_ms 0 or > 86400000: same bound language as sink throttle.window_ms
  • threshold.count < 2 or > 10000
  • missing block.timestamp on the schema: gate requires 'block.timestamp'; this monitor's selectors do not expose it

Changing module or config drops persisted holds for that monitor; they are not migrated. If the pipeline cannot be brought back afterwards, the write is not 2xx and /status reports the network abandoned; persist may already have landed.

HTTP API reference

blockwatcher’s REST control plane is an axum router mounted on the [api].listen address from instance configuration. Every mutation goes through the same ControlHandle a --seed load and the engine’s own boot path use, and every read goes through core’s typed storage facade without taking any lock the engine holds: a read stays answerable while a write is in flight. There is no path prefix: routes are exactly as shown below, against the listener’s own address, assembled in router (crates/blockwatcher-api/src/serve.rs, lib.rs).

flowchart LR
    rpc["RPC endpoints<br/>(external chains)"]
    %% Source modules are family-owned; the running list is GET /catalog, not this map.
    sources["Sources<br/>one per network"]
    sinks["Sinks<br/>webhook · script · log"]
    storage["Storage<br/>checkpoints · dead letters · resources"]
    api["REST API"]
    metrics["Metrics"]
    engine["engine<br/>bounded channels · checkpoints"]

    subgraph pipeline["Engine pipeline"]
        direction LR
        decoder["Decoder"]
        matcher["Matcher<br/>predicates"]
        gate["Gate<br/>threshold · max_once"]
        decoder --> matcher
        matcher --> gate
    end

    rpc --> sources
    sources -->|"decode and match"| decoder
    gate --> sinks
    api -->|"manages resources"| storage
    storage <--> engine
    engine -->|"drives"| pipeline
    engine -.->|"reports"| metrics

    classDef module fill:none,stroke:#a9a3e3
    classDef core fill:none,stroke:#8a8d86,stroke-dasharray: 5 5

    class rpc,sources,sinks,decoder,matcher,gate module
    class engine,api,storage,metrics core
classDef dim fill:none,stroke:#999999,color:#999999,opacity:0.35
classDef focus fill:#ffd43b,stroke:#d9480f,stroke-width:3px,color:#1a1a1a
class rpc,sources,sinks,storage,metrics,engine,decoder,matcher,gate dim
class api focus
click sources "../concepts/selectors.html"
click decoder "../concepts/chain-agnosticism.html"
click matcher "../concepts/predicates.html"
click gate "../concepts/gates.html"
click sinks "../concepts/delivery.html"
click storage "../concepts/resources.html"
click api "http-api.html"
click metrics "observability.html"
click engine "../concepts/pipeline.html"

Key takeaways

  • Every route except GET /health requires a bearer token from the labelled [auth] table, checked in constant time; a missing or unknown token is 401, a valid token with too weak a scope is 403. Every failure returns the same ErrorBody shape.
  • Writes go through the same ControlHandle a --seed load and the boot path use; reads go through core’s typed storage facade without taking any lock a write holds.
  • Every resource kind shares one CRUD shape with ETag/If-Match optimistic concurrency: PUT with no If-Match creates, with If-Match updates, and DELETE requires If-Match. POST /specs/{id}/from-chain is the same create/update ETag contract with an empty body; GET reads the stored pin.
  • A monitor test is a dry run: it reports matches without delivering to any sink, registering a cursor, or moving a checkpoint.
  • Network operations (pause, resume, skip, checkpoint delete, dead letters) act on persisted state and the running pipeline directly; none of them replay through the normal ingestion path.

Every request travels the same shape, from the door to the response body:

flowchart LR
    req["HTTP request"] --> health{"path is /health?"}
    health -->|"yes"| ok["200 ok<br/>no token required"]
    health -->|"no"| auth{"require_bearer"}
    auth -->|"missing/wrong"| e401["401 unauthorized"]
    auth -->|"valid"| scope{"require(min scope)"}
    scope -->|"too weak"| e403["403 forbidden"]
    scope -->|"allowed"| route{"read or write?"}
    route -->|"write"| handle["ControlHandle<br/>same path --seed and boot use"]
    route -->|"read"| facade["core's typed<br/>storage facade"]
    handle --> resp["JSON response"]
    facade --> resp

Bodies are JSON in both directions unless noted. The route table is grouped below the same way the route modules split it: health, status, catalog, schema, resources, spec import, monitor operations, network operations (crates/blockwatcher-api/src/routes/mod.rs).

Authentication

Every route except GET /health requires:

Authorization: Bearer <token>

checked by require_bearer (crates/blockwatcher-api/src/auth/mod.rs) against the labelled [auth] table. Each row’s secret is an env:NAME reference resolved at request time; the process never holds a copy of the value between requests. The comparison is constant-time on length-equal inputs; scheme matching is case-insensitive per RFC 7235. /health is exempted by an explicit path check ahead of the router, not by a separate mount, so a request to a path that genuinely doesn’t exist is refused for lacking a token before the router gets a chance to reveal that it wouldn’t have matched anyway. A valid token is then authorized per MethodRouter by require!(Scope::…): read lists and reads, operate pauses/resumes/ replays, admin writes resources (including script sinks) and POST /specs/{id}/from-chain.

A missing or unknown token gets:

{ "error": { "code": "unauthorized", "message": "a bearer token is required" } }

with status 401 and a WWW-Authenticate: Bearer response header.

curl -H "Authorization: Bearer $BLOCKWATCHER_API_TOKEN" http://127.0.0.1:8080/status

Errors

Every failure (an engine refusal or one the API layer decides on its own) leaves through the same body shape, ErrorBody/ErrorDetail (crates/blockwatcher-api/src/error.rs):

{
  "error": {
    "code": "version_conflict",
    "message": "version conflict: expected 3, actual 5",
    "actual_version": 5
  }
}

actual_version appears only on a 412 version conflict; every other error omits it. A 500 always answers with a fixed generic body, {"error":{"code":"internal","message":"internal error"}}, regardless of what actually failed; the real detail goes to the server log only, never the wire, in body and into_response (error.rs).

StatusCodeWhen
400malformed_if_matchIf-Match present but not a quoted integer ETag.
401unauthorizedMissing or incorrect bearer token.
404not_foundNo record at that id.
409already_existsPUT with no If-Match against an id that already exists.
409conflictCurrent state refuses the request (e.g. skip on a network that isn’t paused, checkpoint delete while the network resource exists).
409checkpoint_provenanceA stored checkpoint was written by a different source module than the one now configured.
412version_conflictIf-Match named a version storage no longer has; body carries actual_version.
413too_many_payloadsA dry-run request exceeded the input cap.
422invalid_resourceThe body, or a referenced resource, doesn’t validate. Throttle, aggregate, and retry bound refusals (max_deliveries / max_batch / window_ms of 0, windows over one day, max_batch over 10_000, initial_backoff_ms above max_backoff_ms) are this code, never 500 internal.
422unknown_module / unsupported_chain / compile_failed / module_init_failed / missing_reference / still_referencedOther write-time validation refusals: see Resources. unknown_module is also the honest answer when a gate-triggered pipeline restart cannot construct a stored sink.
428precondition_requiredDELETE sent with no If-Match.
502replay_failedA dead-letter replay exhausted its retry budget again.
503unavailableA dependency (e.g. an RPC tip lookup) is temporarily unreachable.
503restart_refused_after_quiesce_timeoutA gate envelope change or gated delete stopped the pipeline and then refused to bring it back because the post-drain storage fence timed out. /status reports the network abandoned.
500internalA server-side fault; detail is in the log, never the wire.

Health

GET /health

The one route served without a token: a liveness probe has to answer even when secrets aren’t available yet. Asserts nothing about any pipeline.

Response 200:

{ "status": "ok" }
curl http://127.0.0.1:8080/health

Status

GET /status

A point-in-time snapshot of every currently running, paused, or abandoned pipeline, sorted by network id (blockwatcher_core::EngineStatus, crates/blockwatcher-core/src/status.rs).

Response 200, one entry per network in pipelines:

{
  "instance": "b3f1a2c4-9e21-4a6a-8c3e-1f2d3a4b5c6d",
  "paused_monitors": ["usdc-sepolia-alerts"],
  "pipelines": [
    {
      "network": "sepolia",
      "source": { "status": "catching_up", "behind": 42 },
      "checkpoint": { "cursor": { "primary": 11424039, "secondary": 54 }, "module": "evm-rpc" },
      "head": { "primary": 11424081, "secondary": 0 },
      "lag": 42,
      "in_flight_events": 0,
      "event_queue": { "len": 0, "capacity": 1000 },
      "sink_queues": { "log-sink": { "len": 0, "capacity": 100 } },
      "counters": {
        "decoded": 118, "undecodable": 0, "matched": 12, "match_errors": 0,
        "delivered": 12, "dead_lettered": 0, "dispatch_failed": 0,
        "checkpoint_write_failed": 0, "dead_letter_write_failed": 0,
        "misrouted": 0, "checkpoint_regressions_refused": 0,
        "untimestamped": 0, "gate_persist_failed": 0, "gate_hits_dropped": 0,
        "gated": 0, "gate_emitted": 0
      },
      "dead_letter_count": 0,
      "gate_outbox_depth": 0,
      "paused_monitors": []
    }
  ]
}

instance is an opaque per-process identity; two reads returning different values mean the engine restarted in between. It is null when the composition root embedding blockwatcher supplies none.

Top-level paused_monitors is every monitor an operator has paused, as persisted — pipeline membership does not enter into it, so a monitor on a paused network still appears here. It is null when storage would not answer, deliberately never an empty list: a caller cannot tell “nothing is paused” from “the store did not answer” from an empty list, and this field is what a consumer reads as the truth about pause.

source.status is one of starting, live, degraded (carries reason), catching_up (carries behind), paused (the control-plane pause view, never something a source itself reports), or abandoned (no running pipeline and no pause — a failed restart left the network unattended). checkpoint is null until the first event on that network fully completes; lag is null whenever either side it subtracts is unknown.

dead_letter_count is read fresh from storage on every call — unlike counters.dead_lettered, an in-memory count that resets to 0 whenever this pipeline restarts (a sink or monitor edit restarts every network that references it). A caller that wants “how many dead letters exist right now” should read dead_letter_count, not the counter. It is null, never 0, when that storage read fails.

gate_outbox_depth is how many committed gate emissions are still awaiting delivery: the durable outbox rows written when a gate emitted that no sink worker has settled yet. Also read fresh from storage on every call, so a paused network’s pending emissions stay visible for as long as they sit undelivered; a non-zero depth on a paused pipeline means a resume still owes deliveries. Like dead_letter_count, it is null, never 0, when the storage read fails.

A pipeline entry’s own paused_monitors is the monitors that running pipeline is actually suppressing, read from its published set. Compare with the top-level field: that is what an operator asked for, this is what is in force. They diverge only when a republish failed after a persisted pause, which is logged and repaired by the next rebuild.

curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8080/status

Catalog

GET /catalog

The modules this process registered: each source as an object with a name and advertised capabilities, each decoder as an object with a name and chain() tag, and the other roles as name lists (blockwatcher_core::CatalogView). Clients use it to offer source and decoder choices; they do not hardcode module names. Takes no engine lock.

Response 200:

{
  "sources": [ { "name": "evm-rpc", "caps": { "confirmed_tip": true } } ],
  "decoders": [{ "name": "evm", "chain": "evm" }],
  "matchers": ["expr"],
  "sinks": ["http", "log", "webhook"],
  "gates": ["max_once", "threshold"],
  "storages": ["memory", "sqlite"]
}

Lists are sorted. decoders[].chain is the decoder’s own chain() string. A binary that compiled fewer features returns fewer names.

curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8080/catalog

Schema

GET /specs/{id}/schema

The whole vocabulary a predicate written against this spec may address: compiled through the spec’s chain decoder, not read off the raw stored payload, so it’s the decoder’s own translation rather than the ABI or IDL text itself.

Response 200 (blockwatcher_types::SchemaSet):

{
  "events": [
    { "name": "Transfer", "kind": "event", "fields": [
      { "name": "from", "ty": "address" },
      { "name": "value", "ty": "uint" }
    ] }
  ],
  "namespaces": {
    "tx": [{ "name": "hash", "ty": "bytes" }]
  }
}

Errors: 404 not_found if the spec doesn’t exist.

curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8080/specs/usdc-erc20/schema

Resources: networks, specs, sinks, monitors

Every resource kind answers on the identical CRUD shape, generated once by the resource_routes! macro so a client written against any kind works against all of them (crates/blockwatcher-api/src/routes/resources.rs):

MethodPath
GET/networks, /specs, /sinks, /monitors
GET/networks/{id}, /specs/{id}, /sinks/{id}, /monitors/{id}
PUT/networks/{id}, /specs/{id}, /sinks/{id}, /monitors/{id}
DELETE/networks/{id}, /specs/{id}, /sinks/{id}, /monitors/{id}

The collection GET returns the bare list, unpaginated: resource counts are operator-scale, not event-scale. Every item route carries optimistic concurrency through ETag / If-Match:

  • GET {id} returns the resource with its current version in ETag ("3").
  • PUT {id} with no If-Match is a create: 201 with the new version in ETag, or 409 already_exists if the id is already there. PUT {id} with If-Match: "<version>" is a conditional update: 200 with the new version on success, 412 version_conflict (body carries actual_version) if the version has moved on, 404 not_found if the record is gone. The path’s id and the body’s id field must agree, or the write is refused as 422 invalid_resource.
  • DELETE {id} requires If-Match: "<version>": omitting it is 428 precondition_required, since a delete has no later read that would reveal it clobbered a change it never saw. A version that doesn’t match is 412 version_conflict; success is 204.

If-Match takes a strong, quoted, numeric ETag exactly as GET returned it ("3"); anything else is 400 malformed_if_match.

Resource bodies, from crates/blockwatcher-types/src/resource.rs (every shape rejects an unrecognized field except Monitor.selectors[], whose extra keys are decoder-owned and checked later, at compile time):

Network: source.config’s shape is the named module’s own; for evm-rpc (EvmRpcConfig, crates/blockwatcher-evm/src/source/rpc/config.rs) that’s a pool of endpoints (each an EndpointDef with name, a url_secret reference, optional priority/rate_limit/weight, crates/blockwatcher-evm/src/source/endpoint.rs), a required start_block, and the module’s own tunables:

{
  "id": "sepolia",
  "chain": "evm",
  "source": {
    "module": "evm-rpc",
    "config": {
      "start_block": 11424310,
      "endpoints": [
        { "name": "primary", "url_secret": "env:SEPOLIA_RPC_URL", "priority": "high", "rate_limit": { "rps": 10 } }
      ],
      "confirmations": 12,
      "max_lag_blocks": 100,
      "poll_interval_ms": 3000,
      "logs_window": { "initial": 1000, "max": 5000 },
      "probe_interval_ms": 30000
    }
  }
}

Family-owned source.config field tables live on Chain families.

Spec: payload is the chain’s own decode artifact, opaque to core (a Solidity ABI array for evm):

{ "id": "usdc-erc20", "chain": "evm", "payload": [ { "type": "event", "name": "Transfer", "inputs": [] } ] }

SinkDef: retry is optional; a sink with none falls back to [engine].default_retry. throttle is optional too, and caps how often the sink admits deliveries; a sink with none is not throttled at all, and a present throttle object fills in any omitted field with 60 deliveries per 60,000ms. aggregate is optional as well, and batches several matches into one digest delivery; a sink with none delivers every match on its own, unbatched. See Delivery policies for what each field means and how a rejection is enforced:

{
  "id": "ops-slack",
  "module": "webhook",
  "config": { "url_secret": "env:OPS_SLACK_WEBHOOK_URL" },
  "retry": { "max_attempts": 5, "initial_backoff_ms": 200, "max_backoff_ms": 30000 },
  "throttle": { "max_deliveries": 60, "window_ms": 60000 },
  "aggregate": { "window_ms": 30000, "max_batch": 100 }
}

Monitor: predicate is optional (no predicate matches everything the selectors decode); actions names one or more SinkDef ids:

{
  "id": "usdc-sepolia-transfers",
  "network": "sepolia",
  "selectors": [{ "spec": "usdc-erc20", "addresses": ["0x1c7D..."], "events": ["Transfer"] }],
  "predicate": "args.value > 1_000e6",
  "actions": ["ops-slack"]
}

An optional gate is a sibling of predicate. Absent is passthrough:

{
  "id": "usdc-burst",
  "network": "sepolia",
  "selectors": [{ "spec": "usdc-erc20", "addresses": ["0x1c7D..."], "events": ["Transfer"] }],
  "predicate": "args.value > 1_000e6",
  "gate": { "module": "threshold", "config": { "count": 3, "window_ms": 3600000 } },
  "actions": ["ops-slack"]
}

Errors common to every write: 422 codes covering unresolvable references, an uncompilable predicate or gate, an unconstructible module config, and so on: see Resources: write-time validation for exactly what’s checked per kind, and 404 not_found / 409 already_exists / 412 version_conflict for the concurrency cases above. DELETE on a Network, Spec, or SinkDef additionally refuses as 422 still_referenced while any stored monitor still names that id.

A gate write is 422 when:

  • the module name is unknown (the message lists catalog gate names, the same shape as an unknown sink module)
  • this monitor’s selectors do not expose block.timestamp: gate requires 'block.timestamp'; this monitor's selectors do not expose it
  • count or window_ms is out of range (threshold.count is 2..=10000; window_ms is 1..=86400000)

A gate envelope change, or a gated monitor’s delete, stops that network’s pipeline, drops holds, and brings the pipeline back. If that bring-up fails (an unknown sink module a hand-edited row left behind, a quiesce timeout, a storage fault), the request is not 2xx: persist may already have landed, /status reports the network abandoned, and the error names why. Retrying the same write then conflicts on the version the write produced. A refused DELETE (stale If-Match) does not clear that resource’s pause row.

curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8080/monitors

etag=$(curl -s -o /dev/null -w '%header{etag}' \
  -H "Authorization: Bearer $TOKEN" \
  http://127.0.0.1:8080/monitors/usdc-sepolia-transfers)

curl -X PUT http://127.0.0.1:8080/monitors/usdc-sepolia-transfers \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "If-Match: $etag" \
  -d '{"id":"usdc-sepolia-transfers","network":"sepolia","selectors":[{"spec":"usdc-erc20","addresses":["0x1c7D..."],"events":["Transfer"]}],"predicate":"args.value > 2_000e6","actions":["ops-slack"]}'

curl -X DELETE http://127.0.0.1:8080/monitors/usdc-sepolia-transfers \
  -H "Authorization: Bearer $TOKEN" -H "If-Match: \"4\""

Spec import

POST /specs/{id}/from-chain

Fetches a spec payload from the named network’s chain and writes it as this spec id (crates/blockwatcher-api/src/routes/spec_import.rs). Admin scope. The spec’s chain is the network’s, never a body field: an extra chain is unknown and a 422. After a successful write, the stored spec is a normal {id, chain, payload} pin — GET /specs/{id} is how a caller reads it, indistinguishable from a hand-written spec.

{ "network": "stellar-testnet", "address": "C..." }

C-addresses only. A G-address is 422 invalid_resource.

The response matches PUT /specs/{id}: status and ETag, empty body. No If-Match creates (201 with the new version in ETag). If-Match: "<version>" is a conditional update (200 with the new version). A stale ETag is 412 version_conflict (body carries actual_version). There is no JSON body on success; GET reads the pin.

Refusal cases:

  • No importer for the network’s chain: 422 unsupported_chain. The message lists the importer chains this process loaded.
  • No decoder for the network’s chain: 422 unsupported_chain listing decoder chains, before any RPC call.
  • Network id does not exist: 422 missing_reference. The spec references a network that is not stored.
  • Network chain or source changed during the fetch: 409 conflict. Retry.
  • Bad address, unusable source config, or a JSON-RPC permanent refusal: 422 invalid_resource (SpecImportRejected). Body parse failures stay 422 via ApiError::from. A stored row that will not deserialize is 500 internal through ApiError::stored.
  • RPC unreachable: 503 unavailable, naming endpoint names, never a URL.

A refused import writes nothing: GET /specs/{id} stays 404 if the id was new. import_spec_from_chain persists only after the importer returns a payload.

curl -X POST http://127.0.0.1:8080/specs/native-xlm/from-chain \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"network":"stellar-testnet","address":"C..."}'

curl -H "Authorization: Bearer $TOKEN" \
  http://127.0.0.1:8080/specs/native-xlm

Monitor operations

POST /monitors/{id}/pause
POST /monitors/{id}/resume
POST /monitors/{id}/test

pause and resume persist the monitor’s pause: a process restart does not clear it, and every path that rebuilds the monitor set — a network, sink, or spec write that restarts the pipeline, or a fresh boot — reads the persisted set back, per the pause handler’s own doc comment, which covers both operations (crates/blockwatcher-api/src/routes/monitors_ops.rs). The persist is what succeeds; publishing the change to a pipeline that happens to be running is best-effort, logged rather than reported on failure. Both answer 204 on success.

test is a dry run: it reports every match the monitor’s current selectors, predicate, and gate (fresh empty journal, no persist of gate_hits) would produce against supplied input, delivering nothing: no sink is called, no cursor is registered, and no checkpoint moves, which is what makes it safe to point at a monitor serving live traffic. The request body is exactly one of payloads (caller- supplied raw payloads) or fetch (a bounded history read through the network’s own source module):

{ "payloads": [ { "json": { "from": "0x...", "value": "1000000" } } ] }
{ "fetch": { "from": { "primary": 11424000, "secondary": 0 }, "to": { "primary": 11424100, "secondary": 0 }, "limit": 50 } }

Response 200:

{
  "results": [
    { "matched": [ { "id": "...", "monitor": "usdc-sepolia-transfers", "network": "sepolia", "event": { "kind": "event", "name": "Transfer", "fields": {}, "cursor": { "primary": 0, "secondary": 0 } } } ], "undecodable": 0, "no_match": false, "eval_errors": 0 }
  ],
  "explanations_supported": true,
  "held": 0,
  "dropped": 0
}

held and dropped live on the report (not each result): they count this request’s gate Retain and Discard decisions. A monitor with no gate leaves both at 0. One payload against threshold with count: 3 returns matched: [] and held: 1. Three in-window payloads fire one digest whose matches are flattened into matched.

Each result’s explanation field is present only when a decoded event didn’t match and the matcher module was consulted for why: omitted when nothing needed explaining, JSON null when the matcher has no explain support, and the explanation tree otherwise.

no_match is true when no selector wanted this input at all — a log at the wrong address/topic0, a function no monitor named — as opposed to a decoded event the predicate rejected, or an unremarkable input with nothing to report. All three otherwise leave matched empty and undecodable/eval_errors at 0; no_match is what tells them apart.

Errors: 422 invalid_resource when both or neither of payloads / fetch is present, or when fetch targets a source that can’t fetch history (message "this source cannot fetch history"); 413 too_many_payloads past the shared cap of 100 inputs (blockwatcher_core::MAX_TEST_INPUTS).

curl -X POST http://127.0.0.1:8080/monitors/usdc-sepolia-transfers/pause \
  -H "Authorization: Bearer $TOKEN"

curl -X POST http://127.0.0.1:8080/monitors/usdc-sepolia-transfers/test \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"payloads":[{"json":{"from":"0x1","to":"0x2","value":"5000000"}}]}'

Network operations

POST   /networks/{id}/pause
POST   /networks/{id}/resume
POST   /networks/{id}/skip
DELETE /networks/{id}/checkpoint
GET    /networks/{id}/dead-letters
DELETE /networks/{id}/dead-letters
POST   /networks/{id}/dead-letters/{match_id}/replay
DELETE /networks/{id}/dead-letters/{match_id}

pause and resume persist the network’s pause in exactly the same sense as the monitor operations above: a restart does not clear it, and a paused network is never spawned at boot, per the pause handler’s own doc comment (crates/blockwatcher-api/src/routes/networks_ops.rs). pause cancels and drains the running pipeline as part of the same request; resume restarts it. Both answer 204 on success; a restart failure inside resume, after the persisted pause has already been cleared, is logged rather than returned as an error.

Skip

POST /networks/{id}/skip

Moves a paused network’s checkpoint and the source module’s own absolute cold start forward, without replaying the gap in between: catch-up while the operator has decided it isn’t worth reading.

{ "to": "tip" }
{ "to": { "cursor": 11424100 } }

block is accepted as the legacy spelling of cursor.

Response 200 (blockwatcher_core::SkipReport):

{ "network": "sepolia", "cursor": { "primary": 11424100, "secondary": 0 }, "start_cursor": 11424100, "previous_checkpoint": { "cursor": { "primary": 11423000, "secondary": 12 }, "module": "evm-rpc" } }

Refusal cases, all named explicitly rather than left to fall through a generic status:

  • Not paused: 409 conflict: "cannot skip network '{id}' while it is not paused; pause it first".
  • Rewind: 422 invalid_resource when the target is behind the current checkpoint: "cannot skip to block {N}: checkpoint is already at {M}".
  • Unsupported tip: 422 invalid_resource, "this source cannot report a confirmed tip", when "to": "tip" targets a source module whose caps.confirmed_tip on GET /catalog is false (evm-mempool and stellar-rpc). Skip to an absolute cursor still works while paused on a source that declares a cold start (stellar-rpc): the module moves whichever cold-start field it declares (start_ledger on Stellar).
  • No cold start: 422 invalid_resource, "source '{module}' has no cold start to move", for a feed that has no absolute start to move at all (evm-mempool).
  • A tip lookup that is merely unreachable right now (a transient RPC failure) is 503 unavailable instead, distinct from the source genuinely not supporting the concept at all.

Checkpoint delete

DELETE /networks/{id}/checkpoint

An explicit reset for an orphaned checkpoint (one whose network resource has already been deleted). Deleting a network deliberately leaves its checkpoint behind so a network re-created under the same id resumes where the old one stopped; this route is how an operator clears that row for good once they know it won’t be reused. The checkpoint is the only row a network delete leaves waiting: pending gate emissions are dead-lettered by the delete itself (see the gate delivery guarantee), so they show up in this network’s dead letters rather than sitting in an outbox nothing reads.

Response: 204 on success.

Errors: 409 conflict ("cannot reset checkpoint for network '{id}' while the network resource still exists") while the network resource is still there; 404 not_found if there’s no checkpoint for the id at all.

Dead letters

GET    /networks/{id}/dead-letters
DELETE /networks/{id}/dead-letters
POST   /networks/{id}/dead-letters/{match_id}/replay
DELETE /networks/{id}/dead-letters/{match_id}

A dead letter can be listed, replayed, or discarded without an attempt at delivery, singly or in bulk. Discarding is permanent and does not consult the sink at all; replaying is the only route that resends a letter.

These routes answer for any id that still holds letters, whether or not its network resource exists: a deleted network’s letters (including the ones its own delete minted from pending gate emissions) stay listable, replayable (the sink resource outlives the network), and discardable. An id with neither a network resource nor letters is 404 not_found, so a typo’d id still refuses rather than answering an empty list.

GET takes offset and limit query parameters (limit defaults to 100) and pages the queue in insertion order:

curl -H "Authorization: Bearer $TOKEN" \
  "http://127.0.0.1:8080/networks/sepolia/dead-letters?offset=0&limit=50"

Response 200:

{
  "entries": [
    {
      "match_id": "5f1136ec114001c7f726218b59527365",
      "monitor": "usdc-sepolia-transfers",
      "sink": "ops-slack",
      "cursor": { "primary": 11424039, "secondary": 54 },
      "attempts": 5,
      "reason": "permanent: webhook host returned 500 five times",
      "payload": { "type": "match", "id": "5f1136ec...", "monitor": "usdc-sepolia-transfers", "network": "sepolia", "event": {} }
    }
  ]
}

payload is omitted for a letter recorded before replay support existed, which is exactly the case replay below refuses outright, since there is nothing to resend. A stored payload is a tagged SinkEvent (type: match or type: retracted). Legacy rows that stored a bare Match object still load as type: match.

POST /networks/{id}/dead-letters/{match_id}/replay

Rebuilds the letter’s sink from its stored config and re-enters the same retry path a live delivery uses. Success deletes the letter: 204. Exhaustion leaves it queued with attempts and reason updated in place and answers 502 replay_failed. A letter with no stored payload refuses as 422 invalid_resource with the message "dead letter has no replay payload (recorded before replay support)" rather than attempting anything. A letter whose payload is a retraction refuses as 422 invalid_resource with "dead letter payload is a retraction; only match events can be replayed".

Identity is (match_id, sink): MatchId is not unique across sinks. ?sink= may be omitted when that match id is unique on the network; otherwise the call answers 409 conflict with "match '{id}' is dead-lettered for more than one sink; pass sink to name the row". The same query applies to the single-letter DELETE below.

curl -X POST \
  "http://127.0.0.1:8080/networks/sepolia/dead-letters/5f1136ec114001c7f726218b59527365/replay?sink=ops-slack" \
  -H "Authorization: Bearer $TOKEN"
DELETE /networks/{id}/dead-letters

Discards every letter matching the optional sink and monitor query filters, without attempting delivery on any of them. Absent filters means “any”; both present combine as AND. Nothing matching discards zero, which is not an error.

Response 200:

{ "discarded": 3 }
curl -X DELETE \
  "http://127.0.0.1:8080/networks/sepolia/dead-letters?sink=ops-slack" \
  -H "Authorization: Bearer $TOKEN"
DELETE /networks/{id}/dead-letters/{match_id}

Discards one letter by its match id, without attempting delivery. The optional ?sink= query is the same disambiguation as replay: omit it only when the match id is unique.

Response: 204 on success.

Errors: 404 not_found when no letter with that match id (and sink, if given) exists — which is what lets a caller tell a race against a concurrent replay from a success. 409 conflict when the match id is queued for more than one sink and sink was omitted.

curl -X DELETE \
  "http://127.0.0.1:8080/networks/sepolia/dead-letters/5f1136ec114001c7f726218b59527365?sink=ops-slack" \
  -H "Authorization: Bearer $TOKEN"

Observability

blockwatcher exposes two independent views of a running instance: the HTTP API’s GET /status (a point-in-time JSON snapshot of every pipeline, covered on that page) and a Prometheus scrape endpoint. Pipeline events appear there as cumulative counters and gauges since process start; the RPC pool also exports a per-attempt duration histogram. This page covers the scrape endpoint: how to turn it on, exactly what it exports, and what to alert on.

flowchart LR
    rpc["RPC endpoints<br/>(external chains)"]
    %% Source modules are family-owned; the running list is GET /catalog, not this map.
    sources["Sources<br/>one per network"]
    sinks["Sinks<br/>webhook · script · log"]
    storage["Storage<br/>checkpoints · dead letters · resources"]
    api["REST API"]
    metrics["Metrics"]
    engine["engine<br/>bounded channels · checkpoints"]

    subgraph pipeline["Engine pipeline"]
        direction LR
        decoder["Decoder"]
        matcher["Matcher<br/>predicates"]
        gate["Gate<br/>threshold · max_once"]
        decoder --> matcher
        matcher --> gate
    end

    rpc --> sources
    sources -->|"decode and match"| decoder
    gate --> sinks
    api -->|"manages resources"| storage
    storage <--> engine
    engine -->|"drives"| pipeline
    engine -.->|"reports"| metrics

    classDef module fill:none,stroke:#a9a3e3
    classDef core fill:none,stroke:#8a8d86,stroke-dasharray: 5 5

    class rpc,sources,sinks,decoder,matcher,gate module
    class engine,api,storage,metrics core
classDef dim fill:none,stroke:#999999,color:#999999,opacity:0.35
classDef focus fill:#ffd43b,stroke:#d9480f,stroke-width:3px,color:#1a1a1a
class rpc,sources,sinks,storage,api,engine,decoder,matcher,gate dim
class metrics focus
click sources "../concepts/selectors.html"
click decoder "../concepts/chain-agnosticism.html"
click matcher "../concepts/predicates.html"
click gate "../concepts/gates.html"
click sinks "../concepts/delivery.html"
click storage "../concepts/resources.html"
click api "http-api.html"
click metrics "observability.html"
click engine "../concepts/pipeline.html"

Key takeaways

  • blockwatcher exposes two independent views of a running instance: /status (point-in-time JSON) and /metrics (cumulative Prometheus counters and gauges), forked from the same events but never derived from each other.
  • The metrics listener is disabled by default and, once enabled, runs on its own socket with no authentication and no relation to the API’s bearer token.
  • Every pipeline metric is defined once as a named constant and carries a pipeline label; sink-scoped emissions also carry a sink label except the counters marked “pipeline only”.
  • blockwatcher-rpc’s connection pool publishes counters and a per-attempt duration histogram, and evm-mempool sources publish their own metrics, under separate prefixes outside the per-network pipeline contract.
  • A rising dead-letter count, a growing in_flight_events, and a restart failure are the alerting signals this page calls out explicitly.

Turning it on: [metrics]

Disabled by default, for the same reason [api] is: a listener nobody asked for is a listener nobody remembered to firewall.

[metrics]
enabled = true
listen = "127.0.0.1:9090"

Once enabled, GET /metrics on listen serves Prometheus exposition text (crates/blockwatcher-metrics/src/lib.rs): no path prefix, no authentication, and no relation to the API’s labelled [auth] table. The metrics listener is a separate axum::Router on its own socket, bound independently of the control plane. A deployment that wants the scrape endpoint restricted to its own network should bind it to a private address or firewall the port; blockwatcher itself puts nothing in front of it. Recorder upkeep (flushing accumulated Prometheus internals) runs on a 5-second interval for as long as the process is up, so a scrape always sees fresh state rather than data queued since the last request. See Configuration reference for the full [metrics] section and its BLOCKWATCHER_METRICS__LISTEN override.

The pipeline metric contract

Every metric below is defined once, as a named constant, in crates/blockwatcher-core/src/metrics.rs: the module’s own doc comment calls these names “a contract: an exporter and its dashboards are built against them,” fixed once shipped. Every emission carries a pipeline label naming the network id; an emission scoped to one sink also carries a sink label, except the rows marked “pipeline only” below, which stay pipeline-scoped even though the event they count is sink-adjacent. An emission scoped to one gate also carries monitor and gate (module name).

Full metric reference
MetricTypeLabelsMeaning
blockwatcher_events_decoded_totalcounterpipelineOne raw payload decoded successfully against one active monitor’s selector. Counted per monitor-decode attempt, not per raw event: one payload against three active monitors adds up to three.
blockwatcher_events_undecodable_totalcounterpipelineA payload a monitor’s selector could not decode, counted per monitor-decode attempt for the same reason as above.
blockwatcher_matches_totalcounterpipelineA decoded event whose monitor had no predicate, or whose predicate evaluated true.
blockwatcher_match_errors_totalcounterpipelineA predicate that type-checked at write time nonetheless failed to evaluate at runtime. Should stay at zero; a non-zero value points at a matcher bug, not an operator mistake.
blockwatcher_deliveries_totalcounterpipeline, sinkA sink event delivered successfully to one sink: a lone match, a retraction, or a digest. A Digest counts as one delivery however many matches it bundled: this counter tracks deliveries, not matches, so an aggregating sink’s value here is lower than its match count by design.
blockwatcher_digest_deliveries_totalcounterpipeline, sinkA SinkEvent::Digest delivered successfully to one sink. Every digest delivery also increments blockwatcher_deliveries_total once, the same as any other successful delivery; this counter narrows that total to the digest share, so blockwatcher_digest_deliveries_total / blockwatcher_deliveries_total for one sink is the fraction of its deliveries that were batched rather than single matches.
blockwatcher_delivery_retries_totalcounterpipeline, sinkOne delivery attempt that failed and is about to retry, incremented per retry, not per final outcome.
blockwatcher_dead_letters_totalcounterpipeline, sinkA sink event durably recorded as a dead letter: a delivery (match or retraction) that exhausted its retry budget, or a match a throttle policy suppressed without attempting (its record carries attempts 0). A dead-lettered digest adds one row here per contained match, never one row for the digest as a whole.
blockwatcher_dead_letters_pruned_totalcounterpipeline (pipeline only)The dead_letter_retention cap dropped the oldest dead letter(s) for this pipeline as part of the same write that recorded a new one. Deliberately carries no sink label: retention is a per-pipeline cap, not a per-sink one. Fires from either place a dead letter is recorded: a sink’s own delivery exhaustion, or the one-shot retract path during an invalidate. A non-zero rate means an operator can no longer list or replay everything that failed on that pipeline; omitting the key (the default) never fires.
blockwatcher_deliveries_throttled_totalcounterpipeline, sinkA Match a sink’s throttle policy suppressed and dead-lettered instead of attempting, because that sink had already spent its window’s delivery budget. A failed delivery never counts here: only a success spends budget, so this counts admission refusals, not delivery failures. Counted per suppressed match, not per suppressed delivery: a throttled digest of N matches adds N here, agreeing with the N dead-letter rows it produces, even though the digest itself spent only one unit of the throttle budget.
blockwatcher_dispatch_failed_totalcounterpipeline (pipeline only)A match that never reached a sink worker at all: the worker’s channel was closed, or (should be unreachable) the monitor named a sink with no running worker. Distinct from a dead letter: this never got as far as a delivery attempt.
blockwatcher_events_misrouted_totalcounterpipelineA raw event arrived carrying a different network’s id than the pipeline it reached. Always zero in a correctly wired deployment; non-zero means a source is emitting for someone else’s network.
blockwatcher_checkpoint_write_failures_totalcounterpipelineA checkpoint persist that failed against storage and is being retried on a doubling backoff.
blockwatcher_checkpoint_regressions_refused_totalcounterpipelineA checkpoint the writer refused because its cursor was older than one the same writer instance already persisted. Always zero under a correct source; non-zero means a source emitted a regressing cursor mid-run.
blockwatcher_in_flight_eventsgaugepipelineHow many dispatched matches are still outstanding (neither delivered nor dead-lettered), sampled once a second and on every checkpoint publish or retry. A value that never drains is the same stall GET /status’s in_flight_events field shows.
blockwatcher_pipelines_aborted_totalcounterpipeline (pipeline only)A pipeline forced through a hard abort during drain: the shutdown deadline was missed and a straggling task had to be cancelled outright, or a restart’s own drain hit the same deadline. Deliberately carries no sink label: an abort is a property of the whole pipeline, not any one delivery.
blockwatcher_pipelines_quiesce_timeout_totalcounterpipeline (pipeline only)A Storage::quiesce call, made after an escalated drain (or re-attempted for a debt an earlier one left owed) to wait out whatever an aborted task’s storage calls left running, that did not resolve within its budget. Always zero under a healthy backend. Counted once at stop_pipeline itself no matter which caller reached it: a restart, a gate-envelope change or a gated monitor’s delete, and a reorg invalidation all refuse in the same shape on this timeout (spawning a replacement, dropping a held journal, or pruning and retracting, respectively, could each race the outstanding write), leaving the network abandoned. A whole-engine shutdown facing the same timeout only warns and counts it, since the process is exiting either way.
blockwatcher_dead_letter_write_failures_totalcounterpipeline, sinkA dead-letter record itself failed to write and is being retried forever. The only signal an exporter has that a sink’s dead-letter storage is stuck: the retry never gives up on its own.
blockwatcher_pipeline_source_restarts_totalcounterpipelineThe restart supervisor actually respawned this pipeline after its source exited before its own cancellation fired.
blockwatcher_pipeline_source_restart_failures_totalcounterpipelineA restart attempt the supervisor made and that itself failed, for a reason other than the engine shutting down. The network is left with no running pipeline and nothing further will retry it. Distinct from blockwatcher_source_invalidation_failures_total, which counts a failed invalidate rather than a failed crash-restart.
blockwatcher_pipeline_monitor_swap_failures_totalcounterpipelineAn ungated monitor put or delete whose persist succeeded but whose running monitor set could not be republished. The request still returns success; the pipeline keeps serving the previous set until a restart rebuilds it from storage. Distinct from blockwatcher_pipeline_source_restart_failures_total, which counts a pipeline that is no longer running, and from a gate-envelope wipe whose bring-up fails, which returns the error instead of swallowing it.
blockwatcher_source_invalidations_totalcounterpipelineA source returned Invalidated { from }. Counted once per handled invalidate for that network.
blockwatcher_source_invalidation_failures_totalcounterpipelineA handled invalidate that failed to finish retract or rewind. The network is left with no running pipeline and an unrewound checkpoint until an operator acts. Distinct from blockwatcher_pipeline_source_restart_failures_total.
blockwatcher_retracts_totalcounterpipeline, sinkA Retracted event handed to the one-shot invalidate pass. A successful deliver also increments blockwatcher_deliveries_total.
blockwatcher_journal_gap_totalcounterpipelineThe invalidate cursor plus journal_depth did not cover the checkpoint high-water mark, so some delivered match ids were already pruned. Never silent: also logged at error.
blockwatcher_rewinds_refused_totalcounterpipelineA non-zero rate means an invalidation arrived that no checkpoint write could apply backward: its cursor was not behind the stored checkpoint, no checkpoint was stored at all, or the post-drain checkpoint read failed. Stored state was left as it was (an absent checkpoint is not fabricated) and the pipeline restarted; expect replayed duplicates, never gaps. The common cause is benign: the proven fork sits at the tracker’s own newest emitted block, inside the checkpoint’s own range, so refusing the rewind is the correct outcome. Suspect a stalled sink only when refusals on one network pair with retract or dead-letter anomalies there too.
blockwatcher_operator_actions_totalcounteraction, plus pipeline or monitorA control-surface mutation whose persist has succeeded. action is stable snake_case (pause_monitor, resume_monitor, pause_network, resume_network, discard_dead_letter, discard_dead_letters, import_spec). Identity is pipeline (network id) or monitor as appropriate. One counter, not one name per action. import_spec is counted after the spec is persisted, labeled with the network the payload was fetched from.
blockwatcher_gate_hits_totalcounterpipeline, monitor, gateA timestamped hit offered to on_hit.
blockwatcher_gate_emits_totalcounterpipeline, monitor, gateAn Emit applied (one per mint, not per contained match).
blockwatcher_gate_discards_totalcounterpipeline, monitor, gateA Discard applied.
blockwatcher_gate_untimestamped_totalcounterpipeline, monitor, gatePredicate-true, no usable block.timestamp; not inserted.
blockwatcher_gate_hits_dropped_totalcounterpipeline, monitor, gateOldest hold dropped because of the 10_000 cap.
blockwatcher_gate_persist_failed_totalcounterpipeline, monitor, gateStorage put failed; that cursor is stalled.
blockwatcher_gate_decision_invalid_totalcounterpipeline, monitor, gateon_hit returned a decision the engine rejected; this hit discarded. Should stay at zero.
blockwatcher_gate_journal_heldgaugepipeline, monitor, gateHits currently held, set after every evaluation. Sustained values near gate_hits_cap are the signal to look at gate write volume.

That is every metric blockwatcher-core exports through the metrics facade. A test in metrics.rs pins each constant’s string literal against the contract, so a typo in the definition itself cannot silently drift from what a dashboard expects.

The same events, twice

Each pipeline event forks into two independent destinations, neither derived from the other:

flowchart LR
    event["pipeline event<br/>decode, match, deliver..."] --> atomics["PipelineCounters<br/>plain atomics"]
    event --> facade["metrics facade<br/>named constants"]
    atomics --> status["GET /status<br/>point-in-time JSON"]
    facade --> recorder["prometheus recorder<br/>5s flush"]
    recorder --> scrape["GET /metrics<br/>exposition text"]

Every row above has a twin: crates/blockwatcher-core/src/counters.rs’s PipelineCounters holds one plain atomic per status field (decoded, undecodable, matched, match_errors, delivered, dead_lettered, dispatch_failed, checkpoint_write_failed, dead_letter_write_failed, misrouted, checkpoint_regressions_refused, untimestamped, gate_persist_failed, gate_hits_dropped, gated, gate_emitted); the Prometheus contract additionally exposes in_flight_events, pipelines_aborted, the restart counters, the monitor-swap-failure counter, the invalidation-failure counter, the invalidate / retract / journal-gap counters, dead_letters_pruned, and deliveries_throttled and digest_deliveries, which have no per-network JSON counterpart), and both are incremented from the same call site. The atomics feed GET /status’s counters object directly: a point-in-time read, no labels, no cardinality cost, nothing to scrape, while the Prometheus constants feed a time series an alerting rule can take a rate() of. Neither is derived from the other; they are two independent destinations for the same underlying event, which is why the numbers agree at any instant but serve different jobs: reach for /status when debugging one network right now, and for /metrics when watching every network’s trend over time.

Other metrics on this endpoint

blockwatcher-rpc’s connection pool (the load-balancing and circuit-breaking layer evm-rpc, evm-mempool, evm-subgraph, and stellar-rpc sit on) publishes its own operational metrics onto the same process-wide recorder, under a blockwatcher_rpc_ prefix (crates/blockwatcher-rpc/src/pool.rs). They are not part of the per-network contract above and carry an endpoint label (the RPC endpoint name from a network’s endpoints[] config) rather than pipeline:

MetricLabelsMeaning
blockwatcher_rpc_attempts_totalendpointOne attempt execute made against an endpoint, whether it succeeded or not.
blockwatcher_rpc_attempt_duration_secondsendpointHistogram of that attempt’s duration in seconds. Recorded for every attempt that hits the wire: success, timeout, and classified failure. Default Prometheus buckets (5ms–10s).
blockwatcher_rpc_attempt_failures_totalendpoint, classAn attempt that did not succeed; class is the failure’s ErrorClass rendering, or "timeout" for the attempt’s own deadline elapsing.
blockwatcher_rpc_breaker_opened_totalendpointA consecutive-failure streak that tripped that endpoint’s breaker from admitting to open.
blockwatcher_rpc_rate_limit_waits_total(none)An execute iteration that slept because every candidate endpoint was rate-limited: pool-wide, not attributable to one endpoint.
blockwatcher_rpc_exhausted_totalreasonAn execute call that ended with no served value; reason is deadline_exceeded, no_endpoint_available, or pin_unavailable.
blockwatcher_rpc_probe_failures_totalendpointA periodic health probe against an endpoint that did not produce a value.

evm-rpc sources publish metrics of their own under a blockwatcher_evm_ prefix (crates/blockwatcher-evm/src/source/rpc/run.rs); the reorg counter carries pipeline and depth, the skip counter carries only pipeline, and the contradiction and range-split counters carry pipeline and endpoint:

MetricLabelsMeaning
blockwatcher_evm_reorgs_totalpipeline, depthOne linkage break this source classified. depth is within_confirmations (retried in place), beyond_confirmations (proven fork, Invalidated { from }), or beyond_window (no tracked ancestor matched; Invalidated { from } just below the oldest tracked height, bounded by the tracker’s own depth rather than a proven fork block).
blockwatcher_evm_bloom_skips_totalpipelineOne leaf window whose eth_getLogs call was skipped because every fetched header’s logsBloom refuted every monitored address, or every monitored topic0, in the filter: either dimension refuted alone already rules out a match, since eth_getLogs requires both to hold. Counted once the window’s fetch returns, whether or not the window later survives reorg verification and is actually emitted.
blockwatcher_evm_bloom_contradictions_totalpipeline, endpointOne log whose own block’s fetched bloom failed to admit that log’s own address and topic0, proof that the named endpoint’s blooms do not describe the logs it returns. The first one disables bloom_screen for the running source instance that observed it; a nonzero rate afterward on the same running instance means every window since has been fetched in full rather than screened. That disabling does not survive a restart of the pipeline’s source, so it is not a durable fix on its own; see the alerting hint below.
blockwatcher_evm_range_splits_totalpipeline, endpointOne leaf a provider forced out of a wider eth_getLogs range it refused to serve in full. Counted once per leaf (so a window split into three leaves increments this twice, for the two beyond the first), against the endpoint that forced the split.

evm-mempool sources publish metrics of their own, under the same blockwatcher_evm_ prefix (crates/blockwatcher-evm/src/source/mempool/run.rs). The skip counter carries endpoint; the reconnect counter does not, since a lost subscription is not attributable to one endpoint:

MetricLabelsMeaning
blockwatcher_evm_mempool_skips_totalpipeline, endpoint, reasonOne subscription hash that produced no emission for a reason outside this source’s control; reason is gone (the lookup answered null because the transaction was mined or evicted first) or lookup_failed (the pool gave up). Neither is an error: a pending stream is best-effort by nature.
blockwatcher_evm_mempool_reconnects_totalpipeline, reasonOne lost WebSocket subscription this source had to reconnect from; reason is dial_failed, stream_closed, transport_error, or idle_timeout.

Alerting hints

Dead-letter count rising. increase(blockwatcher_dead_letters_total[15m]) > 0 sustained, for one sink label, means that sink is failing permanently or exhausting retries, not a one-off blip. Pull the reason off GET /networks/{id}/dead-letters (each entry’s reason string leads with transient: or permanent:) before deciding whether to fix the sink target or just replay.

A sink’s throttle is swallowing everything. increase(blockwatcher_deliveries_throttled_total[15m]) sustained at roughly the same rate as increase(blockwatcher_matches_total[15m]) for one sink label means that sink’s throttle policy is dead-lettering nearly every match rather than occasionally shedding a burst. Widen max_deliveries or window_ms for that sink, or pause the monitors feeding it, rather than leaving it to accumulate dead letters that need replaying later. This match-for-match comparison only holds for a sink with no aggregate policy: blockwatcher_deliveries_throttled_total counts suppressed matches while blockwatcher_deliveries_total counts a digest as one delivery regardless of how many matches it carried, so for an aggregating sink the throttled-versus-delivered ratio is not match-over-match. Compare against blockwatcher_digest_deliveries_total instead to see how many of that sink’s deliveries were digests in the first place.

Checkpoint lag growing. Watch blockwatcher_in_flight_events for a value that climbs and never comes back down, alongside blockwatcher_checkpoint_write_failures_total ticking upward. The first says a delivery is stuck open (check which sink’s queue is backed up), and the second says storage itself is the problem; either one means the network’s checkpoint has stopped advancing, which is exactly the signal GET /status’s lag field surfaces from the other side.

Threshold never fires. Check blockwatcher_gate_untimestamped_total, whether count is higher than traffic, and whether invalidate drained holds (must not). Compare event timestamps, not wall clock. GET /status pipeline counters gated (Retain+Discard) and gate_emitted (one per Emit) are the point-in-time twins.

Checkpoint stuck and persist_failed ticking. Same class as a closed sink: fix storage; do not skip the event. Watch blockwatcher_gate_persist_failed_total.

Tip reorg “lost” a burst. If holds with cursor ≤ from were dropped, that is a bug (drain-all). Replay will not restore them.

Gate log lines carry network, monitor, and gate (module name).

A network’s source has stopped coming back. blockwatcher_pipeline_source_restart_failures_total > 0 for a pipeline label means the restart supervisor tried and failed to bring that network’s source back up, and, per the metric’s own contract, nothing further will retry it on its own; the pipeline is down until an operator intervenes. This is a stronger signal than an occasional blockwatcher_pipeline_source_restarts_total tick, which just means a source exited and was cleanly respawned. A failed invalidate is a different abandonment: watch blockwatcher_source_invalidation_failures_total for that.

A monitor write returned success but the running set did not change. blockwatcher_pipeline_monitor_swap_failures_total > 0 for a pipeline label means an ungated put or delete persisted, then failed to republish the compiled monitor set. The pipeline is still running the previous set until a restart rebuilds it from storage. This is not a restart failure: the source is up, serving stale config.

A deep invalidate failed to finish. blockwatcher_source_invalidation_failures_total > 0 for a pipeline label means retract or rewind did not complete. The checkpoint is left unrewound and the source is not restarted; this is not a crash-restart failure. Inspect dead letters for type: retracted payloads, then intervene.

A deep invalidate left a journal gap. increase(blockwatcher_journal_gap_total[15m]) > 0 for a pipeline label means that network’s invalidate cursor plus journal_depth did not cover the checkpoint high-water mark, so some already-delivered match ids cannot be retracted. The matching error log names the network, from, the checkpoint, and the configured depth. Raise journal_depth (it must stay well above confirmation depth) and treat consumer-side undo for ids older than the window as an operator problem: the engine will still rewind and restart.

A source keeps invalidating. increase(blockwatcher_source_invalidations_total[15m]) climbing on one pipeline is unusual for a quiet chain; pair it with blockwatcher_retracts_total to see whether sinks are actually receiving undos. An unrecovered retract leaves the checkpoint unrewound: watch blockwatcher_source_invalidation_failures_total, blockwatcher_dead_letters_total, and in_flight_events on that network rather than expecting a restart.

A reorg past the tracked window. increase(blockwatcher_evm_reorgs_total[15m]) with depth="beyond_window" on one pipeline means no tracked ancestor matched the live chain. The source invalidates from just below the oldest tracked height, a rewind bounded by the tracker’s own depth rather than genesis: the engine retracts journaled deliveries above that bound and restarts the source, the same path as any other invalidation, so expect blockwatcher_source_invalidations_total to tick alongside this depth label.

A bloom-skip rate of zero is not itself a fault. blockwatcher_evm_bloom_skips_total staying flat on a network with a selective monitor (one whose filter names addresses or topic0s) and an active chain is informational, not a fault: it means bloom_screen is disabled for that network, or the merged filter is broad enough (no addresses and no topic0s) that there is nothing left for a header’s bloom to refute. Read it alongside the filter a network’s monitors actually produce before treating it as a signal of anything gone wrong.

A bloom-contradiction count that is not zero. increase(blockwatcher_evm_bloom_contradictions_total[15m]) > 0 for a pipeline and endpoint pair means that endpoint served a header bloom that failed to admit a log it returned for the same block, which can only happen when its blooms do not describe its own logs. Two things follow, and neither is automatic:

  • The running source instance that observed the contradiction has already disabled bloom_screen for itself, so no further window on that particular instance is at risk. That protection belongs to the instance, not the endpoint or the process: a restart of that pipeline’s source (the supervisor recovering from an exit, a proven-reorg invalidation, or a monitor change that escalates to a restart) builds a fresh instance with screening enabled again, against the same endpoint that already proved its blooms unreliable. Set bloom_screen = false for that network to make the disabling survive a restart; nothing else does.
  • The contradiction proves the endpoint’s blooms are wrong, but says nothing about when the endpoint started serving wrong ones. Every window this source screened earlier in its run rested on the same untrustworthy blooms; the contradiction is only the first one this source happened to catch because a later window’s own headers still forced a real fetch. Read blockwatcher_evm_bloom_skips_total for the same pipeline to see how many windows were screened before the trip, and re-scan that range through POST /monitors/{id}/test’s fetch mode (which never screens) or a fresh network with start_block covering it: the checkpoint has already advanced past those windows, and nothing rewinds them on its own.

Read the matching warning log for the network, endpoint, the contradicting window’s own block range, the first contradicting block, and the contradiction count, and treat that endpoint’s blooms as untrusted for any other purpose too.

An evm-mempool connection is flapping. increase(blockwatcher_evm_mempool_reconnects_total[15m]) > N for a pipeline label means that network’s WebSocket subscription keeps dropping and reconnecting; GET /status’s source.status only ever shows whichever state (live, degraded, catching_up) is current at scrape time, so a short-lived flap between two scrapes is invisible there even though it loses whatever was pending in the node’s mempool during the gap.

Running with Docker

docker/ ships a two-service Compose stack (blockwatcher itself, plus a companion web UI), meant as a runnable local demo, not a production manifest. It has its own instance config, its own Dockerfiles, and its own .env, and it seeds examples/compose-demo on first boot: Sepolia USDC Transfer and Stellar Testnet native XLM (classic Payment, SAC transfer events, SAC transfer invocations). Every match is delivered to the dashboard’s ui-ingest webhook. Pause either network on the dashboard if you only want one pipeline. To attach the dashboard to a cargo run example instead of this stack, see The dashboard.

The stack: docker/compose.yaml

services:
  blockwatcher:
    build:
      context: ..
      dockerfile: docker/Dockerfile.blockwatcher
    command: ["--config", "/etc/blockwatcher/blockwatcher.toml", "--seed", "/opt/compose-demo/resources"]
    env_file:
      - .env
    environment:
      UI_INGEST_URL: http://ui:8080/ingest
      UI_INGEST_SECRET: ${UI_INGEST_SECRET:?set it in docker/.env}
    volumes:
      - ./blockwatcher.toml:/etc/blockwatcher/blockwatcher.toml:ro
      - ../examples/compose-demo:/opt/compose-demo:ro
      - blockwatcher-data:/data
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
      interval: 5s
      timeout: 3s
      retries: 30
    restart: unless-stopped

  ui:
    build:
      context: ..
      dockerfile: docker/Dockerfile.ui
    environment:
      BLOCKWATCHER_API_URL: http://blockwatcher:8080
      BLOCKWATCHER_API_TOKEN: ${BLOCKWATCHER_API_TOKEN:?set it in docker/.env}
      UI_OPERATOR_SECRET: ${UI_OPERATOR_SECRET:?set it in docker/.env}
      UI_INGEST_SECRET: ${UI_INGEST_SECRET:?set it in docker/.env}
      RUST_LOG: info
      UI_ALLOWED_HOSTS: "127.0.0.1,localhost,[::1],ui:8080"
    ports:
      - "127.0.0.1:8080:8080"
    volumes:
      - ui-data:/data
    depends_on:
      blockwatcher:
        condition: service_healthy
    restart: unless-stopped

volumes:
  blockwatcher-data:
  ui-data:

The container and volume topology (a different picture from The dashboard’s application-level request flow, which this stack’s ui service is the one running) looks like this:

flowchart LR
    subgraph blockwatcher_c["blockwatcher service, no published port"]
        blockwatcherbin["blockwatcher binary"]
        blockwatchervol[("blockwatcher-data<br/>/data/blockwatcher.db")]
    end
    subgraph ui_c["ui service, 127.0.0.1:8080"]
        uibin["ui server"]
        uivol[("ui-data<br/>/data/ui.db")]
    end

    blockwatcher_c -->|"depends_on:<br/>service_healthy"| ui_c
    ui_c -->|"REST proxy<br/>to blockwatcher:8080"| blockwatcher_c
    blockwatcher_c -->|"webhook POST<br/>to ui:8080/ingest"| ui_c

Both services build from the repository root (context: ..) so each Dockerfile can COPY whichever part of the workspace it needs: the blockwatcher service’s build only touches crates/; the ui service’s build touches ui/.

blockwatcher boots with --config /etc/blockwatcher/blockwatcher.toml (the file bind-mounted read-only from ./blockwatcher.toml) and --seed /opt/compose-demo/resources, the example’s resource directory bind-mounted read-only from ../examples/compose-demo (what that seed loads, and when, is covered below). env_file: .env is where BLOCKWATCHER_API_TOKEN comes from; UI_INGEST_URL is a value blockwatcher itself never reads at boot: it’s resolved later, at delivery time, once the ui service asks blockwatcher to create a sink. Its healthcheck curls its own /health (the one route the HTTP API serves without a bearer token), five seconds apart, up to 30 times, which is what ui’s depends_on: condition: service_healthy waits on before starting. blockwatcher-data is the one persistent volume: it holds /data/blockwatcher.db, the SQLite store named in blockwatcher.toml below, so pipeline state, resources, and checkpoints survive a docker compose restart or a rebuild. Note there is no ports: entry for this service at all: its API is reachable from ui over the compose network at http://blockwatcher:8080, but not published to the host; reaching it directly from outside the stack means adding a port mapping yourself or running docker compose exec blockwatcher ….

ui is the only service exposed to the host, and only on the loopback interface (127.0.0.1:8080:8080, not 0.0.0.0). BLOCKWATCHER_API_TOKEN, UI_OPERATOR_SECRET, and UI_INGEST_SECRET are required at compose-parse time (:?set it in docker/.env); the last two must not be the same value.

UI_ALLOWED_HOSTS and UI_INGEST_URL work together, and neither makes sense read alone. On its own first boot (ui/server/src/main.rs), the ui service calls blockwatcher’s API to create a webhook sink named ui-ingest, whose url_secret names UI_INGEST_URL (a reference blockwatcher resolves in its own environment, not the UI’s), which is exactly why the blockwatcher service (not ui) is the one carrying that variable above. From then on, every match any monitor produces that names ui-ingest among its actions gets delivered as a webhook request from the blockwatcher container straight into ui, addressed by compose service name and arriving with Host: ui:8080 (a header this companion checks against an allowlist that defaults to loopback names only) (ui/server/src/guard.rs, ui/server/src/config.rs). Ingest also presents x-blockwatcher-ingest from UI_INGEST_SECRET; operators reach /api with a session from UI_OPERATOR_SECRET. The Host/Origin guard is what keeps a DNS-rebinding page from looking like this host. Without UI_ALLOWED_HOSTS naming ui:8080 explicitly, that legitimate ingest call would be indistinguishable from an attack and rejected identically. ui-data is this service’s own persistent volume, separate from blockwatcher’s: the two services never share a data directory.

The two Dockerfiles

docker/Dockerfile.blockwatcher: a two-stage build. The first stage (rust:1.98-bookworm) copies just Cargo.toml, Cargo.lock, and crates/, then cargo build --release -p blockwatcher: no ui/ in this stage’s build context, since the binary crate doesn’t need it. The second stage (debian:bookworm-slim) installs only ca-certificates (for outbound TLS to an RPC provider) and curl (for the healthcheck above), copies the release binary in, and sets it as ENTRYPOINT. Two stages, not one, is what keeps the shipped image free of the Rust toolchain and the crate source it was built from.

docker/Dockerfile.ui: three stages, because the UI is two components. node:22-bookworm builds the web assets (npm ci, npm run build) from ui/web; a second, independent rust:1.98-bookworm stage builds ui/server; the final debian:bookworm-slim stage copies the server binary and the web build’s dist/ output into one image, EXPOSEs 8080, and sets three environment defaults (UI_STATIC_DIR=/app/static, UI_BIND=0.0.0.0:8080, UI_DB_PATH=/data/ui.db) that the compose file above never overrides, so they’re exactly what the running container uses.

The same two Dockerfiles are what a vX.Y.Z tag pushes to GHCR as ghcr.io/thethirdorigin/blockwatcher and ghcr.io/thethirdorigin/blockwatcher-ui, both tagged with the workspace version. The UI image tag is the release it shipped with, not ui/server’s own crate version.

docker/blockwatcher.toml: the containerized instance config

[api]
enabled = true
listen = "0.0.0.0:8080"

[[auth.tokens]]
label = "compose"
scope = "admin"
secret = "env:BLOCKWATCHER_API_TOKEN"

[storage]
module = "sqlite"
config = { path = "/data/blockwatcher.db" }

Two differences from the annotated example on the Configuration reference page are worth calling out. First, [api].listen binds 0.0.0.0, not 127.0.0.1: correct inside a container, where “loopback” means only the container’s own network namespace and would make the ui service’s cross-container call unreachable; the compose file is what keeps this from being exposed carelessly, since blockwatcher publishes no host port at all. Second, there is no [metrics] section here, so the Prometheus scrape endpoint described on the Observability page stays at its default (disabled) in this stack; enabling it means adding the section to this file and, if it should be reachable from outside the container, a ports: mapping in compose.yaml to go with it. There is also no [engine] section, so every engine tunable (channel capacities, drain deadline, retry policy, the matcher module) runs at whatever blockwatcher-core’s own defaults are. docker/ itself carries no resources/ tree: the network, spec, sink, and monitor JSON the stack seeds is examples/compose-demo’s, bind-mounted in, and once the first boot has loaded it the ui service is how those four resource kinds are created and edited against the running blockwatcher API.

Seeding under compose

The command: in compose.yaml passes --seed /opt/compose-demo/resources, the read-only mount of examples/compose-demo/resources: Sepolia and Stellar Testnet networks, their specs, the ui-ingest webhook, and four monitors that all name that sink. --seed is a one-time, first-boot-only load (it never touches a store that already holds anything, see Seed), so the seed lands exactly once: every later docker compose up boots with whatever blockwatcher-data’s SQLite file already holds, and the running instance is authoritative from then on. A fresh seed therefore takes a docker compose down -v first, which discards both data volumes. Pause a network on the dashboard to stop that pipeline without wiping the store. To seed a different resource directory instead, point the bind mount (and the --seed path) at it before the first boot, or POST resources through the ui service’s API-proxying UI once it is healthy.

Running it

cp docker/.env.example docker/.env

Edit docker/.env and set BLOCKWATCHER_API_TOKEN to any string, UI_OPERATOR_SECRET and UI_INGEST_SECRET to two different strings (compose interpolates all three with :?set it in docker/.env, and the companion refuses to boot if the last two are equal), and SEPOLIA_RPC_URL to an RPC endpoint and STELLAR_RPC_URL to a stellar-rpc URL, the variables the seeded networks’ url_secret name. Any other url_secret a resource names later has to resolve from this same .env too, since env_file puts every variable in it into the blockwatcher container’s environment whether blockwatcher itself defines it or not.

Then stamp recent cursors into both seeded networks (each start is absolute, and a stale one replays history):

./examples/compose-demo/setup.sh
docker compose -f docker/compose.yaml up --build -d

The dashboard becomes reachable at http://127.0.0.1:8080 as soon as the ui process binds that port — Compose depends_on: service_healthy only starts the process, it does not wait for ui-ingest to register. Until that sink exists, the published port answers 503 with a waiting page (Retry-After: 3) rather than resetting the TCP handshake. Matches appear there as Sepolia produces USDC transfers and Stellar Testnet produces native XLM payments and SAC transfers. Pause sepolia or stellar-testnet on Networks to run one pipeline. The script-sink contract is a separate example, sink-script-monitor. docker compose down -v removes both named volumes along with the containers (and re-arms the first-boot seed); drop -v to keep blockwatcher-data/ui-data across a teardown.

Monitoring ERC-20 transfers [RPC]

Your first monitor already walked examples/source-evm-rpc-monitor/ end to end: running it, reading the output, narrowing a predicate through the API. This page takes a different pass at the same directory: every file, reproduced in full, annotated field by field, as a reference for what each line actually does rather than a “do this next” narrative. Read that page first if you haven’t run the example yet; read this one when you want to know exactly what you’re looking at.

source-evm-rpc-monitor/
├── .env.example
├── blockwatcher.toml
├── setup.sh
└── resources/
    ├── networks/sepolia.json
    ├── specs/usdc-erc20.json
    ├── sinks/log-sink.json
    └── monitors/usdc-sepolia-transfers.json

.env.example

SEPOLIA_RPC_URL=https://sepolia.infura.io/v3/YOUR_API_KEY
BLOCKWATCHER_API_TOKEN=local-test-token

Two variables, both required. Nothing under resources/ ever holds SEPOLIA_RPC_URL’s actual value: resources/networks/sepolia.json below names the variable itself, and blockwatcher resolves it from its own process environment the moment it needs it. That indirection exists because the URL usually is the credential: most providers put the API key somewhere in the path or query string, so the file that would otherwise carry it stays out of the resource entirely and out of anything that might get committed. BLOCKWATCHER_API_TOKEN can be any string here: the example’s blockwatcher.toml binds the API to 127.0.0.1 only, so there is no network exposure to defend against, just a token the example’s own curl commands need to match.

blockwatcher.toml

[api]
enabled = true
listen = "127.0.0.1:8080"

[[auth.tokens]]
label = "operator"
scope = "admin"
secret = "env:BLOCKWATCHER_API_TOKEN"

[metrics]
enabled = true
listen = "127.0.0.1:9090"

[storage]
module = "sqlite"
config = { path = "blockwatcher.db" }

[engine]
event_channel_capacity = 1000
sink_channel_capacity = 100
drain_deadline_ms = 5000
matcher = { module = "expr", config = {} }

Every section here is covered generally on the Configuration reference; what’s worth noting about this instance of it: [metrics] is turned on (unlike the Docker stack’s config, which leaves it off), so curl localhost:9090/metrics works alongside the API without any extra setup (see Observability for what’s on that endpoint). [storage].config.path is a bare relative filename, blockwatcher.db, so the database lands next to wherever the process’s current directory is when it starts: inside examples/source-evm-rpc-monitor/ if you cd there first, per the running instructions. [engine]’s three explicit values (event_channel_capacity, sink_channel_capacity, drain_deadline_ms) each differ from EngineConfig’s own defaults (256/64/10000 respectively, see Configuration reference); an empty [engine] section would boot with those defaults instead.

setup.sh

#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"

[ -f .env ] || { echo "error: no .env — run: cp .env.example .env, then edit it" >&2; exit 1; }
set -a; . ./.env; set +a

: "${SEPOLIA_RPC_URL:?error: SEPOLIA_RPC_URL is unset in .env}"
case "$SEPOLIA_RPC_URL" in
  *YOUR_API_KEY*) echo "error: SEPOLIA_RPC_URL still holds the placeholder YOUR_API_KEY" >&2; exit 1 ;;
  http://*|https://*) ;;
  *) echo "error: SEPOLIA_RPC_URL must be an http(s) URL, not a ${SEPOLIA_RPC_URL%%:*}: one" >&2; exit 1 ;;
esac

head_hex=$(curl -fsS "$SEPOLIA_RPC_URL" \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \
  | jq -re '.result') || { echo "error: the endpoint did not answer eth_blockNumber" >&2; exit 1; }

start=$(( head_hex - 20 ))

net=resources/networks/sepolia.json
tmp=$(mktemp)
jq --argjson b "$start" '.source.config.start_block = $b' "$net" > "$tmp" && mv "$tmp" "$net"

(Trimmed of its own comments and echo lines above, the repository copy explains its own reasoning inline.) The script is three checks and one mutation, in order: a .env file exists at all; the RPC URL is set, isn’t still the literal placeholder, and has a scheme that’s actually http(s) rather than, say, a stray ws:// pasted from the wrong example; and a live eth_blockNumber call succeeds against it, subtracted by 20 blocks and written into sepolia.json’s source.config.start_block in place, using jq and a temp file so a crash mid-write can never leave the resource file truncated. Nothing about this is required for blockwatcher itself to run: it’s a convenience so a fresh clone doesn’t need a human to look up a current Sepolia block number by hand. Running the network file with its original start_block: 11424310 still works if that block hasn’t rolled out of the RPC provider’s retained history; it just costs a longer catching_up wait if 11424310 is very old by the time you try it.

resources/networks/sepolia.json

{
  "id": "sepolia",
  "chain": "evm",
  "source": {
    "module": "evm-rpc",
    "config": {
      "start_block": 11424310,
      "endpoints": [
        {
          "name": "primary",
          "url_secret": "env:SEPOLIA_RPC_URL",
          "priority": "high",
          "rate_limit": { "rps": 10 }
        }
      ],
      "confirmations": 12,
      "max_lag_blocks": 100,
      "poll_interval_ms": 3000,
      "logs_window": { "initial": 1000, "max": 5000 },
      "probe_interval_ms": 30000
    }
  }
}

chain: "evm" selects the evm decoder family; source.module: "evm-rpc" selects confirmed-block scanning over evm-mempool‘s pending stream (see Selectors § The source for what that choice determines about what a selector here can ever match). endpoints is a pool of one: name is this instance’s own label for it (shows up in the blockwatcher_rpc_* metrics’ endpoint label, per Observability); priority: "high" matters once a second endpoint with a lower priority is added: the pool prefers higher-priority candidates and only falls back when they’re rate-limited or breaker-open; rate_limit.rps: 10 caps outbound calls to a level a free-tier provider key tolerates. confirmations: 12 is how many blocks must sit on top of one before its logs are trusted (deeper than the testnet’s own instant-final default, a realistic value for demonstrating a real reorg-safety margin rather than the bare minimum). max_lag_blocks: 100 is the threshold past which source.status reports catching_up instead of live. poll_interval_ms: 3000 is how often the source checks for a new head. logs_window governs how many blocks one eth_getLogs call spans while catching up: starting at 1000 and growing to 5000 as the gap narrows. None of these seven tuning keys are required: every one has its own default, and the quickstart’s network file carries none of them, but each is set here to a number an operator running against a real testnet would actually pick, rather than a value chosen only to demonstrate that the field exists.

resources/specs/usdc-erc20.json

{
  "id": "usdc-erc20",
  "chain": "evm",
  "payload": [
    {
      "type": "event",
      "name": "Transfer",
      "anonymous": false,
      "inputs": [
        { "name": "from",  "type": "address", "indexed": true },
        { "name": "to",    "type": "address", "indexed": true },
        { "name": "value", "type": "uint256", "indexed": false }
      ]
    },
    {
      "type": "event",
      "name": "Approval",
      "anonymous": false,
      "inputs": [
        { "name": "owner",   "type": "address", "indexed": true },
        { "name": "spender", "type": "address", "indexed": true },
        { "name": "value",   "type": "uint256", "indexed": false }
      ]
    }
  ]
}

This is a Solidity ABI fragment list: the raw artifact evm’s decoder compiles once, at write time, into the schema a selector and predicate actually work against. indexed: true on from/to/owner/spender means those parameters live in the log’s topics, not its data; value is indexed: false because ERC-20’s Transfer/Approval standard puts the transferred or approved amount in the log’s data word instead (a deliberate ABI design choice this fragment merely records, not something blockwatcher infers). anonymous: false on both means each event keeps its normal topic0 event signature hash, which is what the decoder actually dispatches on. Only Transfer is ever selected by this example’s monitor; Approval sits in the spec unused, because nothing requires a spec to carry only what one particular monitor asks for: a second monitor on the same network watching approvals could reference this same spec file without it changing at all.

resources/sinks/log-sink.json

{
  "id": "log-sink",
  "module": "log",
  "config": {},
  "retry": {
    "max_attempts": 1,
    "initial_backoff_ms": 100,
    "max_backoff_ms": 1000
  }
}

module: "log" puts each match on the process’s own stdout as a single line of JSON, the shipped sink with the least that can go wrong at delivery time, since there’s no network call and no credential to resolve. config: {} because the log sink takes no configuration at all. retry.max_attempts: 1 means exactly one delivery attempt: on failure (realistically, only a broken stdout pipe) the match dead-letters immediately rather than retrying a failure mode backoff can’t fix. initial_backoff_ms/max_backoff_ms are present but never exercised at max_attempts: 1: they’d matter only if this sink retried, which it doesn’t.

resources/monitors/usdc-sepolia-transfers.json

{
  "id": "usdc-sepolia-transfers",
  "network": "sepolia",
  "selectors": [
    {
      "addresses": ["0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"],
      "spec": "usdc-erc20",
      "events": ["Transfer"]
    }
  ],
  "predicate": "args.value > 0",
  "actions": ["log-sink"]
}

network: "sepolia" ties this monitor to the network resource above: one network, always, per monitor. The one selector entry restricts to a single contract address (Sepolia’s USDC), names usdc-erc20 as the spec to decode against, and lists events: ["Transfer"]: with events present and functions absent, selector compilation fills only the events table with that one name and leaves the functions table empty; Approval, though declared in the spec, is never decoded by this selector because it was never named. In English, the predicate args.value > 0 reads “keep every transfer that moved a nonzero amount” , which in practice is nearly every one, since a zero-value Transfer is legal ABI-wise but rare in the wild; it exists mainly to show that a predicate is present and doing something, not to meaningfully filter this particular feed. actions: ["log-sink"] sends every match to the one sink above; a monitor with more sinks in its actions list would fan the same match out to each of them independently.

Variations

Watch a different contract. The spec is generic ERC-20 (nothing in usdc-erc20.json is USDC-specific), so pointing at a different token on Sepolia only means a different addresses entry on the monitor (or a second monitor entirely, if you want both watched at once). PUT the existing monitor with a new address and a fresh If-Match:

export TOKEN=$BLOCKWATCHER_API_TOKEN

etag=$(curl -s -o /dev/null -w '%header{etag}' \
  localhost:8080/monitors/usdc-sepolia-transfers -H "Authorization: Bearer $TOKEN")

curl -s -X PUT localhost:8080/monitors/usdc-sepolia-transfers \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H "If-Match: $etag" \
  -d '{
    "id": "usdc-sepolia-transfers",
    "network": "sepolia",
    "selectors": [{
      "addresses": ["0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14"],
      "spec": "usdc-erc20",
      "events": ["Transfer"]
    }],
    "predicate": "args.value > 0",
    "actions": ["log-sink"]
  }'

(0xfFf9… is Sepolia’s canonical WETH9: any ERC-20 deployment works the same way.)

A predicate with more than one clause. Your first monitor already showed a single threshold; the predicate language supports combining conditions with &&. To keep only large transfers that don’t originate from a specific address (say, a known exchange hot wallet you want to exclude from the feed):

args.value > 500e6 && args.from != 0xF977814e90dA44bFA03b6295A0616a897441aceC

Both operands type-check against the schema usdc-erc20 compiles: args.value and args.from are both Transfer’s own declared parameters (value a uint256, from an address). Excluding by sender has to go through args.from here, not tx.from: a log-decoded occurrence never carries tx.from/tx.to/tx.value at all (see the selectors comparison table), since nothing in a log’s own envelope holds them; only a functions selector, decoding calldata rather than a log, has a tx.from to read.

Deliver to a webhook instead of the log sink. Add a second sink resource using the webhook module (its url_secret follows the same env:NAME indirection as the network’s RPC URL above), then point the monitor’s actions at it instead of (or alongside) log-sink:

{
  "id": "ops-webhook",
  "module": "webhook",
  "config": {
    "url_secret": "env:OPS_WEBHOOK_URL",
    "headers": { "X-Source": "blockwatcher-source-evm-rpc-monitor" },
    "timeout_ms": 10000
  },
  "retry": { "max_attempts": 5, "initial_backoff_ms": 200, "max_backoff_ms": 30000 }
}

PUT it to /sinks/ops-webhook, export OPS_WEBHOOK_URL in the same shell blockwatcher runs in, then PUT the monitor again with "actions": ["ops-webhook"]. A higher max_attempts than the log sink’s 1 makes sense here: an HTTP endpoint has real transient failure modes (a momentary 503, a timeout) that a retry can actually recover from, unlike a broken stdout pipe.

Monitoring ERC-20 transfers [Mempool]

examples/source-evm-mempool-monitor/ is the evm-mempool twin of examples/source-evm-rpc-monitor: same contract, same usdc-erc20 spec file, same sink shape: watching pending calls to USDC’s transfer function instead of confirmed Transfer events. This page gives it the same file-by-file tour, but leads with what’s different, because what’s different here isn’t cosmetic.

What’s different from watching confirmed events

evm-mempool trades completeness for latency: every fact below is already established in more depth on Selectors § The position problem and Delivery guarantees § The evm-mempool exception; this is the short version an operator needs before running this example:

  • The cursor is a per-run arrival counter, not a chain position. There is no start_block to stamp (a pending transaction has no place in the chain yet), and a restart’s fresh checkpoint starts the counter over, so it can never be replayed across a process boundary.
  • Dedupe on the transaction’s own hash, never on the match id. The same pending call seen again after a restart, or even twice within one run if it gets mined mid-hydration, is minted a different arrival number and therefore a different id.
  • tx.status and every block.* field are always absent. Nothing here has a receipt or a mined block to read them from.
  • tx.index is usually absent, but not always. A call can get mined between the pending-tx notification and the hydration lookup that fetches its full data: the copy that comes back then carries a real transactionIndex it didn’t have a moment earlier.
  • This is why this source falls outside at-least-once delivery. A crash can lose whatever was pending during the gap; nothing resumes it.
  • Resuming replays nothing: there’s no history behind a paused mempool feed to catch up on, unlike a paused evm-rpc network.

.env.example

SEPOLIA_WS_URL=wss://sepolia.infura.io/ws/v3/YOUR_API_KEY
SEPOLIA_RPC_URL=https://sepolia.infura.io/v3/YOUR_API_KEY
BLOCKWATCHER_API_TOKEN=local-test-token

Three variables where the confirmed-events example needed two. The new one, SEPOLIA_WS_URL, is what newPendingTransactions is subscribed over: it must be ws:///wss://, never the http(s) scheme SEPOLIA_RPC_URL uses. The two are usually the same provider project’s websocket and HTTP endpoints; SEPOLIA_RPC_URL here does a different job than in the confirmed-events example: not scanning blocks, but hydrating each pending hash the subscription reports into a full transaction via eth_getTransactionByHash. That lookup happens for every hash the subscription hands over, ahead of anything the monitor’s selector or predicate would otherwise rule out. So spend tracks the chain’s overall mempool arrival rate, not how many of those pending calls end up matching this example’s monitor.

blockwatcher.toml

[api]
enabled = true
listen = "127.0.0.1:8080"

[[auth.tokens]]
label = "operator"
scope = "admin"
secret = "env:BLOCKWATCHER_API_TOKEN"

[metrics]
enabled = true
listen = "127.0.0.1:9090"

[storage]
module = "sqlite"
config = { path = "blockwatcher.db" }

[engine]
event_channel_capacity = 1000
sink_channel_capacity = 100
drain_deadline_ms = 5000
matcher = { module = "expr", config = {} }

Identical, key for key, to source-evm-rpc-monitor’s instance config: the [api]/[metrics]/[storage]/[engine] sections have nothing evm-mempool-specific about them; every difference between the two examples lives in the network resource below, not in the process’s own plumbing.

setup.sh

Unlike the confirmed-events example’s setup.sh, there is no start_block to compute or stamp here: nothing pending has a chain position for a script to look up. What it does instead: confirm .env exists and both URLs are set, confirm SEPOLIA_WS_URL actually uses a ws/wss scheme and SEPOLIA_RPC_URL an http/https one (catching the two swapped, a URL still holding the YOUR_API_KEY placeholder, or either one obviously mistyped), and make one real eth_blockNumber call against the HTTP endpoint to prove it answers. It cannot exercise the WebSocket endpoint itself, since only blockwatcher’s own boot ever dials that one, so the script’s own final message says as much rather than implying a check it didn’t perform.

resources/networks/sepolia-mempool.json

{
  "id": "sepolia-mempool",
  "chain": "evm",
  "source": {
    "module": "evm-mempool",
    "config": {
      "ws_url_secret": "env:SEPOLIA_WS_URL",
      "endpoints": [
        {
          "name": "primary",
          "url_secret": "env:SEPOLIA_RPC_URL",
          "priority": "high",
          "rate_limit": { "rps": 10 }
        }
      ],
      "reconnect_ms": 1000,
      "idle_policy": {
        "ping_after_ms": 30000,
        "pong_deadline_ms": 10000
      }
    }
  }
}

source.module: "evm-mempool" is the one field that changes which raw material a selector on this network can ever see. See Selectors § The source. ws_url_secret names the subscription endpoint, resolved the same env:NAME way as every other secret reference in this config; endpoints is the same EndpointDef pool shape evm-rpc uses, here doing hydration calls rather than log/block scans. Three keys have no counterpart in the confirmed-events example’s network config at all: reconnect_ms is the delay before redialing after the WebSocket connection drops; idle_policy.ping_after_ms is how long the connection can go without a frame before blockwatcher sends its own ping to check it’s still alive; idle_policy.pong_deadline_ms is how long it then waits for the pong before declaring the connection dead and reconnecting. When any of the three losses above happens, GET /status reports source.status: degraded with a reason string that’s the full, human-readable error (e.g. no frame arrived within 10s of an idle ping; the connection is presumed half-open for a pong that never came, per crates/blockwatcher-evm/src/ws.rs), not a short code; SourceStatusView passes that string through verbatim (crates/blockwatcher-core/src/status.rs). The short codes dial_failed/stream_closed/transport_error/idle_timeout exist on a different surface entirely: they’re the reason label on the blockwatcher_evm_mempool_reconnects_total counter (and the matching field in this source’s own tracing logs), one increment per subscription lost. That’s the surface to alert on for a flapping connection, since a status snapshot only ever shows whichever state is current, and a reconnect that lands before the next scrape reads as healthy either way. Absent from this file entirely, and refused if added: start_block. There is no chain position for a pending transaction to resume from, so the config has nothing to name.

resources/specs/usdc-erc20.json

Same id, same two events as the confirmed-events example’s spec, plus three function fragments this example actually uses:

{
  "type": "function",
  "name": "transfer",
  "stateMutability": "nonpayable",
  "inputs": [
    { "name": "to", "type": "address" },
    { "name": "amount", "type": "uint256" }
  ],
  "outputs": [ { "name": "", "type": "bool" } ]
}

(approve and transferFrom follow the same shape in the actual file, with their own parameter lists.) A function fragment’s inputs are its call arguments, decoded from the transaction’s calldata rather than a log’s topics or data, which is why functions selectors have nothing to do with indexed, a concept that only means something for an event’s log encoding. stateMutability and outputs are recorded from the ABI but don’t affect decoding or matching; only inputs, alongside the function’s own name (hashed into its first-four-bytes selector), matter to what a functions selector can dispatch on. This spec is not the same file as the confirmed-events example’s: it’s a separate copy under this example’s own resources/, extended with the three function fragments above that copy doesn’t carry, but it keeps the same id and the same two event fragments unchanged. That’s the pattern worth reaching for whenever you want both a confirmed audit trail and an early pending-call signal off the same contract: one evm-rpc network and one evm-mempool network, each with its own copy of a spec that started identical and only grows the fragments the mempool side actually needs, rather than each deployment maintaining two divergent ABI descriptions of the same contract from scratch.

resources/monitors/usdc-sepolia-pending-transfers.json

{
  "id": "usdc-sepolia-pending-transfers",
  "network": "sepolia-mempool",
  "selectors": [
    {
      "addresses": ["0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"],
      "spec": "usdc-erc20",
      "functions": ["transfer"]
    }
  ],
  "predicate": "args.amount > 0",
  "actions": ["log-sink"]
}

The one selector-body difference from the confirmed-events monitor: functions: ["transfer"] where that one had events: ["Transfer"]. This isn’t a style choice: an events selector would still compile cleanly against this network, since compilation only ever validates a selector against its spec, never against which source module the network happens to run, but it could never produce a single match here: evm-mempool never emits a log at any point in its lifetime for such a selector to catch. args.amount here is transfer’s own second parameter (named amount, not value, because that’s what this ABI fragment calls it); it is unrelated to args.value, the Transfer event’s third parameter the other example’s predicate reads. Both predicates read as “keep every nonzero transfer,” just against two different decoded shapes.

resources/sinks/log-sink.json

Identical to the confirmed-events example’s, with the same module, same single-attempt retry policy, and the same reasoning: nothing about the log sink’s own behavior changes based on which source fed it a match.

When mempool watching is worth it

This source earns its place when the thing you care about is someone tried to call this function, and minutes or even seconds of latency change the value of knowing: flagging a large pending transfer before it’s mined, watching for a specific address’s activity the moment it hits a node’s mempool, anything where “usually right, occasionally wrong, but fast” beats “always right, but a block or twelve confirmations later.” It earns its place a lot less (arguably not at all) anywhere the record has to be complete or auditable: billing off matched events, anything feeding a ledger, anything where a transaction that gets dropped or replaced after this source already delivered it would leave a consumer holding a phantom event with no retraction ever coming. The evm-rpc twin of this example is the one to reach for whenever that completeness matters more than the head start.

Variations

Widen the functions watched. The spec already declares approve and transferFrom alongside transfer; add them to the selector’s functions list to catch pending approvals and delegated transfers too:

"functions": ["transfer", "approve", "transferFrom"]

Exclude a known address instead of just thresholding the amount. args.amount > 0 only rules out the zero-amount edge case; combine it with tx.from (always present on a functions selector, mined or not) to drop calls from an address you already know about (say, a market maker’s hot wallet that transfers constantly and would otherwise dominate the feed):

args.amount > 1_000e6 && tx.from != 0xF977814e90dA44bFA03b6295A0616a897441aceC

Split the two endpoints across different provider projects. ws_url_secret and endpoints[].url_secret are independent secret references: nothing requires them to name variables from the same provider account. Pointing the subscription at one provider and the hydration pool at another spreads load and removes a single provider outage as a way to lose the feed entirely; it costs nothing but a second env:NAME variable and a second free-tier key.

Monitoring ERC-20 transfers [Subgraph]

examples/source-evm-subgraph-monitor/ is the evm-subgraph twin of examples/source-evm-rpc-monitor: same contract, same usdc-erc20 spec file, same events: ["Transfer"] monitor, same sink shape — watching confirmed USDC Transfer logs, but polling a GraphQL host this example deploys instead of eth_getLogs on an archive node. This page gives it the same file-by-file tour, but leads with what’s different, because what’s different here isn’t cosmetic. How to deploy subgraph/ and run the process is the example README; this page is what each file is for.

source-evm-subgraph-monitor/
├── .env.example
├── blockwatcher.toml
├── setup.sh
├── subgraph/
│   ├── schema.graphql
│   ├── subgraph.yaml
│   └── src/mapping.ts
└── resources/
    ├── networks/sepolia.json
    ├── specs/usdc-erc20.json
    ├── sinks/log-sink.json
    └── monitors/usdc-sepolia-transfers.json

What’s different from watching confirmed events over RPC

evm-subgraph trades archive-node completeness for an already-indexed range. Every fact below is established in more depth on EVM § evm-subgraph; this is the short version an operator needs before running this example:

  • Completeness is whatever the mapping stored, not the chain. A monitor for an address or event the AssemblyScript never wrote never fires. InterestSet _in filters only narrow the GraphQL fetch; they cannot widen coverage.
  • You must deploy subgraph/ first. The source POSTs at a query URL that already implements Log, Transaction, and _meta. A GraphQL host missing those fields yields named Degraded (schema mismatch), not empty success. Deploy subgraph/ (or any host that implements that schema).
  • The head is the subgraph’s indexed tip, read from _meta { block { number hash } }, already trailing the chain by indexing delay. confirmations therefore defaults to 0 in this example (rpc’s is 12).
  • Cursors and envelopes match rpc. Kinded secondary packing, 0x JSON the evm decoder already reads, SourceCaps.event_timestamps: true. Switching this network’s module from evm-rpc to evm-subgraph does not change what a Transfer selector compiles against.
  • The seed ids collide with the rpc example. Both use network sepolia and monitor usdc-sepolia-transfers. They are alternative trees, not two pipelines you seed into one storage; the mempool twin avoids that by using sepolia-mempool.

.env.example

SEPOLIA_SUBGRAPH_URL=https://YOUR_HOST/subgraphs/name/usdc-sepolia-logs
# SEPOLIA_SUBGRAPH_TOKEN=Bearer YOUR_API_KEY
BLOCKWATCHER_API_TOKEN=local-test-token

Two required variables where the rpc example also needed two, but the URL is a GraphQL query endpoint, not JSON-RPC. Nothing under resources/ ever holds SEPOLIA_SUBGRAPH_URL’s actual value: sepolia.json names the variable, because a hosted query URL is often the credential. YOUR_HOST is the placeholder setup.sh refuses, the same job YOUR_API_KEY does on the rpc twin.

SEPOLIA_SUBGRAPH_TOKEN is optional and commented out. If the host requires a bearer token, set the full Authorization header value (including Bearer ) and add header_secrets on the endpoint row (shown with the network file below). The token stays in .env; the resource only names the variable.

blockwatcher.toml

[api]
enabled = true
listen = "127.0.0.1:8080" # curl in this README. Dashboard: 9080; companion takes 8080.

[[auth.tokens]]
label = "operator"
scope = "admin"
secret = "env:BLOCKWATCHER_API_TOKEN"

[metrics]
enabled = true
listen = "127.0.0.1:9090"

[storage]
module = "sqlite"
config = { path = "blockwatcher.db" }

[engine]
event_channel_capacity = 1000
sink_channel_capacity = 100
drain_deadline_ms = 5000
matcher = { module = "expr", config = {} }

Identical in keys to source-evm-rpc-monitor’s instance config: nothing here is GraphQL-specific. The comment on listen is the one extra line — The dashboard needs :8080 for the companion, so a local UI attach moves the engine to 9080 before cargo run.

setup.sh

Same job as the rpc example’s script — stamp an absolute start_block just behind a live head — but the head it asks for is the subgraph’s indexed tip, not Sepolia’s eth_blockNumber:

head_num=$(curl -fsS "$SEPOLIA_SUBGRAPH_URL" \
  "${curl_headers[@]}" \
  -d '{"query":"{ _meta { block { number } } }"}' \
  | jq -re '.data._meta.block.number')
start=$(( head_num - 20 ))
if [ "$start" -lt 0 ]; then
  start=0
fi

It also refuses a still-placeholder YOUR_HOST, a non-http(s) scheme, and a host that does not answer _meta (the error text says to deploy subgraph/ first: that failure is the same schema-mismatch class a running source would publish as Degraded). If SEPOLIA_SUBGRAPH_TOKEN is set, the _meta POST sends it as Authorization. The floor at 0 is what a subgraph whose indexed head is still below 20 needs: bash arithmetic would otherwise write a negative start_block.

Shipped sepolia.json has start_block: 0 on purpose so a clone does not carry an operator-stamped height. Running with 0 still works; it is a long catching_up wait if the mapping indexed from genesis.

resources/networks/sepolia.json

{
  "id": "sepolia",
  "chain": "evm",
  "source": {
    "module": "evm-subgraph",
    "config": {
      "start_block": 0,
      "endpoints": [
        {
          "name": "primary",
          "url_secret": "env:SEPOLIA_SUBGRAPH_URL",
          "priority": "high",
          "rate_limit": { "rps": 10 }
        }
      ],
      "confirmations": 0
    }
  }
}

source.module: "evm-subgraph" is the one field that changes which raw material a selector on this network can ever see. See Selectors § The source. url_secret is the same env:NAME shape rpc uses for SEPOLIA_RPC_URL. confirmations: 0 is the documented default for this source: the subgraph head already lags the chain. Absent here, and filled by defaults: logs_window, entity_page_size, poll_interval_ms, probe_interval_ms, max_lag_blocks. Rpc-only keys (receipts, bloom_screen, header_batch, …) are refused as unknown fields.

A host that wants a bearer token adds one object on the endpoint, still as a secret reference:

"header_secrets": { "authorization": "env:SEPOLIA_SUBGRAPH_TOKEN" }

Content-Type: application/json is always set at construct; do not put it in header_secrets.

resources/specs/usdc-erc20.json

Same id, same two event fragments as the rpc example’s spec (Transfer and Approval). This copy lives under this example’s own resources/; it is not a shared file. The subgraph mapping also handles both events (it stores a Log row for each, and a Transaction row when input is at least 4 bytes). The shipped monitor only names Transfer. Adding Approval to the selector is a one-field change here the same way it is on the rpc twin — if those rows are in the deployed subgraph, which for this example they are, because subgraph.yaml registers both handlers.

resources/monitors/usdc-sepolia-transfers.json

{
  "id": "usdc-sepolia-transfers",
  "network": "sepolia",
  "selectors": [
    {
      "addresses": ["0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"],
      "spec": "usdc-erc20",
      "events": ["Transfer"]
    }
  ],
  "predicate": "args.value > 0",
  "actions": ["log-sink"]
}

Identical to the rpc example’s monitor, including the selector body: events: ["Transfer"] and args.value (the event parameter, not a function’s amount). Compilation still validates the selector against the spec, never against which source module the network runs. What changes is whether the subgraph stored a Log for that address and topic0. An events selector against evm-subgraph is the supported shape; a functions selector also compiles, and this source will query transactions when some monitor watches functions, but only rows the mapping saved can match.

resources/sinks/log-sink.json

Identical to the rpc example’s, with the same module, same single-attempt retry policy, and the same reasoning: nothing about the log sink’s own behavior changes based on which source fed it a match.

subgraph/

This directory is the part the other ERC-20 examples do not have. The source does not compile it or talk to graph-node; an operator deploys it to Goldsky, The Graph, or a graph-node they run, then points SEPOLIA_SUBGRAPH_URL at the query URL. CI never hits a live host: tests use a scripted GraphQL mock.

schema.graphql

The host schema the source documents on the EVM family page, as this example actually ships it:

type Log @entity(immutable: true) {
  id: ID!
  address: Bytes!
  topic0: Bytes!
  topics: [Bytes!]!
  data: Bytes!
  blockNumber: BigInt!
  blockHash: Bytes!
  blockTimestamp: BigInt!
  transactionHash: Bytes!
  transactionIndex: BigInt!
  logIndex: BigInt!
}

type Transaction @entity(immutable: true) {
  id: ID!
  hash: Bytes!
  from: Bytes!
  to: Bytes
  input: Bytes!
  selector: Bytes!
  value: BigInt!
  status: BigInt!
  blockNumber: BigInt!
  blockHash: Bytes!
  blockTimestamp: BigInt!
  transactionIndex: BigInt!
}

No Block entity. to is nullable (a create). id is padded decimal ASCII so id_gt is lexicographic chain order: 12-digit block, 5-digit txIndex, 5-digit logIndex (000018000000-00003-00012).

subgraph.yaml

Sepolia, USDC 0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238, both Transfer and Approval with receipt: true (the mapping needs the receipt to copy log topics and tx.status). source.startBlock: 0 is a placeholder: set it to a recent Sepolia block (or USDC’s deployment block) before deploying. Genesis indexing is valid and slow; setup.sh cannot fix a mapping that has not caught up.

src/mapping.ts

Writes one Log per handled event with topic0 = topics[0], skips a log whose topics are empty, writes one Transaction per transaction the first time it is seen (keyed by padded block+txIndex), and skips transactions whose input is shorter than 4 bytes so selector is always present. Padding is pad(block, 12) — the same 12-digit block field the source’s id_gt paging assumes.

When subgraph watching is worth it

This source earns its place when the range you care about is already indexed (or cheap to index once) and eth_getLogs over that range is the expensive part: a long Sepolia catch-up, a dedicated logs subgraph you already run for other tools, a host whose query URL is the credential you want in env: rather than an archive-node key. It earns its place a lot less anywhere the mapping is narrower than the monitor: billing off “every Transfer of this token” when the subgraph only stored a subset is a silent miss, not a Degraded. The evm-rpc twin is the one to reach for when the chain itself is the completeness bound.

Variations

Watch Approval as well. The spec and the mapping already store it:

"events": ["Transfer", "Approval"]

Attach the dashboard. The engine and the UI companion both default to :8080. Move [api] listen to 9080, keep UI_INGEST_URL on the engine process, add a ui-ingest sink and name it in actions — the full sequence is The dashboard § Alongside a local example.

Deliver to a webhook instead of the log sink. Same as the rpc example: a webhook sink whose url_secret is another env:NAME, then "actions": ["ops-webhook"].

Monitoring Stellar Testnet [RPC]

Your first monitor walks the EVM Sepolia example end to end. This page is the same kind of pass for examples/source-stellar-rpc-monitor/: every seed file, annotated, as a reference for what each line does. Run that directory’s README.md first if you have not started the pipeline yet; read this one when you want to know exactly what you are looking at.

source-stellar-rpc-monitor/
├── .env.example
├── blockwatcher.toml
├── setup.sh
└── resources/
    ├── networks/stellar-testnet.json
    ├── specs/classic.json
    ├── specs/native-xlm.json
    ├── sinks/log-sink.json
    ├── monitors/xlm-payments.json
    ├── monitors/xlm-transfers.json
    └── monitors/xlm-transfer-calls.json

The seed watches three things on one stellar-rpc network: classic native XLM Payment operations, native SAC transfer events, and native SAC transfer invocations. A classic Payment and a CAP-67 transfer event on the same ledger are two decoded occurrences; the decoder does not merge them. Selector keys and spec payloads for this family are on Stellar.

.env.example

STELLAR_RPC_URL=https://soroban-testnet.stellar.org
BLOCKWATCHER_API_TOKEN=local-test-token

Two variables, both required. Nothing under resources/ ever holds STELLAR_RPC_URL’s actual value: resources/networks/stellar-testnet.json below names the variable itself, and blockwatcher resolves it from its own process environment the moment it needs it. The public SDF Testnet node needs no API key; swap the URL if you run your own. BLOCKWATCHER_API_TOKEN can be any string here: the example’s blockwatcher.toml binds the API to 127.0.0.1 only.

blockwatcher.toml

[api]
enabled = true
listen = "127.0.0.1:8081"

[[auth.tokens]]
label = "operator"
scope = "admin"
secret = "env:BLOCKWATCHER_API_TOKEN"

[metrics]
enabled = true
listen = "127.0.0.1:9091"

[storage]
module = "sqlite"
config = { path = "blockwatcher.db" }

[engine]
event_channel_capacity = 1000
sink_channel_capacity = 100
drain_deadline_ms = 5000
matcher = { module = "expr", config = {} }

Every section here is covered generally on the Configuration reference. This instance listens on 8081 / 9091 so it can run beside examples/source-evm-rpc-monitor on 8080 / 9090. [storage].config.path is a bare relative filename, blockwatcher.db, so the database lands next to the process’s current directory: inside examples/source-stellar-rpc-monitor/ if you cd there first.

setup.sh

start_ledger is absolute and required. The committed network JSON uses 1, which is a valid construct-time value (the floor the schema accepts) and the same number registry_examples/stellar_rpc.json constructs. A public node’s oldestLedger sits far above 1, so boot refuses that placeholder at the first getHealth. The script asks the endpoint for oldestLedger and latestLedger and stamps a ledger just behind the head, clamped into that window.

resources/networks/stellar-testnet.json

{
  "id": "stellar-testnet",
  "chain": "stellar",
  "source": {
    "module": "stellar-rpc",
    "config": {
      "start_ledger": 1,
      "endpoints": [
        {
          "name": "primary",
          "url_secret": "env:STELLAR_RPC_URL",
          "rate_limit": { "rps": 5 }
        }
      ],
      "poll_interval_ms": 2000,
      "request_timeout_ms": 10000
    }
  }
}

chain is stellar; source.module is stellar-rpc. There is no confirmations key: closed ledgers are not rewound. url_secret is an env:NAME reference, never the URL. After ./setup.sh, start_ledger is a ledger inside the node’s retention window rather than 1.

resources/specs/classic.json

{
  "id": "classic",
  "chain": "stellar",
  "payload": { "catalog": "classic" }
}

The built-in catalog of every classic OperationType. InvokeHostFunction becomes a function_call; every other classic type, including Payment, is an operation.

resources/specs/native-xlm.json

The payload is XDR-JSON of ScSpecEntry: a CAP-67 transfer event (from / to on the topic list, amount as scalar data) and a transfer(from, to, amount) function. The native SAC id is not in the spec; it lives on the monitors that select this spec.

resources/sinks/log-sink.json

One JSON line per match to stdout, identical to the Sepolia example’s log sink.

resources/monitors/

xlm-payments.json selects classic Payment with no address list and keeps native-asset payments (args.asset == "native"). Omitting addresses means “every address”; interest-union then blanks address filters for the whole pipeline, so getEvents is unfiltered and the decoder still drops everything that is not a selected transfer on the native SAC. Add a G-address to this monitor if you want event fetches restricted to the SAC id.

xlm-transfers.json selects events: ["transfer"] on CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC. xlm-transfer-calls.json selects functions: ["transfer"] on the same id. Operation or function interest spends getTransactions (512-ledger windows); event interest spends getEvents.

Addresses in the seed are StrKey. Decoded args.from / args.to on the match JSON are 0x hex of prefixed canonical bytes. amount.int is stroops (10,000,000 is 1 XLM).

Running

From examples/source-stellar-rpc-monitor/:

cp .env.example .env
./setup.sh
set -a; . ./.env; set +a
cargo run --bin blockwatcher -- check ./resources
cargo run --bin blockwatcher -- --config ./blockwatcher.toml --seed ./resources

check constructs every module the seed references, including resolving url_secret, so STELLAR_RPC_URL has to be set to a real http/https URL. The check inspects the scheme; the endpoint does not need to be reachable. After boot, GET /status on 127.0.0.1:8081 is the operator surface; see the example README.md for curl and jq.

Troubleshooting

A symptom-first index into failure modes that are either expected behavior worth recognizing, or genuine problems with a specific fix. Every quoted string below is copied from the code that produces it, not paraphrased, so it’s safe to grep the same text against your own logs.

SymptomDiagnosisFix
Every API call answers 401 with {"error":{"code":"unauthorized","message":"a bearer token is required"}}No Authorization header, a malformed one, or a token that doesn’t match any row of [auth]. Checked by require_bearer (crates/blockwatcher-api/src/auth/mod.rs). GET /health is the exemption.Send Authorization: Bearer <token>. Confirm the env:NAME variable that row’s secret names is set in the process’s environment: it’s read at request time, not cached, so a variable exported after boot still works, but an unset one fails every request identically.
Every mutating API call answers 403The token authenticated but its scope is below that MethodRouter’s minimum (read cannot PUT a sink). Distinct from 401.Use a token whose scope is at least operate or admin as the route table requires.
A resource write answers 422 invalid_resource naming an unknown field '…' or unknown event/function '…'A typo in a predicate field path or a selector’s events/functions name. Predicate field lookups get an edit-distance suggestion (unknown field 'args.vlaue' — did you mean 'args.value'?', the suggestion helpers and field_error in crates/blockwatcher-expr/src/typecheck/diagnostics.rs); an unknown selector name gets the spec’s first declared name of that kind instead (kind_suggestion in crates/blockwatcher-evm/src/decoder/selector.rs). Both port errors share one message shape: unknown field '{field}'{ — did you mean '{s}'?} (crates/blockwatcher-ports/src/error.rs:89,112).Read the suggestion: it’s often exactly right. If not, confirm the real spelling with GET /specs/{id}/schema, which reflects the spec’s compiled schema, not the raw ABI text.
A monitor never produces a match: decoded/matched stay at 0 in GET /status’s countersFour independent causes look identical from the outside: the monitor (or its network) is paused; the selector names the wrong kind for its source (a kind the decoder accepts can still be a no-op if that source never supplies it; see the family page); the predicate is stricter than intended and every occurrence resolves Unknown/False; or, on evm-rpc, bloom_screen has skipped eth_getLogs believing every block in a window empty (see the row below). A paused monitor is excluded from decode interest entirely, in build_monitor_set (crates/blockwatcher-core/src/compile.rs), so it shows as decoded: 0 exactly like a monitor that’s simply seeing nothing yet.Check source.status on GET /status first: paused there is the control-plane pause view, distinct from a source genuinely having nothing to report. Confirm the selector kind matches what the network’s source module actually produces. Temporarily drop the predicate field (absent matches everything a selector decodes) and see whether matched starts climbing; if it does, the predicate was the filter.
No matches on a network whose blocks demonstrably contain them, and blockwatcher_evm_bloom_skips_total climbing, with blockwatcher_evm_bloom_contradictions_total still at zerobloom_screen (default true) trusts every fetched header’s logsBloom to prove absence before skipping eth_getLogs (crates/blockwatcher-evm/src/source/rpc/bloom.rs, scan.rs). A running source instance disables the screen for itself the first time a fetched window’s own log contradicts that block’s own bloom, so a skip that is still costing matches with the contradiction counter flat means either this endpoint’s blooms are wrong in a way that never contradicts a log the same endpoint has actually returned (consistently absent, consistently zeroed, or wrong on exactly the blocks this filter never happens to match), or the instance that would have caught it has since restarted and is screening again from a clean slate; see the row below either way.Set bloom_screen = false for that network (crates/blockwatcher-evm/src/source/rpc/config.rs) and confirm the matches appear.
blockwatcher_evm_bloom_contradictions_total is nonzero for a networkAn endpoint returned a log whose own address or topic0 was not admitted by that log’s own block’s bloom, proof that endpoint’s blooms do not describe its own logs (crates/blockwatcher-evm/src/source/rpc/bloom.rs’s admits_log, called from scan.rs). The source instance that observed it has already disabled bloom_screen for itself, logged once as evm-rpc: endpoint's logs bloom does not describe its own returned logs; disabling bloom screening against this endpoint for the lifetime of this source instance, which a restart does not preserve; set bloom_screen = false for this network to keep screening off across a restart too. That disabling belongs to the running instance, not the endpoint or the process: a pipeline restart (the supervisor recovering an exited source, a proven-reorg invalidation, or a monitor change that escalates to a restart) rebuilds the source and resumes screening against the same endpoint. The contradiction also only proves the blooms are wrong, not since when; every window this source screened earlier in the same run rested on the same untrustworthy blooms, and the checkpoint has already advanced past them.Set bloom_screen = false for that network (crates/blockwatcher-evm/src/source/rpc/config.rs) so the setting, not the latch, is what survives a restart. Then read blockwatcher_evm_bloom_skips_total for the same pipeline to see how many windows were screened before the trip, and re-scan that range (POST /monitors/{id}/test’s fetch mode never screens) to recover whatever those windows may have missed; nothing rewinds it automatically.
An evm-subgraph network stays source.status: degraded with a reason containing subgraph schema does not implement Log/Transaction/_metaThe host answered HTTP 200 with GraphQL errors[] naming a missing logs / transactions / _meta field (Cannot query field). That is a named Degraded, not an empty successful window: the run loop does not advance next. Classified Transient so the pipeline stays up (crates/blockwatcher-evm/src/source/subgraph/graphql.rs, run.rs).Deploy (or point at) a subgraph that implements the documented Log / Transaction / _meta schema on the EVM family page. Changing logs_window or interest filters cannot create fields the schema does not have.
An evm-subgraph query is refused as too expensive, or the host returns HTTP 429GraphQL errors[] carrying complexity, too many, too large, payload, or query timeout classify as RetryNarrower: the run loop halves logs_window width and retries the same next block — lossless, no blocks skipped. HTTP 429 is RateLimited (the account’s request budget): the pool backs off, honoring a Retry-After delta-seconds header when present, and does not shrink the range (crates/blockwatcher-evm/src/source/subgraph/run.rs, graphql.rs).For complexity/payload refusals, wait for the shrink to bottom out at one block, or lower logs_window.initial / entity_page_size so the first query already fits. For sustained 429, lower the endpoint rate_limit.rps or raise poll_interval_ms.
The same match (same content, same or, for evm-mempool, a different id) is delivered again after a restartExpected: this is at-least-once delivery, not a bug. The checkpoint had not yet advanced past that event when the process stopped, so the restart re-decodes and re-dispatches it.Deduplicate on the match’s id (deterministic given the same network, monitor, and decoded content, via MatchId::derive: crates/blockwatcher-types/src/id.rs); for evm-mempool specifically, deduplicate on the transaction’s own hash field instead, since that source’s id is not stable across a restart.
dead_lettered climbs in GET /status’s counters, and GET /networks/{id}/dead-letters keeps growingThe sink is down, misconfigured, or permanently rejecting deliveries. Every entry’s reason string leads with its ErrorClass rendering (transient, permanent, rate_limited, or retry_narrower) followed by the underlying message, built as format!("{class}: {message}") (crates/blockwatcher-core/src/pipeline/sink_worker.rs), e.g. permanent: webhook host returned 500 five times. A letter whose payload has "type":"retracted" cannot be replayed.Read the reason field to tell “sink refused this outright” from “we ran out of retry budget.” Fix the sink (a bad url_secret, an endpoint that’s actually down) or its config, then POST /networks/{id}/dead-letters/{match_id}/replay for a Match payload: success deletes the letter (204); another exhaustion bumps attempts/reason again and answers 502 replay_failed rather than dropping it. Retract letters are refused with "dead letter payload is a retraction; only match events can be replayed".
Logs show invalidate cursor is older than the retained journal window; some match ids cannot be retracted and blockwatcher_journal_gap_total ticksA deep invalidate rewound past what journal_depth still retained, so some already-delivered match ids have no Retracted event. Rewind and restart still happen (crates/blockwatcher-core/src/engine/invalidate.rs).Raise [engine].journal_depth well above the source’s confirmation window (default 1024). Consumers must handle ids older than the window as an operator problem; the gap is never silent.
A network stops advancing after a source invalidate, in_flight_events stays up, no replacement matches, and blockwatcher_source_invalidation_failures_total ticksAn unrecovered retract: the retract pass failed or dead-lettered without completing, so the checkpoint is left unrewound and the source is not restarted (handle_invalidation, crates/blockwatcher-core/src/engine/invalidate.rs). This is not a crash-restart failure (blockwatcher_pipeline_source_restart_failures_total stays at zero).Inspect GET /networks/{id}/dead-letters for type: retracted payloads, fix the sink, and intervene; the supervisor will not retry this control path on its own the way it retries a crashed source.
An evm-mempool network shows source.status: live, but nothing is ever deliveredThe subscription itself succeeded: Live/Degraded/CatchingUp/Starting (crates/blockwatcher-ports/src/source.rs) only tracks the WebSocket connection’s own health, via dial_failed, stream_closed, transport_error, or idle_timeout (crates/blockwatcher-evm/src/source/mempool/run.rs), not whether the provider actually forwards newPendingTransactions notifications at any useful rate. Some providers, especially free or shared tiers, throttle or silently limit this feed even while answering every other RPC call normally; there is no distinct error for this, because from blockwatcher’s side nothing has failed.Confirm the provider’s plan actually supports mempool streaming (check its docs, or watch whether any hashes arrive by temporarily widening the monitor’s functions list). On a genuinely idle testnet, no one may have called the watched function recently at all; that’s not a provider problem, just a quiet mempool.
blockwatcher check <dir> exits 1 instead of printing ok: N networks, N specs, N sinks, N monitorscheck constructs every module the seed references (crates/blockwatcher/src/check.rs), so it fails on the same things a real boot would: an unregistered module name (unknown {family} module '{name}'; available: […], crates/blockwatcher-core/src/error.rs:9-14), an unresolvable reference, an uncompilable predicate or selector, or a secret an env:NAME reference names that isn’t set in check’s own environment.Read the printed message: it names the offending record or module, never a value that might be a secret. Export every variable a seed’s url_secret references name before running check, exactly as they’d need to be set for a real boot.
Monitor write 422 gate requires 'block.timestamp'Selectors’ schemas have no block.timestamp (mempool-only decode, or a spec that never put the header in the tree)Drop the gate, or select an event/source whose decoder exposes that path. Do not expect the engine to use wall time.
Three Transfers, no digestHits span more than window_ms in block time; or count not yet reached; or untimestamped counter is upInspect timestamps in the decoded event, not wall clock. Check blockwatcher_gate_hits_total vs gate_emits_total.
Second burst never fires after the first digestExpecting a sliding hour that keeps T1; threshold uses session resetAfter fire, T1–T3 are gone; T4–T6 are a new session. That is the specified behaviour.
max_once later hits not in dead lettersDiscard is not throttleExpected. Use throttle if you need replay.
After a 2-block reorg, threshold under-firesJournal was drain-all (bug)Holds with cursor ≤ from must survive. File a bug; replay cannot reconstruct them.
Boot refuses immediately with binding the api listener on 127.0.0.1:8080: Address already in use (os error 48) (or the same for metrics)Something else (often a previous blockwatcher process that didn’t exit cleanly) already holds that port. Binding happens before the engine starts specifically so this surfaces as a boot failure rather than something a running process discovers later (crates/blockwatcher/src/run.rs:34-41,265-269).Find and stop whatever holds the port (lsof -i :8080 or equivalent), or change [api].listen/[metrics].listen (or their BLOCKWATCHER_API__LISTEN/BLOCKWATCHER_METRICS__LISTEN overrides) to a free address.

Reading the exit code

A process that already ran and stopped tells you more than any log line if you check its exit code, from the same table as the CLI’s own --help text (crates/blockwatcher/src/cli.rs):

CodeMeaning
0Clean drain, or a check that passed.
1Config, seed, or boot failure, or a check that refused.
2Shutdown aborted at least one pipeline at the drain deadline.
64The command line itself didn’t parse (unrecognized argument '…', a flag missing its value, etc.).

See Configuration reference § CLI flags for the full command grammar these codes apply to.

When there’s nothing wrong

One thing that looks like a problem and is actually the system working as designed:

  • RUST_LOG unset still prints boot lines, restart notices, and shutdown reports. It defaults to info, not silence, in init_tracing (crates/blockwatcher/src/lib.rs): every diagnostic goes to stderr, never stdout, so it never mixes into match output on a process running with no [api] configured at all.

Benchmarks

scripts/bench/ times blockwatcher end to end against a cached upstream RPC, so a real change in decode, predicate, or delivery cost shows up as a real number rather than noise from repeated network calls. This page states exactly what a number here measures, how to reproduce it, and the baseline every later run diffs against.

The harness itself is chain-agnostic: bench.sh and run-one.sh boot blockwatcher --config <name>/blockwatcher.toml --seed <name>/seed and poll GET /status regardless of which source module the scenario’s network resource names. What a scenario measures, and what its numbers mean, is specific to one chain family, so this page has one section per family. A family that has a harness scenario gets its own section here with its own scenarios and its own result columns, rather than being folded into another family’s table under a different name.

Methodology

Applies to every scenario regardless of chain family.

What every run measures

Each scripts/bench/scenarios/<name>/ is a full blockwatcher instance, booted fresh with storage.module = "memory", so every run starts from the seed alone with nothing carried over from a previous one. A run is timed from process start until GET /status’s checkpoint cursor for the scenario’s one network reaches a fixed end position, matching every occurrence the scenario’s monitor selects along the way and delivering each match to that monitor’s log sink. The clock stops at the checkpoint, not at process exit, but delivery to the sink is inside that window: a monitor whose matches never finish delivering would keep the checkpoint from advancing. The runner polls the status endpoint every 0.2 seconds, so each wall-clock number carries up to about 0.2 seconds of quantization on top of the streamed work it measures.

Out of scope

  • Webhook delivery latency: every scenario’s sink is log, not webhook, so no network hop to a receiving service is timed.
  • Any storage backend other than memory: a scenario’s checkpoint and resources live in process memory for the run’s duration and are discarded when it exits, so SQLite’s or any other backend’s write cost is not represented here.
  • Cold-cache latency against the real upstream: bench.sh warms each scenario’s cache with one discarded run before timing begins, so the timed rows measure blockwatcher’s own cost, not the upstream’s.

Tooling

  • Docker, for the caching proxy each family’s scenarios sit behind.
  • curl, jq, and bc, used by scripts/bench/run-one.sh for status polling, checkpoint extraction, and elapsed-time arithmetic.
  • hyperfine.
  • A release build at target/release/blockwatcher (cargo build --release -p blockwatcher).
  • A real upstream endpoint reachable from this machine for whichever family’s scenarios are being run; see that family’s section for the exact form it takes and how to point the cache at it.

How bench.sh runs a scenario

bench.sh warms each scenario’s cache with one discarded run, times five runs per scenario with hyperfine (exported to scripts/bench/results-<scenario>.md, gitignored as a machine-local artifact), then prints one more counted run per scenario as a scenario,seconds,<count-column> CSV row on its own stdout. The timed rows and the counted row together are what each family’s baseline table transcribes; what the count column measures (RPC attempts, for EVM) is specific to the family.

Rows in a family’s baseline measure the engine as of this page’s own revision: the harness (this page, bench.sh, and each scenario’s seed) ships alongside the code it measures, so the commit and date a row was recorded at are in this file’s git history, and a re-run that changes a number updates the row in the same commit as the change that moved it. Changes that affect decode, predicate, or delivery cost re-run scripts/bench/bench.sh and append their own rows below a family’s baseline rather than replacing it, so a regression or an improvement is visible against a fixed starting point.

EVM

Every scenario in this section runs the evm-rpc source against Ethereum mainnet (chain 1) through a caching eRPC proxy: the numbers describe EVM-family behavior at mainnet density, and carry to neither another chain family nor another EVM source module (evm-mempool is not benchmarked here: it has no historical range to replay against a cache).

Scenarios

Each scenario’s network seed pins the evm-rpc source’s logs_window to { initial: 50, max: 50 }, sized to the baseline upstream’s own eth_getLogs range cap (see Reproducing an EVM run). Without that pin the source’s own default window (initial: 512) is wider than that cap, so its first eth_getLogs call for the range is refused every run and the source narrows and retries; with the pin, no call is ever refused, so what’s measured is blockwatcher’s own decode/predicate/delivery cost against a fixed, upstream-independent request pattern, not the shape of any one provider’s range limit. The request pattern is therefore fixed and upstream-independent (no refusal, no mid-scan narrowing), which is also what makes it fully cacheable: a warm run touches the eRPC cache for every historical call and the real upstream only for head probes.

The rpc-attempts column comes from a separate counted run, not from hyperfine’s five timed runs: hyperfine owns the timed runs’ stdout, so counting is a sixth pass over the same already-warm cache. That count also includes head probes made while the run is in flight, so it varies slightly run to run and between scenarios whose historical request pattern is otherwise identical.

Reproducing an EVM run

BENCH_UPSTREAM_RPC=https://your-mainnet-endpoint.example \
  docker compose -f scripts/bench/compose.yaml up -d
export BLOCKWATCHER_BENCH_RPC=http://127.0.0.1:4000/main/evm/1
scripts/bench/bench.sh

scripts/bench/compose.yaml pins the eRPC image to ghcr.io/erpc/erpc:0.1.2; bumping the pin invalidates recorded rows until the baseline below is re-run.

The baseline below ran with BENCH_UPSTREAM_RPC=https://eth-pokt.nodies.app (a shared, free-tier mainnet endpoint), which is also where the 50-block eth_getLogs cap each seed’s logs_window is pinned to comes from; a different upstream may need a different logs_window value to stay unrefused, per its own advertised range cap. A heavily rate-limited upstream may also need its cache pre-warmed before the first timed run: replaying the range’s own requests (the same methods and params a warm run would make) paced below the provider’s limit fills the eRPC cache without ever tripping it, which a live engine process retrying on its own schedule cannot reliably do against a tight limiter.

Baseline

scenariowall (mean ± σ)rpc attemptsmachine
s1-broad-transfers1.533 s ± 0.127 s153Apple M4 Pro, 64 GB, macOS 26.5.2
s2-selective-weth892.5 ms ± 134.6 ms177Apple M4 Pro, 64 GB, macOS 26.5.2
s3-function-transfer1.565 s ± 0.330 s130Apple M4 Pro, 64 GB, macOS 26.5.2

s1-broad-transfers restricts nothing, so it decodes and delivers every ERC-20 Transfer in the range; its wall time reflects the log sink’s per-match cost multiplied across every one of them, not just RPC or decode cost. s2-selective-weth decodes the same range but keeps almost everything out of its predicate, so its wall time sits far closer to pure RPC-and-decode cost. s3-function-transfer turns on the full-block-with-transactions path (a functions selector fetches every block in a window via eth_getBlockByNumber rather than one ranged eth_getLogs call, plus one eth_getTransactionReceipt per matching call), so its rpc-attempts count reflects that access pattern rather than logs_window’s cap. All three rows use a fully warmed cache, so none of them include upstream latency.

Rows below this baseline carry no date of their own, on the same terms as the baseline itself: the commit and date each was recorded at are in this file’s git history.

Concurrent receipts and batched headers

The evm-rpc source’s receipt_concurrency and header_batch, both at their defaults (4 receipts in flight per leaf, 20 headers per JSON-RPC batch), against the same fixed range, the same seeds, and the same machine as the baseline above.

scenariowall (mean ± σ)rpc attemptsmachine
s1-broad-transfers1.459 s ± 0.181 s12Apple M4 Pro, 64 GB, macOS 26.5.2
s2-selective-weth672.5 ms ± 228.4 ms13Apple M4 Pro, 64 GB, macOS 26.5.2
s3-function-transfer796.3 ms ± 101.9 ms34Apple M4 Pro, 64 GB, macOS 26.5.2

Request count is where the change lands hardest: batching turns a 50-block window’s 50 eth_getBlockByNumber round trips into three, which is what separates 12 and 13 attempts from the baseline’s 153 and 177, and 34 from its 130. Wall time moves only where those round trips dominated it. s3-function-transfer roughly halves, fetching its receipts four at a time across a window whose headers also arrive in one request rather than eight; s2-selective-weth drops about a quarter; s1-broad-transfers stays inside its own spread, since its wall time is the log sink’s per-match cost across every Transfer in the range rather than anything the request count governs.

Logs-bloom pre-screen

The evm-rpc source’s bloom_screen setting, at its default (true), against the same fixed range, the same seeds, and the same machine as the baseline above.

scenariowall (mean ± σ)rpc attemptsmachine
s1-broad-transfers2.101 s ± 0.559 s12Apple M4 Pro, 64 GB, macOS 26.5.2
s2-selective-weth531.7 ms ± 125.0 ms13Apple M4 Pro, 64 GB, macOS 26.5.2
s3-function-transfer1.440 s ± 0.406 s36Apple M4 Pro, 64 GB, macOS 26.5.2

Request counts hold steady against the previous section’s rows: 12 and 13 attempts match exactly, and 34 moving to 36 reflects how far past the end block each run got before the runner stopped it, described below, rather than a change in the historical request pattern. Wall time for each scenario sits inside its own spread from the previous section, so nothing here reads as a regression or an improvement on its own.

The column’s own arithmetic caps how far this setting could ever move it. A screened window skips exactly one call, its eth_getLogs, and it cannot skip any of the header requests that came before: those headers are the screen’s own input, since a window’s blooms are read out of them, so every one is already fetched by the time the decision exists. One window’s own cost is ceil(logs_window / header_batch) header requests plus that single log call, which is three and one at a 50-block window and a 20-header batch. Reading one s2-selective-weth run’s requests out of the eRPC instance’s own log shows exactly that shape, three eth_getBlockByNumber for every eth_getLogs. So a window spends three header requests per log call, and a screen that fired on every window in the range would still leave those three standing. Set against the previous section’s rows, which already cut 153, 177, and 130 attempts down to 12, 13, and 34 by batching those same headers, the log call is the smaller remaining share rather than the dominant one, so a large drop in this column was never available here at any fill rate.

That same log also shows why a row’s total is not the benchmarked range divided by the window width. The run issued four log calls, covering 22000000 to 22000049 and 22000050 to 22000099 inside the range, and 22000100 to 22000149 and 22000150 to 22000199 beyond it. The runner stops a scenario once the pipeline’s checkpoint reaches the end block, and by that point the source has already started the windows after it, so a row’s total carries however many of those a given run reached before it was stopped. That is why these totals move between runs in steps of roughly one window’s cost rather than a request at a time, and why a row is an upper bound on the requests the benchmarked range itself needs rather than an exact count of them.

That also makes this column the wrong instrument for the saving the setting is named for. A skipped eth_getLogs is one fewer log call, and a run that skipped every log call it could would still report most of the attempts it reports now, because the header requests and the head probes are counted here too. blockwatcher_evm_bloom_skips_total counts the windows that skipped, which is the quantity the setting acts on directly.

Holding steady is the expected result on this fixture, not evidence that the screen does nothing. The screen skips a window’s eth_getLogs call only when every fetched header’s bloom refutes every monitored address, or every monitored topic0, in that window, since eth_getLogs requires both to hold and either dimension refuted alone already rules out a match; a refutation itself is certain only when the m3 membership test returns a negative for an item genuinely absent from that block. Sampling every seventh block of the benchmarked range (landing on 22000000 through 22000098) through the same eRPC cache and counting set bits in each logsBloom puts the average fill across this range at about 45% of its 2048 bits, so even for an item absent from a given block, bit collisions alone let the test falsely report it present in roughly 0.45^3 (about 9%) of blocks. A 50-block window only skips when all 50 of its headers refute, so the probability that every one of them does compounds to under 1%, even for an address that appears nowhere on chain; at that rate a skip is a rare event across a handful of windows, not a routine one. On top of that baseline rarity, s2-selective-weth monitors an address and topic0 that genuinely occur inside the range, so its windows could never skip regardless of fill rate, and s1-broad-transfers restricts nothing at all, so a filter with no address or topic to refute against is never screened by design.

Where the setting earns its keep is sparser blocks and narrower windows: a chain or range whose headers carry a lower average bloom fill pushes the false-admission rate down for every block in a window, and a smaller logs_window needs fewer of those blocks to agree before it can skip. Re-running the s2-selective-weth scenario against this same cached range, with its seed’s logs_window pinned to { initial: 10, max: 10 } and its monitored address replaced by one that appears nowhere in the range, then reading blockwatcher_evm_bloom_skips_total off the metrics endpoint confirms the mechanism directly: those narrower windows do skip, and the series appears and grows as the run proceeds. The rows above are the honest result for a dense mainnet range at the seeds’ default window, not the feature’s best case.

Internals

This part is for a contributor changing blockwatcher’s own code: the system invariants those boundaries answer to, the guardrail a change must not forget, how the workspace’s crates are grouped and why, what each crate owns, and how the test suite is layered. It assumes the concepts pages already, and reads the source alongside it.

flowchart LR
    rpc["RPC endpoints<br/>(external chains)"]
    %% Source modules are family-owned; the running list is GET /catalog, not this map.
    sources["Sources<br/>one per network"]
    sinks["Sinks<br/>webhook · script · log"]
    storage["Storage<br/>checkpoints · dead letters · resources"]
    api["REST API"]
    metrics["Metrics"]
    engine["engine<br/>bounded channels · checkpoints"]

    subgraph pipeline["Engine pipeline"]
        direction LR
        decoder["Decoder"]
        matcher["Matcher<br/>predicates"]
        gate["Gate<br/>threshold · max_once"]
        decoder --> matcher
        matcher --> gate
    end

    rpc --> sources
    sources -->|"decode and match"| decoder
    gate --> sinks
    api -->|"manages resources"| storage
    storage <--> engine
    engine -->|"drives"| pipeline
    engine -.->|"reports"| metrics

    classDef module fill:none,stroke:#a9a3e3
    classDef core fill:none,stroke:#8a8d86,stroke-dasharray: 5 5

    class rpc,sources,sinks,decoder,matcher,gate module
    class engine,api,storage,metrics core
click sources "../concepts/selectors.html"
click decoder "../concepts/chain-agnosticism.html"
click matcher "../concepts/predicates.html"
click gate "../concepts/gates.html"
click sinks "../concepts/delivery.html"
click storage "../concepts/resources.html"
click api "../reference/http-api.html"
click metrics "../reference/observability.html"
click engine "../concepts/pipeline.html"

Key takeaways

  • This part is for a contributor changing blockwatcher’s own code, and assumes the concepts pages already.

  • Architecture decisions are invariants of the system. Conventions are invariants of a change. Neither page is a copy of the other.

  • The workspace map explains how crates are grouped by role and how they depend on each other.

  • Testing strategy describes how the test suite is layered. Extending blockwatcher walks a new module through the whole process end to end.

  • Conventions: what a pull request must not forget.

  • Workspace map: the crates, grouped by role, with their dependency edges.

  • Testing strategy: the layers of tests and what each one proves.

  • Extending blockwatcher: the mechanical steps a new module goes through, worked through a real one end to end.

  • Architecture decisions: the system invariants the workspace is shaped to keep.

  • The trust boundary (API scopes, companion login, ingest header) lives in the repository’s docs/threat-model.md, next to SECURITY.md.

Conventions

This page is the guardrail around a change: what a pull request must not forget, how code is written and checked, and the process that keeps claims and catalogs from drifting. It is not the system design.

The shape the workspace must keep, and why, lives on Architecture decisions. If a change would alter that shape, that page is the constraint. This page is what the change must still remember after it agrees with the architecture.

Extending blockwatcher, Testing strategy, and Claims are the long form of adding a module, of the test layers, and of the matrix. This page does not retell them.

Key takeaways

  • Architecture decisions are invariants of the system. This page is invariants of a change.
  • Adding a module, a file around a port, or an operator-facing feature has a closed checklist. Skipping a step is how a second pattern starts.
  • A published guarantee is not done until the claims triple lands in the same change.
  • Comments, config, errors, and dependencies have one house style. A second style is spaghetti even when the architecture is untouched.

Adding a module

A new module is complete when all of these are true. The worked example is Extending blockwatcher. If the module needs a core change, the port is wrong; stop.

  1. Port impl, plus a compile-time assert_port::<Concrete>() beside it so the type is actually Arc<dyn Port>, not a look-alike inherent impl. An inherent method block whose names and signatures match the trait compiles and can pass method-level tests without ever proving the trait bound.
  2. const NAME and factory next to its own code, registered in that family’s get_all().
  3. Config struct with deny_unknown_fields and validation.
  4. Rustdoc header documenting its trade-offs.
  5. Tests against the port contract through the same harness the fake uses. If the port has a compiled artifact (Decoder, Matcher), a foreign artifact is handed in and rejected. If it does not (Source), the port’s own behavioural contract (cursor ordering, cancellation, status publication) is asserted on the real module.
  6. A registry-completeness test that enumerates the family’s get_all() and constructs every entry from its documented example config (registry_examples/<name>.json). Extending walks this for the log sink.
  7. Wiki catalog row: chain-family modules on that family’s page under docs/wiki/src/concepts/families/; every other module on Modules and trade-offs.

Nothing else. One registration path.

Layers around a port

The three kinds of code, and why they exist, are on Architecture decisions. When adding a file around a port:

  • Name it as a decision, an effect, or a driver. A file that cannot say which it is is a driver by default, and drivers are where trapped decisions hide.
  • A decision imports types and a clock. It does not import a port trait, a completion guard, a checkpoint, storage, or anything that performs I/O. It is testable with an injected now.
  • An effect imports that one port trait and types. It does not import guards or checkpoints.
  • Decision and effect modules are declared by name in scripts/check-dep-graph.sh, which enforces those import limits.

A driver over the size budget is the same prompt as any other overrun: a decision is usually still inside it.

Feature class

The two classes, and why they must not merge, are on Architecture decisions. When adding an operator-facing feature, classify it first. This split is enforced by review, not by a gate: no script can tell a lossless optimization from one whose author believed it was.

Transparent optimization owes: a losslessness proof in its own rustdoc; a runtime interlock that disables it for the rest of the process if the proof is violated (monotone, no config knob of its own); a metric for the saving and for the interlock; a config shape that is a bool or a tuning number; default on.

Semantic policy owes: a documented trade-off next to its own code; accounting for every item it suppresses or defers (a guard completes, or a dead letter is written; nothing is dropped silently); a config shape that is an explicit opt-in structure; default off.

A feature that cannot say which class it is has not been designed. An optimization whose correctness check is itself a toggle, or a suppression policy treated as transparent, has been misclassified.

Claims

Architecture decisions states that a published guarantee is a claim. Shipping one means three artifacts in the same change:

  1. Documented on the concepts or reference page an operator would actually read.
  2. Catalogued as a new stable ID on Claims. IDs are never reused. The row names the wiki home and the assurance instrument.
  3. Tested by a named test (unit, e2e, live, or an explicit gap / by-design reason on that same row) that carries /// Claims: naming the ID. grep -rn "/// Claims:" crates cross-checks the matrix against reality.

There is one matrix. A second table, a spreadsheet, or IDs that live only in tests is a second pattern. scripts/verify-claims.sh covers the e2e half; the matrix itself is reviewed against the wiki pages it cites.

Comments

A comment says something the code cannot: a constraint, an invariant, a rationale, a consequence, or a warning. Write it for a reader who never saw the PR.

  • No history, no task or review references, no time-relative wording (“currently”, “the old X”), no narration of the next line. If a comment would be redundant given a better name, rename.
  • Cite the substance, not a pointer into Architecture decisions or this page. Numbering moves.
  • A number or coverage claim names the test or procedure that witnesses it, or it does not belong in a comment.
  • TODO only with an owner and a condition a reader can act on.
  • Doc comments describe contracts, not steps inside.
  • A decision that looks wrong deserves one permanent sentence saying why.

Prefer deleting a comment to letting it drift. This governs comments and identifiers in shipped code (crates/, scripts/, .github/, ui/). None of these rules are language-specific: a TypeScript construct carries no gravestone a Rust module may not. Documents about the process cite these sections freely.

Errors and configuration

One error enum per port, in blockwatcher-ports next to the trait, thiserror-derived. Adapters flatten SDK errors into it at the boundary. Box large payloads. Where retry behavior matters, classify Transient | Permanent | RateLimited | RetryNarrower.

Every config struct: serde with deny_unknown_fields, per-field defaults, and a test that partial config deserializes to those defaults. No untagged enums in config. The two planes (instance config vs resources) are an architectural invariant; this page only owns field-level strictness.

Tests and dependencies

Testing strategy is the layers. What a change must not forget:

  • Port fakes live with the port and type-check in CI.
  • mockall::automock sits behind the testing feature, consumed from [dev-dependencies] only. Never on a production path.
  • Decoders: golden-file fixtures (real chain input, expected DecodedEvents out). A decoder that declares dotted or digit-bearing field names includes a fixture pairing each declaration with the nested shape it decodes to.
  • The engine is proven by integration tests over a fake source, the real pipeline, and a fake sink, asserting checkpoint, ordering, and backpressure.
  • blockwatcher-expr carries property tests and a fuzz target. A language change that skips them has not been tested.
  • Time-dependent tests use tokio::test(start_paused = true) unless a real sleep is documented.
  • A headline property is pinned by a test that was shown to fail when the property breaks. Order-insensitive equality (IndexMap, JSON objects) does not pin order; content hashes or explicit sequence assertions do.

Versions are pinned once in [workspace.dependencies]. A direct dependency on a core crate needs a justification in the PR. Registry releases only; never a git revision of a third-party crate. A fork we must carry is a fork we own and publish. Prefer std or a small crate over a framework used for one function.

Size is a prompt, with numbers

Architecture decisions states that an overrun asks whether a concept is missing. The numbers scripts/check-dep-graph.sh uses:

  • No production file over ~500 lines, no crate over ~5k, core (blockwatcher-types + blockwatcher-ports + blockwatcher-core) under ~8k.
  • No port trait over ~10 methods without an OVER_BUDGET_PORTS entry. A port over that count is the same prompt: a facet wants extraction.
  • Production lines are code above a file’s own #[cfg(test)] module, excluding blanks, comments, and doc comments. Doc comments are excluded because a module must document its trade-offs next to its own code, and a budget that charged for rustdoc would put those two rules in opposition.
  • An over-budget file, crate, or port is allowed only with a registry entry in that script (OVER_BUDGET, OVER_BUDGET_CRATES, OVER_BUDGET_PORTS) that names the missing concept. An overrun with no entry fails CI.

A #[cfg(test)] suite is judged by whether it still reads as one concept’s proof, not by line count.

Hard prohibitions

System invariants, restated here only as a stop list. The why is on Architecture decisions.

  • Never match on a chain discriminant in core crates.
  • Never introduce a second pattern for a solved problem. Amend the pattern or follow it.
  • Never use lossy channels for delivery-critical events, or ignore a send or lag error.
  • Never advance a cursor past work that was not delivered or dead-lettered.
  • Never let a cursor regress except for two counted, explicit cases: a positively detected reorg, rewound only to the proven fork point (when the entire tracked history is refuted, rewind to just below the oldest tracked height, bounded by the tracker’s own depth, never to genesis); or a restart that re-reads the checkpoint after drain. A rewind is a decision with evidence, never a fallback for confusion. Never stitch one logical scan across endpoints with different chain views. The operational sequence lives in Delivery guarantees.
  • Never let the binary crate accumulate logic. It wires; it does not implement.
  • Never hardcode network or chain data in Rust source. That is resource data.
  • Never shut down by abort(). Tasks are tracked, cancelled cooperatively, and drained with a deadline.
  • Never block on async work inside a sync factory (block_in_place). Factories are async from day one.

Practice, owned on this page:

  • Never depend on a git revision of a third-party crate.
  • Never de-optimize builds to cope with dependency weight. Cut or quarantine instead.
  • Never ship test fixtures, builders, or mocks on production paths.
  • Never encode composite identity in a formatted string.
  • Never swallow an error, construct-and-drop an error for its log side-effect, or return Ok on a path that did not do the work.
  • Never accept unknown config fields or untagged config enums.
  • Never leave a comment that only makes sense to someone who watched the code get written.
  • Never ship a behavioral guarantee without the claims triple.

Checks

CI, and the release process, live in the repository’s CONTRIBUTING.md. The scripts a change is expected to still pass:

  • cargo fmt, cargo check --locked, clippy with warnings denied (#[allow] needs a comment naming the exception).

  • scripts/check-dep-graph.sh (rings, import limits for decision and effect files, size registry, git-dep ban). Every Rust tree in the repository is named in it, not only the workspace members: a crate with its own [workspace] and its own lockfile is listed there, and a tree the script does not name fails it. Otherwise living outside the workspace would be an exemption from every net at once, granted to whoever never asked.

    What a named tree is checked for depends on whether anything consumes it. A tree that ships gets the full set, allowlist and size budgets included. A harness that only drives the crate beside it — the cargo-fuzz trees — gets discovery and the git-dependency ban but not the dependency allowlist, because that net asks what a consumer inherits and a fuzz binary has no consumer.

  • scripts/verify-claims.sh when the proof is an e2e scenario.

A branch that lands as one unit gets one review of the whole diff before merge. Reviews of individual commits or tasks do not substitute: they cannot see defects that only exist across the full change.

Architecture fitness is reviewed against Architecture decisions and this page together. A mechanical pass on this page is not a pass on the system’s shape.

Amendment

An amendment of process (this page) or of a system invariant (Architecture decisions) travels as its own commit, message stating what moved and why, separate from the code it enables. Silence is not consent: code that contradicts a rule does not amend it. Amending an architectural invariant is a design change; amending a comment rule is a process tweak. Same commit discipline, different weight.

Amendments are expected. Most come from a reviewer noticing that a rule overstated its own enforcement, or that two rules collided in a case neither anticipated. Recording the collision is the point.

Workspace map

blockwatcher’s Cargo.toml declares one workspace of member crates. This page groups them by the role each one plays, shows the actual dependency edges between them, and explains how the grouping was checked against scripts/check-dep-graph.sh (the script CI runs to fail a build the moment a crate’s dependencies drift outside its assigned role), rather than simply asserted from memory.

Key takeaways

  • blockwatcher’s workspace crates are grouped into three rings, each crate sitting in exactly one: core (cannot do without, forbidden from chain/HTTP/storage knowledge), modules (chain knowledge or external systems), and glue and test (the composition root plus test-only scaffolding).
  • The grouping is verified, not asserted: it falls out of what check-dep-graph.sh actually enforces per crate, an allowlist of direct dependencies plus a denylist checked against the full transitive tree.
  • The dependency graph table lists every production workspace-internal edge; the diagram below it is the transitive reduction, so no edge is silently dropped, only pruned from the picture.

Three rings

Core

The crates a running instance cannot do without, and the only crates the dependency gate forbids from ever seeing a chain SDK, an HTTP client, or a storage driver.

  • blockwatcher-types, the vocabulary crate: the canonical value model, resource shapes, and deterministic IDs. It depends on nothing but serde and two arbitrary-precision integer crates.
  • blockwatcher-ports, the trait boundary: the six port definitions, their error enums, and the in-memory fakes that stand in as proof that a port hides no chain-specific detail.
  • blockwatcher-core, the engine: pipeline assembly, checkpointing, hot reload of resources, and the control handle the API drives.
  • blockwatcher-api, the REST surface: turns HTTP requests into calls against the engine’s control handle.

Modules

The crates that hold chain knowledge or talk to an external system, each built so that adding, removing, or swapping one never touches a core file.

  • blockwatcher-expr, the default predicate language: a small expression engine that is also the shipped matcher module.
  • blockwatcher-rpc, a chain-agnostic endpoint pool: retries, failover, and health tracking over any request/response transport, without ever deciding what a request means.
  • blockwatcher-storage, the storage backends: an in-memory implementation and a sqlite-backed one, both behind the Storage port.
  • blockwatcher-sinks, the delivery modules: webhook, script, and log sinks.
  • blockwatcher-gates, the gate modules: threshold and max_once. Depends on types + ports, never core; catalog fold is in blockwatcher-embed.
  • blockwatcher-metrics, Prometheus wiring: installs a recorder and serves the metrics scrape endpoint from its own axum server.
  • blockwatcher-evm, the EVM chain family: the evm-rpc, evm-mempool, and evm-subgraph sources and the EVM log decoder, built on blockwatcher-rpc and the alloy SDK.
  • blockwatcher-stellar, the Stellar chain family: the stellar-rpc source and the stellar decoder, built on blockwatcher-rpc and stellar-xdr.

Glue and test

The composition root and the crates that exist only to make the rest of the workspace testable. blockwatcher-embed is loaded in production when a host (or the binary) boots the engine; the test crates are not.

  • blockwatcher-embed, the in-process composition façade: build_catalog and the engine types a host needs to boot without the binary’s CLI, API, metrics, or signals. Glue, like the binary: it may depend on blockwatcher-core.
  • blockwatcher (binary), the composition root: wires modules into a running process, seeds initial resources on first boot, and offers an offline config check. Catalog construction is delegated to blockwatcher-embed.
  • blockwatcher-testkit, shared test scaffolding: a recording metrics recorder and harnesses that exercise a port’s contract the same way across every module that implements it.
  • blockwatcher-evm-testkit, a scripted mock JSON-RPC and WebSocket node that blockwatcher-evm’s own tests drive; nothing production-facing ever depends on it.
  • blockwatcher-stellar-testkit, a scripted JSON-RPC mock node that blockwatcher-stellar’s own tests drive; nothing production-facing ever depends on it. It declares no workspace crate in [dependencies].
  • blockwatcher-e2e, black-box tests that drive the real blockwatcher binary against a local chain; it ships no production code of its own at all.

Verifying the rings

The three rings above are not a description layered on top of the crates. They fall out of what scripts/check-dep-graph.sh actually enforces per crate. The script runs two checks against every workspace crate: an allowlist of the crates and external packages each one may depend on directly, and a denylist of chain-SDK/HTTP/storage/web-framework package families that must not appear anywhere in that crate’s transitive tree, with narrow, per-crate exemptions where a crate’s own direct allowlist already approved exactly one such family.

Reading those two lists crate by crate gives a mechanical way to reproduce each ring, rather than trusting a label:

  • Core is blockwatcher-types, blockwatcher-ports, and blockwatcher-core, plus every crate whose allowlist is permitted to name blockwatcher-core as a direct dependency and which is not glue. Only blockwatcher-api qualifies for that core-ring “plus”: no module crate’s allowlist names blockwatcher-core. blockwatcher-embed and the blockwatcher binary also name blockwatcher-core, and they are classified as glue rather than core because they are composition roots, not an HTTP surface over the engine. blockwatcher-types, blockwatcher-ports, and blockwatcher-core carry no family exemption at all in the script (they cannot carry a chain SDK, an HTTP client, or a storage driver anywhere in their tree); blockwatcher-api carries exactly one exemption, for the web-framework family its own allowlist already approves (axum and what it pulls in), and no others, so it can serve HTTP without ever being able to see a chain SDK or a storage driver either.
  • Glue and test is the binary, blockwatcher-embed, plus the crates the script names explicitly rather than folding into the general allowlist rule: blockwatcher-testkit, blockwatcher-evm-testkit, and blockwatcher-stellar-testkit are checked by name to confirm no crate outside [dev-dependencies] depends on them (the mechanical form of “test scaffolding never ships”), and blockwatcher-e2e’s allowlist entry is empty on purpose, so any production dependency at all fails the check; the crate is structurally incapable of shipping code. The binary’s own allowlist is exempted for every chain-SDK/HTTP/storage family the module crates individually carry, because it is the one crate that links all of them into a single process, plus axum for the listeners it owns. blockwatcher-embed‘s allowlist names blockwatcher-core and the module crates it folds; its family exemption covers those modules’ families (alloy, reqwest, hyper, tower-http, rusqlite, sqlx, stellar-xdr) and not axum, because embed never serves HTTP.
  • Modules is everything left over: blockwatcher-expr, blockwatcher-rpc, blockwatcher-storage, blockwatcher-sinks, blockwatcher-gates, blockwatcher-metrics, blockwatcher-evm, and blockwatcher-stellar. Each either carries a family exemption for each backend it exists to offer (blockwatcher-storage for rusqlite and, behind its default-off postgres feature, sqlx; blockwatcher-sinks for reqwest, blockwatcher-evm for alloy and its transport crates, blockwatcher-stellar for stellar-xdr, blockwatcher-metrics for axum) or, for blockwatcher-expr, blockwatcher-rpc, and blockwatcher-gates, carries none because none needs one, and none of them appears in the core or glue/test criteria above.

This reproduces the brief’s grouping exactly, derived from the gate’s own rules rather than restated from a design doc.

Dependency graph

The table below lists every workspace-internal edge exactly as declared in each crate’s [dependencies] section (production dependencies only: [dev-dependencies] edges, such as every crate’s dev-dependency on blockwatcher-testkit, are deliberately excluded, since those are what the gate above forbids from ever becoming production edges).

CrateDepends on (workspace, [dependencies] only)
blockwatcher-typesnone
blockwatcher-portsblockwatcher-types
blockwatcher-coreblockwatcher-types, blockwatcher-ports
blockwatcher-apiblockwatcher-types, blockwatcher-ports, blockwatcher-core
blockwatcher-exprblockwatcher-types, blockwatcher-ports
blockwatcher-rpcblockwatcher-ports
blockwatcher-evmblockwatcher-types, blockwatcher-ports, blockwatcher-rpc
blockwatcher-stellarblockwatcher-types, blockwatcher-ports, blockwatcher-rpc
blockwatcher-evm-testkitblockwatcher-types, blockwatcher-evm, blockwatcher-rpc
blockwatcher-stellar-testkitnone
blockwatcher-testkitblockwatcher-types, blockwatcher-ports
blockwatcher-storageblockwatcher-types, blockwatcher-ports
blockwatcher-sinksblockwatcher-types, blockwatcher-ports
blockwatcher-gatesblockwatcher-types, blockwatcher-ports
blockwatcher-metricsnone
blockwatcher-embedblockwatcher-core, blockwatcher-types, blockwatcher-ports, blockwatcher-storage, blockwatcher-gates, blockwatcher-expr, blockwatcher-evm, blockwatcher-stellar, blockwatcher-sinks
blockwatcher (binary)blockwatcher-types, blockwatcher-ports, blockwatcher-core, blockwatcher-embed, blockwatcher-api, blockwatcher-metrics, blockwatcher-storage
blockwatcher-e2enone

Most edges in the table are implied by another edge in the same table (blockwatcher-core depends on blockwatcher-types directly, but also reaches it via blockwatcher-ports, which already depends on blockwatcher-types). Drawing every edge makes the diagram unreadable without adding information, so the diagram below draws the transitive reduction instead: it keeps an edge only when no other path in the table above already reaches the same target, and every edge it omits is one step away from an edge it keeps: for example blockwatcher-core → blockwatcher-types is omitted because blockwatcher-core → blockwatcher-ports → blockwatcher-types already covers it, and the same reduction removes blockwatcher-api’s direct edges to blockwatcher-types and blockwatcher-ports (both reachable via blockwatcher-api → blockwatcher-core), a chain-family crate’s direct edges to blockwatcher-types and blockwatcher-ports (reachable via blockwatcher-rpc), blockwatcher-embed’s direct edge to blockwatcher-core’s own dependencies, and the binary’s edges to blockwatcher-types, blockwatcher-ports, and blockwatcher-core (reachable via blockwatcher → blockwatcher-api) and to blockwatcher-storage (reachable via blockwatcher → blockwatcher-embed). No edge is added that is not in the table above; none is silently dropped from the record. They are only pruned from the picture.

graph TD
  subgraph core["core"]
    types[blockwatcher-types]
    ports[blockwatcher-ports]
    core_[blockwatcher-core]
    api[blockwatcher-api]
  end

  subgraph modules["modules"]
    expr[blockwatcher-expr]
    rpc[blockwatcher-rpc]
    storage[blockwatcher-storage]
    sinks[blockwatcher-sinks]
    gates[blockwatcher-gates]
    metrics[blockwatcher-metrics]
    evm[blockwatcher-evm]
    stellar[blockwatcher-stellar]
  end

  subgraph glue["glue / test"]
    embed[blockwatcher-embed]
    bin[blockwatcher]
    testkit[blockwatcher-testkit]
    evmtestkit[blockwatcher-evm-testkit]
    stellartestkit[blockwatcher-stellar-testkit]
    e2e[blockwatcher-e2e]
  end

  ports --> types
  core_ --> ports
  api --> core_

  expr --> ports
  rpc --> ports
  storage --> ports
  sinks --> ports
  gates --> ports
  evm --> rpc
  stellar --> rpc

  evmtestkit --> evm
  testkit --> ports

  embed --> core_
  embed --> storage
  embed --> expr
  embed --> evm
  embed --> stellar
  embed --> sinks
  embed --> gates

  bin --> api
  bin --> metrics
  bin --> embed

blockwatcher-metrics, blockwatcher-e2e, and blockwatcher-stellar-testkit are the only nodes with no outgoing edge at all, in either the full table or the reduced diagram, and that absence is itself a checked property rather than an omission: blockwatcher-metrics declares no workspace crate in [dependencies] (it wraps metrics-exporter-prometheus directly), blockwatcher-stellar-testkit declares none either (its mock node is a self-contained axum listener), and blockwatcher-e2e declares no [dependencies] at all: the empty allowlist entry scripts/check-dep-graph.sh gives it exists precisely to keep that crate at zero production dependencies. blockwatcher-testkit and blockwatcher-evm-testkit have outgoing edges but no incoming ones in this graph, because every crate that uses them does so through [dev-dependencies], which this graph excludes by construction (the same rule the dependency gate checks by name for the testkit crates).

blockwatcher-types

blockwatcher-types is blockwatcher’s vocabulary crate: the canonical value model every decoder normalizes into, the shapes of every configuration resource (Network, Spec, Monitor, SinkDef, …), the schema types a predicate type-checks against, and the deterministic identifiers that let a downstream consumer deduplicate under at-least-once delivery. Nothing here runs anything: there is no pipeline, no async runtime, no retry loop. This crate only defines what everything else in the workspace passes around.

Its dependency list is the point, not an implementation detail: serde, serde_json, num-bigint, and indexmap, and nothing else. Every other crate in the core ring (blockwatcher-ports, blockwatcher-core, blockwatcher-api) and every module crate builds on this vocabulary without pulling in a chain SDK, an HTTP client, or a storage driver by way of it, because there is nothing in this crate’s own tree for them to pull in.

Key takeaways

  • blockwatcher-types is blockwatcher’s vocabulary crate: the canonical value model, every resource shape, the schema types a predicate checks against, and the deterministic identifiers a consumer deduplicates by.
  • Nothing here runs anything: no pipeline, no async runtime, no retry loop; this crate only defines what everything else passes around.
  • It depends on serde, serde_json, num-bigint, and indexmap, and nothing else, which is what lets every other crate build on this vocabulary without pulling in a chain SDK, an HTTP client, or a storage driver by way of it.

Responsibilities

  • Define Value, the canonical value model every decoded field is expressed in: see Chain-agnosticism for the full discussion; this page does not repeat it.
  • Define the configuration resource shapes: Network, Spec, Monitor, SinkDef, ModuleSel, RawSelector, DeliveryRetry, ResourceKind, VersionedRecord (the JSON-serializable structs a storage backend persists and the HTTP API accepts and returns). Monitor.gate is Option<ModuleSel>; config structs live with the modules (threshold / max_once in blockwatcher-gates), not a closed MonitorGate enum in types. GateHitRecord and GateMeta are the persisted journal shapes (gate.rs).
  • Define the schema vocabulary a predicate type-checks against: ValueType, EventSchema, FieldSchema, SchemaSet, and the FieldResolution/ FieldOrigin pair SchemaSet::resolve returns.
  • Define the pipeline’s own data shapes: RawEvent/RawPayload (what a source emits), DecodedEvent (what a decoder produces), Match (what a matcher accepts), SinkEvent (what a sink receives: Match or Retracted), and DeadLetter (what a sink gives up on).
  • Define Cursor and Checkpoint, the position and resume-point types every source’s progress is tracked in, without interpreting either one.
  • Define the deterministic identifier types: the string_id!-built newtypes (NetworkId, MonitorId, SinkId, SpecId, ChainKind) and MatchId, whose derive function is the one hashing algorithm in the crate.
  • Define SecretRef, the env:NAME reference type, and note that resolve is the one place this crate touches the outside world at all.

Not this crate’s job: deciding what a chain-specific decoder does with any of these shapes (blockwatcher-evm and future chain modules own that); defining the port traits that operate on them (blockwatcher-ports); running a pipeline, retrying a delivery, or persisting a resource (blockwatcher-core, blockwatcher-storage); the predicate language itself: this crate defines the schema vocabulary a predicate checks against, not the grammar or evaluator (blockwatcher-expr, see Predicates and the expression language).

Key types and traits

NameKindRole
ValueenumThe canonical value model; see Chain-agnosticism
Network, Spec, Monitor, SinkDef, ModuleSelstructConfiguration resource shapes stored via the Storage port and exposed over the HTTP API
RawSelector, DeliveryRetry, ResourceKind, VersionedRecordstruct/enumSupporting resource vocabulary: a monitor’s selector entry, the engine’s delivery-retry policy, the four resource kinds, and storage’s optimistic-concurrency envelope
ValueTypeenumThe type each schema field is declared at, shaped after Value’s own variants
EventKindstructAn open, string_id!-built vocabulary of occurrence kinds (event/function_call/transaction, or a chain family’s own); carried by both EventSchema and DecodedEvent, and hashed directly in MatchId::derive
EventSchema, FieldSchemastructOne declared occurrence and one declared field within it
SchemaSetstructThe fields and namespaces a compiled monitor makes available for a predicate to check against; resolve is the field-path resolution algorithm every predicate compile runs against
FieldResolution, FieldOriginenumThe three-way outcome of SchemaSet::resolve and whether a resolved field was declared or derived through an array
Cursor, CheckpointstructA source’s position in its own feed, and the resume point (cursor plus opaque source_state) a network’s pipeline persists
RawEvent, RawPayloadstruct/enumWhat a source emits into the pipeline, before decoding
DecodedEventstructThe normalized output of decoding: kind, name, canonical Value fields, cursor
Match, SinkEvent, DeadLetterstruct/enumOne accepted occurrence (with its deterministic MatchId), the closed event a sink delivers (Match or Retracted { match_id }), and the record of one that exhausted its delivery budget
MatchId, NetworkId, MonitorId, SinkId, SpecId, ChainKindstructDeterministic and open-vocabulary identifier newtypes; MatchId::derive is the crate’s one hashing algorithm
SecretRefstructAn env:NAME reference; resolve is the crate’s one I/O operation

How data flows through it

This crate has no pipeline of its own: its “flow” is the sequence of types that hand off from one pipeline stage to the next, all defined here even though the stages themselves live in other crates:

flowchart LR
    Config["Network / Spec / Monitor / SinkDef<br/>(write time)"] -.->|"read by"| Decoder
    Config -.->|"read by"| Matcher
    Source -->|"RawEvent / RawPayload"| Decoder
    Decoder -->|"DecodedEvent<br/>(Value tree, via SchemaSet)"| Matcher
    Matcher -->|"Match<br/>(MatchId::derive)"| SinkEvent
    SinkEvent -->|"Sink::deliver"| Sink
    Sink -.->|"on exhausted retries"| DeadLetter

Every arrow above is a type this crate defines; every box is a port implementation that lives elsewhere. SchemaSet::resolve runs once, at write time, when a monitor’s predicate is compiled against its selectors’ schemas, and never again while that pipeline is running: a decoded event at evaluation time is read through the already-resolved Ast, not re-resolved against the schema.

Neighbours

blockwatcher-types depends on the following in production (nothing else in the workspace):

  • serde: (de)serialization derive support
  • serde_json: JSON wire format
  • num-bigint: arbitrary-precision integers
  • indexmap: ordered maps

In [dev-dependencies] only:

  • proptest: property tests in id.rs over MatchId::derive

The following crates depend on it directly (per the dependency table):

  • blockwatcher-ports: trait boundary for all modules
  • blockwatcher-core: engine pipeline
  • blockwatcher-api: HTTP routes
  • blockwatcher-expr: predicate language and matcher module
  • blockwatcher-evm: EVM chain module
  • blockwatcher-evm-testkit: EVM module tests
  • blockwatcher-testkit: workspace test fixtures
  • blockwatcher-storage: storage module
  • blockwatcher-sinks: sink modules
  • blockwatcher (binary): process binary

blockwatcher-rpc reaches it only transitively, through blockwatcher-ports.

Reading the source

  1. Start at lib.rs: the module list, the re-exports, and the doc comment stating the crate’s one I/O exception (SecretRef::resolve).
  2. value.rs: the Value enum, its decimal/hex wire encoding, and from_json/get_path, the two general-purpose helpers built on it.
  3. schema.rs: ValueType, EventKind (an open vocabulary, not limited to the three constants it ships), EventSchema, SchemaSet, and resolve, the field-path resolution algorithm every predicate compile depends on.
  4. resource.rs: the configuration resource shapes: Network, Spec, Monitor, SinkDef, DeliveryRetry, RawSelector, ResourceKind, VersionedRecord. Monitor.gate is Option<ModuleSel>.
  5. gate.rs: GateHitRecord, GateMeta, GATE_HITS_CAP.
  6. id.rs: the string_id! macro, the identifier newtypes it builds, and MatchId::derive’s hashing algorithm.
  7. event.rs: the pipeline’s own data shapes: RawEvent, RawPayload, DecodedEvent, Match, SinkEvent, DeadLetter.
  8. cursor.rs: Cursor and Checkpoint.
  9. hex.rs: the internal 0x-hex serde helpers Value and RawPayload both serialize through.
  10. secret.rs: SecretRef, and the crate’s one I/O exception.

blockwatcher-ports

blockwatcher-ports is the trait boundary that makes blockwatcher chain-agnostic: it defines the port traits every module implements (Source, Decoder, Matcher, Gate, Sink, Storage), their write-time and runtime error types, the registration contract every module family enumerates itself through, and, behind two features, the in-memory test doubles that let consumers exercise a port’s contract without linking any real module at all.

Its production dependencies are blockwatcher-types plus async plumbing (tokio, tokio-util, futures, async-trait) and (de)serialization glue (serde, serde_json, thiserror). No chain SDK, no HTTP client, no storage driver appears anywhere in its allowlist or its transitive tree; that is what lets blockwatcher-core, which depends on this crate directly, drive a pipeline without ever importing one.

Key takeaways

  • blockwatcher-ports is the trait boundary that makes blockwatcher chain-agnostic: it defines the port traits (Source, Decoder, Matcher, Gate, Sink, Storage), their error types, and the module registration contract.
  • Behind the fakes and testing features it also provides in-memory test doubles for every port, so a consumer can exercise a port’s contract without linking any real module.
  • No chain SDK, HTTP client, or storage driver appears anywhere in its allowlist or transitive tree, which is what lets blockwatcher-core depend on it directly without importing one.

Responsibilities

  • Define the port traits and the associated types each one needs: Source (SourceCtx, SourceStatus, SourceOutcome, InterestSet, ScanRange), Decoder (CompiledSpec, CompiledSelector, DecodeOutcome, SpecSet), Matcher (CompiledPredicate, Explanation, ExplanationNode), Gate (CompiledGate, GateHit, GateDecision, GateAux, GateCtx), Sink, and Storage (BatchEntry).
  • Define the write-time and runtime error enum for each port (SourceError, SpecError, SelectorError, PredicateError, SinkError, GateError, StorageError) and ErrorClass, the retry classification every one of them reports through the Classify trait.
  • Define ModuleRegistry, the trait a module family implements once per module to name itself and hand back a constructor, plus the *Factory type alias beside each port trait that its Factory associated type resolves to.
  • (fakes feature) Provide one in-memory implementation per port: FakeSource, FakeDecoder, FakeMatcher, PassthroughGate, FakeSink, MemoryStorage, and FlakyStorage (a fault-injecting wrapper around MemoryStorage), each registered through the same ModuleRegistry contract a real module uses, so they are executable proof that a port hides no backend-specific detail, not just a convenience.
  • (testing feature) Re-export mockall-generated mocks: one per port trait (MockSource, MockDecoder, MockMatcher, MockGate, MockSink), and one per storage facet (MockResourceStore, MockCheckpointStore, MockDeadLetterStore, MockPauseStore, MockDeliveryJournal, MockGateStore) for unit-level substitution.

Not this crate’s job: implementing any real module: blockwatcher-evm, blockwatcher-rpc, blockwatcher-storage, blockwatcher-sinks, and blockwatcher-expr each implement exactly one port over real chain, network, or storage logic; blockwatcher-gates implements Gate the same way; running a pipeline, retrying a failed delivery, or deciding when a checkpoint advances (blockwatcher-core owns all of that: a Sink or Source implementation never retries internally); the predicate language’s grammar and evaluator (blockwatcher-expr defines those against the Matcher trait this crate only declares).

Key types and traits

NameKindRole
SourcetraitPulls raw activity into a pipeline; owns its own cursor semantics and run loop
DecodertraitCompiles a chain artifact into chain-agnostic schemas once, then decodes raw payloads against the compiled result
MatchertraitCompiles predicate source against a SchemaSet, then evaluates it against decoded events
GatetraitCompiles a monitor’s gate.config, then decides Retain/Discard/Emit over an engine-owned journal
SinktraitDelivers one SinkEvent (Match or Retracted); the engine owns all retry/backoff/dead-letter policy
StoragetraitResource CRUD, checkpoints, dead letters, and the bounded delivery journal, with optimistic concurrency on every write
ModuleRegistrytraitThe NAME + Factory + factory() contract every module declares itself through
ClassifytraitReports one of four ErrorClass values for any port error
ErrorClassenumTransient / Permanent / RateLimited / RetryNarrower: the whole retry-policy vocabulary
SourceError, SpecError, SelectorError, PredicateError, SinkError, GateError, StorageErrorenumOne thiserror enum per port, each with its own Classify impl
CompiledSpec, CompiledSelector, CompiledPredicate, CompiledGatestructType-erased compiled artifacts (downcast::<T>()), built once at write time and read on the hot path
InterestSet, ScanRangestructHints a source may use to narrow fetching, and the bounded range for a one-shot history scan
SourceCtx, SourceStatus, SourceOutcomestruct/enumEverything the engine hands a running source, its typed liveness status, and how run returns without a SourceError (Ended or Invalidated { from })
DecodeOutcomestructOne decode() call’s result: decoded events plus an undecodable payload count
Explanation, ExplanationNodeenum/structThe dry-run “why did or didn’t this match” tree a Matcher::explain returns
BatchEntrystructOne create in Storage::put_batch’s create-only batch
FakeSource, FakeDecoder, FakeMatcher, PassthroughGate, FakeSink, MemoryStorage, FlakyStoragestruct(fakes feature) In-memory, minimal implementations of every port

Object safety

Every port trait is consumed as a trait object, never through its concrete type: each *Factory alias resolves to Arc<dyn Source>, Arc<dyn Decoder>, Arc<dyn Matcher>, Arc<dyn Gate>, Arc<dyn Sink>, or Arc<dyn Storage> (source.rs, decoder.rs, matcher.rs, gate.rs, sink.rs, storage.rs), and every module’s registered factory constructs and returns exactly one of those. Staying dyn-compatible is load-bearing for that reason, and every trait keeps it this way:

  • No generic methods. None of them declares a method with its own type parameter: every method’s arguments and return type are concrete port types (&RawEvent, &CompiledSpec, &SchemaSet, and so on). The generic constructors this crate does define, CompiledSpec::new::<T> and CompiledSelector::new::<T> (decoder.rs) and CompiledPredicate::new::<T> (matcher.rs) and CompiledGate::new::<T> (gate.rs), are inherent methods on plain structs, not methods on a port trait, so they never have to satisfy object safety at all.
  • async fn only behind the macro that makes it dyn-compatible. Source, Sink, and Storage each declare async fn methods and are annotated #[async_trait] (source.rs, sink.rs, storage.rs), which rewrites every async fn into a plain fn returning a boxed, pinned future (the shape a trait object can actually hold), since a bare async fn in a trait is not itself object-safe. Decoder and Matcher have no async methods at all: compile_spec, compile, decode, interest, and merge_interest on Decoder, and compile, matches, explain, and referenced_fields on Matcher, all run synchronously, so both stay a plain pub trait X: Send + Sync with no macro needed (decoder.rs, matcher.rs). Gate is the same shape: compile, on_hit, and defaulted on_invalidate are synchronous (gate.rs).

The Send + Sync bound itself sits once, on the trait declaration, for the same reason: every one is declared pub trait X: Send + Sync as a supertrait bound (source.rs, decoder.rs, matcher.rs, gate.rs, sink.rs, storage.rs), so every dyn X is already Send + Sync by construction. None of the *Factory aliases repeats the bound on its own Arc<dyn X>: there is no Arc<dyn X + Send + Sync> anywhere in this crate, because the supertrait already settled it once, at the trait itself.

How data flows through it

The crate’s defining shape is the split between write time, when a monitor or spec is compiled into an opaque artifact, and the hot path, which only ever reads that artifact back through a typed downcast:

flowchart TD
    subgraph wt["Write time: once per monitor/spec"]
        Spec -->|"Decoder::compile_spec"| CS["CompiledSpec"]
        CS -->|"Decoder::compile(selectors, specs)"| CSel["CompiledSelector"]
        Text["predicate source"] -->|"Matcher::compile(text, schemas)"| CP["CompiledPredicate"]
    end
    subgraph hp["Hot path: once per event"]
        Raw["RawEvent"] -->|"Decoder::decode(raw, selector)"| DE["DecodedEvent"]
        DE -->|"Matcher::matches(predicate, event)"| Bool["bool"]
        Bool -->|"Sink::deliver(event)"| Result["Ok / SinkError"]
    end
    CSel -.->|"read via downcast"| DE
    CP -.->|"read via downcast"| Bool

Every port besides Storage follows this same compile-once, evaluate-many shape; Storage is the exception, since every one of its operations is already a runtime call with no separate write-time compilation step.

Neighbours

blockwatcher-ports depends on the following in production:

  • blockwatcher-types: vocabulary crate
  • tokio: async plumbing
  • tokio-util: async utilities
  • futures: future combinators
  • async-trait: trait async support
  • serde: (de)serialization derive
  • serde_json: JSON values for module configuration and factories
  • thiserror: error derive

Optionally in production (behind testing and fakes features):

  • mockall: mockall-generated trait mocks (testing feature)
  • num-bigint: arbitrary-precision integers (fakes feature)

In [dev-dependencies] only:

  • tokio (test-util feature): for fakes module async tests

The following crates depend on it directly (per the dependency table):

  • blockwatcher-core: engine pipeline
  • blockwatcher-api: HTTP API
  • blockwatcher-expr: predicate language and matcher module
  • blockwatcher-rpc: RPC chain module
  • blockwatcher-evm: EVM chain module
  • blockwatcher-testkit: workspace test fixtures
  • blockwatcher-storage: storage module
  • blockwatcher-sinks: sink modules
  • blockwatcher-gates: gate modules
  • blockwatcher (binary): process binary

blockwatcher-evm-testkit reaches it only transitively, through blockwatcher-evm and blockwatcher-rpc.

Reading the source

  1. Start at lib.rs: the module list, the full set of re-exports, and which of them are feature-gated.
  2. error.rs: ErrorClass, Classify, and the seven port error enums; read this before any port trait, since every fallible method returns one of these.
  3. registry.rs: ModuleRegistry and BoxFuture, the shared contract every module family’s own registration builds on.
  4. source.rs, decoder.rs, matcher.rs, gate.rs, sink.rs, storage.rs: the port traits, each beside the associated types and factory alias it needs; read in this order, since Source’s InterestSet and Decoder’s interest/merge_interest defaults are easiest to follow before Matcher, Gate, and Sink, which are simpler traits.
  5. fakes/mod.rs, then fakes/source.rs, fakes/decoder.rs, fakes/matcher.rs, fakes/gate.rs, fakes/sink.rs, fakes/storage.rs, and fakes/flaky_storage.rs: one minimal implementation per port, each registered through ModuleRegistry exactly as a real module would be.

blockwatcher-expr

blockwatcher-expr is the internals of blockwatcher’s default predicate language and the expr matcher module that wraps it: a lexer, a recursive-descent parser, a write-time type-checker, and a total three-valued evaluator, plus the ExprMatcher port implementation and module registration that make the whole pipeline selectable as matcher = "expr" in blockwatcher.toml. It is a module crate: chain-agnostic itself, and the one Matcher in the shipped module catalog (see Modules), though the port boundary in blockwatcher-ports never limits a deployment to it.

This page covers the crate’s own internals: parsing, type-checking, compilation, and evaluation. It does not restate the predicate language’s grammar, operator table, or type system, which Predicates and the expression language already documents in full from an operator’s point of view; where the two overlap, this page links there rather than duplicating it.

Key takeaways

  • blockwatcher-expr is the predicate language’s internals: a lexer, a recursive-descent parser, a write-time type-checker, and a total three-valued evaluator, plus the ExprMatcher port implementation.
  • It is chain-agnostic itself and the one Matcher in the shipped module catalog, though the port boundary never limits a deployment to it.
  • This page covers the crate’s own parsing, type-checking, compilation, and evaluation internals; the language’s grammar and type system are already documented from an operator’s point of view on Predicates.

Responsibilities

  • Scan predicate source text into an ordered stream of tokens, each one tagged with the byte range it came from, decoding numeric, hex, and string literals as it goes so nothing downstream re-parses them (lexer.rs).
  • Parse tokens into a spanned Ast via recursive descent, one function per precedence level, enforcing three bounds (nesting depth, tree height, and source length) so a hostile predicate cannot exhaust the stack or the heap at compile time (parser.rs).
  • Type-check the Ast against a monitor’s SchemaSet, admitting or rejecting every operator’s operands and producing an operator-facing PredicateError (with an edit-distance did-you-mean suggestion for an unknown field or namespace) the moment a predicate is written (typecheck/mod.rs, typecheck/diagnostics.rs).
  • Evaluate a type-checked Ast against a DecodedEvent with three-valued (Kleene) logic: total, allocation-light, and never a panic (eval.rs).
  • Wrap the whole pipeline as Predicate (compile/matches/explain/ referenced_fields) and as ExprMatcher, the blockwatcher-ports::Matcher implementation the expr module registers under (lib.rs, matcher.rs).
  • (testing feature) Expose test_schemas::schemas() (the fixture this crate’s own tests, its fuzz targets, and its checked-in fuzz corpus all compile predicates against) to external consumers, most notably the crate’s own tests/corpus_replay.rs, which links blockwatcher-expr as a dev-dependency with testing enabled specifically to reach it.

Not this crate’s job: defining the Matcher trait, SchemaSet, or ValueType it type-checks against (blockwatcher-ports, blockwatcher-types); choosing which matcher module a deployment runs (blockwatcher.toml, read by blockwatcher-core); shipping a second predicate language: none exists, and nothing about the port boundary requires this one.

Key types and traits

NameKindRole
PredicatestructA predicate that already passed parsing and type-checking; compile is its sole constructor, which is what lets matches run without any failure path
ExprMatcherstructThe blockwatcher-ports::Matcher implementation wrapping Predicate
Registry, ExprConfigstructThe ModuleRegistry registration for "expr"; ExprConfig is an empty, deny_unknown_fields struct, since the module takes no configuration of its own
Ast, Nodestruct/enumThe parsed tree: a Node paired with the byte Span it was parsed from
BinOpenumThe 17 binary operators the grammar admits
FamilyenumThe type-checker’s own, coarser type vocabulary (Int/Str/Bytes/Bool/Array/Map): Int collapses ValueType::Int/Uint, Bytes collapses Address/Bytes
NodeTypestructA node’s computed type: its Family set, whether every integer declaration behind a path is Uint-only, and a literal’s constant value (the state families/bin_type thread through the type-check walk)
TruthenumThe evaluator’s three-valued result: True / False / Unknown

How data flows through it

flowchart LR
    S["source text"] -->|"lexer::lex"| T["tokens"]
    T -->|"parser::parse"| A["Ast<br/>(untyped)"]
    A -->|"typecheck::check(schemas)"| P["Predicate<br/>(typed AST, compiled)"]
    P -->|"eval::evaluate(event)"| Tr["Truth"]
    Tr -->|"== Truth::True"| M["matches(): bool"]
    A -.->|"lex/parse/type error"| E["PredicateError"]

typecheck::check does not transform the tree: it walks the same Ast parser::parse returned and either accepts it or fails with a PredicateError; the “typed AST” in the diagram above is that same tree plus the guarantee that every path resolved and every operator’s operands admitted it. Predicate::compile (lib.rs) runs the whole left half of this diagram in order (parse, then check), and only a result that survived both becomes a Predicate; matches (lib.rs) is the whole right half, eval::evaluate(&self.ast, event) == eval::Truth::True, over the canonical Value tree a DecodedEvent carries. explain and referenced_fields are separate walks over the same typed Ast: the former re-evaluates and renders the failing subtree, the latter just collects every Node::Path. Neither one is on this diagram’s critical path, since both run only when a caller asks for them.

Neighbours

blockwatcher-expr depends on the following in production:

  • blockwatcher-types: vocabulary and schemas
  • blockwatcher-ports: trait boundary for matcher port
  • serde: (de)serialization derive
  • serde_json: JSON wire format
  • num-bigint: arbitrary-precision integers

In [dev-dependencies]:

  • blockwatcher-expr (at path = "." with testing feature): allows tests/corpus_replay.rs to reach test_schemas::schemas() under bare cargo test
  • blockwatcher-ports (fakes feature): FakeMatcher for cross-checking in matcher.rs tests
  • indexmap: ordered maps
  • proptest: property testing
  • tokio: async runtime

Only the following crate depends on it directly (per the dependency table):

  • blockwatcher-embed: the composition façade registers the matcher into the engine’s module catalog; the blockwatcher binary reaches this crate only through embed

Reading the source

  1. Start at lib.rs: the module list, Predicate’s public surface, and the doc comment on the compile/evaluate field-addressing contract every decoder must honor (pinned end to end by tests/dotted_field_addressing.rs).
  2. lexer.rs: Token, Span, and lex; token-decimal expansion and the two string escapes it recognizes.
  3. parser.rs: Ast, Node, BinOp, the recursive-descent grammar, and the three compile-time bounds (MAX_DEPTH, MAX_TREE_HEIGHT, MAX_SOURCE_LEN), each checked right where a hostile input could exceed it rather than by a separate validation pass afterward.
  4. typecheck/mod.rs: Family, NodeType, families/bin_type (the per-operator-class admission rules), and check, the entry point Predicate::compile calls.
  5. typecheck/diagnostics.rs: every rejection message’s exact wording and the bounded edit-distance search behind the did-you-mean suggestion.
  6. typecheck/message_guard.rs (cfg(test) only): a falsification harness that reads every rejection message as a claim and checks it against every predicate the checker accepts, so a message cannot assert something an accepted predicate disproves.
  7. eval.rs: Truth, the three-valued Kleene evaluate, and explain/explain_failed, the dry-run diagnostic walk.
  8. matcher.rs: ExprMatcher (the Matcher port implementation), Registry/ExprConfig (the "expr" module registration), and matchers::get_all().
  9. test_schemas.rs: the schema fixture behind the testing feature, shared by this crate’s own tests, its proptest suites, its fuzz targets, and tests/corpus_replay.rs.

blockwatcher-core

blockwatcher-core is blockwatcher’s engine: it assembles a running pipeline out of a network’s already-constructed source, its chain’s decoder, and the engine’s one matcher; tracks every dispatched match until it is either delivered or dead-lettered; decides the checkpoint that tracking makes safe to persist; and is the one path (ControlHandle) by which a resource write, delete, pause, dry run, or dead-letter replay reaches a running deployment. It is the largest crate in the workspace, and this is its largest page.

Its production dependencies are exactly the names scripts/check-dep-graph.sh’s ALLOW_BLOCKWATCHER_CORE entry lists:

  • blockwatcher-types: the vocabulary every resource, event, and identifier this crate moves is expressed in
  • blockwatcher-ports: the port traits spawn’s tasks drive (Source, Decoder, Matcher, Gate, Sink, Storage)
  • serde: the derive machinery behind every (de)serializable shape in the crate
  • serde_json: every resource’s on-the-wire and in-storage JSON representation
  • thiserror: the derive behind EngineError (error.rs)
  • tokio (macros/rt/time features): the async runtime every task, channel, and timer in the crate runs on
  • tokio-util: CancellationToken, the primitive spawn’s whole cancellation topology (cancel, hard_cancel, drain_token) is built from
  • futures: FutureExt::catch_unwind, used by the source task wrapper to catch a panicking source without taking the pipeline down with it
  • metrics: the facade every named constant in metrics.rs emits through
  • tracing: every structured log line in the crate

No chain SDK, HTTP client, or storage driver appears anywhere in this list or its transitive tree. blockwatcher-core also carries no family exemption at all in that same script’s family_exemptions_for (family_exemptions_for in scripts/check-dep-graph.sh): nothing forbidden may appear anywhere in its transitive tree under any named carve-out. See Architecture decisions § Chain knowledge stays out of the core and Workspace map § Verifying the rings for the rule this crate is one example of.

The pipeline and Delivery guarantees already describe this crate’s pipeline at an operator’s level: one task per stage, the backpressure it enforces, the guarantee a checkpoint carries. This page goes one level deeper: every file inside pipeline/ and control/ mapped to what it owns, the exact channel and cancellation-token wiring spawn builds, and the checkpoint writer’s own retry state machine. Where the two levels overlap, this page links to the concept page rather than restating it.

Key takeaways

  • blockwatcher-core is the engine: it assembles one pipeline per network from a source, a decoder, the one matcher, and an optional per-monitor gate, and is the only path (ControlHandle) through which a resource write reaches a running deployment.
  • No chain SDK, HTTP client, or storage driver appears anywhere in its dependency tree, and it carries no family exemption at all.
  • It is the largest crate in the workspace, depending only on blockwatcher-types and blockwatcher-ports among workspace crates.
  • This page goes one level deeper than the pipeline and delivery-guarantees concept pages: every file inside pipeline/ and control/, the exact channel and cancellation wiring spawn builds, and the checkpoint writer’s own retry state machine.

Responsibilities

  • Assembles and runs one pipeline per network: a single function, pipeline::spawn, constructs every channel and cancellation token a pipeline needs and then starts a source task, a processor task, one sink worker task per sink any of that network’s monitors reference, and a checkpoint writer task on top of them (crates/blockwatcher-core/src/pipeline/mod.rs). See Pipeline workers below for how every file in pipeline/ maps to one of those tasks.
  • Owns the whole control plane: Engine::start/Engine::shutdown for boot validation and a deadline-bounded drain, and ControlHandle for every resource write, delete, pause/resume, dry run, checkpoint skip, and dead-letter replay a running deployment accepts (engine.rs, control/). See The control plane.
  • Tracks every dispatched match until it is delivered or dead-lettered, and derives from that the one cursor a network’s pipeline may safely persist, through Progress/CompletionGuard (progress.rs) and CheckpointWriter (pipeline/checkpoint_writer.rs). See Progress and checkpointing.
  • Compiles a monitor’s selectors and predicate, and a chain’s specs, against the live decoder and the engine’s one matcher instance, once at write time, never on the event path (compile.rs, compiled.rs).
  • Names every module family a config can select from and reports an actionable refusal, listing every registered alternative, for a name nothing registered (catalog.rs).
  • Fans every pipeline event out to two independent observability destinations: the atomic PipelineCounters a status read serves with no cardinality cost (counters.rs), and the metrics facade constants an exporter may or may not be listening to (metrics.rs). See blockwatcher-metrics for the side of that pair which actually exports anything.
  • Reads and type-validates every stored resource before anything downstream ever sees it (resources.rs), and reports a point-in-time snapshot of every running, paused, or abandoned pipeline (status.rs).

Not this crate’s job: deciding what a chain’s wire format means, or implementing any source, decoder, sink, or storage module: blockwatcher-evm, blockwatcher-storage, and blockwatcher-sinks each own exactly one, behind the port traits blockwatcher-ports declares; running the predicate language itself (blockwatcher-expr: this crate only calls Matcher::compile/matches through the port); exposing anything over HTTP (blockwatcher-api wraps ControlHandle in routes) or Prometheus (blockwatcher-metrics installs the recorder this crate’s metrics.rs module writes into, and serves the scrape endpoint); defining the port traits it drives or the vocabulary types it moves (blockwatcher-ports, blockwatcher-types).

Key types and traits

NameKindRole
Engine, EngineDepsstructThe running engine: one pipeline per network, boot validation, and a deadline-bounded shutdown drain; EngineDeps is what Engine::start takes by value (storage, module catalog, config)
Engine::start, Engine::shutdown, ShutdownReportfn / structBoot’s all-or-nothing validate-then-spawn pass, and shutdown’s per-pipeline drain report (drained vs. aborted)
ControlHandlestructThe one path a running engine’s resources change through; mutating methods hold Engine’s single mutation lock for persist and restart. import_spec_from_chain fetches without that lock, re-reads the network under the lock, then persists through put_spec_locked
EngineErrorenumEvery refusal the engine or a mutation can produce, each variant carrying the identity of what it happened to
EngineConfig, SourceRestartstructBoot-time instance configuration (channel capacities, drain deadline, journal_depth, default retry policy) and the restart supervisor’s backoff policy
ModuleCatalogstructName-keyed home for every module family’s factories; a name nothing registered fails as UnknownModule, listing every alternative that was
Resources, Versioned<T>structTyped, validated reads over the primitive Storage port; Versioned pairs a value with the optimistic-concurrency version it was read at
CompiledMonitor, MonitorSetstructOne monitor’s write-time artifacts (compiled selector, predicate, actions), and a network’s whole compiled set plus its runtime pause set and merged source interest
compile_monitor, build_monitor_set, spec_set_for_chainfnCompiles one monitor / unions a network’s interest and referenced-fields hints across its monitors / compiles every spec on a chain: all write-time, never on the hot path
import_spec_payloadfnLooks up the spec importer for a chain and fetches a payload. ControlHandle::import_spec_from_chain and the spec-import CLI both call this so an unsupported chain is the same EngineError either way (spec_import.rs)
Progress, CompletionGuardstructThe contiguous-prefix tracker that derives the checkpoint a pipeline may safely persist; see Progress and checkpointing
PipelineCounters, CounterSnapshotstructPer-pipeline atomic event counts, and the point-in-time serializable copy GET /status (and this crate’s own metrics module) reads from
EngineStatus, PipelineStatus, QueueDepth, SourceStatusViewstruct / enumThe GET /status response shape: one entry per running, paused, or abandoned pipeline, its queue depths, and a serializable mirror of blockwatcher_ports::SourceStatus
TestInput, TestOutcome, TestReport, MAX_TEST_INPUTSenum / struct / constThe monitor dry-run’s request, per-input result, and whole-report shapes, and the shared input cap both this crate and the API refuse past
SkipReport, SkipTargetstruct / enumWhat skip_network moved a paused network’s checkpoint and the source module’s own cold start to, and where it was told to move it (a confirmed tip, or an absolute cursor)
NO_REPLAY_PAYLOAD, NO_REPLAY_RETRACTEDconstThe exact refusal messages for replaying a dead letter recorded before payload storage existed, and for a letter whose payload is a retraction; matched verbatim by the API layer

Pipeline workers

pipeline/ holds one file per pipeline stage plus the glue between them: nothing in the directory is shared infrastructure unrelated to running one network’s pipeline:

File-by-file map: pipeline/
FileOwns
mod.rsspawn itself, PipelineSpec (what a caller hands in), PipelineHandle (what a caller gets back), ChannelGauge<T> (a status-readable, non-owning view onto a bounded channel), and SourceExit (the restart supervisor’s own signal). This is the assembly file: every channel, every cancellation token, and every task the other files’ types run inside is created here, once, in spawn (pipeline/mod.rs).
processor.rsProcessor: the decode-and-match stage. One task per network, consuming raw events and, for every active, unpaused monitor, running the decoder against that monitor’s compiled selector and the matcher against its compiled predicate (processor.rs). Gate arithmetic is not inlined here: pipeline/gate.rs applies the decision, persists the journal, and mints from indices.
gate.rsApply a compiled gate’s on_hit decision, persist gate_hits/gate_meta, mint Match/Digest from returned indices. Processor calls it; window math lives in blockwatcher-gates.
sink_worker.rsSinkWorker and WorkItem: the delivery stage. One task per sink a network’s monitors reference; the retry attempts, the backoff between them, and the eventual dead letter all live here, in one place, so a webhook sink and a script sink fail exactly the same way regardless of what either one’s own module code does (sink_worker.rs).
aggregate.rsAggregateBuffer: the per-sink aggregation window. It owns what is held and until when; delivering, journaling, and dead-lettering what it hands back stays the sink worker’s job, identically for a digest and for a lone match (aggregate.rs).
throttle.rsThrottleWindow: the per-sink admission window. It owns how much of the budget is left and until when; which events answer to the budget, and what becomes of one it refuses, stays the sink worker’s job, which dead-letters a refusal on the same terms it dead-letters a failed delivery (throttle.rs).
delivery.rsdeliver_with_retry and DeliverOutcome: the retry loop itself, factored out rather than duplicated at each call site: SinkWorker::run calls it for live traffic, and ControlHandle::replay_dead_letter (control/dead_letters.rs) calls the identical function again for an operator-triggered replay. Neither caller gets its own copy of the retry-count/backoff-doubling arithmetic, or its own idea of which ErrorClass values are worth a second attempt (delivery.rs).
checkpoint_writer.rsCheckpointWriter and DrainSignal: the persistence stage. One task per network, persisting whatever Progress publishes and refusing anything older than what this instance already wrote (checkpoint_writer.rs). See Progress and checkpointing for what it actually does.

progress.rs sits one directory up from all of them, deliberately: Progress is not a task, it is a shared, lock-protected structure the processor registers events into and sink workers complete guards against. See Progress and checkpointing.

Pipeline worker topology

spawn (pipeline/mod.rs) builds every channel, watch, and cancellation token a pipeline needs before it spawns a single task, and every task below is handed only the subset it actually touches:

flowchart TD
    handle["PipelineHandle<br/>(held by Engine)"]

    subgraph one["one spawn() call"]
        src["Source task<br/>ctx.events (Sender)<br/>ctx.interest / ctx.status (watch)<br/>ctx.cancel"]
        proc["Processor task<br/>reads events_rx<br/>reads set_rx (watch)<br/>writes Progress.begin<br/>writes sink senders"]
        sw["Sink worker task<br/>(one per sink)<br/>reads its own WorkItem receiver<br/>completes guards via Progress<br/>cancel = hard_cancel"]
        cpw["Checkpoint writer task<br/>reads checkpoint_rx (watch)<br/>reads drain_token<br/>cancel = hard_cancel"]
        prog[("Progress<br/>shared struct, not a task")]
    end

    src -->|"events_tx: mpsc(event_channel_capacity)<br/>pipeline/mod.rs"| proc
    proc -->|"sink_tx: mpsc(sink_channel_capacity)<br/>one per sink, pipeline/mod.rs"| sw
    proc -->|"begin(cursor, outstanding)"| prog
    sw -->|"guard.complete()"| prog
    prog -->|"checkpoint_rx: watch&lt;Option&lt;Checkpoint&gt;&gt;"| cpw
    cpw -->|"put_checkpoint"| store[("checkpoint<br/>blockwatcher-storage")]

    handle -->|"set_tx.send_replace<br/>(hot swap)"| proc
    handle -->|"interest_tx.send_replace<br/>(hot swap)"| src
    src -->|"status_tx.send_replace"| handle

    parent(("parent_cancel<br/>engine root")) -.->|".child_token()"| src
    hard(("hard_cancel<br/>independent root,<br/>only a deadline<br/>escalation fires it")) -.-> sw
    hard -.-> cpw
    proc -.->|"Arc&lt;DrainSignal&gt; canary<br/>dropped on task exit"| drain{{"drain_token"}}
    sw -.->|"canary dropped<br/>on every worker's exit"| drain
    drain -.->|"cancelled() once<br/>every canary has dropped"| cpw

    classDef token fill:none,stroke:#e39a3a
    class parent,hard,drain token

Two details that are easy to miss reading the concept-level diagram alone:

  • The bounded mpsc channels are the only queues in this diagram. Every other arrow is a tokio::sync::watch (publishes only the latest value, never queues several) or a direct call against Progress’s own mutex-guarded state. See Backpressure below.
  • hard_cancel is not a descendant of parent_cancel, and the source task never holds it at all. Only the sink workers and the checkpoint writer check it; only a deadline escalation inside drain_pipeline (engine/drain.rs, see Boot, restart, and shutdown) ever fires it. If the pipeline’s own ordinary shutdown signal (cancel) triggered it directly instead, a sink worker or the checkpoint writer would give up on work still sitting in a bounded channel that an un-escalated drain, given the time it is normally owed, would have gone on to flush, per spawn’s own comment (pipeline/mod.rs).
  • The drain-signal canary is held by the processor and every sink worker, never by the source. DrainSignal’s Drop impl fires drain_token once every clone of it has dropped (checkpoint_writer.rs); the checkpoint writer’s own Progress clone, and PipelineHandle’s, would otherwise keep Progress’s watch channel open forever, since neither ever drops for the life of the handle. Excluding the source is deliberate too: a source exiting has nothing to do with whether the processor and sink workers still have buffered work to finish draining.

Backpressure: the bounded channels

Between source and processor sits one tokio::sync::mpsc channel of capacity event_channel_capacity (default 256); between processor and each sink worker sits one of capacity sink_channel_capacity (default 64), both configured once in EngineConfig (config.rs) and created inside spawn (pipeline/mod.rs). These are the only bounded channels a pipeline has. Nothing else in the topology above can exert backpressure: Progress’s internal VecDeque<Slot> (progress.rs) has no bound at all (it grows with however many events are in flight and shrinks as their guards complete), and every watch channel (set_tx/set_rx, interest_tx/interest_rx, status_tx/status_rx, checkpoint_rx) holds at most one pending value by construction: a send_replace overwrites whatever was there, it never queues a second one behind it. A hot-swapped monitor set or a republished checkpoint can never itself pile up; only the mpsc channels above can.

Both channel operations are ordinary tokio::sync::mpsc::Sender::send calls with nothing wrapped around them: no try_send, no timeout, no fallback path (the sink dispatch call site is processor.rs). That is the entire mechanism: a full channel just makes that one .await pend. The pipeline § Backpressure: two bounded channels, no drops walks through where that pending call propagates to (the processor’s whole dispatch loop, then the source’s own send) and Delivery guarantees covers why a stall is the accepted outcome rather than a drop. This page’s own contribution is the inventory above: the bounded points it names are the only ones in the entire topology, and their configured capacities are the only backpressure knobs EngineConfig exposes: nothing else in spawn’s wiring can ever be the thing that is full.

Progress and checkpointing

Delivery guarantees already names Progress as the mechanism deciding when a cursor becomes safe to persist; this section is the data structure and algorithm underneath that decision. Progress’s state (State, progress.rs, held inside Inner alongside the watch Sender, progress.rs) is a VecDeque<Slot> behind one Mutex: each Slot (progress.rs) carries one raw event’s cursor, its source_state, and a remaining count of matches dispatched from it that have not yet completed. begin (progress.rs) pushes one Slot per raw event, in the order the processor calls it, and nothing ever removes a slot except from the front. advance_locked (progress.rs) is the entire algorithm on top of that queue: walk forward from the front for as long as Slot::remaining is zero, drop every slot that check passes, and, only if at least one slot was dropped, call send_replace with the cursor of the last one. Running the walk and the send_replace inside the same critical section is what rules out two concurrent completions racing each other into the watch channel in the wrong order; a version that unlocked between the two could have one thread publish cursor 3 and a second thread, scheduled just behind it, publish cursor 1 afterward, and nothing would ever notice or correct it since the slots behind both are already gone.

CompletionGuard::complete (progress.rs) is Slot::remaining -= 1 followed by the same advance_locked call; dropping a guard without calling complete (a worker task dying mid-delivery, most notably) leaves that slot’s remaining nonzero forever, which permanently stalls the prefix at that slot rather than expiring or timing out. Delivery guarantees § The contract, precisely covers what that guarantee means to a consumer; the mechanism above is what it compiles down to.

CheckpointWriter (checkpoint_writer.rs) is the other half: a task per network that watches Progress’s publish channel and persists whatever it sees, forever retrying a failed write against the same value. Persisting whatever the watch currently holds, rather than a queued history of values, is safe specifically because of how advance_locked publishes: since every publish happens while holding the same lock a later publish must also acquire, a later value can never be an earlier cursor than one already sent. One rule this task adds on top of Progress’s own monotonicity: persist (checkpoint_writer.rs) tracks last_persisted within this writer instance’s own lifetime and refuses (counts, logs, and skips) any checkpoint older than that, independent of what Progress would otherwise publish. That guard exists for a source that violates its own non-decreasing-cursor contract mid-run; it is explicitly not enforced across a restart, because a fresh writer’s first persist is unconditional: the mechanism Boot, restart, and shutdown below relies on to make a legitimate rewind (a positively detected reorg, a resumed process) possible at all.

stateDiagram-v2
    [*] --> Waiting
    Waiting --> Waiting: sample tick (every 1s)<br/>gauge IN_FLIGHT_EVENTS
    Waiting --> CheckRegression: checkpoint_rx.changed()<br/>or drain fires<br/>(borrow_and_update reads the latest value)
    CheckRegression --> Waiting: cursor behind last_persisted<br/>refused, counted<br/>(CHECKPOINT_REGRESSIONS_REFUSED)
    CheckRegression --> Persisting: cursor at or past last_persisted
    Persisting --> Persisted: storage.put_checkpoint Ok<br/>last_persisted = cursor
    Persisted --> Waiting: loop, unless drain already fired
    Persisted --> [*]: drain fired, nothing left to flush
    Persisting --> Backoff: storage.put_checkpoint Err<br/>counted (CHECKPOINT_WRITE_FAILED)
    Backoff --> Persisting: backoff elapses<br/>(doubles, capped at max_backoff_ms)
    Backoff --> Abandoned: hard_cancel fires<br/>(deadline escalation only)
    Abandoned --> [*]
    Waiting --> [*]: drain fired,<br/>nothing pending to flush

Two states are worth naming precisely against the source: CheckRegression is persist’s own comparison against last_persisted (checkpoint_writer.rs), and it is the only place a value is discarded rather than eventually written: a discard here is never silent, it increments PipelineCounters::checkpoint_regressions_refused and the matching metrics.rs constant, and logs at error level naming both cursors. Abandoned is reachable only from Backoff, and only because hard_cancel fired: the writer never gives up on a value it has already decided to persist for any other reason, including the pipeline’s own ordinary cancel token, which this task does not even hold a reference to.

The control plane

blockwatcher-core’s own module comments draw the same distinction this section does: control/ is “the caller-facing half — it owns the validate/compile/ persist ordering, the optimistic concurrency, and the mutation lock” (engine/control.rs:4-6), while engine/control.rs (a different file, one level up from control/) is “the engine-side half,” owning what happens to the running pipeline once a mutation has already validated and persisted its change (engine/control.rs:1-2).

control/: the caller-facing half

One file per method family on ControlHandle:

File-by-file map: control/
FileOwns
mod.rsControlHandle itself; pause_monitor/resume_monitor and pause_network/resume_network: the persisted lifecycle toggles, see below; status(); and network_context, the shared resolve-network-then-decoder-then-specs helper every method that acts on one monitor uses (control/mod.rs).
writes.rsput_monitor, put_network, put_sink, put_spec, and restart_affected: creation and update for every resource kind, each validating references and compiling before persisting, then restarting or hot-swapping whatever running state the write affects (control/writes.rs).
import.rsimport_spec_from_chain: fetch through import_spec_payload without the mutation lock, re-read the network under the lock, then persist through put_spec_locked (control/import.rs).
deletes.rsdelete_monitor, delete_network_checkpoint, delete_network, delete_sink, delete_spec, and refuse_if_referenced: removal, and the one rule this family owns on its own: a resource any stored monitor still names is refused before storage is touched at all (control/deletes.rs).
inspect.rstest_monitor (the dry run: TestInput/TestOutcome/TestReport, MAX_TEST_INPUTS) and spec_schema: the read-only surface, taking no mutation lock and touching nothing running (control/inspect.rs).
skip.rsskip_network, SkipTarget, SkipReport: the operator “skip catch-up” patch: moves a paused network’s checkpoint and the source module’s own cold start forward without replaying the gap, deliberately bypassing put_network (which would clear the pause and restart) (control/skip.rs).
dead_letters.rslist_dead_letters, replay_dead_letter, find_dead_letter, and NO_REPLAY_PAYLOAD: a storage read and an operator-triggered re-entry into deliver_with_retry (see Pipeline workers above) (control/dead_letters.rs).

Every mutating method here follows the same order, stated once in control/mod.rs’s own module doc comment and never varied: “validate references, compile or construct whatever needs it, persist with optimistic concurrency, then update whatever is actually running — in that order, always, for every mutating method, writes and deletes alike” (control/mod.rs:65-69). A write or delete that has already persisted is treated as done regardless of what happens next: whatever running state it still has to touch (a republished monitor set, a restarted pipeline) is attempted best-effort, and a failure there is logged with the resource and reason rather than turned into an error response for a resource storage already durably holds. Exception: a gate envelope change or gated monitor delete that cannot bring the pipeline back after stopping it returns that failure even after persist, so HTTP is non-2xx while /status reports the network abandoned.

Pause is persisted

Both pause surfaces, ControlHandle::pause_monitor/resume_monitor and pause_network/resume_network (control/mod.rs), persist the pause to storage before touching anything running, and every path that builds a monitor set or boots a pipeline reads that persisted state back:

  • Pausing a monitor writes its id to storage’s paused-monitor set (ControlHandle::set_paused, control/mod.rs), then republishes the network’s running monitor set through build_monitor_set with that same persisted set (Engine::republish_paused_monitors, engine/control.rs). The persist is the request: a pipeline that is not running, or a republish that fails, is logged and left for the next rebuild, which reads the persisted set from scratch and always applies it — the same path a network, sink, or spec write’s restart takes, and the same one boot itself seeds from (engine/boot.rs). Deleting a monitor clears its pause row with it (control/deletes.rs); a refused delete (stale If-Match) restores that pause so a still-present resource does not silently un-pause.
  • Pausing a network persists the pause (Storage::set_paused), marks it in Engine.paused_networks (a HashSet<NetworkId>, engine.rs) so a concurrent SourceExit cannot race a restart back into existence, then stops its pipeline through the same bounded drain a restart uses. Resuming clears the persisted row and restarts the pipeline unless one is already running for it; a restart that then fails is logged rather than returned, since the persist already succeeded (resume_network, control/mod.rs). Boot reads the persisted set before constructing any pipeline and never spawns one for a paused network at all, intersected against the networks boot actually built so a pause row that outlived its network cannot render a status entry for one that no longer exists (engine.rs’s constructor). The restart supervisor consults the same in-memory mark (is_paused) and the delete tombstone (is_tombstoned) before ever scheduling a restart of its own, so an operator’s pause is never silently undone by a source exiting and the supervisor bringing it back (engine/supervisor.rs). The tombstone is a HashMap of network id to the sink ids the last pipeline for that network was spawned with, so a late Invalidated after delete still retracts only those sinks. Deleting a network clears its pause row with it.

A monitor’s pause additionally drops out of the source’s merged interest hint the moment it is paused: build_monitor_set excludes a paused monitor’s selector from the interest union it hands the decoder (compile.rs), so pausing a monitor can only ever shrink what its network’s source is asked to fetch, never leave it fetching data nothing will read.

Boot, restart, and shutdown

engine.rs plus its engine/ submodules are the other side of the control plane: what an engine does on its own behalf (boot, shutdown, restart supervision) rather than what a caller’s mutation asks of it.

File-by-file map: engine/
FileOwns
boot.rsvalidate_and_build: the single all-or-nothing pass both Engine::start and Engine::validate run through: parse and type-check every stored resource, build the matcher and one decoder per registered decoder module, construct every sink, check that no monitor names a network, sink, or spec that either doesn’t exist or doesn’t match, and then, network by network, compile its monitors and construct its source and read its checkpoint. A failure on network N never leaves networks 1..N-1 half-validated behind it: nothing is handed to a caller to spawn until the whole pass clears (engine/boot.rs). Also check_checkpoint_provenance, re-exported for control/writes.rs to share (engine/boot.rs).
control.rsbuild_pipeline_spec, restart_pipeline, stop_pipeline, hot_swap_monitors, republish_paused_monitors: everything that happens to a running pipeline once a caller-facing mutation has already validated and persisted its change. Every function here requires Engine’s mutation lock already held by its caller; none of them takes it (engine/control.rs). stop_pipeline also owns the StopOutcome a caller reads to know whether storage is safe to touch next: after an escalated drain it awaits Storage::quiesce under drain::QUIESCE_BUDGET, and restart_pipeline refuses to spawn a replacement (leaving the network abandoned) when that wait times out, rather than read a checkpoint a residual write from the abort could still race.
drain.rsdrain_pipeline: the shared teardown ladder both Engine::shutdown and a single-network stop_pipeline/restart_pipeline drain through: await each task to a deadline, then hard_cancel, a brief grace window, then a forced abort() for any straggler, counted under PIPELINES_ABORTED the moment any escalation was needed at all (engine/drain.rs). Also QUIESCE_BUDGET, the bound both Engine::shutdown and stop_pipeline apply to Storage::quiesce right after an escalation, so an aborted task’s detached storage call is waited out (or, on shutdown, at least warned about and counted as PIPELINES_QUIESCE_TIMEOUT) before its caller treats the network as clean.
supervisor.rsThe restart supervisor task Engine::start spawns. Reacts to nothing but the SourceExit channel: Invalidated is a control path (drain, retract, rewind, restart via engine/invalidate.rs), not a crash backoff. A failed invalidate is counted as SOURCE_INVALIDATION_FAILURES, not SOURCE_RESTART_FAILURES. Every other exit schedules a restart_pipeline call at a per-network delay that doubles with each consecutive failure and resets once a network has run healthily past a configurable interval, and it drops an exit for a network that is already paused or tombstoned on the spot rather than counting it as anything (engine/supervisor.rs).
invalidate.rshandle_invalidation: cooperative drain, prune gate_hits where cursor > from (do not delete cursor ≤ from), re-read the checkpoint, journal list_deliveries_after, one-shot retract pass through deliver_with_retry, then rewind only when from is strictly behind the stored checkpoint (a rewind that would advance it, or fabricate one where none exists, is refused, counted under REWINDS_REFUSED, and the checkpoint stays put), and restart. Retracts go to the live pipeline’s sinks, or after delete to the sink ids stored on that network’s runtime tombstone (not every remaining sink resource). An unrecovered retract leaves the checkpoint unrewound (engine/invalidate.rs).

Two things a reader coming from pipeline/mod.rs’s spawn should carry into this section: restart_pipeline’s replacement pipeline is built to completion before the existing one is touched, so a fallible step (a storage error, a module that no longer constructs) always leaves whatever was running before completely untouched; and the replacement’s checkpoint is deliberately read after the old pipeline has drained, never before. A pre-drain read would miss whatever further completions land while the old pipeline winds down, so the replacement would come up already behind its own predecessor and, on its first persist, push the stored cursor backward (engine/control.rs, doc comment). This is the one place in the whole crate a restart is allowed to make a checkpoint “rewind” in CheckpointWriter’s sense above: the fresh writer’s first persist is unconditional, precisely so this legitimate case is not caught by the same guard that refuses a source’s own contract violation.

Neighbours

blockwatcher-core depends on, in production (the same list named in full above, and no chain SDK, HTTP client, or storage driver anywhere in this list or its transitive tree (see the exemption-free guarantee described above):

  • blockwatcher-types
  • blockwatcher-ports
  • serde
  • serde_json
  • thiserror
  • tokio (macros/rt/time features)
  • tokio-util
  • futures
  • metrics
  • tracing

and, in [dev-dependencies] only:

  • blockwatcher-ports (fakes feature): FakeSource, FakeDecoder, FakeMatcher, FakeSink, MemoryStorage, FlakyStorage; every test in this crate runs against these, never a real module
  • blockwatcher-testkit: RecordingMetrics, used to assert on what a pipeline flow emits through the metrics facade
  • async-trait: implementing Source/Sink test doubles (PanickingSource, CancelThenOkSource, SlowSink) that blockwatcher-ports::fakes doesn’t already provide
  • tokio (rt-multi-thread/test-util features): multi-threaded and paused-time async tests

The following crates depend on it directly (per the dependency table):

  • blockwatcher-api: wraps every ControlHandle method in an HTTP route
  • blockwatcher-embed: calls Engine::start for in-process hosts and for the binary’s own boot
  • blockwatcher (binary): owns the process’s own shutdown signal, and calls Engine::validate for its offline config-check mode

Reading the source

  1. Start at lib.rs: the module list and the full re-export surface; every name in the key types table above is pub re-exported here (CheckpointWriter is the one type this page covers in depth that is not: mod pipeline; has no pub, so nothing outside blockwatcher-core can name it, pub struct on the type itself notwithstanding).
  2. error.rs: EngineError, every variant naming the identity of what it happened to; read this before anything else, since almost every fallible function in the crate returns it.
  3. config.rs: EngineConfig and SourceRestart, and their defaults (event_channel_capacity: 256, sink_channel_capacity: 64, drain_deadline_ms: 10_000, journal_depth: 1024).
  4. catalog.rs: ModuleCatalog, and the family! macro that generates one register/lookup pair per port family so registering, say, a sink factory as a source is a compile error.
  5. compiled.rs, then compile.rs: CompiledMonitor/MonitorSet, and the three write-time functions that build them: compile_monitor (selectors, predicate, and gate), build_monitor_set (read its doc comment on field-union poisoning closely: one un-introspectable monitor’s predicate makes the whole network’s referenced-fields hint unknown, on purpose), and spec_set_for_chain.
  6. resources.rs: Resources/Versioned<T>, and why a listing (load) fails whole on one bad record while a by-id read (load_one) fails only the lookup that names it.
  7. counters.rs, then metrics.rs: PipelineCounters/CounterSnapshot, then the named Prometheus constants and the emission helpers (count, count_sink, count_gate, gauge) every pipeline stage calls alongside its matching atomic increment. Gate metric names (blockwatcher_gate_hits_total and the rest) are the same strings Observability lists. Invalidate, retract, and journal-gap counters live here too; they have no PipelineCounters twin. See blockwatcher-metrics § From a pipeline event to a scrape line for where the second half of that pair actually goes.
  8. progress.rs: Progress/CompletionGuard/Slot/advance_locked; read this before pipeline/checkpoint_writer.rs, which depends on everything it guarantees.
  9. pipeline/mod.rs, then processor.rs, sink_worker.rs, delivery.rs, checkpoint_writer.rs, in that order. See Pipeline workers above for what each one owns.
  10. engine.rs: Engine, EngineDeps, ShutdownReport, and the mod declarations for its own submodules; read Engine::start and Engine::shutdown here before descending into engine/boot.rs, engine/control.rs, engine/drain.rs, engine/supervisor.rs, and engine/invalidate.rs. See Boot, restart, and shutdown above for what each one owns.
  11. spec_import.rs: import_spec_payload, the shared lookup-and-fetch used by ControlHandle::import_spec_from_chain and the spec-import CLI.
  12. control/mod.rs, then writes.rs, deletes.rs, inspect.rs, skip.rs, dead_letters.rs. See The control plane above for what each one owns, and read mod.rs’s module doc comment first: it states the validate-persist-swap ordering and the post-persist contract every one of the other files holds to.
  13. status.rs: SourceStatusView, QueueDepth, PipelineStatus, EngineStatus, and the small pure functions (head_of, lag_between, queue_depth_with_capacity) Engine::status_snapshot composes them from.

blockwatcher-metrics

blockwatcher-metrics is blockwatcher’s Prometheus wiring: it installs the process-global recorder that every metrics facade call anywhere in the workspace writes into, and it serves the /metrics scrape endpoint that renders whatever that recorder has accumulated since process start. It is a module crate in the sense the workspace map uses the word: not because it varies chain behavior, but because it is the one crate in the workspace allowed to carry an HTTP stack for this purpose, so that nothing in the core ring has to.

Its production dependencies are exactly the packages scripts/check-dep-graph.sh’s ALLOW_BLOCKWATCHER_METRICS entry lists, and no workspace crate at all (crates/blockwatcher-metrics/Cargo.toml; confirmed independently by the dependency graph, where blockwatcher-metrics is one of the nodes in the whole table with no outgoing edge):

  • axum: the HTTP framework serve’s /metrics router runs on
  • metrics-exporter-prometheus: PrometheusBuilder/PrometheusHandle and the Prometheus recorder itself
  • thiserror: the derive behind MetricsError
  • tokio (net/time/sync/macros/rt features): the listener, the upkeep interval, and the shutdown-signal plumbing
  • tracing: the crate’s own warning-level logging

That absence of any workspace crate is the point: blockwatcher-core’s own metrics.rs module calls the bare metrics facade crate directly (a header-only dependency with no network stack of its own) and never touches axum or metrics-exporter-prometheus. Only this crate does, which is exactly what scripts/check-dep-graph.sh checks: its ALLOW_BLOCKWATCHER_CORE entry names metrics but not axum or metrics-exporter-prometheus, and carries no family exemption at all, so neither could appear anywhere in blockwatcher-core’s transitive tree either; its ALLOW_BLOCKWATCHER_METRICS entry names both, with FAMILY_EXEMPT_BLOCKWATCHER_METRICS="axum hyper tower-http" covering the transport crates axum itself pulls in. Splitting the exporter from every emitter is what lets a pipeline task keep calling metrics::counter! for free while the HTTP server, its router, and its accept loop live in a crate the core ring never has to compile. See Workspace map § Verifying the rings and Architecture decisions § Chain knowledge stays out of the core for the general rule this crate is one instance of.

This page covers how the exporter itself works (installing the recorder, serving the scrape route, running upkeep) and how the two per-pipeline observability destinations blockwatcher-core maintains relate to it. Observability already documents every exported metric’s name, type, and label from an operator’s point of view; this page does not repeat that table.

Key takeaways

  • blockwatcher-metrics installs the process-global Prometheus recorder every metrics facade call writes into, and serves the /metrics scrape endpoint that renders it.
  • It is the one crate in the workspace allowed to carry an HTTP stack for this purpose, so nothing in the core ring has to.
  • It depends on no workspace crate at all; blockwatcher-core’s own metrics.rs calls the bare metrics facade directly and never touches axum or the Prometheus exporter.

Responsibilities

  • Installs the process-global Prometheus recorder exactly once per process, via PrometheusBuilder::new().install_recorder(), and hands back the same PrometheusHandle on every later call in the same process rather than erroring or reinstalling: the metrics crate has no uninstall, so cumulative series continue across a repeated call instead of resetting (install_recorder, crates/blockwatcher-metrics/src/lib.rs).
  • Serves GET /metrics on an already-bound TcpListener as Prometheus exposition text (text/plain; version=0.0.4; charset=utf-8). Alongside the route itself, serve spawns a second task that calls handle.run_upkeep() every 5 seconds and keeps calling it for the whole time the listener is up. See Observability § Turning it on for what an operator gets out of that (serve, router, lib.rs).
  • Stops accepting new connections the moment shutdown resolves but lets an in-flight scrape finish, via axum::serve(..).with_graceful_shutdown(..) (lib.rs).
  • Owns axum so nothing else in the workspace has to. The doc comment at the top of lib.rs makes the point directly: owning axum there, rather than in the binary, is what keeps the HTTP framework out of the binary’s own dependency graph (lib.rs).

Not this crate’s job: deciding what gets emitted, under what name, or with what labels: every metric constant and its call site lives in the crate that owns the event (blockwatcher-core’s metrics.rs for pipeline events, blockwatcher-rpc’s connection pool for its own, per Observability § Other metrics on this endpoint); binding the listener or reading [metrics].listen: the blockwatcher binary does both and hands this crate an already-bound socket, in boot and serve_metrics (crates/blockwatcher/src/run.rs); the control-plane HTTP API, a wholly separate axum::Router on its own socket (blockwatcher-api); mapping a pipeline counter onto anything (see How data flows through it below; this crate is downstream of that mapping, never a party to it).

Key types and traits

NameKindRole
PrometheusHandlestruct (re-export)The installed recorder’s handle; render() produces the exposition text serve’s route returns, and run_upkeep() is what the upkeep task calls every 5 seconds
install_recorderfnInstalls the process-global Prometheus recorder on the first call in a process, or hands back the already-installed handle on every later one
MetricsErrorenumThe one way install_recorder can fail: a foreign recorder already owns the process and no Prometheus handle is available to reuse
serveasync fnRuns the /metrics axum router on an already-bound listener until shutdown resolves, alongside the 5-second upkeep task, and joins that task before returning

How data flows through it

This crate sits downstream of every emitter, never upstream: nothing inside it calls metrics::counter! or metrics::gauge! on its own behalf. install_recorder only makes the process-global recorder exist; serve only renders whatever that recorder has already accumulated by the time a scrape arrives.

flowchart LR
    core["blockwatcher-core<br/>metrics.rs<br/>pipeline/*, engine/drain.rs, engine/invalidate.rs"] -->|"metrics::counter!/gauge!"| rec[("process-global<br/>metrics::Recorder")]
    rpc["blockwatcher-rpc<br/>connection pool"] -->|"metrics::counter!/histogram!"| rec
    install["install_recorder()<br/>lib.rs"] ==>|"installs once,<br/>returns PrometheusHandle"| rec
    upkeep["upkeep task<br/>every 5s while serve() runs<br/>lib.rs"] -->|"handle.run_upkeep()"| rec
    rec -.->|"handle.render()"| scrape{{"GET /metrics<br/>lib.rs"}}

From a pipeline event to a scrape line

blockwatcher-core maintains two independent, same-call-site destinations for every pipeline event, and only one of them ever reaches this crate:

blockwatcher-core sourceDestinationReaches this crate?
counters.rs’s PipelineCounters plain atomics (the fields on CounterSnapshot)GET /status’s counters object, via CounterSnapshot (crates/blockwatcher-core/src/status.rs, crates/blockwatcher-core/src/engine.rs)No: read directly by Engine::status_snapshot; never touches the metrics facade or this crate at all
metrics.rs’s named Prometheus constants (EVENTS_DECODED, DELIVERIES, SOURCE_INVALIDATIONS, …), incremented via count/count_sink/gauge (crates/blockwatcher-core/src/metrics.rs)The process-global metrics::Recorder this crate installsYes: this is the only path from a pipeline event to a Prometheus series
blockwatcher-rpc’s pool metrics (blockwatcher_rpc_*, crates/blockwatcher-rpc/src/pool.rs)Same process-global recorderYes, the same path, carrying an endpoint label instead of pipeline

Both blockwatcher-core destinations are incremented from the same call site: see, for one example, Processor::process_one’s decode loop, which bumps self.counters.decoded and calls metrics::count(metrics::EVENTS_DECODED, ..) on adjacent lines. Observability § The same events, twice makes the case for why an operator wants both; this page’s narrower point is structural: this crate is the renderer for exactly one of the two rows in that table, and has no view onto the other row’s contents at all: it cannot, since nothing about PipelineCounters ever reaches the metrics facade this crate’s recorder is listening to.

Neighbours

blockwatcher-metrics depends on, in production (no workspace crate among them: crates/blockwatcher-metrics/Cargo.toml; confirmed by the dependency graph):

  • axum
  • metrics-exporter-prometheus
  • thiserror
  • tokio (net/time/sync/macros/rt features)
  • tracing

and, in [dev-dependencies] only:

  • metrics: records a real counter in its own black-box test
  • reqwest: scrapes the served endpoint over real HTTP in that same test

The following crate depends on it directly:

  • blockwatcher (binary): calls install_recorder before seeding and passes the resulting PrometheusHandle to serve_metrics, a thin wrapper spawning blockwatcher_metrics::serve, once [metrics].enabled names a listen address, in boot and serve_metrics (crates/blockwatcher/src/run.rs)

Reading the source

  1. Start at lib.rs’s module doc comment: the one-sentence reason this crate owns axum instead of the binary.
  2. METRICS_HANDLE, MetricsError, and install_recorder: the process-global install-once, reuse-forever contract.
  3. router and serve: the /metrics route, the upkeep task, and the graceful-shutdown wiring that lets an in-flight scrape finish before the listener actually stops.
  4. tests/serve.rs: the crate’s one black-box test, and also its shortest correct usage example: install, record one counter, serve, scrape over real HTTP, shut down, and confirm the port is actually released.

blockwatcher-evm

blockwatcher-evm is the EVM chain family: three sources: evm-rpc, which polls JSON-RPC for confirmed blocks and misses nothing on the chain it watches, evm-subgraph, which polls a documented GraphQL logs schema and emits the same envelopes for whatever that subgraph indexed, and evm-mempool, which subscribes to one node’s pending-transaction feed and reports a transaction before anyone knows whether it will be mined, plus the evm decoder all three feed (crates/blockwatcher-evm/src/lib.rs). An operator selects one source per network; they are never combined in one pipeline. It is a module crate, built on blockwatcher-rpc’s endpoint pool and the alloy ABI-decoding SDK.

EVM documents this family at an operator’s level: selector keys, what events and functions decode, each source’s cursor, and why evm-mempool sits outside at-least-once delivery. This page does not restate any of that: it maps the same facts onto the crate’s actual module layout: which file owns which piece of the pipeline from an RPC or GraphQL endpoint down to a canonical decoded value, the registry that wires it all up, and the decoder’s own compile-once/decode-many mechanics.

Key takeaways

  • blockwatcher-evm is the EVM chain family: three sources, evm-rpc (confirmed blocks, misses nothing), evm-subgraph (confirmed blocks, indexed coverage), and evm-mempool (pending transactions, no guarantee of mining), plus the one evm decoder all three feed.
  • An operator selects one source per network; the three are never combined in one pipeline.
  • This page maps the operator-level facts already documented on the EVM family page onto the crate’s actual file layout, rather than restating them.

Responsibilities

  • evm-rpc (source::rpc::EvmRpcSource, source/rpc/): poll a pool of JSON-RPC endpoints for confirmed blocks and their logs, detect and recover from reorgs against a bounded recent-block memory, and emit both a block’s logs and, only while some monitor watches functions, its matching transactions, on one non-decreasing cursor stream.
  • evm-mempool (source::mempool::EvmMempoolSource, source/mempool/): subscribe to one node’s newPendingTransactions feed over WebSocket, hydrate each candidate hash’s calldata through the same kind of endpoint pool, and forward it on an arrival-counter cursor with no history and no reorg concept.
  • evm-subgraph (source::subgraph::EvmSubgraphSource, source/subgraph/): poll a pool of GraphQL endpoints for the documented logs / transactions / _meta schema, emit the same 0x envelopes evm-rpc does on the same kinded cursor packing, and invalidate from a proven hash fork against already-emitted heights. Coverage is the indexed set.
  • EvmDecoder (decoder/): translate a Solidity JSON ABI into chain-agnostic schemas exactly once, at spec-compile time, and decode a raw log or transaction against the compiled result on the hot path.
  • Compile a monitor’s selectors into a per-address, per-topic0/per-4-byte-selector dispatch table, and derive the fetch-narrowing interest hints a source may use from it (decoder/selector.rs).
  • Register evm-rpc, evm-mempool, evm-subgraph, and the evm decoder under the single registration convention every module family follows, and resolve each endpoint’s url_secret into a value neither this crate nor blockwatcher-rpc ever logs or stores (registry.rs).

Not this crate’s job: running a pipeline, retrying a delivery, or deciding when a checkpoint is safe to persist: blockwatcher-core owns all of that; pooling, retrying, or health-checking an RPC connection at the transport level: blockwatcher-rpc owns endpoint selection, circuit breaking, rate limiting, and retry-by-error-class, and this crate only ever supplies the connection type and the error classification blockwatcher-rpc::Pool<C> asks for; parsing an env:NAME secret reference: blockwatcher_types::SecretRef owns that mechanism; this crate only calls it, once, at source construction (see Endpoints and the env:/url_secret indirection below); defining the Decoder/Source port traits or the vocabulary types they move: blockwatcher-ports, blockwatcher-types.

Key types and traits

NameKindRole
EvmDecoderstructThe EVM Decoder port implementation (decoder/mod.rs)
source::rpc::EvmRpcSourcestructThe evm-rpc Source port implementation (source/rpc/run.rs)
source::mempool::EvmMempoolSourcestructThe evm-mempool Source port implementation (source/mempool/run.rs)
source::subgraph::EvmSubgraphSourcestructThe evm-subgraph Source port implementation (source/subgraph/run.rs)
EvmIntereststructThe EVM-typed extension of InterestSet::chain_specific: the exact 4-byte function selectors some monitor wants (interest.rs)
jsonrpc::EvmEndpointstructOne JSON-RPC endpoint: a reqwest::Client plus its resolved provider url::Url, the connection type evm-rpc and evm-mempool parameterize blockwatcher_rpc::Pool with (jsonrpc.rs)
source::subgraph::graphql::GraphqlEndpointstructOne GraphQL HTTP endpoint: a reqwest::Client, the URL it posts to, and the headers already populated (including Content-Type: application/json); the connection type evm-subgraph parameterizes blockwatcher_rpc::Pool with (source/subgraph/graphql.rs)
jsonrpc::EvmRpcErrorenumEvery JSON-RPC provider failure this family crosses a boundary as; implements Classify for blockwatcher-rpc’s retry policy (jsonrpc.rs)
source::endpoint::EndpointDefstructOne JSON-RPC endpoint’s operator-facing config: name, url_secret, priority, optional rate limit, shared by evm-rpc and evm-mempool (source/endpoint.rs)
source::endpoint::EndpointPriorityenumHigh/Low selection tier, the wire form of blockwatcher_rpc::Priority (source/endpoint.rs)
source::rpc::config::EvmRpcConfigstructevm-rpc’s boot-time config: endpoints, start_block, confirmations, lag tolerance, poll interval, logs window, receipt policy (source/rpc/config.rs)
source::rpc::config::ReceiptPolicyenumAlways/WhenRead: when a matching transaction’s receipt is worth fetching for tx.status alone (source/rpc/config.rs)
source::mempool::config::EvmMempoolConfigstructevm-mempool’s boot-time config: subscription URL, hydration endpoints, reconnect interval, and a nested idle_policy (source/mempool/config.rs)
source::mempool::config::IdlePolicyDefstructThe wire form of ws::IdlePolicy: ping_after_ms / pong_deadline_ms, when to suspect a half-open subscription socket and how long to wait for proof (source/mempool/config.rs)
source::subgraph::config::EvmSubgraphConfigstructevm-subgraph’s boot-time config: GraphQL endpoints, start_block, confirmations (default 0), lag tolerance, poll interval, logs window, entity_page_size (source/subgraph/config.rs)
source::subgraph::config::SubgraphEndpointDefstructOne GraphQL endpoint’s operator-facing config: the JSON-RPC keys plus header_secrets (source/subgraph/config.rs)
source::rpc::chain::RecentChainstructBounded memory of recently seen (block, hash) pairs, used to verify a fetched window still lines up (source/rpc/chain.rs)
source::rpc::chain::Header, LinkageBreakstructOne block’s number/hash/parent, and the two distinct ways a fetched window can fail to attach: an inconsistent endpoint versus a real reorg (source/rpc/chain.rs)
registry::EvmRpcRegistry, EvmMempoolRegistry, EvmSubgraphRegistry, EvmDecoderRegistrystructThe four ModuleRegistry implementations this crate registers (registry.rs)
registry::sources::get_all, registry::decoders::get_all (sources/decoders at crate root)fnFamily enumerations a composition root folds into its module catalog: sources as Vec<SourceModule>, decoders as (name, factory) (registry.rs, re-exported lib.rs)

SpecInner, SelectorInner/Entry, EventPlan/FunctionPlan, NamePlan, and every type inside decoder/selector.rs, decoder/plan.rs, and decoder/abi_types.rs are pub(crate) or narrower, reachable only through CompiledSpec/CompiledSelector’s type-erased downcast: none of them is part of this crate’s external surface, regardless of any inner pub marking on the type itself. The same is true of every type in scan.rs (pub(crate), despite scan being a pub mod) and the entire ws.rs module, which sits behind a private mod ws; in lib.rs.

From an RPC endpoint to a canonical value

Both sources and the decoder agree on one boundary type, blockwatcher_types::RawEvent (crates/blockwatcher-types/src/event.rs:17-27):

#![allow(unused)]
fn main() {
pub struct RawEvent {
    pub network: NetworkId,
    pub chain: ChainKind,
    pub cursor: Cursor,
    pub payload: RawPayload,
    /// Per-cursor resume payload, opaque to core: the engine threads it into
    /// the persisted `Checkpoint` and hands it back to the source at resume,
    /// unread and uninterpreted along the way.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub source_state: Option<serde_json::Value>,
}
}

chain: ChainKind is an open string tag ("evm" here, never an enum; see crates/blockwatcher-types/src/id.rs), and payload: RawPayload is either Json(serde_json::Value) or Bytes(Vec<u8>) (blockwatcher-types/src/event.rs). All three sources in this family always produce RawPayload::Json, an untyped log or transaction object taken straight off the wire (or, for subgraph, translated from GraphQL Bytes/BigInt into that same 0x shape at the source boundary), never a chain-specific Rust struct. Nothing outside this family’s own decoder ever looks inside that JSON value; the tag is what lets blockwatcher-core route the payload to the one decoder registered for "evm" without itself understanding a single field of it.

flowchart LR
    subgraph endpoint["endpoint(s)"]
        rpc_ep["evm-rpc pool<br/>eth_getLogs / eth_getBlockByNumber<br/>/ eth_getTransactionReceipt"]
        mem_ep["evm-mempool pool<br/>newPendingTransactions (ws)<br/>+ eth_getTransactionByHash (http)"]
        sub_ep["evm-subgraph pool<br/>GraphQL POST logs / transactions / _meta"]
    end
    subgraph sources["source::{rpc,mempool,subgraph}"]
        rpcsrc["EvmRpcSource<br/>cursor = block# + ordering bit"]
        memsrc["EvmMempoolSource<br/>cursor = arrival counter"]
        subsrc["EvmSubgraphSource<br/>cursor = block# + ordering bit"]
    end
    subgraph raw["opaque, ChainKind-tagged payload"]
        re["RawEvent<br/>{ chain: 'evm', cursor,<br/>payload: RawPayload::Json(Value) }"]
    end
    subgraph dec["decoder::EvmDecoder"]
        disp["decode()<br/>dispatch: topics -> log, input -> tx"]
        plan["EventPlan / FunctionPlan<br/>(built once, at compile_spec)"]
        toval["to_value()<br/>DynSolValue -> canonical Value"]
    end
    subgraph out["canonical values"]
        de["DecodedEvent<br/>{ kind, name, fields: Value, cursor }"]
    end

    rpc_ep --> rpcsrc --> re
    mem_ep --> memsrc --> re
    sub_ep --> subsrc --> re
    re --> disp
    plan -.->|"read-only,<br/>built at write time"| disp
    disp --> toval --> de

decode dispatches purely on the shape of the JSON payload, never on which source produced it: an object carrying topics is a log, one carrying input (and no topics) is a transaction, and nothing ever carries both (decoder/decode.rs). This is the exact mechanism behind Selectors § The source’s account of how the family’s sources feed one decoder. to_value (decode.rs) is the ABI-to-canonical step proper: it walks an alloy_dyn_abi::DynSolValue against the NamePlan built for it at compile time and produces blockwatcher_types::Value (Int/Uint via BigInt/BigUint from the 32-byte big-endian form, Address/Bytes, Str, Array/Map recursively). This crate’s decoder never invents a value shape of its own; it only ever produces the canonical tree every other chain family’s decoder also produces.

The sources

evm-rpc: acquiring confirmed blocks

One file per concern under source/rpc/:

File-by-file map: source/rpc/
FileOwns
config.rsEvmRpcConfig: endpoints, selection (ordered default, or round_robin to spread windows across a tier’s endpoints, weighted by each endpoint’s weight; only a window’s first, unpinned call is affected — the rest stay pinned to whichever endpoint served it), start_block (required, never head-relative; see the field’s own doc comment on why an absolute block replays rather than silently skips a gap), confirmations (default 12), max_lag_blocks (default 3), poll_interval_ms (default 3_000), logs_window (LogsWindow { initial: 512, max: 2048 }), full_block_window (default 8, the ceiling on how wide a range fetched with transaction bodies is ever requested), probe_interval_ms (default 10_000), retry_backoff_max_ms (default 30_000, the ceiling on the doubling retry delay for a window the run loop could not fetch), receipts (ReceiptPolicy, default Always), receipt_concurrency (default 4, how many eth_getTransactionReceipt calls one leaf may have in flight at once), header_batch (default 20, how many eth_getBlockByNumber requests ride one JSON-RPC batch), bloom_screen (default true, whether to skip a window’s eth_getLogs call when every fetched header’s logsBloom proves no monitored address or topic0 can be present) (config.rs).
backoff.rsRetryBackoff, the pacing for retrying a window the run loop could not fetch: the first delay is one poll interval, doubling per consecutive failure up to retry_backoff_max_ms; a provider’s Retry-After (delta-seconds form, surfaced by ScanError::retry_after) raises a delay toward the same ceiling but never past it, and one served window resets the schedule (backoff.rs).
chain.rsRecentChain, a bounded memory (clamp(2 * confirmations, 8, 64) entries) of recently seen (block, hash) pairs, and verify_extends’s two-way failure classification: a window whose own headers don’t chain into each other (an inconsistent endpoint) versus one that chains internally but doesn’t attach to the tracked tip (a genuine reorg) (chain.rs).
scan.rsLogFilter/Window/WindowData/ScanError (scan.rs), then the single-endpoint window fetch and provider-forced-range-split machinery shared by both the live poll loop and the Source::scan dry run (POST /monitors/{id}/test’s fetch mode): fetch_window_on (scan.rs) is the real fetch logic, and fetch_split (scan.rs) is a thin wrapper over fetch_split_boxed (scan.rs), which recursively bisects a window a provider refuses as too wide. fetch_header_only (scan.rs) fetches and parses one block’s header with no eth_getLogs call at all, for a caller that only wants to know a block’s identity.
emit.rsTurns one fetched window into RawEvents: pack_secondary/SECONDARY_KIND_TX/SECONDARY_KIND_LOG pack the cursor’s secondary component so every transaction in a block sorts below every log sharing it (emit.rs); emit_verified_leaf walks a block’s transactions first, then its logs, in that order, for the streaming path, and scan_range does the identical thing for the dry-run path so the two can never disagree on cursor or payload shape (emit.rs).
resume.rsEvmRpcSource::resume, which never trusts a persisted checkpoint’s block outright: re-fetches that block’s header, compares hashes, and on mismatch walks the persisted RecentChain backward against freshly fetched headers. A still-matching ancestor is a proven fork and returns Invalidated { from } so the engine can retract. A walk that matches no tracked ancestor is beyond_window: every tracked entry names a block the live chain denies, so divergence is proven at or below the oldest tracked height and resume returns Invalidated { from } just below that height, a rewind bounded by the tracker’s own depth, never genesis. Each step of that walk, and resume’s own initial checkpoint check, is one probe of fetch_one_header, which costs one eth_getBlockByNumber and no log call (resume.rs).
run.rsEvmRpcSource itself and its Source::run loop: head probing, the confirmation-depth barrier, window-size growth and shrink, and routing a LinkageBreak. A break inside a window never before emitted is retried in place (within_confirmations). A break against already-emitted work returns Invalidated { from } when a tracked ancestor still matches (beyond_confirmations), and when none does (beyond_window) returns Invalidated { from } just below the oldest tracked height, the same bounded rewind resume uses (run.rs).

Whether a poll cycle fetches full transaction bodies at all is itself interest-driven: with no monitor watching any functions selector, the source never asks for them, and the wire shape it produces stays exactly the header-plus-logs profile: the check that gates this is evm_interest(interest).is_some() at emit.rs, read once per cycle from the merged InterestSet the pipeline hands the source.

What a window costs: receipt_concurrency and header_batch

Two knobs govern how many round trips a window spends and how they are paced. Neither changes what a window produces:

  • receipt_concurrency (default 4): how many eth_getTransactionReceipt calls one leaf’s matching transactions may have in flight at once (emit.rs). A receipt is an unpinned consensus read of an already confirmed block, so concurrency moves pacing alone, never which endpoint’s view a window carries; each branch is the same Degraded-retried fetch a serial pass runs, and the per-endpoint rate limiter and circuit breaker still gate every call. A higher value shortens a function-heavy leaf at the cost of burstier provider load.
  • header_batch (default 20): how many eth_getBlockByNumber requests ride one JSON-RPC batch (scan.rs). A window of w blocks costs ceil(w / header_batch) header round trips rather than w of them. A batch is one HTTP request against one endpoint, so it carries the window’s pin and fails the whole window exactly as a lone request does: the batch size changes how many requests a window costs, never how many endpoints it reads. This is also the ceiling past which widening full_block_window stops amortising header calls and only keeps amortising the filter call.

The default value is a wire-shape decision, and it changes the shape this source posts by default: at any value above 1, a window’s headers go out as a JSON-RPC batch array rather than as one request object per block. A provider that rejects batch arrays therefore fails its very first window, loudly: the source publishes Degraded, the run loop retries that window on its doubling backoff schedule, and the pipeline makes no progress until an operator sets header_batch = 1 for that network. That value is the escape hatch. It restores the single-call wire shape byte for byte instead of sending one-member batches, which is what makes it a genuine fallback for a provider that refuses the batch form at all.

This cost model is for a window: a range fetched to emit events from. A resume or deep-reorg probe fetches no events, only a single block’s identity, so it never pays a window’s shape at all: each probe costs one eth_getBlockByNumber and no log call, regardless of header_batch or bloom_screen, via scan.rs’s fetch_header_only. A deep reorg walk runs this probe once per tracked height it checks, so its total cost scales with how far back the walk has to look, never with a window’s width or filter.

Removing the call entirely: bloom_screen

receipt_concurrency and header_batch pace a window’s round trips; bloom_screen (default true) can remove one of them outright. Once a window’s headers are in hand, each header’s logsBloom is checked against the filter’s addresses and topic0 signatures before eth_getLogs is ever called: if every header’s bloom refutes every monitored address, or every monitored topic0, the filter call is skipped and the window is treated as holding no logs, since eth_getLogs requires both to hold and either dimension refuted alone already rules out a match. Against a protocol-conforming node the skip is lossless: its bloom is a superset of its logs, so no log the filter would have matched can hide behind a bloom that already ruled it out. Enabling the screen nonetheless adds a dependency the unscreened path does not carry: correctness now rests on the bloom as well as on the logs response, so a node or caching proxy that serves correct logs behind a zeroed or otherwise-inaccurate bloom loses those logs silently, the same symptom named in Troubleshooting. The setting trades one saved eth_getLogs call for that dependency.

The screen only ever applies where it has something to refute: a header whose bloom cannot be read is never screened regardless of this setting, and a filter with no addresses and no topic0s is never screened either, since there is nothing in it for a bloom to refute. The dry-run scan path behind POST /monitors/{id}/test’s fetch mode never screens, and never collects a header’s bloom at all, so a test fetch reports the same logs a live window would extract from the same block. Disable bloom_screen for endpoints whose blooms are not trusted, where that traded dependency is not worth accepting. Each skip is counted at fetch time, independent of whether the window later survives reorg verification, as blockwatcher_evm_bloom_skips_total; see Observability.

Trusting a header’s bloom to prove absence has one failure mode: an endpoint or caching proxy whose bloom does not actually describe its own block. bloom.rs‘s admits_log closes that gap using an observation a source that trusts its blooms already pays for and needs no extra call to check: a protocol-conforming bloom is a superset of its block’s logs, so scan.rs collects one bloom per header for any window fetched with bloom_screen: true, whether or not the screen itself found anything in that window to refute, and a window that is not screened — an admitting header, a broad filter, or a filter the screen could not parse — still holds both halves of the proof, the logs themselves and the same blocks’ blooms, fetched moments apart. A window whose source does not trust blooms at all (bloom_screen: false) collects none, so it carries no such proof either way; nothing here checks it, and nothing here vouches for it as clean. After eth_getLogs returns on a window that did collect blooms, scan.rs checks every returned log’s own address and topic0 against its own block’s own bloom. A log with no topics is judged on its address alone, since an anonymous event carries no topic0 for a bloom to have admitted in the first place. Because a bloom filter never produces a false negative for an item it actually holds, a bloom that fails to admit a log the same endpoint just returned can only mean that endpoint’s bloom does not describe the block it claims to belong to: there is no way for this check to fire on a healthy endpoint.

The response trades the optimisation away rather than keep trusting a source that has proven its blooms unreliable: the first contradiction disables bloom_screen on the EvmRpcSource instance that observed it, an AtomicBool tripped with a compare-exchange so the accompanying warning logs exactly once regardless of how many leaves of a split carried the contradiction, naming the endpoint, the first contradicting block, and how many logs contradicted. The latch is monotonic within that instance’s own lifetime; nothing re-enables it short of building a new instance.

Building a new instance is exactly what a pipeline restart does, and restarts are routine: the supervisor restarting an exited source, an Invalidated return after a proven reorg, or a monitor change that escalates to a restart all call the evm-rpc module factory again, which calls EvmRpcSource::new and starts the flag at false. The latch does not, and cannot, remember across that rebuild that this endpoint already proved its blooms wrong; a rebuilt instance resumes screening against the same endpoint from a clean slate. A process-wide memory keyed by endpoint name would close that gap, but it is a different design with its own open questions (an endpoint shared across networks would need a shared verdict, and nothing would evict a stale one), so it is not what this latch does. The durable answer available today is the operator’s own setting: once an endpoint has proven its blooms unreliable, set bloom_screen = false for that network so a later restart does not have to relearn the same fact the hard way. Every contradicting log is still counted, latched or not, as blockwatcher_evm_bloom_contradictions_total, labeled pipeline and the endpoint that served the window; see Observability for how to size the damage already done before the trip, using blockwatcher_evm_bloom_skips_total for the same pipeline.

Disable bloom_screen outright for a chain whose nodes are known to emit zeroed or absent blooms in a way that never contradicts a returned log: a header without a readable bloom is never screened regardless of the setting, and never trips the latch, so that case is not this detector’s to catch.

evm-mempool: acquiring pending transactions

The files under source/mempool/, plus the crate’s shared WebSocket wire client (ws.rs, crate-private, used only from here):

File-by-file map: source/mempool/
FileOwns
config.rsEvmMempoolConfig: ws_url_secret, endpoints (for the eth_getTransactionByHash hydration lookups a subscription hash alone cannot avoid), reconnect_ms (default 1_000), idle_policy (IdlePolicyDef { ping_after_ms: 30_000, pong_deadline_ms: 10_000 }, the wire form of ws::IdlePolicy) (config.rs). There is deliberately no start_block and no confirmation depth: a pending stream has no history to begin from and no reorg barrier to honor (config.rs).
pending.rsPendingHashStream/subscribe_pending: the typed layer over ws.rs that names newPendingTransactions and parses each notification down to the hash it carries, nothing more (pending.rs).
run.rsEvmMempoolSource and its Source::run loop: subscribe, then per hash, check whether any monitor on this pipeline watches functions at all before spending a hydration lookup, with no function interest published, the source skips the eth_getTransactionByHash round trip entirely (run.rs), and forward what comes back, tracking a per-run arrival counter as the cursor (run.rs).

The arrival counter resumes from one past whatever the last persisted checkpoint recorded, never from zero on a warm restart:

#![allow(unused)]
fn main() {
let mut arrival = match ctx.checkpoint.as_ref() {
    Some(cp) => cp.cursor.primary.saturating_add(1),
    None => 0,
};
}

(run.rs:204-207). But since nothing pending during a restart gap is ever recovered, that continuation only prevents a within-run number from repeating; it does not, and cannot, make the counter a replayable position across the gap itself. Selectors § The position problem covers exactly what that costs a consumer.

evm-subgraph: acquiring indexed confirmed blocks

One file per concern under source/subgraph/. GraphQL wire stays here until a second GraphQL source exists; cursor packing and retry backoff are family helpers (source/cursor.rs, source/backoff.rs) because rpc and subgraph both need them.

File-by-file map: source/subgraph/
FileOwns
graphql.rsEffect: GraphqlEndpoint, GraphqlError, post (one HTTP POST, classified). Schema mismatch is Transient via GraphqlError::SchemaMismatch, never Permanent and never empty success (graphql.rs).
config.rsEvmSubgraphConfig / SubgraphEndpointDef / LogsWindow: GraphQL endpoints, header_secrets, start_block (required), confirmations (default 0), entity_page_size (default and ceiling 1000), logs_window as block-range width that shrinks on host limits (config.rs).
query.rsGraphQL documents and variables from interest + window + id_gt. Empty InterestSet omits _in; non-empty includes it. Transactions only if EvmInterest is present (query.rs).
map.rsGraphQL row → decoder 0x envelope. Drops id and selector (map.rs).
fetch.rs_meta, paged logs/transactions, window pin, surface RetryNarrower to the run loop rather than shrinking here (fetch.rs).
emit.rsGroup by block, transactions then logs, pack_secondary identical to rpc, send().await (emit.rs).
tracker.rsBounded (number, hash) memory. No parentHash — not rpc RecentChain (tracker.rs).
resume.rsResume from checkpoint source_state; empty tracker is not a reorg (resume.rs).
run.rsEvmSubgraphSource, Source impl, assert_port, poll loop: named Degraded on schema mismatch, window shrink on RetryNarrower, Invalidated { from } on already-emitted hash disagreement (run.rs).

Endpoints, and the env:/url_secret indirection

source::endpoint::EndpointDef (source/endpoint.rs) is the JSON-RPC endpoint shape evm-rpc and evm-mempool ask an operator for and that the shared pool builder behind those two factories reads: name, url_secret: String, priority (default High), and an optional rate_limit. evm-subgraph uses SubgraphEndpointDef instead, the same keys plus header_secrets, because hosted GraphQL credentials travel as headers and EndpointDef under deny_unknown_fields cannot grow that field. url_secret never carries a literal URL: a provider’s URL routinely is the credential (an API key in the path or host), so the field holds an env:NAME reference instead, and the reference resolves in two steps, not inside this struct:

  1. Form validation, at config-validate time. EvmRpcConfig::validate (body: source/rpc/config.rs, its SecretRef::parse call over each endpoint’s url_secret at config.rs) and EvmMempoolConfig::validate (body: source/mempool/config.rs, its SecretRef::parse call over ws_url_secret at config.rs, and over each endpoint’s url_secret at config.rs) each reject a string that is not an env:NAME reference. Neither method checks whether the reference actually resolves: that needs the environment, and validation is a pure predicate over configuration.
  2. Resolution, once, at construction. registry.rs’s resolve_endpoint_url/resolve_ws_url call SecretRef::parse(url_secret)?.resolve()?, then check the result parses as a URL with the scheme this source can actually dial (http/https for evm-rpc, ws/wss for evm-mempool): a scheme check, not just a parse, since Url::parse alone would accept an env: reference typo’d into the variable itself just as happily as a real URL (registry.rs). This runs once, before a single EvmEndpoint or Pool is built; no message anywhere in this path ever carries the resolved value.

blockwatcher_types::SecretRef is where the env: prefix itself is parsed and where the environment variable is actually read (crates/blockwatcher-types/src/secret.rs). This crate calls it but does not reimplement it, and blockwatcher-rpc never sees a url_secret or an env: string at all: by the time an EndpointConfig reaches that crate’s Pool, the secret has already been resolved into whichever connection value this crate’s own EvmEndpoint carries.

The decoder

EvmDecoder (decoder/mod.rs) implements every method of Decoder (crates/blockwatcher-ports/src/decoder.rs):

MethodWhat it does here
chainReturns ChainKind::new("evm") (decoder/mod.rs)
compile_specParses spec.payload as alloy_json_abi::JsonAbi; rejects an anonymous event (no topic0 to ever key on), a selector collision between two declarations, and an ABI with zero events and zero functions; builds SpecInner { events: HashMap<B256, EventPlan>, functions: HashMap<[u8;4], FunctionPlan> } (decoder/mod.rs)
compileDelegates to selector::compile (below) (decoder/mod.rs)
decodeDelegates to decode::decode (decoder/mod.rs)
interestDelegates to selector::interest (decoder/mod.rs)
merge_interestUnions addresses/signatures like the port’s own default, but additionally: blanks the merged address set the moment any input selector watches every address (an empty addresses means “any address,” and a plain union would narrow a source’s fetch below what one monitor asked for), and unions every input’s EvmInterest.function_selectors into the merged chain_specific (decoder/mod.rs)

Files behind compile_spec split write-time work from the per-event/per-function hot-path plan it produces:

  • abi_types.rs: pure naming and typing rules shared by the schema side and the plan side, so a dotted or positional field name can never drift between what a predicate compiles against and what decode actually emits: positional_name/component_name decide field-name spelling, value_type maps one ABI type string (recursing through array dimensions) to blockwatcher_types::ValueType, collapsing any tuple to ValueType::Map (abi_types.rs).
  • compile.rs: ABI fragment to EventSchema, run once per declared event or function: event_schema/function_schema flatten every parameter, rejecting a duplicate parameter name and a duplicate tuple component name at every nesting depth (compile.rs). This is also where the tx/block/log namespaces every compiled EVM spec carries come from: namespaces() (compile.rs) declares the union of what the log path and the function-call path can each fill in, which is the source Selectors § What each kind decodes documents from the predicate-author’s side.
  • plan.rs: the decode-hot-path plan itself, precomputed once per event or function at spec-compile time and never rebuilt: build/build_function resolve every ABI type string into an alloy_dyn_abi DynSolEvent/DynSolCall up front, and pair each top-level parameter with the exact slot decode will read it from (Source::Indexed(n) or Source::Body(n)) and a NamePlan describing how to reassemble its container shape (plan.rs). decode.rs walks this plan and never re-parses a type string on the hot path: that is the entire reason this file exists apart from compile.rs.

decode.rs (decoder/decode.rs) is the hot path proper: Envelope/TxEnvelope parse a log’s or a transaction’s fixed field set once per call (decode.rs), the dispatch described in From an RPC endpoint to a canonical value above routes to decode_log or decode_call, and to_value performs the actual ABI-value-to-canonical-Value conversion against the precomputed plan. DecodeOutcome (the port’s own type) distinguishes two different non-events precisely: an envelope or ABI-encoded payload this decoder cannot make sense of at all is undecodable (counted, surfaced to operators); a well-formed occurrence whose address/topic0 or four-byte selector no compiled entry named is no_match, not a failure, since most logs on a filtered feed and most transactions in a block fall outside any one monitor (decode.rs).

Selector implementation

Selectors documents the compile-time presence rule (events/functions/addresses, and what “naming neither” means) and what each kind hands a predicate in full; this section is only the structural shape underneath it. The dispatch table selector::compile (decoder/selector.rs) builds against is, exactly (decoder/selector.rs:13-25):

#![allow(unused)]
fn main() {
/// One selector entry, self-contained: its `addresses` restrict only the
/// events and functions it names, never a sibling entry's.
pub(crate) struct Entry {
    /// `None` means any address.
    pub addresses: Option<BTreeSet<[u8; 20]>>,
    pub events: HashMap<B256, EventPlan>,
    pub functions: HashMap<[u8; 4], FunctionPlan>,
}

/// Every entry a monitor's selectors compiled to. Entries are OR'd.
pub(crate) struct SelectorInner {
    pub entries: Vec<Entry>,
}
}

events is keyed by the topic0 a log’s first topic carries; functions is keyed by the 4-byte selector a transaction’s calldata leads with. Each Entry is self-contained (its own addresses restrict only its own two tables, never a sibling entry’s), and SelectorInner.entries is the full OR across every selector a monitor names. selector::interest (decoder/selector.rs) derives the fetch-narrowing InterestSet from this same structure: an entry with no addresses (any address) blanks the merged address hint for the whole selector, which is why EvmDecoder::merge_interest above has to repeat that same rule one level up, across selectors rather than within one. Both Entry and SelectorInner live behind CompiledSelector’s type erasure; nothing outside this crate ever names them directly.

evm-mempool compiles selectors exactly the same way evm-rpc does (selection is checked against the spec, not against the source that will feed it), but structurally can only ever populate the functions half of any entry’s dispatch table, since this source never produces a payload decode would route down the log path at all. An events-only selector on an evm-mempool network therefore compiles cleanly and simply never fires; Selectors § Comparison table is the definitive statement of that outcome per selector kind and source.

The registry

Four ModuleRegistry implementations, one per module this crate ships, each declaring its own const NAME and factory() next to its own code (registry.rs):

RegistryNAMEFactory builds
EvmRpcRegistry"evm-rpc"Deserializes EvmRpcConfig, validates it, resolves every endpoint into a Pool<EvmEndpoint>, and constructs EvmRpcSource::new(pool, config) (registry.rs)
EvmMempoolRegistry"evm-mempool"Deserializes EvmMempoolConfig, validates it, resolves the subscription URL and the hydration endpoints, and constructs EvmMempoolSource::new(pool, ws_url, config) (registry.rs)
EvmSubgraphRegistry"evm-subgraph"Deserializes EvmSubgraphConfig, validates it, resolves every GraphQL endpoint into a Pool<GraphqlEndpoint> (headers include Content-Type: application/json plus header_secrets), and constructs EvmSubgraphSource::new(pool, config) (registry.rs)
EvmDecoderRegistry"evm"Deserializes an empty, deny_unknown_fields config and returns Arc::new(EvmDecoder) (registry.rs)

sources::get_all() and decoders::get_all() (registry.rs, both re-exported at the crate root as sources/decoders) fold the four into the two lookup tables a composition root’s catalog needs. Sources are SourceModule descriptors (SourceModule::of::<EvmRpcRegistry>() and its siblings); decoders stay (name, factory). blockwatcher-embed‘s build_catalog (crates/blockwatcher-embed/src/catalog.rs) consumes both: it loops each enumeration and calls catalog.register_source(module) / catalog.register_decoder(name, factory), which rejects a duplicate name: the same family!-macro-generated contract (crates/blockwatcher-core/src/catalog.rs) every other module family goes through. build_pool (registry.rs) is the one function both EvmRpcRegistry and EvmMempoolRegistry funnel through, so those two sources’ retry, rate-limit, and timeout behavior can never independently drift; evm-subgraph builds Pool<GraphqlEndpoint> through build_graphql_pool in the same file, the same pool crate, a different connection type. That is the same “shared builder” pattern blockwatcher-core’s own catalog.rs doc comment describes for module registration in general.

Neighbours

blockwatcher-evm depends on, in production:

  • blockwatcher-types: vocabulary crate (RawEvent, Value, ChainKind, SecretRef, and every id type)
  • blockwatcher-ports: the Source/Decoder port traits this crate implements
  • blockwatcher-rpc: the endpoint pool evm-rpc and evm-mempool parameterize with EvmEndpoint and evm-subgraph parameterizes with GraphqlEndpoint
  • async-trait: required to implement the async fn-bearing Source trait
  • alloy-primitives: Address/B256/hex helpers
  • alloy-json-abi: parsing a spec’s Solidity JSON ABI
  • alloy-dyn-abi: resolving ABI type strings and decoding calldata/log data against them
  • num-bigint: the arbitrary-precision integers a canonical Value::Int/Uint holds
  • reqwest: the HTTP client behind every EvmEndpoint and GraphqlEndpoint
  • url: parsing and validating a resolved endpoint URL’s scheme
  • serde: (de)serialization derive for every config
  • serde_json: the raw JSON payload shape the sources emit and the decoder consumes
  • thiserror: the derive behind EvmRpcError and GraphqlError
  • tokio (sync/time/macros/rt features): the async runtime the sources’ run loops execute on
  • tokio-util: cancellation plumbing shared with the rest of the workspace
  • tokio-tungstenite: the WebSocket transport ws.rs wraps
  • futures: stream/sink combinators ws.rs uses
  • tracing: this family’s structured logging
  • metrics: the blockwatcher_evm_mempool_skips_total-style counters this family emits

and, in [dev-dependencies] only:

  • blockwatcher-evm-testkit: a scripted mock JSON-RPC/WebSocket node (MockNode, MockWsNode) and a SimChain fixture builder this crate’s own tests drive; the dependency cycle back to blockwatcher-evm is a dev-only edge cargo permits
  • blockwatcher-ports (fakes feature): fakes for cross-checking against the port contract
  • blockwatcher-testkit: shared test scaffolding

The following crates depend on it directly (per the dependency table):

  • blockwatcher-evm-testkit: reaches back into this crate for real types its mock node fixtures build against (a dev-only cycle, never a production edge)
  • blockwatcher-embed: the composition façade registers the family’s sources and the decoder into the engine’s module catalog; the blockwatcher binary reaches this crate only through embed

Reading the source

  1. Start at lib.rs: four pub mod declarations (decoder, jsonrpc, registry, source), two private ones (interest, ws), and the crate-root re-exports (EvmInterest, sources, decoders).
  2. source/endpoint.rs: the config-plane vocabulary shared by the JSON-RPC sources, and its doc comment on why url_secret resolves once at construction rather than per call, unlike the webhook sink’s own per-delivery resolution.
  3. jsonrpc.rs: EvmEndpoint, EvmRpcError, and its Classify impl’s documented mapping table; read this before evm-rpc or evm-mempool, since both build on it.
  4. source/rpc/config.rs, then chain.rs, scan.rs, emit.rs, resume.rs, run.rs in that order: each file’s own doc comment states what it owns and why it sits apart from its neighbours.
  5. source/mempool/config.rs, then pending.rs, run.rs: read run.rs’s module doc comment first; it states every contract difference from evm-rpc in one place.
  6. source/subgraph/graphql.rs, then config.rs, query.rs, map.rs, fetch.rs, emit.rs, tracker.rs, resume.rs, run.rs: GraphQL effect first, then the poll loop that consumes it.
  7. decoder/abi_types.rs, then compile.rs, plan.rs: the three write-time files, in the order the module doc comments cross-reference each other.
  8. decoder/selector.rs, then decoder/decode.rs: compile-time selector dispatch, then the hot path that reads it.
  9. decoder/mod.rs: EvmDecoder’s full Decoder impl, last, once every piece it delegates to is familiar.
  10. registry.rs: the four ModuleRegistry impls, build_pool, build_graphql_pool, and resolve_endpoint_url/resolve_ws_url/resolve_subgraph_url; its own module doc comment states the registration convention every module family in the workspace follows.

blockwatcher-stellar

blockwatcher-stellar is the Stellar chain family: one source, stellar-rpc, which polls stellar-rpc for closed ledgers inside the serving node’s history window, and the stellar decoder that source feeds (crates/blockwatcher-stellar/src/lib.rs). It is a module crate, built on blockwatcher-rpc’s endpoint pool and the stellar-xdr / stellar-strkey SDKs.

Stellar documents this family’s selector keys and source config at an operator’s level. This page maps the same facts onto the crate’s file layout: which file owns ingestion, which owns compile-once/decode-many, and the registry that wires both into the catalog.

Key takeaways

  • blockwatcher-stellar is the Stellar chain family: one source, stellar-rpc (closed ledgers inside the node’s RPC history window), plus the one stellar decoder it feeds.
  • Closed ledgers are not rewound. Event interest spends getEvents; operation or function interest spends getTransactions. On the first successful getHealth, getNetwork passphrases must agree across the pool or the run refuses by endpoint name.
  • A classic spec payload is {"catalog":"classic"}. A Stellar Asset Contract spec is {"catalog":"stellar-asset"}. A Soroban spec is a WASM blob or XDR-JSON of ScSpecEntry. Selector keys are events, functions, and operations.
  • A classic Payment and a CAP-67 transfer event on the same ledger are two decoded occurrences when both are selected; the decoder does not merge them.

Responsibilities

  • stellar-rpc (source::StellarRpcSource, source/): poll a pool of stellar-rpc endpoints for closed ledgers, refuse a configured start_ledger below oldestLedger on the first getHealth, clamp a checkpoint (or a subsequent retention floor) to the window with an explicit gap signal, and emit one RawEvent per matching occurrence (source/run.rs, source/fetch.rs, source/emit.rs).
  • Choose RPC methods from decoder interest: event interest spends getEvents (packed at five filters of five contract IDs; empty IDs send {type: contract}); operation or function interest spends getTransactions. Catch-up uses 10,000-ledger event chunks and narrows transaction windows to 512 ledgers (source/run.rs rustdoc, registry.rs). On first successful getHealth, probe_health calls getNetwork; disagreeing passphrases are SourceError::InvalidConfig naming endpoint names (source/fetch.rs).
  • Pack a ledger-primary cursor with a plane-and-index secondary so invocations, operations, and events in one ledger stay ordered (source/cursor.rs, source/emit.rs).
  • Translate StrKey addresses at the door into prefixed canonical bytes (decoder/address.rs); encode those bytes as 0x hex on the value tree.
  • Compile { "catalog": "classic" } into one schema per OperationType plus named host functions UploadContractWasm, CreateContract, and CreateContractV2 (decoder/classic.rs). InvokeHostFunction is a function_call; every other classic type is an operation.
  • Compile { "catalog": "stellar-asset" } from the pinned SEP-48 JSON through the Soroban compiler (decoder/stellar_asset.rs).
  • Compile a Soroban contract spec from WASM ({"wasm":"<base64>"}, reading the contractspecv0 custom section) or from XDR-JSON of an ScSpecEntry object or array (decoder/soroban.rs).
  • Materialize a spec payload from already-fetched getLedgerEntries JSON (spec_from_chain.rs); the decoder does not fetch.
  • Fetch a spec payload at write time from stellar-rpc getLedgerEntries (fetch_spec_from_rpc, spec_from_rpc.rs): a Stellar Asset Contract instance becomes { "catalog": "stellar-asset" }; a WASM contract becomes { "wasm": "<base64>" } that compile_spec accepts. A G-address is Invalid. An RPC failure is Unavailable and names endpoint names, never a URL. StellarSpecImporter is the SpecImporter adapter: it requires source.module to be stellar-rpc, builds that source’s pool from source.config, and calls fetch_spec_from_rpc.
  • Compile selector bodies that admit events, functions, operations, and addresses (decoder/selector.rs). Naming any of the three kind keys selects only what it names; naming none selects every declaration the referenced spec exposes.
  • Decode a source-owned JSON envelope whose xdr field is base64 XDR (decoder/envelope.rs, decoder/decode.rs). block.timestamp is envelope.close_time. tx.status is envelope.status. Malformed XDR is undecodable; a foreign compiled selector or a non-stellar chain is no_match.

Not this crate’s job: deciding which module a network or spec names (blockwatcher-embed’s catalog fold, the binary’s config); serving HTTP or metrics (blockwatcher-api, blockwatcher-metrics); evaluating predicates (blockwatcher-expr); knowing EVM types (blockwatcher-evm).

Key types and traits

NameKindRole
StellarRpcSourcestructThe Source implementation: closed-ledger polling over Pool<StellarEndpoint> (source/run.rs)
StellarRpcConfigstructBoot-time config: endpoints, url_secret, selection, timeouts, start cursor (source/config.rs)
StellarEndpointstructOne stellar-rpc JSON-RPC target the pool drives (source/jsonrpc.rs)
StellarDecoderstructThe Decoder implementation: claims chain stellar (decoder/mod.rs)
materialize_specfnLibrary writer: ledger-entry JSON → {wasm} or {catalog: stellar-asset} (spec_from_chain.rs)
fetch_spec_from_rpcfnWrite-time fetch: C-strkey → {wasm} or {catalog: stellar-asset} via getLedgerEntries; G-addresses are Invalid; RPC failure is Unavailable without echoing http (spec_from_rpc.rs)
StellarSpecImporterstructSpecImporter adapter: requires stellar-rpc, builds the pool from source.config, calls fetch_spec_from_rpc (spec_from_rpc.rs)
spec_importers::get_allfnThe family’s spec-importer table, keyed by chain; embed folds it the same way it folds sources and decoders (spec_from_rpc.rs)
Envelope, Planestruct/enumSource-owned JSON wrapper around base64 XDR plus status; plane is event / operation / invocation (decoder/envelope.rs)
StellarIntereststructKind flags and addresses the source downcasts from InterestSet::chain_specific (decoder/interest.rs)
StellarRpcRegistry, StellarDecoderRegistrystructModuleRegistry entries named "stellar-rpc" and "stellar" (registry.rs)

How data flows through it

flowchart LR
    health["getHealth<br/>retention window"] --> fetch["fetch_window"]
    fetch -->|"event interest"| events["getEvents<br/>sharded"]
    fetch -->|"operation or function interest"| txs["getTransactions"]
    events --> emit["emit_window<br/>Envelope + cursor"]
    txs --> emit
    emit --> decode["StellarDecoder::decode<br/>base64 XDR"]
    decode --> value["canonical DecodedEvent"]

A configured start_ledger below the node’s retained ledgers on the first health read is refused, naming both bounds. A checkpoint below that window clamps to the new oldest ledger, increments a retention gap counter, and continues — the same path a mid-run floor advance takes. If oldestLedger advances past the next ledger after that read, the run warns with the skipped range, increments a retention gap counter, publishes Degraded, and continues at the new oldest ledger. An inconsistent page publishes Degraded and retries the same window; it does not start at ledger 1. Invalidated is never returned for confirmation depth: closed ledgers are not rewound (source/run.rs). Resume trusts cursor.primary because a closed ledger is final: there is no hash-walk against live headers.

stellar-rpc

Trade-off: this module walks closed ledgers inside the serving node’s RPC history window. It misses nothing the node still retains, and spends RPC quota on getEvents / getTransactions proportional to interest and shard count. Ledgers the node has already dropped are unreachable. A restart resumes from a ledger the node still has; it cannot reconstruct history the node no longer serves.

scan and the live loop both walk wide ranges without truncation in bounded chunks. Event-only catch-up uses at most 10,000 ledgers per chunk. Any chunk that fetches transactions uses at most 512 ledgers. Pagination stops on an empty page, an item beyond the inclusive window end, or a page shorter than the requested limit. getTransactions does not send endLedger; the local inclusive bound still stops pagination. FAILED envelopes are emitted with Envelope.status copied from getTransactions. stellar-rpc does not implement confirmed_tip (the Source port default is Unsupported), so skip { "to": "tip" } is 422 invalid_resource with "this source cannot report a confirmed tip", the same as evm-mempool. Skip to a ledger/block number still works while paused.

The stellar decoder

compile_spec accepts these payload shapes (decoder/mod.rs):

  • {"catalog":"classic"} — one schema per classic OperationType, plus UploadContractWasm, CreateContract, and CreateContractV2.
  • {"catalog":"stellar-asset"} — baked Stellar Asset Contract spec (Transfer and TransferMuxed share the transfer topic).
  • {"wasm":"<base64>"} — Soroban contractspecv0.
  • XDR-JSON of an ScSpecEntry object or array.

An unknown catalog string is SpecError::Invalid.

compile requires a spec and admits only events, functions, operations, and addresses. InvokeHostFunction and the named host functions are selected through functions, never through operations. An explicit empty array for a kind list is SelectorError::Invalid.

decode matches envelope.plane, reads the base64 XDR, and looks up the compiled tables. EventPlan stores data_format. A selected classic Payment and a selected CAP-67 transfer event on the same ledger are two DecodedEvents; the decoder does not fold them into one occurrence. Two spec events that share the on-chain topic transfer (scalar vs muxed data) compile as distinct schema names; selecting transfer includes both plans (decoder/decode.rs, decoder/soroban.rs). materialize_spec walks LedgerEntryData XDR from fetched JSON (spec_from_chain.rs). Write-time import is fetch_spec_from_rpc plus StellarSpecImporter (spec_from_rpc.rs); the decoder still does not fetch. A cargo-fuzz target lives in crates/blockwatcher-stellar/fuzz/ (scval_to_value).

Addresses in selector bodies are StrKey at the door (decoder/address.rs): account, contract, and muxed flavors become prefixed canonical bytes so they cannot collide on the value tree.

The registry

Two ModuleRegistry implementations (registry.rs):

RegistryNAMEFactory builds
StellarRpcRegistry"stellar-rpc"Deserializes StellarRpcConfig, validates it, builds Pool<StellarEndpoint>, constructs StellarRpcSource::new(pool, config)
StellarDecoderRegistry"stellar"Accepts null or empty-object config and returns Arc::new(StellarDecoder)

sources::get_all() returns Vec<SourceModule> (SourceModule::of::<StellarRpcRegistry>()); decoders::get_all() stays (name, factory) (registry.rs, re-exported at the crate root). Those are what blockwatcher-embed’s build_catalog folds under #[cfg(feature = "stellar")].

Neighbours

blockwatcher-stellar depends on, in production:

  • blockwatcher-types, blockwatcher-ports, blockwatcher-rpc
  • stellar-xdr, stellar-strkey, wasmparser, base64
  • reqwest, url, serde, serde_json, thiserror
  • async-trait, tokio, tokio-util, metrics, tracing, num-bigint

and, in [dev-dependencies] only:

blockwatcher-embed depends on this crate when the stellar feature is on. Nothing in the core ring does.

Reading the source

  1. lib.rs: crate doc, decoder / registry / source, re-exports.
  2. registry.rs: the two ModuleRegistry impls and the sources / decoders get_all() tables.
  3. source/config.rs, source/jsonrpc.rs, source/fetch.rs, source/emit.rs, source/run.rs.
  4. decoder/envelope.rs, decoder/address.rs, decoder/classic.rs, decoder/soroban.rs, decoder/selector.rs, decoder/decode.rs.
  5. spec_from_chain.rs (materialize_spec), spec_from_rpc.rs (fetch_spec_from_rpc, StellarSpecImporter, spec_importers::get_all).

blockwatcher-rpc

blockwatcher-rpc is a resilient pool of named remote endpoints, chain- and transport-agnostic: the consumer supplies each operation (any async request/response call over its own connection handle) and its error classification, and the pool supplies everything around the request: endpoint selection by priority, per-endpoint circuit breakers, preemptive rate limiting, a whole-call deadline, per-attempt timeouts, retry by error class, one concurrency permit held across retries, health probing, and endpoint-labeled metrics, per the crate doc comment (crates/blockwatcher-rpc/src/lib.rs). It is a module crate: blockwatcher-evm’s evm-rpc, evm-mempool, and evm-subgraph sources, and blockwatcher-stellar’s stellar-rpc source, are consumers, for example, but nothing about its own dependencies or its public surface names EVM, HTTP, Stellar, or any other chain or transport at all.

This page covers the pool’s own mechanics (selection, the breaker, the limiter, retry and backoff, health probing) and the one seam that makes “chain- and transport-agnostic” true structurally rather than just by convention. Observability § Other metrics on this endpoint already documents every metric this crate exports from an operator’s point of view; this page does not restate that table.

Key takeaways

  • blockwatcher-rpc is a resilient pool of named remote endpoints, chain- and transport-agnostic: the consumer supplies the operation and its error classification, and the pool supplies selection, breaking, rate limiting, retry, and health probing.
  • Nothing about its own dependencies or public surface names EVM, HTTP, or any other chain or transport; blockwatcher-evm’s and blockwatcher-stellar’s sources are consumers, not a special case.
  • This page covers the pool’s own mechanics and the seam that makes it chain- and transport-agnostic structurally, not the metrics it exports, which observability already documents.

Responsibilities

  • Run one caller-supplied async operation against a pool of named endpoints to completion: pick an endpoint, bound the attempt with a timeout, retry by the failure’s ErrorClass, and return either a served value or a typed exhaustion reason: Pool::execute (crates/blockwatcher-rpc/src/pool.rs).
  • Select an endpoint by priority tier (High before Low; within a tier, config order under the default Ordered strategy, or a weighted rotation under RoundRobin), skipping one whose breaker does not currently admit or whose rate limiter has no token available for this call: Pool::select/Pool::select_pinned (pool.rs).
  • Trip a per-endpoint circuit breaker, Breaker, after a configurable run of consecutive classified failures, admit one half-open trial after a cooldown, and reset it on any success (breaker.rs).
  • Enforce a preemptive per-endpoint rate limit (TokenBucket, whose burst capacity equals its configured rate) before a call ever leaves the pool (limiter.rs).
  • Retry a Transient failure or an attempt timeout on a doubling backoff; retry a RateLimited failure on the same schedule without touching the breaker; return a Permanent or RetryNarrower failure immediately, with its retry budget unburned, all inside Pool::execute (pool.rs).
  • Sweep every endpoint whose breaker is not strictly open, concurrently, under the pool’s attempt timeout, without ever feeding a probe’s result back into the breaker itself: Pool::probe_health (pool.rs).
  • Publish blockwatcher_rpc_* metrics through six *_TOTAL counter constants and one duration histogram (pool.rs).

Not this crate’s job: knowing what a request means, what wire format it takes, or what chain it targets: the consumer’s own connection type C and async closure carry all of that, and Pool<C> never inspects either, per the crate doc comment (lib.rs); resolving an env:NAME secret reference into a URL: that lives in blockwatcher-types::SecretRef, called once by whichever module crate constructs a pool (see The env:/url_secret indirection below); retrying a sink delivery or deciding when a match becomes a dead letter: that is blockwatcher-core’s deliver_with_retry (crates/blockwatcher-core/src/pipeline/delivery.rs), a wholly separate retry loop for a wholly separate kind of call; caching a response: what is safe to cache and for how long is a property of the consumer’s domain, not of transport resilience, so this crate does not attempt it at all, per the crate doc comment (lib.rs).

Key types and traits

NameKindRole
Pool<C>structThe endpoint pool itself, generic over one connection type C shared by every endpoint (pool.rs)
Pool::executeasync fnRuns one caller-supplied operation against the pool: selection, breaker, limiter, retry, deadline (pool.rs)
Pool::probe_healthasync fnConcurrent liveness sweep over every non-strictly-open endpoint (pool.rs)
PoolConfigstructPool-wide tuning: max_concurrency, attempt_timeout, backoff_initial/backoff_max, breaker_threshold, breaker_cooldown, strategy (pool.rs)
EndpointConfigstructOne endpoint’s name, Priority, optional RateLimit, and rotation weight (NonZeroU32 — zero is unwritable — capped at MAX_WEIGHT, since the ring materialises one slot per weight unit): no URL, no secret, no transport detail (pool.rs)
SelectionStrategyenumHow a tier’s endpoints are ordered when no pin dictates the choice: Ordered (default, first admissible in config order) or RoundRobin (weighted rotation) (pool.rs)
ExecuteOptionsstructPer-call overrides: the whole-call deadline, an optional endpoint pin, and an exclude set (pool.rs)
Served<T>structA value tagged with the endpoint name that served it (pool.rs)
PriorityenumHigh/Low selection tier; Low is only consulted when zero High endpoints are admissible (pool.rs)
RateLimitstruct{ rps: NonZeroU32 }, the wire shape an EndpointDef’s own rate_limit resolves into (pool.rs)
PoolError<E>enumexecute’s failure type: NoEndpoints, PinUnavailable, DeadlineExceeded, Exhausted, Permanent, Narrower. DeadlineExceeded and Exhausted Display name the attempted endpoints, not only the attempt count (error.rs)
ProbeFailure<E>enumWhy one endpoint’s probe produced nothing: TimedOut or Failed(E) (pool.rs)
PoolSetupErrorenumWhy Pool::new refused construction: NoEndpoints, DuplicateName, or WeightTooLarge (pool.rs)
Attempt<E>, AttemptError<E>struct/enumOne failed attempt’s endpoint and cause, threaded through PoolError::DeadlineExceeded/Exhausted (error.rs)
Classify, ErrorClass (re-exports)trait/enumRe-exported from blockwatcher_ports (lib.rs), not defined here; the one contract a consumer’s error type must satisfy: fn class(&self) -> ErrorClass

The seam: generic over a connection, not a trait

There is no trait Endpoint or trait Transport anywhere in this crate: Pool<C> is generic over a bare connection type C with no bound on C at all (impl<C> Pool<C>, pool.rs). The only contract a consumer must satisfy sits on the error type Pool::execute’s operation returns: E: Classify + std::error::Error (pool.rs), where Classify is declared in blockwatcher_ports (crates/blockwatcher-ports/src/error.rs) and merely re-exported here. That single method, fn class(&self) -> ErrorClass (one of Transient/Permanent/RateLimited/RetryNarrower, blockwatcher-ports/src/error.rs), is everything the pool needs to decide whether an attempt’s failure feeds the breaker, retries on backoff, or returns immediately.

blockwatcher-evm is the concrete instance: it parameterizes Pool<EvmEndpoint>, where EvmEndpoint { client: reqwest::Client, url: url::Url } (crates/blockwatcher-evm/src/jsonrpc.rs) is its own HTTP connection handle, and implements Classify for EvmRpcError directly on its own error enum (jsonrpc.rs), mapping HTTP status codes and JSON-RPC error bodies onto the four classes. blockwatcher-rpc never sees reqwest, url::Url, or anything JSON-RPC-shaped: it only ever calls op(&endpoint.conn) and reads back a Result<T, E> it did not construct.

flowchart LR
    subgraph consumer["blockwatcher-evm (a consumer)"]
        ep["EvmEndpoint<br/>{ client, url }"]
        err["EvmRpcError<br/>impl Classify"]
        op["closure: |ep| jsonrpc::call(ep, ...)"]
    end
    subgraph rpcpool["blockwatcher-rpc::Pool&lt;C&gt;"]
        sel["select()<br/>priority + breaker + limiter"]
        exec["execute()<br/>timeout, retry, backoff"]
        brk[("Breaker<br/>per endpoint")]
        lim[("TokenBucket<br/>per endpoint")]
    end
    ep -->|"C = EvmEndpoint"| rpcpool
    op -->|"op: Fn(&amp;C) -> Fut&lt;Result&lt;T, E&gt;&gt;"| exec
    exec --> sel
    sel --> brk
    sel --> lim
    exec -->|"E::class()"| err
    exec -->|"metrics::counter!/histogram!<br/>blockwatcher_rpc_*"| metrics[("process-global<br/>metrics::Recorder")]

Selection: priority, breaker, limiter, strategy

Pool::select (pool.rs) walks Priority::High endpoints first, then Priority::Low. Within a tier, the walk follows the tier’s rotation ring — endpoint indices in configuration order, each repeated its EndpointConfig::weight — and where it starts is the PoolConfig::strategy choice: Ordered (the default) always starts at the ring’s head, so the first admissible endpoint in configuration order wins every call and config order stays meaningful as the operator’s preference order; RoundRobin starts one position later per selection, spreading consecutive calls across the tier’s admissible endpoints in proportion to their weights. Rotation is best-effort, not strict fairness: the cursor advances once per selection whether or not the endpoint it landed on was admissible, so a broken endpoint’s slot redirects to its successor. Weights are inert under Ordered. Within a tier, an excluded endpoint or one whose breaker does not admit is skipped outright; among the rest, an endpoint whose rate limiter has no token available right now is skipped for this call rather than waited on, so one endpoint’s replenish interval never stalls a call another endpoint could serve immediately. Low is consulted only when zero High endpoints are admissible: a High endpoint that is merely rate-limited still counts as admissible, because being over quota is not ill health, and spilling to Low on quota would leak load past the operator’s priority intent (pool.rs, doc comment). ExecuteOptions::pin bypasses this walk entirely and asks for one named endpoint by name; the pool never substitutes another endpoint behind a pin: a pin that cannot serve fails as PinUnavailable, naming why (pool.rs).

Circuit breaker

stateDiagram-v2
    [*] --> Closed
    Closed --> Closed: success<br/>(resets streak)
    Closed --> Open: threshold consecutive<br/>classified failures
    Open --> HalfOpen: cooldown elapses
    HalfOpen --> Closed: trial succeeds
    HalfOpen --> Open: trial fails<br/>(re-opens for a full cooldown)

Breaker (breaker.rs, crate-private) tracks consecutive_failures and opened_at. admits is true when not strictly open; is_open is true only within cooldown of the failure that tripped it. record_failure returns whether this call is what transitioned the breaker from admitting to strictly open (breaker.rs); a metric counting “breaker opened” events must use that return value, not every failure, since the same streak keeps failing past the threshold without re-tripping, and a failed half-open trial is itself a fresh open transition. Only an attempt timeout or a Transient-classified failure ever calls record_failure; RateLimited explicitly does not, since quota exhaustion is not endpoint ill health (pool.rs).

Rate limiter

TokenBucket (limiter.rs, crate-private) is a per-endpoint token bucket whose burst capacity equals its configured rps: a fresh bucket can serve rps calls immediately, then refills continuously (not in fixed windows) at that same rate. next_available reports the exact wait until one token exists, which is what lets the pool, when every candidate in a tier is rate-limited, sleep exactly the shortest wait across candidates rather than a heuristic (pool.rs).

Retry, backoff, and the deadline

execute acquires one Semaphore permit up front and holds it across every retry; the whole-call deadline bounds even the wait for that permit (pool.rs). Each attempt then runs under min(attempt_timeout, time remaining before deadline) (pool.rs). On the result:

  • Permanent and RetryNarrower return immediately, retries unburned, since the pool cannot repair either by trying again (pool.rs).
  • An attempt timeout or a Transient failure records a breaker failure and retries after a backoff that doubles from backoff_initial up to backoff_max (pool.rs).
  • A RateLimited failure retries on the same doubling schedule but never touches the breaker (pool.rs).

This is retry over one RPC call, distinct from blockwatcher-core’s deliver_with_retry for sink deliveries (Delivery guarantees), and the two retry loops share no code and answer different questions: this one asks “should the pool try another attempt against an endpoint,” that one asks “should the engine try another delivery to a sink, or dead-letter the match.”

Health probing

Pool::probe_health (pool.rs) runs a caller-supplied probe concurrently against every endpoint whose breaker is not strictly open: an open endpoint is omitted from the result entirely (“recovering, no data”), not represented as a failure. Results never feed the breakers back: passive health comes from real traffic through execute, and probing is observation layered on top of it, never a second input into it (pool.rs). A sweep bypasses both the concurrency permit and every endpoint’s rate limiter on purpose, so liveness probing is never starved by the same quota it exists to observe (pool.rs).

The env:/url_secret indirection, and where it actually lives

Nothing in this crate parses an env: reference or a url_secret field: EndpointConfig carries only a name, a Priority, and an optional RateLimit (pool.rs); it has no URL field at all. The indirection an operator writes as "url_secret": "env:SEPOLIA_RPC_URL" resolves in two other places, neither of them blockwatcher-rpc:

  • The reference format itself (SecretRef::parse/SecretRef::resolve) is defined once, in the vocabulary crate, crates/blockwatcher-types/src/secret.rs. resolve is the only I/O that crate performs, and it never caches the result: env: is read fresh at each call, though for an environment variable that changes nothing an operator can observe, since a process’s environment is fixed at spawn.
  • The call site that actually invokes it, for evm-rpc’s endpoint pool, is resolve_endpoint_url in crates/blockwatcher-evm/src/registry.rs (documented in full on blockwatcher-evm); evm-subgraph uses resolve_subgraph_url in the same file. Both run once, at source-construction time, before a single connection handle or Pool is built. By the time an EndpointConfig reaches this crate, the secret has already been resolved into whatever connection value C carries; the pool itself never holds, logs, or has any way to name a secret reference at all.

Neighbours

blockwatcher-rpc depends on, in production:

  • blockwatcher-ports: the trait boundary; only Classify/ErrorClass are actually used, re-exported at the crate root
  • thiserror: the derive behind PoolError/AttemptError/PoolSetupError/ProbeFailure
  • tokio (sync/time/macros/rt features): the semaphore, timers, and async runtime every retry and probe loop runs on
  • metrics: the facade the pool’s named constants emit through
  • tracing: this crate’s own structured logging

and, in [dev-dependencies] only:

  • blockwatcher-testkit: RecordingMetrics, used to assert on emitted metric names and labels instead of a real Prometheus backend
  • tokio (rt-multi-thread/test-util features): multi-threaded and paused-time async tests

The following crates depend on it directly (per the dependency table):

  • blockwatcher-evm: parameterizes Pool<EvmEndpoint> for evm-rpc and evm-mempool, Pool<GraphqlEndpoint> for evm-subgraph, and implements Classify on EvmRpcError and GraphqlError
  • blockwatcher-evm-testkit: a direct production dependency (crates/blockwatcher-evm-testkit/Cargo.toml), not only a transitive one through blockwatcher-evm: single_endpoint_pool (blockwatcher-evm-testkit/src/pool.rs) builds a real Pool<EvmEndpoint> directly, so every EVM source test that points a module at a mock node goes through this crate’s own types

Reading the source

  1. Start at lib.rs: the crate doc comment states the whole contract in one paragraph, and the pub use lines are the entire public surface, drawn from error.rs, blockwatcher_ports, and pool.rs.
  2. error.rs: Attempt/AttemptError/PoolError, each generic over the consumer’s own error type E: std::error::Error.
  3. breaker.rs: Breaker, its methods, and the doc comment’s closed/open/half-open state machine; read this before pool.rs, the only module that calls it.
  4. limiter.rs: TokenBucket, try_take/next_available; read this before pool.rs’s select/select_pinned, its only callers.
  5. pool.rs: Pool<C>, select/select_pinned, execute, probe_health, in that order; the metric constants sit near the top of the file, each documented with the exact condition that records it.

blockwatcher-storage

blockwatcher-storage implements the Storage port twice over: versioned, optimistic-concurrency resources, per-network checkpoints, an append-only dead-letter queue, persisted operator pause, a bounded delivery journal, and the gate hit journal (gate_hits / gate_meta), backed by an in-memory map or a durable sqlite file (crates/blockwatcher-storage/src/lib.rs). It is a module crate: the heavy drivers (rusqlite and, behind the default-off postgres feature, sqlx) are quarantined here, and nothing above the Storage port ever has to know which backend a running instance chose.

Delivery guarantees § Dead letters already states the operator-facing consequence of the choice between backends: sqlite is what makes a dead-letter queue survive a restart at all, memory does not. This page covers the mechanics underneath that: the Storage contract in full, each backend’s concrete data structures, sqlite’s schema and migration approach, and exactly how optimistic concurrency and dead-letter payload absence are implemented: verified against the schema and the SQL itself, not restated at the operator level.

Key takeaways

  • blockwatcher-storage implements the Storage port twice over: an in-memory backend with no persistence, and a durable sqlite-backed one.
  • The heavy drivers (rusqlite and, behind the default-off postgres feature, sqlx) are quarantined in this crate; nothing above the Storage port ever has to know which backend a running instance chose.
  • sqlite is what makes a dead-letter queue survive a restart at all; memory does not.

Responsibilities

  • Implement the full Storage port over an in-memory HashMap-backed structure with no persistence at all: MemoryStorage (memory.rs), including the delivery journal as a per-network Vec.
  • Implement the full Storage port over one sqlite file and one guarded connection, durable across a restart: SqliteStorage (sqlite.rs).
  • Enforce optimistic concurrency on every resource write and delete: a mismatched expected version is a typed VersionConflict, never a silent overwrite, in both backends (memory.rs, sqlite.rs).
  • Apply put_batch’s create-only, all-or-nothing contract atomically in both backends, overriding the port’s own non-atomic default loop (memory.rs, sqlite.rs).
  • Register both backends under the module names "memory" and "sqlite" so a composition root’s catalog can select either by config (registry.rs).
  • (sqlite only) Create its schema idempotently on first open, migrate schema version 1 → 2 (delivery journal table), 2 → 3 (persisted pause), and 3 → 4 (gate_hits / gate_meta) in place, refuse to open a file written by a newer schema version, and serialize every connection access through one mutex inside spawn_blocking so the async runtime never blocks on file I/O: open_blocking (sqlite.rs).

Not this crate’s job: declaring the Storage trait itself, or any of the types it moves (Checkpoint, DeadLetter, VersionedRecord, ResourceKind): those are blockwatcher-ports and blockwatcher-types; deciding when a checkpoint is safe to persist, or retrying a failed persist: blockwatcher-core’s CheckpointWriter owns that policy and calls this crate’s backends only to execute the write it already decided to make; deciding that a dead letter should be discarded outright, singly or in bulk, rather than replayed: that is ControlHandle::discard_dead_letter/ discard_dead_letters in blockwatcher-core, which this crate’s delete_dead_letter/delete_dead_letters only execute; pruning the delivery journal: backends self-prune behind journal_depth on each record_delivery, and there is no operator prune API; picking which backend a deployment runs: that is blockwatcher.toml, read by blockwatcher-core’s module catalog.

Key types and traits

NameKindRole
MemoryStoragestructIn-process Storage implementation; nothing survives the process (memory.rs)
memory::RegistrystructModuleRegistry impl registering the "memory" storage module, taking no configuration (memory.rs)
SqliteStoragestructSingle-file, single-connection durable Storage implementation (sqlite.rs)
sqlite::RegistrystructModuleRegistry impl registering the "sqlite" storage module: { path, busy_timeout_ms } (sqlite.rs)
storages::get_allfnFamily enumeration folding both backends into a factory lookup table (registry.rs)

Neither backend defines its own error type: both return blockwatcher_ports::StorageError (VersionConflict, AlreadyExists, NotFound, Backend { message, class }, InvalidConfig), and neither backend’s Config struct is pub (reachable only through its ModuleRegistry::factory), documented via its own registry_examples/*.json file rather than a public type.

The Storage port contract

Every method below is declared once, in crates/blockwatcher-ports/src/storage.rs, and both backends implement every one of them:

MethodCategoryPurpose
get, listresourcesFetch one resource by (kind, id), or every resource of a kind, unpaginated (storage.rs)
put, deleteresourcesCreate or optimistic-concurrency update, and version-guarded delete (storage.rs)
put_batchresourcesCreate-only batch write; the trait’s own default is a plain, non-atomic loop over put that a backend may override (storage.rs)
get_checkpoint, put_checkpointcheckpointsRead and write one network’s resume point (storage.rs)
list_checkpoints, delete_checkpointcheckpointsEvery persisted checkpoint, unpaginated; remove one explicitly (storage.rs)
record_dead_letterdead lettersAppend a match that exhausted delivery; prune the oldest letters behind retention in the same write, returning how many it dropped (storage.rs)
list_dead_lettersdead lettersPage a network’s dead letters in arrival order (storage.rs)
count_dead_lettersdead lettersHow many dead letters a network currently has, without paging through them; the trait’s own default is list_dead_letters(..).len() (storage.rs)
delete_dead_letter, delete_dead_lettersdead lettersDiscard one letter by match id, or every letter matching optional sink/monitor filters, without attempting delivery (storage.rs)
update_dead_letterdead lettersReplace a letter in place at the same queue position, after a failed replay (storage.rs)
set_paused, list_pausedpausePersist or clear one monitor’s or network’s pause by id, and list every id currently paused for a target (storage.rs)
record_deliverydelivery journalRecord that match_id was delivered or dead-lettered at cursor; prune rows behind journal_depth in the same write (storage.rs)
list_deliveries_afterdelivery journalDeliveries whose cursor is strictly after from, in cursor order; unknown pipeline is empty (storage.rs)
forget_deliverydelivery journalDrop a journaled id after a successful retract; absent ids succeed (storage.rs)
get_gate_stategate journalFetch the full gate state (hits and meta) for one (pipeline, monitor) in one read (storage.rs)
replace_gate_hitsgate journalReplace the entire hit journal for one (pipeline, monitor) (storage.rs)
put_gate_metagate journalUpsert the metadata sidecar for one (pipeline, monitor) (storage.rs)
prune_gate_hits_aftergate journalDelete hold rows with cursor > from for a pipeline; rows with cursor ≤ from stay (storage.rs)
delete_gate_stategate journalDrop that monitor’s gate_hits and gate_meta (gate-envelope change / monitor delete, always with the pipeline stopped) (storage.rs)

Every method on the trait belongs to one of these categories: resources (keyed by ResourceKind × id, with a u64 version for optimistic concurrency), checkpoints (keyed by NetworkId), dead letters (keyed by NetworkId + MatchId), pause (keyed by PauseTarget + id), the delivery journal (keyed by NetworkId + MatchId, ordered by cursor), and the gate journal (gate_hits / gate_meta, keyed by pipeline + monitor). Memory, sqlite, and FlakyStorage all implement it; exercise_storage_contract covers the gate methods too.

Both backends persist the identical categories; only where they land differs:

flowchart LR
    subgraph categories["categories"]
        res["resources<br/>kind + id keyed, versioned"]
        chk["checkpoints<br/>one cursor per network"]
        dl["dead letters<br/>append-only queue per network"]
        pause["pause<br/>target + id keyed"]
        journal["delivery journal<br/>match ids in a cursor window"]
    end
    categories --> mem["MemoryStorage<br/>HashMap, gone on restart"]
    categories --> sql["SqliteStorage<br/>one file, survives restart"]

The memory backend

MemoryStorage holds one Mutex<State> (memory.rs), where State is one plain Rust collection per category and nothing else (memory.rs):

#![allow(unused)]
fn main() {
#[derive(Default)]
struct State {
    records: HashMap<(ResourceKind, String), (u64, serde_json::Value)>,
    checkpoints: HashMap<NetworkId, Checkpoint>,
    /// Appended to, never reordered: operators triage dead letters in the
    /// order delivery gave up on them.
    dead_letters: HashMap<NetworkId, Vec<DeadLetter>>,
    /// Per-pipeline delivered match ids, newest write prunes by primary.
    journal: HashMap<NetworkId, Vec<(Cursor, MatchId)>>,
    /// Present means paused; a resume removes the entry.
    paused: BTreeSet<(PauseTarget, String)>,
}
}

Every method is a lock, a map operation, and an unlock, with no I/O anywhere in the file (module doc, memory.rs). Optimistic concurrency is a plain match over (expected_version, current) in put (memory.rs); put_batch pre-validates every entry against both existing state and the rest of the batch before writing any of it, under one lock acquisition, which makes it genuinely atomic here despite the trait’s own default being a non-atomic loop (memory.rs). Dead letters are a real, ordered Vec<DeadLetter> per network (append-only except for delete_dead_letter/delete_dead_letters and update_dead_letter’s targeted mutations, memory.rs), not a stub: paging (skip/take), removal, and in-place replacement all behave exactly as the port contract specifies. Nothing here is written to disk; a process restart loses every record, checkpoint, dead letter, and pause, matching the crate’s own trade-off note (memory.rs).

The sqlite backend

Schema

One CREATE TABLE/CREATE INDEX batch per schema version (SCHEMA_V1, SCHEMA_V2, SCHEMA_V3, SCHEMA_V4 in sqlite.rs), with SCHEMA_VERSION 4:

CREATE TABLE resources ( … );
CREATE TABLE checkpoints ( … );
CREATE TABLE dead_letters ( … );
CREATE INDEX dead_letters_pipeline ON dead_letters (pipeline, seq);

CREATE TABLE delivery_journal (
  pipeline         TEXT NOT NULL,
  cursor_primary   INTEGER NOT NULL,
  cursor_secondary INTEGER NOT NULL,
  match_id         TEXT NOT NULL,
  PRIMARY KEY (pipeline, match_id)
);
CREATE INDEX delivery_journal_pipeline_primary
  ON delivery_journal (pipeline, cursor_primary);

CREATE TABLE pauses (
  target TEXT NOT NULL,
  id     TEXT NOT NULL,
  PRIMARY KEY (target, id)
);

CREATE TABLE gate_hits (
  pipeline         TEXT NOT NULL,
  monitor          TEXT NOT NULL,
  cursor_primary   INTEGER NOT NULL,
  cursor_secondary INTEGER NOT NULL,
  event_index      INTEGER NOT NULL,
  event_ts         INTEGER NOT NULL,
  event_json       TEXT NOT NULL,
  PRIMARY KEY (pipeline, monitor, cursor_primary, cursor_secondary, event_index)
);
CREATE INDEX gate_hits_pipeline_primary ON gate_hits (pipeline, cursor_primary);
CREATE TABLE gate_meta (
  pipeline      TEXT NOT NULL,
  monitor       TEXT NOT NULL,
  last_emit_ts  INTEGER,
  aux           BLOB,
  PRIMARY KEY (pipeline, monitor)
);

Each table stores its whole record as one JSON TEXT blob (value, checkpoint, entry) alongside only the columns a query needs to filter or order by, pauses excepted: a pause carries no payload beyond its own existence, so target and id are the entire row and presence alone means paused. resources keys on (kind, id); checkpoints keys on pipeline; pauses keys on (target, id); dead_letters gets an autoincrementing seq that is both its primary key and, via the dead_letters_pipeline index, the ordering list_dead_letters pages by: arrival order, for free, from the column sqlite already maintains.

Migration approach

A single idempotent function run once at open (open_blocking, sqlite.rs): MIGRATIONS is an ordered array holding each version’s schema batch ([SCHEMA_V1, SCHEMA_V2, SCHEMA_V3, SCHEMA_V4]), and SCHEMA_VERSION is simply its length, so adding a version and bumping the version number can never drift apart from each other. Opening reads PRAGMA user_version and slices MIGRATIONS from that index onward: a fresh file (0) runs every step, a 1 file runs only SCHEMA_V2 onward, a 2 file runs only SCHEMA_V3 onward, a 3 file runs only SCHEMA_V4, and a file already at SCHEMA_VERSION runs nothing. Every pending step executes inside one transaction, PRAGMA user_version is set to SCHEMA_VERSION inside that same transaction, and the whole thing commits together, so a crash mid-migration can never leave the file at its old version with some of the new tables already present, a state this backend would otherwise treat as fresh and fail to re-create. A version outside 0..=SCHEMA_VERSION (including a negative value, which this backend never writes) refuses to open at all, before any write, naming the found version (sqlite.rs), a refusal pinned by a test that diffs the file’s bytes before and after it to prove nothing was touched.

A future bump means appending one more schema batch to MIGRATIONS, not introducing a new subsystem or a new arm to a hand-written match.

Optimistic concurrency, precisely

put’s update path (sqlite.rs:256-276) is a single conditional UPDATE:

UPDATE resources SET version = version + 1, value = ?4
WHERE kind = ?1 AND id = ?2 AND version = ?3

If rows_affected comes back 0, a follow-up SELECT version tells the two possible causes apart: no row at all (NotFound) versus a row that exists at a different version (VersionConflict { expected, actual }). delete (sqlite.rs) is the identical pattern with a conditional DELETE. A create (expected_version: None) is a plain INSERT, whose rusqlite::Error for a primary-key collision is mapped to AlreadyExists (sqlite.rs). put_batch serializes every entry to JSON before opening a transaction (so a serde failure never leaves one open), then runs one INSERT per entry inside it, dropping the transaction uncommitted on any failure (sqlite.rs), which is what makes it atomic where the port’s own default loop is not.

Dead letters and checkpoint provenance are inside the blob, not a column

record_dead_letter/update_dead_letter serialize the whole DeadLetter struct (match_id, monitor, sink, cursor, attempts, reason, and the optional payload) into the single entry TEXT column (sqlite.rs). A query that needs to find one letter by match_id reaches into that blob with sqlite’s own json_extract, rather than a dedicated column:

DELETE FROM dead_letters
WHERE pipeline = ?1 AND json_extract(entry, '$.match_id') = ?2

(sqlite.rs:454-480, comment noting this scan is accepted at triage-scale, with a dedicated index left for whichever deployment first proves it necessary). Because DeadLetter.payload: Option<SinkEvent> is #[serde(default, skip_serializing_if = "Option::is_none")] (crates/blockwatcher-types/src/event.rs), a dead letter recorded before replay support existed has no SQL NULL to check at all: its entry blob simply has no "payload" key, and serde_json::from_str fills that gap back in as None on read via #[serde(default)]. A payload that is a tagged SinkEvent or a legacy bare Match object both load; see Delivery guarantees § What a sink receives. The 409 checkpoint_provenance check the HTTP API documents (which module wrote a checkpoint) works the same way: Checkpoint.module: Option<String> lives inside the checkpoints.checkpoint JSON blob (sqlite.rs), and this crate stores and returns it opaquely: the module comparison itself happens one layer up, in the engine that reads the decoded Checkpoint back out.

Cursor representation

Cursor { primary: u64, secondary: u64 } never gets its own columns in either backend: in MemoryStorage it is a plain, unserialized struct field inside the in-memory Checkpoint; in SqliteStorage it exists only as the nested {"primary": N, "secondary": N} object inside the whole Checkpoint’s serialized JSON blob, the same shape the HTTP API’s checkpoint field shows on the wire.

Connection handling

SqliteStorage holds Arc<Mutex<rusqlite::Connection>>: one connection, not a pool (sqlite.rs). Every port method funnels through a shared with_conn helper that clones the Arc, locks the mutex, and runs the blocking sqlite call inside tokio::task::spawn_blocking, so the async runtime is never blocked on file I/O even though the connection itself serializes every access (sqlite.rs). with_conn also takes a read guard on a tokio::sync::RwLock<()> fence before spawning, and moves the guard into the blocking closure rather than merely holding it across the .await: if the caller awaiting with_conn is aborted, the closure keeps running on the blocking pool regardless, and the guard travels with it, so it still marks the call as in flight until the closure actually returns. SqliteStorage’s Storage::quiesce override takes the fence’s write side and immediately drops it, which a fair, write-preferring RwLock resolves only once every read guard taken before it has been dropped: this is what lets a caller that just escalated a drain to abort() wait out whatever storage call that abort left running, rather than risk a successor racing it. PRAGMA journal_mode=WAL is set at open for a file-backed database (skipped for :memory:, which cannot run WAL), and busy_timeout is set from the configured busy_timeout_ms (default 5_000) to soften contention from an external reader, not to support two blockwatcher processes writing the same file, which this backend does not support at all (sqlite.rs).

Neighbours

blockwatcher-storage depends on, in production:

  • blockwatcher-types: vocabulary crate (Checkpoint, DeadLetter, MatchId, NetworkId, PauseTarget, ResourceKind, VersionedRecord)
  • blockwatcher-ports: the Storage trait this crate implements twice
  • async-trait: required to implement the async fn-bearing Storage trait
  • serde: (de)serialization derive for each backend’s Config
  • serde_json: the JSON representation every stored value, checkpoint, and dead letter serializes to
  • tokio (rt feature): spawn_blocking for the sqlite backend’s connection access
  • rusqlite: the sqlite driver itself, one of this crate’s two check-dep-graph.sh family exemptions
  • sqlx: the postgres driver, the other family exemption, behind this crate’s default-off postgres feature

and, in [dev-dependencies] only:

  • blockwatcher-ports (fakes feature): blockwatcher_ports::fakes::MemoryStorage, run through the same shared contract test as this crate’s own two backends, for parity
  • blockwatcher-testkit: dead_letter (a fixture builder) and exercise_storage_contract, the shared behavioral-contract suite both backends are proven against
  • tokio (macros/rt/time/test-util features): async tests
  • tempfile: a real filesystem path for sqlite’s file-backed tests

The following crates depend on it directly (per the dependency table):

  • blockwatcher-embed: folds both backends into the catalog build_catalog returns
  • blockwatcher (binary): reaches storage both directly and through embed

Reading the source

  1. Start at lib.rs: three pub mod declarations and nothing else; the module doc names the categories this crate persists.
  2. registry.rs: storages::get_all, and the self-verifying test that constructs every registered backend from its own documented example config.
  3. memory.rs: MemoryStorage, State, and every Storage method in file order; read this first, since every method here is the simplest possible correct implementation of the same contract sqlite.rs implements durably.
  4. sqlite.rs: SCHEMA_V1/SCHEMA_VERSION, open_blocking (the migration logic), then every Storage method; read the module doc comment first for the single-writer trade-off this backend accepts.
  5. tests/contract.rs (workspace-shared, via blockwatcher-testkit): the one suite both backends (and the ports fakes::MemoryStorage) run through, proving they satisfy an identical contract by shared test rather than shared code.

blockwatcher-sinks

blockwatcher-sinks implements the Sink port once per module it ships: webhook, script, and log (crates/blockwatcher-sinks/src/registry.rs). Each sink delivers one sink event, once, and reports a classified failure: the engine owns every retry, every backoff, and every dead-letter decision, so a sink module that retries internally is a bug (lib.rs). It is a module crate: adding, removing, or changing one sink module never touches blockwatcher-core.

Delivery guarantees already documents the generic contract every sink goes through from the engine’s side: deliver_with_retry (crates/blockwatcher-core/src/pipeline/delivery.rs) is the one retry loop every sink call passes through, and exhausting it is what produces a DeadLetter. This page does not restate that loop; it covers what is specific to each sink module: the wire format it sends, its own timeout distinct from the engine’s retry backoff, and exactly what each module classifies as retryable versus permanent, since that classification is what deliver_with_retry acts on.

Key takeaways

  • blockwatcher-sinks implements the Sink port once per module it ships: webhook, script, and log. Each delivers one SinkEvent, once, and reports a classified failure; the engine owns every retry, backoff, and dead-letter decision.
  • A sink module that retries internally is a bug: returning from deliver after exactly one attempt is the contract every module here follows.
  • This page covers what is specific to each sink module (wire format, timeout, retry classification); Delivery guarantees already documents the generic engine-side contract.

Responsibilities

  • Register exactly the sink modules it ships ("log", "webhook", "script") under the same family-enumeration convention every module crate follows (registry.rs).
  • Render every event the identical way, once, for every sink to reuse: canonical_body serializes an blockwatcher_types::SinkEvent to tagged JSON (type: match | retracted | digest) and is the one wire rendering every sink that touches the network or another process uses (lib.rs).
  • webhook: POST the canonical body to a secret-referenced URL, with operator-configured headers and its own request timeout (webhook.rs).
  • script: pipe the canonical body (or a rendered body_template) to a subprocess’s stdin under a configured timeout, and read its exit code (or lack of one) as the delivery outcome (script.rs).
  • log: write one line of the canonical body (or a rendered body_template) to stdout, serialized against concurrent LogSink instances by a process-wide lock so two deliveries can never interleave mid-line (log.rs).
  • Classify every failure (an HTTP status, a transport error, a process exit code, an I/O error) into the shared ErrorClass the engine’s retry loop reads, at the point closest to the fact that produced it (each module’s own logic; see Per-sink delivery semantics below).

Not this crate’s job: retrying a failed delivery, deciding when enough attempts have been spent, or writing a DeadLetter: blockwatcher-core’s sink_worker.rs/delivery.rs own every one of those, and every sink here returns from deliver after exactly one attempt, success or failure (lib.rs); defining Sink, SinkError, or ErrorClass: those are blockwatcher-ports, only used here; resolving an env: secret reference into a value: blockwatcher_types::SecretRef does that, called by webhook.rs per delivery (see webhook).

Key types and traits

NameKindRole
canonical_bodyfnThe one wire rendering of a SinkEvent every sink uses; tagged JSON is the wire-contract anchor (lib.rs)
compile_body_templatefnParses and write-time-validates a body_template source against every deliverable event shape; shared by every module that gains the field, currently webhook, script, and log (lib.rs)
render_body_templatefnRenders a validated body_template environment against one real event at delivery time; shared the same way (lib.rs)
webhook::WebhookSinkstructSink impl: POSTs the canonical body to a secret-referenced URL (webhook.rs)
webhook::RegistrystructModuleRegistry impl exposing NAME = "webhook" (webhook.rs)
script::ScriptSinkstructSink impl: pipes the canonical body to a subprocess’s stdin (script.rs)
script::RegistrystructModuleRegistry impl exposing NAME = "script" (script.rs)
log::LogSinkstructSink impl: writes one canonical-JSON (or rendered body_template) line to stdout (log.rs)
log::RegistrystructModuleRegistry impl exposing NAME = "log" (log.rs)
registry::sinks::get_allfnEnumerates every registered sink module for a composition root’s boot-time catalog (registry.rs)

Every module’s own Config struct (webhook.rs, script.rs, log.rs) is private, not pub: reachable only through its ModuleRegistry::factory, documented via its own registry_examples/*.json rather than as a public type. There is no crate-local error or classification type: SinkError/ErrorClass/Classify are all blockwatcher-ports concepts this crate only uses.

The Sink port and what “delivered vs. dead-lettered” hinges on

Sink is one method (crates/blockwatcher-ports/src/sink.rs:6-12):

#![allow(unused)]
fn main() {
#[cfg_attr(feature = "testing", mockall::automock)]
#[async_trait]
pub trait Sink: Send + Sync {
    /// Deliver one event. The ENGINE owns retry/backoff/dead-letter policy;
    /// the sink reports classified errors and does not retry internally.
    async fn deliver(&self, event: &SinkEvent) -> Result<(), SinkError>;
}
}

SinkError (blockwatcher-ports/src/error.rs) is Delivery { message, class: ErrorClass } or InvalidConfig { message }, and its Classify impl reports class for Delivery and always Permanent for InvalidConfig. So structurally, “delivered” is deliver returning Ok(()); “dead-lettered” is the engine’s deliver_with_retry exhausting its budget on a stream of Errs whose ErrorClass values it read straight off each module’s own return: every classification decision a sink module makes here is a direct input into that outcome, one module never sees the other’s retries, and none of the shipped modules ever emits ErrorClass::RetryNarrower (that class exists for RPC calls whose request can shrink, not deliveries).

Every delivery attempt, across every sink module, funnels through the same shape:

flowchart LR
    attempt["deliver(event)<br/>one attempt"] -->|"Ok(())"| delivered["delivered"]
    attempt -->|"Err(SinkError)"| classify{"ErrorClass"}
    classify -->|"Transient / RateLimited"| retry["engine retries<br/>with backoff"]
    classify -->|"Permanent"| dead["dead letter"]
    retry -->|"attempts exhausted"| dead
    retry --> attempt

Per-sink delivery semantics

webhook

Config (webhook.rs, deny_unknown_fields): url_secret: String (an env:NAME reference, resolved fresh at every delivery: no resolved copy outlives one request), headers: BTreeMap<String, String> (default empty, plain values), header_secrets: BTreeMap<String, String> (default empty, env:NAME references resolved fresh at every delivery like url_secret; a name also present in headers refuses the write), timeout_ms: u64 (default 10_000, rejected if 0 at construction since it would make every attempt time out immediately), body_template: Option<String> (a minijinja template validated at write time; absent keeps the canonical SinkEvent JSON unchanged). WebhookSink itself stores the unresolved SecretRefs, never a resolved URL or header value (webhook.rs).

deliver (webhook.rs) resolves the secret, parses it as a URL, builds body = canonical_body(event)?, and sends POST <url> with Content-Type: application/json plus every configured header, body exactly the canonical JSON with no wrapping or renaming. Redirects are disabled outright: following one would re-send the secret-addressed request to a host the operator never named, so any 3xx is a visible, permanent failure (webhook.rs, classify_status). Retry classification (classify_status, webhook.rs):

ResponseClass
429 Too Many RequestsRateLimited
408 Request Timeout, any 5xxTransient
anything else (including every 3xx)Permanent
a transport-level reqwest::Error (e.g. connection refused)Transient, unconditionally (transport_error, webhook.rs)

The timeout_ms config bounds one HTTP attempt via reqwest::Client::builder().timeout(...); it is set once, at construction, and is orthogonal to blockwatcher-core’s own retry backoff between attempts.

An optional config field, body_template (optional string, absent by default), is a minijinja template for the request body. It renders against a JSON value (canonical_value, lib.rs) that shares SinkEvent’s serde shape with the wire body but is serialized independently of it: canonical_body serializes the event directly, never through this serde_json::Value, because routing it through Value would re-sort top-level keys and break the byte-pinned wire contract (lib.rs). So a template and the default wire body always see identical field names: for a match, top-level type, id, monitor, network, and event (with event.kind, event.name, event.fields, event.cursor); for a retracted event, type and match_id; for a digest, type and matches, each element shaped like a match without its type. A template branches per shape with a guard — {% if type == "digest" %}…{% endif %} — whose digest branch renders only against digest events. Validation and rendering split by when each runs:

  • Write time (Registry::factory, webhook.rs, calling compile_body_template("webhook", source) in lib.rs): a configured body_template is parsed and rendered against three synthetic events — a match, a retracted event, and a two-match digest (synthetic_validation_events, lib.rs) — before construction returns, so every shape a sink can be handed is exercised at the write. A syntax error or a filter that does not exist fails the write as SinkError::InvalidConfig (an API 422), message-prefixed invalid webhook body_template:. A configuration mistake surfaces at the write that introduced it, never as a dead-lettered delivery later.
  • Delivery time (deliver, webhook.rs, calling render_body_template in lib.rs): a template that passed write-time validation still renders fresh per real event. A render failure there is a permanent delivery error, body_template render failed: {e}: deterministic over the same payload, so a retry cannot change the outcome.
  • Undefined fields render empty. The template environment sets UndefinedBehavior::Lenient: a field the template reads that a given event lacks (a retracted event has no network, for instance) renders that lookup as empty rather than failing the render. A template is presentation over the canonical event, and a missing decoration must not dead-letter a correct match. Leniency covers plain printing and iteration only: piping an undefined field through a filter still errors (e.g. {{ event.fields.map.amount.uint | int }} fails write-time validation against the synthetic events, neither of which has that field) — | default(...) before the filter is the escape hatch.
  • Absent body_template keeps the wire byte-identical. With no template configured, deliver sends canonical_body(event) unchanged.

A template replaces the canonical wire contract with an operator-authored one — the canonical body remains the default and the only shape the compatibility test pins.

A worked example: the template

{"text": "{{ type }} on {{ network }}"}

validates at write time against both synthetic events, and against the pinned match fixture (monitor m, network net) renders the POST body:

{"text": "match on net"}

type and network above are operator-known enum-shaped strings, safe to interpolate bare inside hand-written quotes. event.fields and event.name are decoded chain data, not operator-controlled: a value there can carry a quote or brace, so interpolating one into a JSON payload without escaping can produce malformed JSON or let the value inject structure into the destination payload (a forged Slack block, for instance). Pipe a chain-derived value through |tojson instead of wrapping it in hand-written quotes — tojson supplies its own quoting, so it replaces the surrounding "..." rather than nesting inside them:

{"text": {{ type | tojson }}}

renders {"text": "match"}; used on a value that actually contains a quote, the equivalent hand-written-quotes form would break the JSON.

script

Config (script.rs, deny_unknown_fields): command: String, args: Vec<String> (default empty, passed verbatim), timeout_ms: u64 (default 30_000), body_template: Option<String> (absent by default). No env field: the child inherits the process’s own environment; no working-directory field.

deliver (script.rs) spawns tokio::process::Command::new(&command).args(&args) with stdin piped, stdout discarded, stderr captured (a 4 KiB tail), and kill_on_drop(true). With no body_template configured, canonical_body(event)? is written to the child’s stdin, not argv and not an environment variable; with one configured, render_body_template’s rendered string is written instead. Either way stdin is shut down once the write completes. The whole write-plus-wait is wrapped in tokio::time::timeout(self.timeout, ...); on elapse the child is killed via the drop guard and the outcome is Transient (“script timed out after {ms}ms”). Exit-code classification is an explicit, operator-facing contract, not transport inference:

OutcomeClass
exit code 0delivered (Ok(()))
exit code 75 (EX_TEMPFAIL)Transient (the script’s own “please retry” signal)
any other exit codePermanent
killed by signal (includes the timeout-kill path)Transient
spawn failure: command not found or permission deniedPermanent
spawn failure: anything elseTransient

body_template is the same minijinja mechanism webhook uses (see webhook for the context shape, the digest type guard, and the lenient-undefined rules), through the same shared compile_body_template/render_body_template functions in lib.rs: write-time validation against the three synthetic events, invalid script body_template: ... on a syntax or filter error at the write, and a permanent body_template render failed: {e} at delivery if a real event’s shape ever slips past that validation. The one difference is what the rendered text becomes: a request body has to stay valid JSON for the receiving webhook, but a script’s stdin has no inherent structure to protect — an operator whose script parses stdin as JSON (or any other structured format) still owns the same |tojson-style escaping discipline webhook’s worked example shows, since decoded chain data is not operator-controlled and can carry a quote or brace.

The sink-script-monitor example configures a body_template that renders each match as a Match |- Transfer from … to … of … USDC line (and a digest as one Digest |- … line plus one |- … continuation line per bundled match), so the example script simply appends the rendered text — no jq or other post-processing needed on the receiving end. The Docker stack seeds compose-demo instead, which delivers only through ui-ingest.

log

Config (log.rs): body_template: Option<String> (absent by default) is its one field; deny_unknown_fields still rejects any other key as a boot-time typo rather than a silent ignore. LogSink itself carries the compiled template (Option<minijinja::Environment<'static>>), so unlike webhook/script it is no longer a unit struct — tests construct it via LogSink::default(). line (log.rs) builds the emitted line: with no template, canonical_body(event)? plus a trailing newline; with one, render_body_template’s rendered string plus the same trailing newline. deliver writes that line directly to tokio::io::stdout(), under a process-wide AsyncMutex<()> that serializes concurrent LogSink instances so two deliveries can never interleave mid-line. It is not routed through the tracing/log facade at all.

body_template here is the same mechanism documented under webhook and script, through the same shared compile_body_template/render_body_template functions in lib.rs: write-time validation against the three synthetic events, an invalid log body_template: ... message on a syntax or filter error at the write, and a permanent body_template render failed: {e} at delivery if a real event’s shape ever slips past that validation.

Delivery can fail, narrowly: an I/O error writing or flushing stdout (a broken pipe, most plausibly) maps to Transient rather than Permanent (a deliberate loss-aversion choice, since an under-classified retry costs one wasted attempt while an over-eager Permanent would dead-letter a match a momentarily-blocked consumer would otherwise have accepted), in the transient helper (log.rs).

The canonical_body wire-contract test

Two layers pin the same shape:

  • A crate-internal unit test, canonical_body_match_is_tagged (lib.rs), calls canonical_body directly against a fixed SinkEvent::Match and asserts the resulting bytes, parsed back to JSON, equal a hand-written literal that includes "type": "match". A second test, canonical_body_retracted_is_tagged, pins {"type":"retracted","match_id":"…"}. A third, canonical_body_digest_is_tagged, pins a SinkEvent::Digest of two matches to a literal with "type": "digest" and a matches array of their own canonical objects, each rendered exactly as the lone-match literal renders one. Together they pin both the field shape of blockwatcher_types::Match/DecodedEvent/SinkEvent and the exact hash MatchId::derive produces for that fixed input ("14e563bd9d376e738be5e13e4054327c").
  • An integration-level echo of the identical literal through a real HTTP round trip, a_delivery_posts_the_canonical_body_exactly_once (tests/sinks/webhook.rs): a mock axum server records what WebhookSink::deliver actually sent, and the test asserts the received body equals the same pinned literal, byte for byte.

Every other body-comparing test in the crate re-serializes the input and compares against that: those tests would keep passing through a shape change that these two would catch. That is the point of keeping one literal assertion rather than none: the sink modules render SinkEvent to something outside the process, which makes its serde shape an external wire contract the moment any one of them ships, and a hand-written literal is the only form of assertion that a coordinated rename (changing both the struct and every call site that serializes it) cannot silently slip past.

Neighbours

blockwatcher-sinks depends on, in production:

  • blockwatcher-types: vocabulary crate (SinkEvent, SecretRef)
  • blockwatcher-ports: the Sink trait this crate implements once per module, and ErrorClass/SinkError
  • async-trait: required to implement the async fn-bearing Sink trait
  • serde: (de)serialization derive for each module’s Config
  • serde_json: the canonical wire format canonical_body produces
  • tokio (io-util/io-std/process/time/macros features): stdin piping and process timeout (script), async stdout (log)
  • reqwest: the webhook module’s HTTP client, this crate’s one check-dep-graph.sh family exemption
  • minijinja (default-features = false, builtins + serde + json features): the webhook module’s optional body_template renderer; json gates the |tojson escaping filter separately from builtins and depends on serde_json, already in the tree, so memo-map remains the only wholly new transitive dependency — no network, TLS, or async-runtime dependency of its own

and, in [dev-dependencies] only:

  • tokio (macros/rt/net/time features): async tests and the mock server’s listener
  • axum: a mock HTTP receiver webhook’s tests assert against, not a client
  • tempfile: filesystem fixtures for script’s tests
  • blockwatcher-core: exercises a real sink through blockwatcher-core’s actual Engine/sink-worker/deliver_with_retry machinery rather than a reimplemented harness; blockwatcher-core itself may never depend on a module crate, so this dev-only edge crosses that seam from the module side instead, and is how the retry-then-dead-letter contract from Delivery guarantees gets proven end to end for webhook and script specifically
  • blockwatcher-ports (fakes feature): fake ports for the fake source/decoder/matcher/storage blockwatcher-core’s test engine needs alongside a real sink
  • blockwatcher-testkit: shared test scaffolding
  • metrics: asserted against in the engine-delivery tests’ metric checks
  • tracing: structured logging the log-capture tests read
  • tracing-subscriber: captures log output for those assertions (e.g. confirming a resolved webhook URL never reaches a log line)

The following crate depends on it directly (per the dependency table):

  • blockwatcher-embed: the composition façade registers every sink module into the engine’s module catalog; the blockwatcher binary reaches this crate only through embed

Reading the source

  1. Start at lib.rs: the crate doc comment states the whole contract in one sentence, and canonical_body is the one function every sink module below calls before it sends anything anywhere.
  2. registry.rs: sinks::get_all, and its self-verifying test, which constructs every registered module from its own documented example config and panics on an unmatched name, the mechanism that keeps this enumeration and the registry_examples/*.json files from drifting apart.
  3. webhook.rs: WebhookSink, classify_status, transport_error; read the module doc comment first for why redirects are refused and for the split between plain headers and secret-referenced header_secrets.
  4. script.rs: ScriptSink, its exit-code contract (EXIT_TEMPFAIL), and the timeout-then-kill path; read the module doc comment for the inherited-environment and no-working-directory decisions.
  5. log.rs: LogSink, the process-wide STDOUT_LOCK, and why a broken pipe classifies Transient rather than Permanent.

blockwatcher-gates

blockwatcher-gates implements the Gate port once per module it ships: threshold and max_once (crates/blockwatcher-gates/src/registry.rs). Each module compiles a monitor’s gate.config against the same schemas the predicate uses, then answers on_hit over an engine-owned journal. The engine owns persistence, prune, and delivery: a module that opens sqlite, or that drains the journal in on_invalidate, is a bug (lib.rs). It is a module crate: adding, removing, or changing one gate module never touches blockwatcher-core.

Gates already documents the operator-facing contract: event time, one gate per monitor, outstanding 0 on quiet hits, and prune-by-cursor on invalidate. This page covers what is specific to the crate: registration, the two shipped modules, and the compile refusals they share.

Key takeaways

  • blockwatcher-gates implements the Gate port for threshold and max_once. Both depend on blockwatcher-types and blockwatcher-ports only, never blockwatcher-core.
  • Catalog fold is blockwatcher_gates::registry::gates::get_all() in crates/blockwatcher-embed/src/catalog.rs. There is no gates feature: both modules are always registered, the same way storage is.
  • Time-window gates require block.timestamp (unsigned or non-negative int) on the compiled schema. The write refuses with gate requires 'block.timestamp'; this monitor's selectors do not expose it.

Responsibilities

  • Register exactly the gate modules it ships ("threshold", "max_once") under the same family-enumeration convention every module crate follows (registry.rs).
  • threshold: session digest. Config { "count", "window_ms" }. Drop a prefix until the remaining span fits window_ms; if len >= count, Emit the oldest count indices; else Retain (threshold.rs).
  • max_once: first hit per event-time window. Config { "window_ms" }. Emit([this]) when last_emit_ts is none or this event_ts is outside the window; otherwise Discard([this]) (max_once.rs).
  • Share window_ms bounds (1..=86_400_000) and the block.timestamp schema check (lib.rs). threshold.count is 2..=10_000.

Not this crate’s job: persisting gate_hits / gate_meta, pruning on invalidate, minting Match ids, or stalling Progress on persist failure: blockwatcher-core’s pipeline/gate.rs and engine/invalidate.rs own those; defining Gate, GateDecision, GateHit, CompiledGate, GateAux, or GateError: those are blockwatcher-ports; constructing a passthrough: omit gate on the monitor, or use the ports fake (PassthroughGate) in tests.

Key types and traits

NameKindRole
threshold::ThresholdGatestructGate impl: N hits spanning ≤ window_ms of event time fire once, then the bag resets (threshold.rs)
threshold::RegistrystructModuleRegistry impl exposing NAME = "threshold" (threshold.rs)
max_once::MaxOnceGatestructGate impl: at most one alert per event-time window (max_once.rs)
max_once::RegistrystructModuleRegistry impl exposing NAME = "max_once" (max_once.rs)
registry::gates::get_allfnFamily enumeration folding both modules into a factory lookup table (registry.rs)

Neighbours

blockwatcher-gates depends on, in production:

  • blockwatcher-types: vocabulary crate
  • blockwatcher-ports: the Gate port and GateError
  • serde, serde_json: config (de)serialization

The following crate depends on it directly (per the dependency table):

  • blockwatcher-embed: folds get_all() into build_catalog

Using the crate without the engine

blockwatcher-gates depends on blockwatcher-types and blockwatcher-ports only, never blockwatcher-core. A host can compile and decide without the binary. Persist, prune, and Match mint stay the host’s (or core’s) job.

#![allow(unused)]
fn main() {
use blockwatcher_gates::threshold::ThresholdGate;
use blockwatcher_ports::{Gate, GateCtx, GateDecision, GateHit};

let gate = ThresholdGate;
let compiled = gate.compile(&config, &schemas)?;
let mut journal: Vec<GateHit> = Vec::new(); // host-owned

journal.push(hit);
match gate.on_hit(&compiled, &journal, &GateCtx { last_emit_ts: None }) {
    GateDecision::Emit { indices } => {
        // host mints Match / Digest from journal[indices]
        if let Some(max) = indices.iter().copied().max() {
            journal.drain(0..=max);
        }
    }
    GateDecision::Retain => {}
    GateDecision::Discard { .. } => {}
}
}

Reading the source

  1. lib.rs: crate contract (engine owns the journal), window bounds, and the shared block.timestamp check.
  2. threshold.rs, max_once.rs: one module each, config validation and on_hit.
  3. registry.rs: family enumeration build_catalog folds.

blockwatcher-api

blockwatcher-api is blockwatcher’s REST control plane: it turns an HTTP request into exactly one call against blockwatcher-core’s ControlHandle, or against core’s typed storage facade for a read, and turns whatever comes back into a JSON response. Every mutation goes through ControlHandle, the same path a --seed load and a running engine’s own boot use, so a resource never sees two independent validate/persist code paths; every read goes through core’s Resources facade, taking no engine lock, so a status or a GET stays answerable while a mutation is in flight (lib.rs).

Its production dependencies are exactly the names scripts/check-dep-graph.sh’s ALLOW_BLOCKWATCHER_API entry lists (crates/blockwatcher-api/Cargo.toml):

  • blockwatcher-types: every resource shape a route (de)serializes, and the ids used to look one up
  • blockwatcher-ports: Storage (the trait ApiState.storage is a dyn pointer to) and StorageError (matched inside error.rs’s classification)
  • blockwatcher-core: ControlHandle, EngineError, and every other engine type a route names, listed in full in Key types and traits below
  • axum: the HTTP framework the whole route table and serve are built on
  • serde: the derive machinery behind every request and response body
  • serde_json: parsing a request body and rendering a response one
  • tokio (net feature): TcpListener, the type serve accepts already bound
  • tracing: the crate’s own structured logging (an auth rejection, a 500’s real detail)

blockwatcher-api is one of the crates in the core ring, and the only one permitted to carry an HTTP stack at all: Workspace map § Three rings names the single exemption scripts/check-dep-graph.sh grants it, for exactly the axum family (axum, and what it pulls in) and no other forbidden family, so this crate can serve HTTP without ever being able to see a chain SDK or a storage driver either. See Architecture decisions § Chain knowledge stays out of the core for the rule this crate is the one core-ring exception to.

The HTTP API reference already documents every route, request and response body, and status code from an operator’s point of view. This page does not restate that table; it covers how the six route files split the work, how the auth middleware and the error mapping are built, and what keeps this crate itself chain-agnostic despite serving resources (a network’s source.config, a spec’s payload) that are opaque, module-specific JSON on the wire.

Key takeaways

  • Every mutation goes through ControlHandle, the same path a --seed load and boot use; every read goes through core’s Resources facade without taking an engine lock.
  • blockwatcher-api is the one core-ring crate allowed to carry an HTTP stack, exempted only for the axum family.
  • A network’s source.config and a spec’s payload cross this crate as opaque JSON; it never looks inside either one.
  • This page covers route organisation, auth, and error mapping; the HTTP API reference already documents every route from an operator’s point of view.

Responsibilities

  • Builds the complete route table and wraps it in one authentication middleware layer, router (lib.rs).
  • Enforces Authorization: Bearer <token> on every route but /health, matching the presented secret against the labelled [auth] table (auth/mod.rs) and inserting Identity { label, scope }. See Authentication middleware.
  • Maps every failure, engine-raised or decided by this layer on its own, onto one wire error shape and the right HTTP status (error.rs). See Error mapping.
  • Generates every resource kind’s whole CRUD surface from one macro, so PUT/GET/DELETE against a network, a spec, a sink, or a monitor are the same code path repeated rather than each hand-kept separately (routes/resources.rs).
  • Serves the route table on an already-bound listener until a shutdown future resolves, then lets in-flight requests finish (serve.rs), which is what keeps axum out of the blockwatcher binary’s own dependency graph (serve.rs).

Not this crate’s job: deciding whether a write is valid, compiling a predicate, or persisting anything: every one of those already happened inside ControlHandle by the time a handler’s await returns (blockwatcher-core); running a pipeline, a source, a decoder, a matcher, or a sink (also blockwatcher-core, dispatching into the module crates behind its port traits); knowing what any chain’s wire format means (no chain SDK appears anywhere in this crate’s dependency tree at all, per the exemption described above); exposing anything over Prometheus (blockwatcher-metrics owns a wholly separate axum::Router on its own socket); reading [api].listen or resolving [auth] from instance configuration (the blockwatcher binary does both and hands this crate an already-bound listener and an already-built token table, in boot, crates/blockwatcher/src/run.rs).

Key types and traits

NameKindRole
ApiStatestructEverything a handler needs: control (ControlHandle, the only mutation path), storage (Arc<dyn Storage>, what reads are served from), and auth (Arc<Vec<ApiToken>>, labelled credentials, never resolved secret values) (lib.rs)
routerfnBuilds the merged route table from every route file and wraps the whole thing in the auth middleware (lib.rs)
serveasync fnRuns router(state) on an already-bound TcpListener until shutdown resolves, via axum::serve(..).with_graceful_shutdown(..) (serve.rs)
require_bearerasync fn (middleware)The one authentication check every route but /health passes through (auth/mod.rs). Inserts Identity { label, scope }.
require!macroPer-MethodRouter authorization: .route_layer(require!(Scope::Admin)) (auth/mod.rs)
ApiErrorenumEngine(EngineError) or Api { status, code, message }: every way a request can fail, collapsed to one type so every failure leaves through one body shape (error.rs)
ErrorBody, ErrorDetailstructThe wire error shape: {"error": {"code", "message", "actual_version"?}} (error.rs)
resource_routes!macroGenerates one resource kind’s list/read/write/remove handlers and the routes they answer on, invoked once per kind (routes/resources.rs)

Route organisation

routes/ holds one file per concern, and routes::mod’s own module list is the complete inventory (routes/mod.rs). Each file answers a distinct question, which is also why a new resource kind or a new cross-cutting concern gets a new file rather than a growing one:

FileAnswersRoutes
health.rsIs this process up at all?GET /health, exempt from auth
status.rsWhat is every pipeline doing right now?GET /status
catalog.rsWhich module names did this process register?GET /catalog
schema.rsWhat vocabulary can a predicate against this spec address?GET /specs/{id}/schema
resources.rsCreate, read, update, delete, list, for any resource kindGET/PUT/DELETE on /networks, /specs, /sinks, /monitors (collection and {id})
spec_import.rsFetch a spec payload from a network’s chain and write it as this spec idPOST /specs/{id}/from-chain
monitors_ops.rsWhat can an operator do to one monitor beyond CRUD?POST /monitors/{id}/pause, /resume, /test
networks_ops.rsWhat can an operator do to one network beyond CRUD?POST /networks/{id}/pause, /resume, /skip; DELETE /networks/{id}/checkpoint; GET/DELETE on /networks/{id}/dead-letters (collection) and POST .../replay/DELETE on /networks/{id}/dead-letters/{match_id}

The split mirrors the same distinction blockwatcher-core’s own control/ module draws between generic CRUD (writes.rs/deletes.rs, one write and one delete method per kind) and kind-specific extra verbs (skip.rs, dead_letters.rs): resources.rs is one macro invoked four times because every kind’s CRUD answers identically, while spec_import.rs, monitors_ops.rs, and networks_ops.rs exist precisely because a spec’s extra operation (write-time import), a monitor’s extra operations (pause, resume, a dry-run test) and a network’s (pause, resume, skip, checkpoint reset, dead-letter replay) are not the same set and must not be forced into one shared shape just because they are both “operations on a resource.” health.rs, status.rs, catalog.rs, and schema.rs each get their own file for the same reason blockwatcher-core’s progress.rs sits apart from pipeline/: none of them is CRUD or an operation on a stored resource at all, and folding a liveness probe or a read-only snapshot into a file named for something else would make that file’s own scope harder to state.

Every route file exports one router() -> axum::Router<ApiState>, and lib.rs::router does nothing but .merge every route file together and layer the auth middleware on top (lib.rs):

flowchart LR
    req(("incoming<br/>request")) --> mw{"require_bearer<br/>auth/mod.rs"}
    mw -->|"path is '/health'"| health["health::router()<br/>routes/health.rs"]
    mw -->|"any other path,<br/>valid bearer"| merged{{"merged route table<br/>lib.rs"}}
    mw -->|"missing or wrong<br/>bearer token"| err401["401 unauthorized<br/>WWW-Authenticate: Bearer"]
    merged --> status["status::router()"]
    merged --> catalog["catalog::router()"]
    merged --> schema["schema::router()"]
    merged --> resources["resources::router()<br/>(every kind via one macro)"]
    merged --> specimp["spec_import::router()"]
    merged --> monops["monitors_ops::router()"]
    merged --> netops["networks_ops::router()"]

resources.rs additionally carries the shared helpers every one of its four generated kinds calls through: if_match/required_if_match (parsing and requiring a strong numeric ETag out of If-Match), written (201 for a create versus 200 for an update), found (the bare resource plus its version in an ETag), parse (a body that fails to deserialize becomes an EngineError::InvalidResource naming the offending key), and same_id (a path id and a body id that disagree is refused rather than silently resolved one way or the other) (resources.rs).

Authentication middleware

require_bearer (auth/mod.rs) is axum::middleware::from_fn_with_state, layered once around the entire merged router rather than mounted only on the routes that need it (lib.rs). The /health exemption is a path check inside the middleware, ahead of the router, not a separate mount alongside it: a request to a path that does not exist at all is refused for lacking a token before the router gets a chance to reveal, via a 404 versus something else, whether the path would have matched anyway (auth/mod.rs).

For any other path, the middleware:

  1. Resolves state.auth (a SecretRef) to the expected token, fresh, on this request. A resolution failure here is unreachable once actually serving traffic, since the composition root resolves the same reference at boot and refuses to start without it (crates/blockwatcher/src/config.rs’s InstanceConfig::auth); the defensive path that remains answers 500 rather than panicking (auth/mod.rs).
  2. Reads Authorization, and accepts only the bearer scheme, case-insensitively, per RFC 7235 (bearer_credential, auth/mod.rs).
  3. Compares the presented token against the expected one in constant time on length-equal inputs (constant_time_eq, auth/mod.rs): the only thing an unequal-length comparison leaks is that the lengths differ, never which byte.
  4. On a match, calls next.run(request); on anything else, answers unauthorized (auth/mod.rs): a 401 with WWW-Authenticate: Bearer, and a tracing::warn! naming the method and path but never the presented or expected token in any form, because a rejected credential is still a secret and one that merely arrived at the wrong deployment is often the right token somewhere else (auth/mod.rs).

The token itself is never held between requests: ApiState.auth carries only the SecretRef (the env:NAME reference), and step 1 above resolves it anew on every single request that isn’t /health (lib.rs).

Error mapping: from EngineError to HTTP status

ApiError (error.rs) is the one type every handler’s Result resolves its error side to, whether the failure came from the engine (ApiError::Engine, via From<EngineError>) or was decided by this layer on its own (ApiError::Api { status, code, message }, used for a malformed If-Match, a missing one on a delete, or a genuinely internal condition). classify (error.rs) is the exhaustive match from every EngineError variant onto its status and wire code, with no catch-all arm: a new variant added to blockwatcher-core fails this crate’s build until someone gives it a status here, rather than silently inheriting whatever a neighbouring arm happens to answer.

EngineError variantStatusCode
UnknownModule, InvalidResource, SpecImportRejected, UnsupportedChain, Compile, ModuleInit, MissingReference, StillReferenced422unknown_module / invalid_resource / unsupported_chain / compile_failed / module_init_failed / missing_reference / still_referenced
Storage(VersionConflict)412version_conflict
Storage(NotFound)404not_found
Storage(AlreadyExists)409already_exists
ShuttingDown503shutting_down
Unavailable503unavailable
NoRunningPipeline404no_running_pipeline
ReplayFailed502replay_failed
Conflict409conflict
CheckpointProvenance409checkpoint_provenance
Storage(Backend), Storage(InvalidConfig), DuplicateModule, DuplicateChainDecoder, InvalidEngineConfig500internal

The first group is every refusal a caller caused and can fix by editing the request; the last group is a broken invariant of the process itself (the duplicate-registration pair cannot even arise from a request, since a boot that reached the point of serving already rejected them), given explicit arms rather than folded into a catch-all so a future variant cannot land there by default (error.rs, comment).

One case gets special handling ahead of classify: ApiError::stored (error.rs), which every handler that reads or writes storage calls instead of the plain From conversion. It rewrites InvalidResource specifically to a 500, because arriving there from a stored record (as opposed to a caller-supplied body, which handlers reject earlier through parse and its own From) can only mean a record already in storage no longer deserializes: an older binary’s row, a hand edit, a newer schema. That is the deployment’s problem, not the request’s, so it goes to the log at error level and the wire gets the generic 500 body instead of a 422 that would blame the caller for state they never touched and cannot fix. ApiError::from(EngineError) (the plain, un-rewritten path) is reserved for the handlers where an InvalidResource genuinely is the caller’s own doing (the skip handler maps its rewind refusals through plain ApiError::from; the replay and monitor-test handlers do the same through their own small mappers, replay_error in routes/networks_ops.rs and test_error in routes/monitors_ops.rs), so the choice of mapper at each call site encodes which side of that distinction a given handler is on.

body() (error.rs) renders the fixed {"code":"internal","message": "internal error"} for any 500, regardless of which branch produced it, and into_response (error.rs) is the one place the real detail (a storage backend’s message, a connection string, a stored record’s identity) reaches tracing::error! before the response goes out: the log gets everything, the wire gets nothing past the fixed string. Every other status keeps its variant’s own Display text as message, and actual_version (error.rs) is populated only for Storage(VersionConflict), carrying the version storage actually found so a caller’s retry can send the If-Match it should have sent the first time.

Staying chain-agnostic

blockwatcher-api sits in the core ring specifically because it is not permitted to know what any chain’s wire format means, and nothing about how it is built requires it to: every handler speaks in blockwatcher-types resource shapes (Network, Spec, SinkDef, Monitor) and calls exactly two engine surfaces, ControlHandle for a mutation and Resources for a read, neither of which exposes anything chain-specific in its own signature. A few concrete details make this a property of the code, not just a stated intention:

  • A network’s source.config and a spec’s payload cross this crate as opaque serde_json::Value fields inside their resource shape (blockwatcher_types::Network/Spec): this crate never looks inside either one. Whether source.config parses as a valid evm-rpc pool, or payload as a valid Solidity ABI array, is decided by the named module’s own code, reached through ControlHandle::put_network/put_spec, never by anything in routes/.
  • GET /specs/{id}/schema (routes/schema.rs) is the one route that looks like it might hand back chain detail, and deliberately doesn’t: it calls ControlHandle::spec_schema, which compiles the spec through its chain’s own decoder into blockwatcher_types::SchemaSet, a chain-agnostic field/type-name vocabulary. It serves the decoder’s translation of the artifact, never the artifact itself, which is exactly what keeps this route’s response shape identical whether the spec behind it is an EVM ABI or something no chain family has been written for yet, per the schema handler’s own doc comment (routes/schema.rs).
  • Nothing in this crate’s dependency tree can reach a chain SDK at all: the exemption scripts/check-dep-graph.sh grants it covers the axum family only, so a chain SDK pulled in anywhere in this crate’s tree would fail the same transitive check that already forbids it for blockwatcher-core itself (see the dependency list at the top of this page, and Workspace map § Three rings).

Neighbours

blockwatcher-api depends on, in production (the same eight-entry list named in full above):

  • blockwatcher-types
  • blockwatcher-ports
  • blockwatcher-core
  • axum
  • serde
  • serde_json
  • tokio (net feature)
  • tracing

and, in [dev-dependencies] only:

  • blockwatcher-testkit: shared test scaffolding
  • blockwatcher-ports (fakes feature): the in-memory Storage/Source/etc. fakes a route handler test runs against instead of a real module
  • async-trait: implementing test-only trait impls the fakes crate doesn’t already provide
  • reqwest: driving the router over real HTTP in this crate’s own tests
  • tokio (macros/rt-multi-thread/time features): async test execution

The following crate depends on it directly (per the dependency table):

  • blockwatcher (binary): boots the engine, builds a ControlHandle, binds the API’s listener, and hands both plus the resolved auth reference to blockwatcher_api::serve as ApiState, in boot and serve_api (crates/blockwatcher/src/run.rs)

Reading the source

  1. lib.rs: the module doc comment, ApiState, and router: the whole crate’s shape in one file.
  2. auth/: require_bearer and require! (mod.rs), Scope (scope.rs), and the audit trail (audit.rs).
  3. error.rs: ApiError, classify, and stored versus from: read this before any route file, since every fallible handler returns ApiError.
  4. routes/mod.rs, then the files it lists, in the order Route organisation above covers them: health.rs, status.rs, catalog.rs, schema.rs, resources.rs (the macro, then its invocations, one per resource kind), spec_import.rs, monitors_ops.rs, networks_ops.rs.
  5. serve.rs: the graceful-shutdown wrapper the blockwatcher binary calls once the API listener is bound and the engine is up.

blockwatcher-embed

blockwatcher-embed is the in-process composition façade: it folds every compiled-in module family into one ModuleCatalog, and it re-exports the engine types a host needs to boot and stop without linking the blockwatcher binary. The binary itself calls build_catalog here rather than keeping a second registrar (crates/blockwatcher/src/run.rs).

It sits in the glue ring, the same ring as the binary: it may depend on blockwatcher-core, and scripts/check-dep-graph.sh allowlists it that way. It does not depend on blockwatcher-api, axum, the metrics HTTP listener, CLI parsing, or process signals.

Its production dependencies are exactly the ALLOW_BLOCKWATCHER_EMBED entry scripts/check-dep-graph.sh lists (crates/blockwatcher-embed/Cargo.toml):

  • blockwatcher-core: Engine, EngineDeps, Engine::start, ControlHandle, ModuleCatalog
  • blockwatcher-types, blockwatcher-ports: ChainKind and SpecImporter for with_spec_importers / spec_importers
  • blockwatcher-storage: always folded into the catalog (memory, sqlite); not behind a feature
  • blockwatcher-gates: always folded into the catalog (threshold, max_once); not behind a feature — there is no gates flag
  • blockwatcher-expr, blockwatcher-evm, blockwatcher-stellar, blockwatcher-sinks: optional (dep: in [features]), one per feature flag, mirroring the binary

The family exemption list (FAMILY_EXEMPT_BLOCKWATCHER_EMBED) covers the module crates this façade links (alloy, reqwest, hyper, tower-http, rusqlite, stellar-xdr) and not axum: embed never serves HTTP.

Embedding blockwatcher in a host process already covers this crate from a host’s side: the boot recipe, an in-process Sink, and how shutdown differs from the binary. This page does not restate that recipe; it covers what the crate actually exports, which feature flag removes which registration, and where it sits in the dependency graph.

Key takeaways

  • blockwatcher-embed is glue, not a module: it may depend on blockwatcher-core, and it is the one place module families are registered.
  • Default features (a flag per chain family, plus expr and sinks) mirror the binary; storage and gate modules (threshold, max_once) are always present. There is no gates feature: turning a gate module off is not a compile-time switch; an unknown gate.module at write is 422 listing catalog names.
  • It re-exports build_catalog, DEFAULT_MATCHER (when expr is on), Engine, EngineDeps, EngineConfig, EngineError, ControlHandle, spec_importers, and with_spec_importers. It does not wrap Engine::start.
  • The binary is a client of this crate for catalog construction. Nothing in the workspace depends on embed except the binary.

Responsibilities

  • Registers every compiled-in module family into one ModuleCatalog, feature-gated so what a build can select is exactly what it linked (catalog.rs).
  • Re-exports the small set of engine types a host needs for the happy path (lib.rs).
  • Forwards the binary’s chain-family, expr, and sinks feature flags onto the same optional module crates, so a binary built without a family flag also builds embed without that family (crates/blockwatcher/Cargo.toml).

Not this crate’s job: parsing CLI or instance config, seeding a directory, binding listeners, installing SIGTERM/SIGINT, or serving HTTP or Prometheus (blockwatcher binary, blockwatcher-api, blockwatcher-metrics); running a pipeline or deciding when a checkpoint advances (blockwatcher-core); implementing any module a catalog can select (each lives in its own module crate).

Key types and functions

NameKindRole
build_catalogfnFolds every compiled-in family’s get_all() into one ModuleCatalog, feature-gated per family (catalog.rs)
spec_importersfnFolds every compiled-in family’s spec_importers::get_all() into a chain-keyed map; empty when no importer feature is on (importers.rs)
with_spec_importersfnExtends EngineDeps with those importers so a host (or the binary) does not name a chain crate (importers.rs)
DEFAULT_MATCHERconst"expr", behind feature expr. The name build_catalog registers for the matcher family; the binary’s config fallback uses it so an unset [engine].matcher selects the same module (catalog.rs)
Engine, EngineDeps, EngineConfig, EngineErrorre-exportThe same types blockwatcher-core publishes; Engine::start takes EngineDeps by value
ControlHandlere-exportThe one path a running engine’s resources change through

Feature-flag wiring

Cargo.toml’s optional features each gate one dep: entry, all on by default:

[features]
default = ["evm", "stellar", "expr", "sinks"]
evm = ["dep:blockwatcher-evm"]
stellar = ["dep:blockwatcher-stellar"]
expr = ["dep:blockwatcher-expr"]
sinks = ["dep:blockwatcher-sinks"]

build_catalog (catalog.rs) is where the effect of each flag is entirely mechanical: a #[cfg(feature = "...")] block around one fold, family by family:

FeatureOff, build_catalog no longer registers
chain familyThat family’s sources::get_all() (Vec<SourceModule>, folded with catalog.register_source(module)) and decoders::get_all(). See the family page.
exprblockwatcher_expr::matchers::get_all() (expr)
sinksblockwatcher_sinks::registry::sinks::get_all() (webhook, script, log)

Storage’s own registration, blockwatcher_storage::registry::storages::get_all() (memory, sqlite, and, behind this crate’s default-off postgres feature, postgres), gates only the postgres backend: blockwatcher-storage is a plain, non-optional dependency, so memory and sqlite are never removed. Gate modules, blockwatcher_gates::registry::gates::get_all() (threshold, max_once), are the same: always registered.

A build missing a feature does not merely fail to offer the module by name; the factory function itself is absent, since the whole fold is compiled out. A config naming a module a build didn’t link refuses at catalog lookup time with the alternatives that build actually carries (catalog.rs’s own tests, an_unknown_storage_module_is_refused_naming_what_this_build_carries).

Neighbours

blockwatcher-embed depends on, in production:

  • blockwatcher-core
  • blockwatcher-types
  • blockwatcher-ports
  • blockwatcher-storage
  • blockwatcher-gates
  • blockwatcher-expr (feature expr, on by default)
  • blockwatcher-evm (feature evm, on by default)
  • blockwatcher-stellar (feature stellar, on by default)
  • blockwatcher-sinks (feature sinks, on by default)

and, in [dev-dependencies] only:

  • tokio (macros/rt features): the catalog construction tests

The following crate depends on it directly (per the dependency table):

  • blockwatcher (binary): calls build_catalog at boot, seed validation, and checkpoint prune, and with_spec_importers when constructing EngineDeps; forwards its own feature flags onto this crate’s matching features.

Reading the source

  1. lib.rs: the crate doc comment (why hosts depend here rather than assembling a catalog themselves) and the re-export surface.
  2. catalog.rs: build_catalog, and read it beside Feature-flag wiring above.
  3. importers.rs: spec_importers and with_spec_importers.

blockwatcher (binary)

blockwatcher is the composition root: it wires the engine, the module catalog, storage, the REST control plane, and the metrics exporter together, and implements none of them itself. Catalog construction lives in blockwatcher-embed; this crate calls build_catalog there so registration has one home. Every other rule it appears to enforce (what a resource must look like, what makes a monitor compilable) actually lives in blockwatcher-core or in a module crate; this crate only decides when each of those pieces gets constructed, in what order, and what the process does when asked to stop (lib.rs). It ships as a library target plus a thin binary, so the crate’s own integration tests can drive a boot in-process rather than only through a spawned process (lib.rs), and it is Unix-only in the sense that its one stop path is SIGTERM/SIGINT: a build for a target without those signals would have no way to ask it to stop, so it does not compile instead of compiling into a monitor nothing can shut down (lib.rs).

Its production dependencies are exactly the ALLOW_BLOCKWATCHER entry scripts/check-dep-graph.sh lists (crates/blockwatcher/Cargo.toml):

  • blockwatcher-types, blockwatcher-ports, blockwatcher-core: the vocabulary and the engine this crate boots
  • blockwatcher-embed: build_catalog and the same engine re-exports a host uses; this crate’s chain-family features plus expr / sinks also enable the matching blockwatcher-embed features
  • blockwatcher-api: the REST control plane, served once the engine exists
  • blockwatcher-metrics: the Prometheus recorder and scrape endpoint, installed and served independently of the API
  • blockwatcher-storage: the only storage modules (memory, sqlite); a plain, non-optional dependency, so both are always present regardless of feature flags. Gate modules are registered the same way sinks are, through embed’s catalog fold (blockwatcher-gates is always-on, not a binary feature). Chain-family modules, expr, and sinks are not direct dependencies: this crate’s feature flags forward onto blockwatcher-embed (see Feature-flag wiring below)
  • serde, serde_json: InstanceConfig and every resource shape a seed file or check deserializes
  • thiserror: the derive behind BootError, ConfigError, SeedError
  • toml: parsing an instance configuration file
  • figment: layering the TOML file with BLOCKWATCHER_* environment overrides
  • tokio (rt-multi-thread/macros/signal/net features): the async runtime main.rs builds, and the stop-signal plumbing run.rs installs
  • tokio-util: CancellationToken, the listener shutdown signal Running holds
  • tracing, tracing-subscriber (env-filter feature): init_tracing, which sends every diagnostic to stderr so stdout stays reserved for a running monitor’s own match output

Embed is the crate that links module families; this crate’s chain-family features plus expr / sinks forward onto embed rather than declaring those crates. The dependency gate still exempts the binary for the families embed pulls in, because they appear in its transitive tree. Workspace map § Glue and test is where this crate’s role in the three rings is described, and Architecture decisions § Dependencies always point toward the vocabulary, never toward the engine is the rule that keeps every module crate usable from some other binary entirely: none of them may depend back on this one, or on blockwatcher-core.

Installation and building and Configuration reference already cover this crate from an operator’s side: how to build it, what each feature flag removes, what check’s exit codes mean, and every InstanceConfig key. This page does not restate any of that; it covers the boot order verified against run.rs’s actual source, the drain mechanics behind the exit codes, and which blockwatcher-embed catalog registrations a feature flag removes.

Key takeaways

  • blockwatcher is the composition root: it wires the engine, the module catalog, storage, the REST control plane, and the metrics exporter together, and implements none of them itself. Catalog construction is blockwatcher-embed::build_catalog; this crate’s feature flags forward onto embed rather than declaring the module crates. Nothing in the workspace depends on it back.
  • Every rule it appears to enforce actually lives in blockwatcher-core or a module crate; this crate only decides when each piece gets constructed, in what order, and what happens when asked to stop.
  • It ships as a library plus a thin binary, and is Unix-only in the sense that its one stop path is SIGTERM/SIGINT.

Responsibilities

  • Parses the command line by hand (cli.rs) into one of Command’s Run, Check, PruneCheckpoints, SpecImport, Help, or Version variants, without reading a file or constructing a module until a command actually runs (cli.rs).
  • Loads instance configuration once at boot, TOML layered with BLOCKWATCHER_* environment overrides, and never re-reads it for the life of the process (config.rs).
  • Boots in the order Startup sequence below verifies: resolves everything that can refuse without a side effect first, constructs storage, claims both listen ports, seeds only an empty store, and starts the engine last (run.rs::boot, run.rs).
  • Registers no modules of its own: blockwatcher_embed::build_catalog folds every compiled-in family, feature-gated so what a build can select is exactly what it linked (run.rs, crates/blockwatcher-embed/src/catalog.rs).
  • Validates a seed directory offline, against the exact modules this binary compiled in, without starting a pipeline or opening a listener (check.rs).
  • Owns the process’s stop signal and the shutdown drain that follows it, mapping the result onto the process exit code (run.rs::run, run.rs).
  • Sweeps orphaned checkpoint rows whose network resource no longer exists, offline, outside the running process (prune.rs).
  • Fetches a spec payload through the compiled-in importer for a network JSON file’s chain and writes {id, chain, payload} JSON, without starting the monitor (spec_import.rs).

Not this crate’s job: deciding whether a resource is valid, compiling a predicate, or running a pipeline (blockwatcher-core, called through Engine::start/Engine::validate and ControlHandle); implementing any module a config can select by name (each lives in its own module crate, gated behind this crate’s own feature flags); serving HTTP or Prometheus (blockwatcher-api::serve and blockwatcher_metrics::serve, both handed an already-bound listener by this crate and otherwise left alone, run.rs).

Key types and functions

NameKindRole
CommandenumWhat the command line asked for: Run { config, seed }, Check { dir }, PruneCheckpoints { config, dry_run }, Help, Version (cli.rs)
parsefnHand-rolled argument parser; refuses trailing arguments after a terminal command rather than ignoring them (cli.rs)
InstanceConfig, ApiSection, MetricsSection, EngineSectionstructThe whole instance configuration, every section defaulting so an empty file is valid (config.rs)
loadfnBuilds InstanceConfig from an optional TOML file plus BLOCKWATCHER_* overrides; refuses when neither is present (config.rs)
build_catalogfn (in blockwatcher-embed)Folds every compiled-in module family’s get_all() into one ModuleCatalog; this crate calls blockwatcher_embed::build_catalog (run.rs, seed.rs, prune.rs)
boot, Running, BootErrorfn / struct / enumThe whole startup sequence, the booted deployment handed back to a caller, and every way boot can refuse (run.rs)
run, exit_codeasync fn / fnThe whole process lifecycle (install stop handlers, boot, wait, drain) and the drain-report-to-exit-code mapping (run.rs)
StopSignalsstructUnix SIGTERM/SIGINT (Windows ctrl_c/ctrl_close/ctrl_shutdown), installed once before boot so a signal arriving mid-boot is not lost (run.rs)
SeedBundle, Paused, load, validate, persist_or_clearstruct / fnA seed directory’s resources and the ids its paused.json pauses, read whole, proved to construct through the engine’s own boot validation, then written once (seed.rs)
check, Summaryasync fn / structThe offline seed-directory check and what a passing run prints (check.rs)

Startup sequence

boot (run.rs) is the single function every Run command goes through, and its actual order, read straight from the source rather than assumed, is:

  1. Resolve everything that can refuse without a side effect. If [api].enabled, parse [api].listen and resolve [auth] (config.api_listen()/config.auth()); if [metrics].enabled, parse [metrics].listen; resolve the engine config, including the compiled-in matcher fallback (run.rs).
  2. Build the module catalog (blockwatcher_embed::build_catalog), folding every compiled-in family’s factories (run.rs).
  3. Construct storage, via the catalog’s factory for [storage].module, inline in boot; warn if the resolved module is memory, since nothing survives that backend across a restart (run.rs).
  4. Bind both listen ports via bind_listener, metrics before API, installing the Prometheus recorder via install_recorder in the same step as the metrics bind (run.rs).
  5. Apply the seed, only if --seed <dir> was given and only into a store still holding zero resources of every kind, checked inline in boot (run.rs; the emptiness check and the load/validate/persist steps are apply_seed, run.rs). Ids named by the directory’s optional paused.json are recorded as paused in the same step, which is before step 6 starts the engine, so a seeded resource that starts paused never polls once.
  6. Start the engine, Engine::start(EngineDeps::new(storage, catalog, config)), then build the one ControlHandle onto it (run.rs).
  7. Spawn the listener tasks, serve_metrics/serve_api, now that the engine and control handle exist for them to serve (run.rs).
flowchart TD
    a["1: resolve api/metrics addr + token,<br/>resolve engine config,<br/>inline in boot()<br/>run.rs"] --> b["2: blockwatcher_embed::build_catalog()<br/>blockwatcher-embed/src/catalog.rs"]
    b --> c["3: construct storage<br/>via catalog's factory,<br/>inline in boot()<br/>run.rs"]
    c --> d["4: bind metrics listener<br/>+ install_recorder<br/>run.rs"]
    d --> e["4: bind api listener<br/>via bind_listener<br/>run.rs"]
    e --> f{"seed dir given?"}
    f -->|"yes, and store<br/>holds zero resources"| g["5: apply_seed:<br/>load, validate, persist<br/>run.rs"]
    f -->|"no, or store<br/>already non-empty"| h["6: Engine::start(EngineDeps)<br/>run.rs"]
    g --> h
    h --> i["ControlHandle::new(engine)<br/>run.rs"]
    i --> j["7: spawn listener tasks<br/>serve_metrics / serve_api<br/>run.rs"]

Two orderings here are easy to get backwards from memory, and both are deliberate, per boot’s own doc comment (run.rs):

  • The catalog is built, and storage constructed, before either listener is bound, but both listeners are bound before the seed is applied. Claiming a port is one of the last things that can refuse without having written anything an operator has to undo; seeding is the first thing that writes. A typoed listen address must be discovered before a store holding any resource stops being eligible for seeding, not after.
  • The engine starts last. Starting it runs real sources and delivers real events to real sinks. Everything above it, including a metrics recorder that refuses to install on a cold process, must have already refused if it was going to, so a boot that reports failure has sent nothing anywhere.

The check command

blockwatcher check <dir> (check.rs) is a wholly separate path from boot: it never reads an instance config file, never opens a real listener, and never touches real storage. check (check.rs) calls validate (check.rs), which:

  1. Loads the seed directory (seed::load), the same all-or-nothing read apply_seed uses.
  2. Resolves the engine config from InstanceConfig::default() rather than any real file, so the matcher this check validates against is the one this build compiled in, and the refusal a build with none produces is identical to what a real boot would say.
  3. Runs seed::validate, the exact function apply_seed calls: it builds a real ModuleCatalog, constructs a scratch in-memory store, persists the bundle into it, and runs the whole bundle through Engine::validate (the same construction and compilation path Engine::start runs, minus actually spawning a pipeline).

A pass prints Summary’s Display to stdout (check.rs:55-63) and returns 0:

ok: 3 networks, 5 specs, 2 sinks, 8 monitors

A failure prints the refusal to stderr and returns 1 (check.rs). Because validation constructs every module, a sink config that resolves a secret through env:NAME needs that variable present in check’s own environment, exactly as a real boot would need it. check never returns 2 or 64: those two codes belong respectively to a drain that aborted at the deadline and to a command line this binary could not parse, and check goes through neither the drain path nor the general argument parser once its own subcommand name has matched inside parse (cli.rs).

Feature-flag wiring

Cargo.toml’s optional features each forward onto the matching blockwatcher-embed feature, all on by default. This crate does not declare the module crates itself; embed is the one composition façade that links them:

[features]
default = ["evm", "stellar", "expr", "sinks"]
evm = ["blockwatcher-embed/evm"]
stellar = ["blockwatcher-embed/stellar"]
expr = ["blockwatcher-embed/expr"]
sinks = ["blockwatcher-embed/sinks"]

blockwatcher_embed::build_catalog (crates/blockwatcher-embed/src/catalog.rs) is where the effect of each flag is entirely mechanical: a #[cfg(feature = "...")] block around one fold, feature by feature, over the source, decoder, matcher, and sink families. This crate’s flags also enable the matching blockwatcher-embed features so the catalog this binary links is the catalog embed would build with the same flags:

FeatureOff, build_catalog no longer registersOff, elsewhere
chain familyThat family’s sources and decoder. What each flag removes is on the family page.none
exprblockwatcher_expr::matchers::get_all() (expr) (catalog.rs in blockwatcher-embed)config::default_matcher (config.rs) has no fallback module to offer, so an unset [engine].matcher refuses boot as ConfigError::NoMatcher rather than substituting anything
sinksblockwatcher_sinks::registry::sinks::get_all() (webhook, script, log) (catalog.rs in blockwatcher-embed)none

Storage’s own registration, blockwatcher_storage::registry::storages::get_all() (memory, sqlite, and, behind the postgres feature forwarded from this binary and from blockwatcher-embed, postgres), gates only the postgres backend: blockwatcher-storage is a plain, non-optional dependency of embed (catalog.rs in blockwatcher-embed), so memory and sqlite are never removed.

A build missing a feature does not merely fail to offer the module by name; the factory function itself is absent from the binary, since the whole fold is compiled out. A config naming a module a build didn’t link refuses at catalog lookup time with the alternatives that build actually carries, never with the module it lacks silently treated as unavailable without saying so (blockwatcher-embed’s own tests, an_unknown_storage_module_is_refused_naming_what_this_build_carries, assert exactly this against the real catalog).

Shutdown and drain

run (run.rs) installs StopSignals (Unix SIGTERM/SIGINT, or the three Windows equivalents) before calling boot, specifically so a signal arriving during a slow first boot (seeding, module construction) is answered here rather than escalated to a kill by whatever supervisor sent it (run.rs). A tokio::select! races the signal against boot itself, biased so a boot that has already finished always wins over a signal that happened to arrive in the same instant: a completed boot always gets a real drain rather than being torn down mid-construction (run.rs). A signal that wins the race (boot still in flight) exits 0 immediately, with a warning that a store may hold part of an interrupted seed and that a store holding any resource is never seeded again (run.rs).

Once boot succeeds, run waits for the next stop signal, then calls Running::shutdown (run.rs), which:

  1. Calls Engine::shutdown(). This is blockwatcher-core’s own drain: every pipeline’s drain_pipeline (crates/blockwatcher-core/src/engine/drain.rs) awaits its tasks up to EngineConfig::drain_deadline_ms (default 10_000), escalating to hard_cancel plus a 500ms grace on timeout, then aborting any straggler; see blockwatcher-core § Boot, restart, and shutdown for that ladder in full. The result is a ShutdownReport { drained, aborted }.
  2. Cancels listener_shutdown (a CancellationToken), which both serve_metrics and serve_api are watching, telling each axum::serve call to stop accepting new connections and let whatever it is already answering finish (run.rs).
  3. Waits for each listener task, for at most listener_grace. This is Duration::from_millis(engine_config.drain_deadline_ms), computed once at boot (run.rs) and reused rather than given its own separate number: a request left half-answered must not be able to extend the shutdown any further than a wedged sink already could. A listener that does not finish inside its grace is abandoned (the handle is dropped, not aborted, so an in-flight response gets whatever time the process exit leaves it) with a tracing::warn! naming which listener (run.rs).

exit_code (run.rs) maps the ShutdownReport alone onto the process exit code: 0 if aborted is empty, 2 otherwise. Config, boot, and seed failures short-circuit earlier and exit 1; a command line parse could not make sense of exits 64 (cli.rs). The full table:

CodeReached from
0exit_code on a clean drain, or a stop signal that arrived before boot finished, or check/prune-checkpoints/spec-import succeeding
1config::load, boot, or a seed failure printed and returned early (run.rs); check/prune-checkpoints/spec-import refusing
2exit_code when ShutdownReport.aborted is non-empty: the drain deadline forced at least one pipeline’s abort
64cli::parse rejecting the command line (USAGE_EXIT, cli.rs)

prune-checkpoints (prune.rs) is offline and outside this lifecycle entirely: it loads instance config, opens storage the same way boot does, sweeps or lists (--dry-run) orphaned checkpoint rows, and returns only 0 or 1 (prune.rs); it never starts an engine, never binds a listener, and never goes through the drain above. spec-import (spec_import.rs) is the same kind of offline command: it reads a network JSON file, fetches through import_spec_payload and the compiled-in importer map, and writes spec JSON; it never starts an engine.

Neighbours

blockwatcher depends on, in production (the same list named in full above):

  • blockwatcher-types
  • blockwatcher-ports
  • blockwatcher-core
  • blockwatcher-embed
  • blockwatcher-api
  • blockwatcher-metrics
  • blockwatcher-storage
  • serde, serde_json
  • thiserror
  • toml
  • figment
  • tokio (rt-multi-thread/macros/signal/net features)
  • tokio-util
  • tracing, tracing-subscriber (env-filter feature)

and, in [dev-dependencies] only:

  • blockwatcher-testkit: shared test scaffolding
  • blockwatcher-ports (fakes feature): the in-memory fakes a boot test constructs against instead of a real module
  • blockwatcher-evm-testkit: a real Pool<EvmEndpoint> for tests that need the evm feature’s real construction path rather than a fake
  • tempfile: scratch directories for seed and config tests
  • tokio (macros/rt-multi-thread/time/test-util features): paused-time and multi-threaded async tests
  • figment (test feature): the env/fs jail config.rs’s tests run inside

Nothing in the workspace depends on blockwatcher: it is the composition root, the end of every dependency chain rather than the start of one (per the dependency table, where its row is the longest but has no crate pointing back at it).

Reading the source

  1. lib.rs: the module doc comment (why the binary is Unix-only, why a library target sits beside it) and init_tracing.
  2. cli.rs: Command, parse, and USAGE: the whole surface a user types against, before any file is read or any module constructed.
  3. config.rs: InstanceConfig and its sections, load, and engine_config’s matcher-fallback logic (including journal_depth).
  4. seed.rs: SeedBundle, load, validate, persist_or_clear: read this before run.rs::apply_seed, which is a thin caller over exactly these functions. Catalog construction is blockwatcher_embed::build_catalog.
  5. run.rs: boot, Running, and run, in that order; read boot’s own doc comment (run.rs) beside Startup sequence above.
  6. check.rs: check and validate, both short, and both callers of seed.rs functions already covered above.
  7. prune.rs: prune_checkpoints and sweep, the one command that never touches the engine at all.
  8. spec_import.rs: spec-import, the other offline command: fetch a payload and write spec JSON.
  9. main.rs: the actual entry point, and how little of the crate’s real decision-making happens here.

blockwatcher-testkit

blockwatcher-testkit is shared test scaffolding: a recording metrics recorder and a handful of small harnesses that let a module’s tests drive a port the same way every other module’s tests do, instead of each crate growing its own copy of “wait for this condition” or “assert the storage contract holds.” Its whole lib.rs doc comment states the constraint the rest of this page is built on: “Dev-dependency only — the dependency gate rejects any production edge onto this crate by name” (crates/blockwatcher-testkit/src/lib.rs:1-2). See Workspace map § Verifying the rings for exactly how scripts/check-dep-graph.sh checks that by name rather than folding it into the ordinary allowlist rule.

Its production dependencies:

  • blockwatcher-types: RawEvent (source_run.rs) and the resource/checkpoint/ dead-letter vocabulary storage_contract.rs writes and reads
  • blockwatcher-ports: Storage/StorageError (storage_contract.rs) and SourceError (source_run.rs), the port traits this crate’s harnesses drive
  • metrics: the Recorder/Counter/Gauge/Key types recorder.rs implements against
  • serde_json: the JSON values storage_contract.rs’s exercise writes and compares
  • tokio (time/rt features): the timers wait.rs and source_run.rs await on
  • tokio-util: CancellationToken, the type source_run.rs’s stop cancels and asserts the Source port’s cancellation contract against

No [dev-dependencies] section exists in crates/blockwatcher-testkit/Cargo.toml at all: this crate’s own unit-tested modules (endpoint_url.rs, recorder.rs) exercise themselves with nothing beyond what production already pulls in.

Key takeaways

  • blockwatcher-testkit is shared test scaffolding: a recording metrics recorder and small harnesses that let a module’s tests drive a port the same way every other module’s tests do.
  • It is dev-dependency only; the dependency gate rejects any production edge onto this crate by name.
  • Its harnesses take a real or fake port implementation as an argument and drive it; they never substitute for a port directly.

Responsibilities

Small files, each owning exactly one harness:

  • endpoint_url.rs: publish_endpoint_url, which writes a URL into a freshly claimed environment variable and hands back the env:NAME reference an evm-rpc endpoint’s url_secret expects, so a test can point a source at a mock node without writing that node’s ephemeral URL into seed JSON.
  • recorder.rs: RecordingMetrics, a metrics::Recorder that records every counter, gauge, and histogram emission, and every registration, instead of exporting them, so a test can assert an emission fired with the labels it claims, or that a module registered the metrics it documents even when a code path never drove them.
  • source_run.rs: recv and stop, for driving a running Source from a test: taking its next event with a bounded wait, and cancelling it while asserting the port’s cancellation contract (a cancelled run returns promptly and returns Ok(()), never an error, never a panic).
  • storage_contract.rs: exercise_storage_contract, the one behavioral contract every Storage implementation in the workspace, the ports fake included, must pass, plus dead_letter, a fixture builder for the DeadLetter value that exercise and any other test needs.
  • wait.rs: until, the bounded poll every suite that waits on a condition shares, so a broken property fails its own test rather than hanging the suite.

Not this crate’s job: implementing a real module, deciding what a port’s contract means (blockwatcher-ports defines the traits and error enums this crate’s harnesses drive; this crate only exercises them), or substituting for a port directly. blockwatcher-portsfakes feature (FakeSource, FakeDecoder, FakeMatcher, FakeSink, MemoryStorage, FlakyStorage) and testing feature (mockall-generated mocks) are the in-memory implementations and unit-level substitutes a test builds against; see blockwatcher-ports § Responsibilities. This crate’s own harnesses take a real or fake port implementation as an argument or a type parameter and drive it, rather than being one.

Key types and functions

NameKindRole
publish_endpoint_urlfnClaims a fresh, unique environment variable, writes url into it, and returns the env:NAME reference (endpoint_url.rs)
RecordingMetricsstructA metrics::Recorder recording every counter increment/absolute, gauge write, and histogram sample, queryable via count/gauge_writes/histogram_values/registrations (recorder.rs)
recvasync fnThe next event a running Source emits, or a panic past a 10-second deadline (source_run.rs)
stopasync fnCancels a running Source and asserts it returns promptly with Ok(()) (source_run.rs)
exercise_storage_contractasync fnRuns every section of the Storage port’s behavioral contract against a fresh instance the caller constructs per section (storage_contract.rs)
dead_letterfnA DeadLetter fixture for a synthetic Transfer event at a given block (storage_contract.rs)
untilasync fnPolls an async condition every 10ms up to a 60-second deadline, panicking with a caller-supplied subject on timeout (wait.rs)

The metrics recorder

RecordingMetrics installs thread-locally via metrics::set_default_local_recorder, which is why its own doc comment warns that tests using it must run on a current-thread tokio runtime: “the default #[tokio::test] flavor” (recorder.rs). An emission from a worker thread of a multi-thread runtime never reaches it. describe_* is a no-op for all three kinds; register_counter, register_gauge, and register_histogram each capture the name and labels and hand back a handle that records (CounterHandle, GaugeHandle, HistogramHandle in recorder.rs). Query the result with count, gauge_writes / gauge_value, and histogram_values. count’s own doc comment names the one caveat that follows from folding absolute into increment: “mixing increment and absolute on one counter yields a sum, not the counter’s true value — this double aggregates every event, it does not track counter semantics”.

blockwatcher-core (pipeline/mod.rs, tests/engine/restart.rs), blockwatcher-rpc (src/pool.rs), blockwatcher-sinks (tests/sinks/engine_delivery.rs), and blockwatcher-evm (tests/mempool_loop.rs, tests/reorg_and_failover.rs) each import RecordingMetrics to assert on what their own pipeline or pool emits through the metrics facade, in place of a real Prometheus backend.

Driving a source: recv and stop

Both functions in source_run.rs exist because the two obvious alternatives (a bare recv().await and a bare abort()) can hang a whole suite instead of failing one test. recv wraps the receiver in a 10-second tokio::time::timeout (source_run.rs); stop cancels the supplied token, then asserts, within the same deadline, that the Source’s run future returns and returns exactly Ok(()) (source_run.rs:31-41):

#![allow(unused)]
fn main() {
pub async fn stop(
    cancel: CancellationToken,
    handle: tokio::task::JoinHandle<Result<(), SourceError>>,
) {
    cancel.cancel();
    tokio::time::timeout(DEADLINE, handle)
        .await
        .expect("run did not return promptly after cancel")
        .expect("run task panicked")
        .expect("run returned an error instead of Ok(()) after cancel");
}
}

blockwatcher-evm’s mempool_loop.rs, reorg_and_failover.rs, and source_loop.rs all import both functions to drive evm-rpc and evm-mempool sources against a mock node.

The storage contract

exercise_storage_contract takes a factory (Fn() -> Future<Output = Arc<dyn Storage>>) rather than one instance, and calls it once per section (storage_contract.rs), because its own module doc comment states the precondition every section depends on: “fresh must return an empty store on every call” (storage_contract.rs:1-3). The sections it runs, in order, cover optimistic-concurrency create/update/delete and its rejection paths, ResourceKind namespacing and sorted listing, per-network checkpoint round-tripping, dead-letter insertion-order paging, and, its most pointed section, values and cursors that a typed SQL column could not hold at all: a u64 above 2^53, non-ASCII text, a nested JSON null, and a checkpoint cursor at u64::MAX, “bigger than i64::MAX, the ceiling of SQLite’s signed 64-bit INTEGER type” (storage_contract.rs:317-324). blockwatcher-storage’s tests/contract.rs runs this same exercise against the ports fake, its own in-memory module, and its sqlite module in turn, so all three are proven equivalent by a shared test rather than by shared code; see blockwatcher-storage § Neighbours for that crate’s side of the same import.

Bounded waiting: until

wait.rs’s own doc comment states the shared budget: a 10ms poll interval against a 60-second deadline, “generous, because it covers a loaded machine that may be compiling at the same time” (wait.rs:1-13). The wait between polls is a real tokio::time::sleep, never a yield_now spin, which is what lets a #[tokio::test(start_paused = true)] test’s virtual clock auto-advance past whatever the condition is waiting on (wait.rs). blockwatcher-api’s tests/api/helpers.rs, blockwatcher-core’s tests/engine/helpers.rs, and the blockwatcher binary’s tests/boot.rs and tests/escalation_seam.rs each wrap this same until behind their own crate-local convenience function rather than calling it inline at every call site.

blockwatcher-e2e needs the identical shape but cannot depend on this crate at all (its own allowlist entry is empty, see blockwatcher-e2e), so its tests/e2e/harness.rs hand-writes a second until rather than importing this one, quoting a captured process’s stderr into its panic message the way a black-box scenario needs to. publish_endpoint_url is duplicated the same way, for the same reason, in tests/e2e/harness.rs’s write_chain_seed (crates/blockwatcher-e2e/tests/e2e/harness.rs, doc comment).

Neighbours

Every consumer reaches for a different subset of this crate’s harnesses:

flowchart LR
    testkit["blockwatcher-testkit"] --> core["blockwatcher-core<br/>RecordingMetrics, until"]
    testkit --> api["blockwatcher-api<br/>until"]
    testkit --> rpc["blockwatcher-rpc<br/>RecordingMetrics"]
    testkit --> sinks["blockwatcher-sinks<br/>RecordingMetrics"]
    testkit --> storage["blockwatcher-storage<br/>exercise_storage_contract"]
    testkit --> evm["blockwatcher-evm<br/>RecordingMetrics, recv, stop"]
    testkit --> bin["blockwatcher binary<br/>publish_endpoint_url, until"]

blockwatcher-testkit depends on, in production:

  • blockwatcher-types
  • blockwatcher-ports
  • metrics
  • serde_json
  • tokio (time/rt features)
  • tokio-util

The following crates depend on it, every edge under [dev-dependencies] only (per the dependency table, and checked by name rather than by the general allowlist rule, per Workspace map § Verifying the rings):

  • blockwatcher-core: RecordingMetrics (pipeline/mod.rs’s own tests, tests/engine/restart.rs) and until (tests/engine/helpers.rs)
  • blockwatcher-api: until (tests/api/helpers.rs)
  • blockwatcher-rpc: RecordingMetrics (src/pool.rs’s own tests)
  • blockwatcher-sinks: RecordingMetrics (tests/sinks/engine_delivery.rs)
  • blockwatcher-storage: dead_letter and exercise_storage_contract (tests/contract.rs)
  • blockwatcher-evm: RecordingMetrics, recv, stop (tests/mempool_loop.rs, tests/reorg_and_failover.rs, tests/source_loop.rs)
  • blockwatcher (binary): publish_endpoint_url and until (tests/boot.rs, tests/escalation_seam.rs)

Reading the source

  1. lib.rs: the module list, the crate’s whole re-export surface (one name or pair per module), and the doc comment stating the dev-dependency-only constraint the gate enforces by name.
  2. wait.rs: until, the smallest file and the one every other harness’s own tests lean on indirectly through the pattern it establishes.
  3. source_run.rs: recv, then stop; read the module doc comment first, since both functions exist to turn a possible hang into a bounded failure.
  4. storage_contract.rs: exercise_storage_contract’s section calls, then each section function in the order it calls them; dead_letter at the bottom of the file is the one fixture builder the sections above it also share.
  5. recorder.rs: RecordingMetrics, the three handles, and the Recorder impl; the module doc comment states the thread-local caveat before anything else.
  6. endpoint_url.rs: publish_endpoint_url; short enough to read in one pass, and its doc comment is also the fullest explanation in the crate of why an ephemeral mock node’s URL has to travel through the environment rather than through seed JSON.

blockwatcher-evm-testkit

blockwatcher-evm-testkit is a scripted JSON-RPC and WebSocket mock node, an in-memory chain to serve it from, and the cursor-packing pin and pool builder blockwatcher-evm’s own tests share, so that no test in that crate, or in the blockwatcher binary, or in blockwatcher-e2e’s in-process scenarios has to run against a real Ethereum client. Its own lib.rs doc comment states the shape this crate’s dependency direction takes: “Consumed exclusively via [dev-dependencies]: nothing here ever reaches a production binary. It depends on blockwatcher-evm while blockwatcher-evm dev-depends on it — a cycle cargo permits precisely because the edge back is a dev edge” (crates/blockwatcher-evm-testkit/src/lib.rs:4-8). See Workspace map § Verifying the rings for how scripts/check-dep-graph.sh checks the dev-dependency-only half of that by name, the same way it checks blockwatcher-testkit.

The harness and the system under test line up like this: a scripted node on one side, a real evm-rpc or evm-mempool source on the other, meeting over the same wire protocol a real node would speak:

flowchart LR
    simchain["SimChain<br/>in-memory chain"] --> mocknode["mock_node<br/>scripted json-rpc"]
    mockws["MockWsNode<br/>scripted directives"] --> wsnode["mock_ws_node<br/>scripted eth_subscribe"]
    mocknode --> pool["single_endpoint_pool<br/>real Pool of EvmEndpoint"]
    pool --> src1["evm-rpc source<br/>blockwatcher-evm"]
    wsnode --> src2["evm-mempool source<br/>blockwatcher-evm"]
    src1 --> test["test asserts on<br/>decoded events, cursors"]
    src2 --> test

Its production dependencies:

  • blockwatcher-types: the Cursor type cursors.rs’s pin constructs
  • blockwatcher-evm: EvmEndpoint, the type pool.rs’s builders parameterize a real Pool with, and the reason this crate depends on blockwatcher-evm rather than the other way around
  • blockwatcher-rpc: Pool, PoolConfig, EndpointConfig, Priority, the connection-pool types single_endpoint_pool constructs a real instance of
  • axum (ws feature): the HTTP server both mock_node and mock_ws_node bind, and the WebSocket upgrade ws_mock.rs speaks
  • alloy-primitives: keccak256, the hash function every block and transaction hash in sim_chain.rs derives from
  • reqwest: the HTTP client pool.rs’s evm_endpoint configures
  • serde_json: the JSON-RPC request/response shapes every module in this crate builds
  • tokio (net/rt-multi-thread/sync/time features): the listener and the broadcast channel behind MockWsNode’s directives run on
  • url: parsing every node’s bound address

Key takeaways

  • blockwatcher-evm-testkit is a scripted JSON-RPC and WebSocket mock node, an in-memory chain, and the cursor-packing pin blockwatcher-evm’s own tests share, so no test needs a real Ethereum client.
  • It is consumed exclusively via [dev-dependencies]; it depends on blockwatcher-evm while blockwatcher-evm dev-depends back on it, a cycle cargo permits because the edge back is dev-only.
  • A scripted node stands in for a real node on one side, and a real evm-rpc or evm-mempool source runs unmodified on the other, meeting over the same wire protocol.

Responsibilities

  • Runs a scripted JSON-RPC node on an ephemeral loopback port: mock_node takes a handler closure over (method, params) and answers whatever MockReply it returns, so a test swaps behavior by the closure it passes rather than by editing this crate (lib.rs).
  • Runs a scripted eth_subscribe WebSocket node the same way: mock_ws_node answers any first message of a connection as a successful subscription, then forwards whatever a test pushes through MockWsNode as eth_subscription notifications (ws_mock.rs).
  • Provides SimChain, a mutable in-memory chain a mock_node handler can serve: deterministic, salt-derived block and transaction hashes, and reorg_from/reorg_to for producing a fork mid-test (sim_chain.rs).
  • Provides single_endpoint_pool and evm_endpoint, so a test that needs a real Pool<EvmEndpoint> pointed at a mock node builds one in one call instead of assembling PoolConfig/EndpointConfig/Priority itself (pool.rs).
  • Provides log_cursor and tx_cursor, a hand-mirrored copy of the evm-rpc source’s cursor-packing formula, kept deliberately separate from the production pack_secondary function it mirrors (cursors.rs).
  • Provides CallLog, which records every JSON-RPC call a mock node’s handler observed, method and params rather than just method, so a test can assert not only call order but which range or block a specific call actually named (lib.rs).

Not this crate’s job: implementing evm-rpc or evm-mempool themselves (blockwatcher-evm owns both; this crate only mocks the wire they speak to), running anything against a real EVM node (blockwatcher-e2e‘s anvil-backed scenarios are the only place in the workspace that does; see blockwatcher-e2e), or standing in for a chain-agnostic port directly (blockwatcher-portsfakes feature does that; this crate mocks one family’s wire protocol underneath a real module, not the port trait above it).

Key types and functions

NameKindRole
mock_nodeasync fnStarts a scripted HTTP JSON-RPC node on an ephemeral port, returning once it accepts connections (lib.rs)
MockNodestructThe running node’s handle; url is where to point an endpoint, and dropping it aborts the listener task (lib.rs)
MockReplyenumOne scripted answer: Result, Error, Status, RawBody (a literal status and body, sent verbatim), or Hang (accepted, never answered) (lib.rs)
CallLogstructRecords every call a handler observed, method and params, in order (lib.rs)
SimChainstructA mutable chain of contiguous, gapless blocks; with_blocks, extend, reorg_from, reorg_to, add_log, add_tx, head, hash_of, and handler (sim_chain.rs)
mock_ws_nodeasync fnStarts a scripted WebSocket eth_subscribe node (ws_mock.rs)
MockWsNodestructThe running WS node’s handle: push_hash, push_raw, close_connection, go_silent, pings_received (ws_mock.rs)
SOLO_ENDPOINTconstThe name every single-endpoint pool this crate builds gives its one endpoint (pool.rs)
evm_endpointfnAn EvmEndpoint dialing a given URL with only a 2-second request timeout configured (pool.rs)
single_endpoint_poolfnA real Pool<EvmEndpoint> of exactly one high-priority, unlimited endpoint (pool.rs)
log_cursor, tx_cursorfnThe hand-mirrored cursor for an emitted log or transaction (cursors.rs)

The mock JSON-RPC node

mock_node’s handler sees each request’s method and params and returns a MockReply; handle_request (lib.rs:136-167) turns that into an HTTP response:

#![allow(unused)]
fn main() {
async fn handle_request(
    State(handler): State<Arc<RequestHandler>>,
    Json(body): Json<Value>,
) -> Response {
    let method = body
        .get("method")
        .and_then(Value::as_str)
        .unwrap_or_default();
    let id = body.get("id").cloned().unwrap_or(json!(1));
    let params = body.get("params").cloned().unwrap_or(Value::Null);

    match handler(method, &params) {
        MockReply::Result(result) => {
            Json(json!({"jsonrpc": "2.0", "id": id, "result": result})).into_response()
        }
        MockReply::Error { code, message } => Json(json!({
            "jsonrpc": "2.0",
            "id": id,
            "error": {"code": code, "message": message},
        }))
        .into_response(),
        MockReply::Status(status) => StatusCode::from_u16(status)
            .expect("test-provided status code is a valid HTTP status")
            .into_response(),
        MockReply::RawBody { status, body } => (
            StatusCode::from_u16(status).expect("test-provided status code is a valid HTTP status"),
            body,
        )
            .into_response(),
        MockReply::Hang => std::future::pending::<Response>().await,
    }
}
}

RawBody and Hang exist for exactly the shapes Result, Error, and Status cannot express: a body missing both result and error, one carrying both, a well-formed JSON-RPC error wrapped in a non-2xx status, and a node that accepts the connection and never answers at all, so the caller’s own timeout, not the mock server, is what ends that call.

SimChain: a mutable, reorg-capable chain

Every block’s hash is block_hash’s keccak256(number ++ salt) (sim_chain.rs), and every block’s parentHash is read from whatever the previous block’s hash happens to be at read time, never recomputed from the formula. That is what lets reorg_from and reorg_to change the salt for a suffix of the chain and get back an internally consistent set of hashes for exactly that suffix, while the shared prefix keeps its original hashes untouched (sim_chain.rs, module doc comment). reorg_from replaces every block from a given number up with a same-height, different-salt fork; reorg_to is the shape that can shorten or lengthen the chain instead, up to a target length (sim_chain.rs). SimChain::handler answers exactly the methods eth_blockNumber, eth_getBlockByNumber, eth_getLogs, and eth_getTransactionReceipt, and panics on any other, “since nothing in this family’s source implementation calls anything else” (sim_chain.rs:265-269).

SimChain’s own module doc comment lists the mock liberties a test must not lean on:

Deliberate mock liberties a test must not lean on: `SimChain`'s handler
answers `eth_getBlockByNumber` for numeric block tags only (a real
client's `"latest"`/`"finalized"` tags panic it); its
`eth_getLogs` enforces no range or result-size cap — range-limit behavior
is driven by a handler injecting explicit error replies — and matches
only topic position 0; logs carry no `removed` field; and a log's
`transactionHash` is its block's hash, not a real transaction's.
Transactions added via `SimChain::add_tx` carry a fixed zero `value` —
there is no parameter for it at all — and none of a real transaction's
other fields (`gas`, `nonce`, `type`, `chainId`, `gasPrice`/
`maxFeePerGas`, the `v`/`r`/`s` signature fields, `accessList`, and so
on); a test needing those must construct transaction JSON directly
rather than going through a chain. Receipts from
`eth_getTransactionReceipt` carry only `status` and positional fields
(`transactionHash`, `blockNumber`, `blockHash`) — no gas usage, logs,
`contractAddress`, or any of a real receipt's other fields.

(crates/blockwatcher-evm-testkit/src/lib.rs:21-36)

MockWsNode: a scripted mempool endpoint

ws_mock.rs exists for blockwatcher-evm’s mempool tests: its own module doc comment states the shape of its handshake: it “answers the first JSON request of every connection as a successful eth_subscribe, then forwards whatever hashes the test pushes as eth_subscription notifications” (ws_mock.rs:1-7). lib.rs’s own module doc comment names the liberties that handshake takes: the mock answers any first message this way, “without checking the method name or the subscribed event,” and the subscription id it hands back is always the literal "0x1", never a freshly generated one; only ever one subscription exists per connection at all, so eth_unsubscribe and a second eth_subscribe on the same socket are not handled, silently ignored the same as any other message once the first reply is sent (lib.rs:38-44). close_connection severs the current socket without stopping the listener, so the next dial gets a fresh subscription (ws_mock.rs); go_silent stops touching the socket at all: no reads, no writes, not even the automatic pong axum’s WebSocket would otherwise send, which leaves exactly the half-open shape a vanished peer leaves behind (ws_mock.rs). pings_received lets a test observe that a keepalive ping arrived without the mock ever having to answer it itself: axum auto-answers pings, so this counter only proves the idle clock never fired.

The cursor-packing pin

cursors.rs’s own module doc comment states why log_cursor and tx_cursor exist as hand-written functions rather than calls into production code: “a test asserting the wire contract must not verify the packer with itself, or a bug in pack_secondary and a matching bug here would cancel out and the test would still pass. Keep this hand-written, even though it is tempting to ‘simplify’ into a call to the real function — that temptation is exactly what would silence the pin” (cursors.rs:1-16).

#![allow(unused)]
fn main() {
/// The cursor an emitted log carries.
pub fn log_cursor(block: u64, log_index: u64) -> Cursor {
    Cursor::new(block, (1u64 << 32) | log_index)
}

/// The cursor an emitted transaction carries — see [`log_cursor`].
pub fn tx_cursor(block: u64, tx_index: u64) -> Cursor {
    Cursor::new(block, tx_index)
}
}

Both encode the same rule the evm-rpc source packs an occurrence’s kind into the secondary field’s high bits by: transactions are kind 0, logs are kind 1, so both kinds share one non-decreasing stream per block.

single_endpoint_pool: a real pool over a mock node

pool.rs’s own module doc comment explains why this helper lives in a library crate rather than in each test binary’s own common module: “a pub item in a private mod common of a test binary is dead code the moment one binary stops using it,” while “a pub item in a library crate is never dead code,” which is what lets every consuming suite share exactly the parts it needs (pool.rs:1-9). single_endpoint_pool builds a real blockwatcher_rpc::Pool<EvmEndpoint> of exactly one high-priority, unlimited endpoint named SOLO_ENDPOINT, pointed at a caller-supplied URL:

#![allow(unused)]
fn main() {
pub fn single_endpoint_pool(url: url::Url) -> Pool<EvmEndpoint> {
    Pool::new(
        PoolConfig::default(),
        vec![(
            EndpointConfig {
                name: SOLO_ENDPOINT.into(),
                priority: Priority::High,
                rate_limit: None,
            },
            evm_endpoint(url),
        )],
    )
    .expect("a single healthy endpoint always constructs a pool")
}
}

blockwatcher-evm’s tests/mempool_loop.rs and tests/common.rs import it directly; blockwatcher-evm’s src/source/rpc/scan.rs instead defines its own local, differently-shaped evm_endpoint/single_endpoint_pool pair for its own module tests rather than importing this one. This crate depends on blockwatcher-rpc directly (not only transitively through blockwatcher-evm), for exactly this function; see blockwatcher-rpc § Neighbours for that crate’s account of the same edge.

Neighbours

blockwatcher-evm-testkit depends on, in production:

  • blockwatcher-types
  • blockwatcher-evm
  • blockwatcher-rpc
  • axum (ws feature)
  • alloy-primitives
  • reqwest
  • serde_json
  • tokio (net/rt-multi-thread/sync/time features)
  • url

The following crates depend on it, every edge under [dev-dependencies] only (per the dependency table, and checked by name per Workspace map § Verifying the rings):

  • blockwatcher-evm: every one of MockNode, MockReply, CallLog, SimChain, mock_ws_node, log_cursor, tx_cursor, evm_endpoint, and single_endpoint_pool, imported inside #[cfg(test)] modules across registry.rs, source/mempool/pending.rs, source/rpc/{emit,fixtures,run,scan}.rs (fixtures.rs’s source_against takes a &MockNode directly), ws.rs, and the tests/*.rs integration suites (the dependency cycle this creates back onto blockwatcher-evm is exactly the dev-only edge this page’s introduction names)
  • blockwatcher (binary): a real Pool<EvmEndpoint> for tests that need the evm feature’s real construction path rather than a fake (tests/boot.rs, tests/escalation_seam.rs)
  • blockwatcher-e2e: MockNode, SimChain, mock_node directly, for its in-process scenarios (tests/e2e/shutdown.rs, tests/e2e/stdout_purity.rs) that need a scripted chain but not a real one; see blockwatcher-e2e § Neighbours for the contrast with that crate’s anvil-backed scenarios, which use a real node instead

Reading the source

  1. lib.rs’s module doc comment: the dependency-cycle rationale, the ephemeral-port binding rule, and the full list of deliberate mock liberties, before any of the code that implements them.
  2. mock_node, MockNode, MockReply, handle_request (lib.rs): the HTTP JSON-RPC mock at the center of the crate.
  3. CallLog (lib.rs): the call-recording type several integration suites assert against.
  4. sim_chain.rs: SimChain’s fields, then with_blocks, extend, reorg_from, reorg_to, add_log, add_tx, head, hash_of, and handler, in that order; the module doc comment names every liberty the handler takes before the handler itself does.
  5. ws_mock.rs: MockWsNode’s fields and methods, then mock_ws_node and serve_connection; read go_silent last, since it is the one path through this file that deliberately never touches the socket again.
  6. cursors.rs, then pool.rs: the smallest files in the crate, each read in one pass alongside its own module doc comment’s rationale.

blockwatcher-stellar-testkit

blockwatcher-stellar-testkit is a scripted JSON-RPC mock node blockwatcher-stellar’s own tests share, so that no test in that crate has to run against a live stellar-rpc endpoint. Its crate doc states why the mock is a crate rather than a #[cfg(test)] module: “Consumed exclusively via [dev-dependencies]: nothing here ever reaches a production binary. Its crate boundary exists so this mock can be shared by both a unit test module (#[cfg(test)] code inside src/) and any number of tests/*.rs integration binaries, without duplicating it or tripping the dead-code lint on whichever MockReply variants one particular consumer happens not to construct” (crates/blockwatcher-stellar-testkit/src/lib.rs). See Workspace map § Verifying the rings for how scripts/check-dep-graph.sh checks the dev-dependency-only half of that by name, the same way it checks blockwatcher-testkit and blockwatcher-evm-testkit.

It declares no workspace crate in [dependencies]: the mock is a self-contained axum listener. There is no production cycle with blockwatcher-stellar; that crate reaches this one only as a [dev-dependencies] edge.

The harness and the system under test line up like this: a scripted node on one side, a real stellar-rpc source on the other, meeting over the same JSON-RPC wire a real node would speak:

flowchart LR
    mock["mock_node<br/>scripted json-rpc"] --> src["stellar-rpc source<br/>blockwatcher-stellar"]
    src --> test["test asserts on<br/>decoded events, cursors"]

Its production dependencies:

  • axum: the HTTP server mock_node binds
  • reqwest: the HTTP client this crate’s own unit test dials
  • serde_json: the JSON-RPC request/response shapes handle_request builds
  • tokio (net/rt-multi-thread/sync/time features): the listener and DelayedResult’s wait run on
  • url: parsing the bound address into MockNode::url

Key takeaways

  • blockwatcher-stellar-testkit is a scripted JSON-RPC mock node blockwatcher-stellar’s own tests share, so no test needs a live stellar-rpc endpoint.
  • It is consumed exclusively via [dev-dependencies]; it declares no workspace crate in [dependencies].
  • A scripted node stands in for a real node on one side, and a real stellar-rpc source runs unmodified on the other, meeting over the same wire protocol.

Responsibilities

  • Runs a scripted JSON-RPC node on an ephemeral loopback port: mock_node takes a handler closure over (method, params) and answers whatever MockReply it returns, so a test swaps behavior by the closure it passes rather than by editing this crate (lib.rs). mock_stellar_rpc is the same constructor, returning (url, node) together.
  • Dispatches a JSON-RPC batch (an array body) through that same handler in request order and answers with the members’ replies as an array. MockNode::bodies is the raw POST body of every request the node received, which is how a test tells one batched request from several single ones — the handler alone cannot, since it sees a batch member exactly as it sees a lone request (lib.rs).
  • Provides CallLog, which records every JSON-RPC call a mock node’s handler observed, method and params rather than just method, so a test can assert not only call order but which ledger a specific call named (lib.rs).

Not this crate’s job: implementing stellar-rpc itself (blockwatcher-stellar owns the source; this crate only mocks the wire it speaks to), standing in for a chain-agnostic port directly (blockwatcher-portsfakes feature does that; this crate mocks one family’s wire protocol underneath a real module, not the port trait above it), or building a Pool<StellarEndpoint> (blockwatcher-stellar’s tests assemble that against MockNode::url). It has no in-memory chain fixture and no WebSocket mock: stellar-rpc polls HTTP JSON-RPC for closed ledgers.

Key types and functions

NameKindRole
mock_nodeasync fnStarts a scripted HTTP JSON-RPC node on an ephemeral port, returning once it accepts connections (lib.rs)
mock_stellar_rpcasync fnmock_node, returning (url, node) together (lib.rs)
MockNodestructThe running node’s handle; url is where to point an endpoint, bodies() is every raw POST body in arrival order, and dropping it aborts the listener task (lib.rs)
MockReplyenumOne scripted answer: Result, Error, Status, StatusWithHeaders, RawBody, Hang, or DelayedResult (lib.rs)
CallLogstructRecords every call a handler observed, method and params, in order (lib.rs)

The mock JSON-RPC node

mock_node‘s handler sees each request’s method and params and returns a MockReply. A JSON object body is one call. A JSON array body is a batch: each member is dispatched through the same handler in request order, and the HTTP response is the members’ JSON-RPC replies as an array.

RawBody, Hang, StatusWithHeaders, and DelayedResult exist for shapes Result, Error, and Status cannot express: a body missing both result and error, one carrying both, a well-formed JSON-RPC error wrapped in a non-2xx status, a quota window named in a Retry-After header rather than a body, a node that accepts the connection and never answers (so the caller’s own timeout ends the call), and a successful result held open for after so overlapping in-flight calls stay in flight.

The crate doc names the mock liberties a batch test must not lean on:

Deliberate mock liberties a batch test must not lean on: a member reply
that is not a per-member body — a bare status, a raw body, or a hang —
answers the whole batch, and the members after it are never dispatched,
where a real node has no such coupling between one member and the
response's status line. There is no partial-batch reply at all.

(crates/blockwatcher-stellar-testkit/src/lib.rs)

Neighbours

This crate depends on no workspace crate in production. The following crate depends on it, under [dev-dependencies] only (per the dependency table, and checked by name per Workspace map § Verifying the rings):

  • blockwatcher-stellar: MockNode, MockReply, CallLog, and mock_node, imported inside #[cfg(test)] in source/run.rs

Reading the source

  1. lib.rs’s crate doc comment: the crate-boundary rationale, the ephemeral-port binding rule, batch dispatch, and the mock liberties, before any of the code that implements them.
  2. MockReply, mock_node, MockNode, handle_request, dispatch (lib.rs): the HTTP JSON-RPC mock.
  3. CallLog (lib.rs): the call-recording type source/run.rs asserts against.
  4. The crate’s own mock_stellar_rpc_answers_jsonrpc test: the smallest proof the listener speaks JSON-RPC.

blockwatcher-e2e

blockwatcher-e2e is black-box, end-to-end coverage of the real blockwatcher binary: every scenario spawns the binary the way an operator would (a config file, an optional seed directory, environment variables, signals) and asserts on what an operator can actually observe (stdout, stderr, the exit status, and HTTP), never on an internal type. Its own lib.rs states why that library target exists at all while staying empty: “This library target is empty by design and stays empty. The crate exists so tests/ has a workspace member to hang from, and every dependency it declares is a dev-dependency: with no production dependencies at all, the crate structurally cannot ship anything, which is why the dep-graph gate’s allowlist for it is empty rather than populated” (crates/blockwatcher-e2e/src/lib.rs:3-7).

Every scenario follows the same shape: spawn the real binary, point it at whichever chain fixture it needs, and assert only on what an operator could observe from outside the process:

flowchart LR
    test["scenario<br/>tests/e2e/*.rs"] --> spawn["spawn_blockwatcher<br/>harness.rs"]
    spawn --> bin["blockwatcher binary<br/>nested cargo build"]
    bin --> chain{{"anvil node,<br/>or MockNode / SimChain"}}
    bin --> observable["stdout, stderr,<br/>/health, /status"]
    bin -->|"webhook actions"| recv["Receiver<br/>recording webhook server"]
    test -->|"asserts on"| observable
    test -->|"asserts on"| recv

crates/blockwatcher-e2e/Cargo.toml has an empty [dependencies] table and populates [dev-dependencies] only: alloy-primitives, axum, blockwatcher-evm-testkit, reqwest, serde_json, tempfile, and tokio (macros/rt-multi-thread/net/sync/time features). That empty [dependencies] table is what scripts/check-dep-graph.sh’s blockwatcher-e2e allowlist entry names too: an empty string, so any production dependency at all fails the check by construction, the same rule Workspace map § Verifying the rings and Architecture decisions § How the dependency gate turns rules into a mechanical check both describe as one of the two crate-shaped rules the gate checks by name rather than through the general allowlist mechanism.

Key takeaways

  • blockwatcher-e2e is black-box, end-to-end coverage of the real blockwatcher binary: every scenario spawns the built binary and asserts only on what an operator can observe from outside it.
  • Its library target is deliberately empty, and every dependency is a dev-dependency, so the crate structurally cannot ship anything.
  • It declares an empty [dependencies] table, the same rule the dependency gate checks by name.
  • Nothing in the workspace depends on it; it drives the binary from outside rather than being driven by anything.

Staying out of the default build

The root Cargo.toml’s [workspace] table lists blockwatcher-e2e as an ordinary entry in members, and declares no default-members key at all (the root Cargo.toml’s [workspace] members list): every cargo check --workspace, cargo clippy --workspace, and cargo test --workspace invocation in CI, and every bare cargo check/cargo build a contributor runs from the workspace root, includes this crate exactly like any other member. Nothing about default-members is what keeps it out of an ordinary build.

What actually keeps it out is structural, and follows directly from the empty [dependencies] table above: lib.rs compiles to nothing but an empty module, no workspace crate names blockwatcher-e2e in its own [dependencies] (there would be nothing there to depend on even if one tried), and the binary an operator actually runs, built via cargo build --bin blockwatcher or cargo run, never touches this crate at all. This crate’s scenario files only compile and run under cargo test, as the tests/e2e/*.rs integration binary tests/e2e/main.rs roots (tests/e2e/main.rs), and that integration binary in turn spawns a second, independently-built copy of the blockwatcher binary as a subprocess (see Building the binary it drives below) rather than linking against it. An ordinary build produces nothing from this crate worth shipping; only cargo test does anything here at all.

The scenarios in tests/e2e/anvil.rs additionally need a real EVM node on PATH. .github/workflows/ci.yml’s check job provisions anvil via foundry-rs/foundry-toolchain@v1.9.1 immediately before cargo test --workspace --all-features, and sets BLOCKWATCHER_E2E_REQUIRE_ANVIL: "1" on that same step (the check job in .github/workflows/ci.yml), so an absent anvil in that job would be a hard failure rather than a silent skip. The windows-check job runs no tests at all, only cargo check --workspace --locked, and its own comment states why: “e2e needs anvil and stays on the Linux job” (the windows-check and feature-powerset jobs in .github/workflows/ci.yml). See Testing strategy § CI for where this crate’s job appearances fit among the workspace’s other suites.

Responsibilities

  • tests/e2e/harness.rs: the rig every scenario shares. spawn_blockwatcher spawns the built binary with its config, an optional seed directory, and environment, capturing stdout and stderr to files; Scenario is the handle back (wait_for_health, wait_for_exit, sigterm, stdout, stderr), and its Drop impl kills and reaps an orphaned process so one scenario’s failure can never blame the next scenario’s port contention or storage file (harness.rs). Receiver is a recording webhook server every delivery-driven scenario points a sink at (harness.rs). write_chain_seed writes the network/spec/sink/monitor JSON every chain-driven scenario shares (harness.rs). until and anvil_available_or_skip are described in their own sections below.
  • tests/e2e/anvil.rs: the scenarios whose subject is the real binary against a real node: one emitted event reaching a webhook end to end, and a process killed mid-stream (SIGKILL) coming back without losing anything.
  • tests/e2e/auth.rs: the control plane’s bearer gate and the opacity of the token behind it, asserted from outside the process: an unauthenticated or wrongly-authenticated request is refused, an authenticated one succeeds, and the token’s value reaches no log line, no error, and no response body.
  • tests/e2e/shutdown.rs: graceful shutdown with a sink that will never finish; the subject is which clock decides the exit, the configured drain deadline or the wedge.
  • tests/e2e/stdout_purity.rs: the log sink’s line-protocol contract, asserted on the real process’s stdout with tracing enabled and talking: every stdout line is one parseable canonical match JSON object, because every diagnostic goes to stderr instead.

Not this crate’s job: implementing anything a deployed instance loads (this crate is structurally incapable of that; see Staying out of the default build above); exercising a module’s own unit-level or module-level behavior (blockwatcher-evm’s and blockwatcher-storage’s own tests/ do that against a mock node or a real sqlite file respectively; see Testing strategy); or substituting for anvil with a mock when a scenario’s whole point is what a real node does with a transaction, a block, and a confirmation barrier: tests/e2e/anvil.rs’s own module doc comment states this directly: “Nothing here can be answered by a mock” (tests/e2e/anvil.rs:1-9).

Key types and functions

NameKindRole
spawn_blockwatcherasync fnSpawns the built blockwatcher binary with a config, optional seed, and environment, capturing stdout/stderr to files in the caller’s directory (harness.rs)
ScenariostructThe spawned process’s handle: wait_for_health, wait_for_exit, sigterm, stdout, stderr; Drop kills and reaps an orphan (harness.rs)
blockwatcher_binaryasync fnBuilds --bin blockwatcher into a nested target directory on first use, via a blocking cargo build --message-format=json call, and caches the resulting path for the rest of the process (harness.rs)
ReceiverstructA recording webhook server on an ephemeral port; bodies() returns every parsed body received so far, panicking if the listener has stopped (harness.rs)
ChainSeed, write_chain_seedstruct / fnWhat a chain-driven scenario varies about the shared seed (node URL, confirmations, poll interval, sink), and the function that writes that seed’s network/spec/sink/monitor JSON (harness.rs)
untilasync fnThe bounded poll every wait in this crate shares, quoting a captured process’s stderr tail into its panic on timeout (harness.rs)
anvil_available_or_skipfnWhether anvil is on PATH; prints a skip marker and returns false normally, panics instead when BLOCKWATCHER_E2E_REQUIRE_ANVIL=1 (harness.rs)
serialasync fnA static tokio::sync::Mutex guard serializing every scenario that holds it, so a booting child never competes with another scenario’s child for the same starved CPU (harness.rs)
reserve_addrfnA loopback address nothing is listening on, for a scenario that must know the API’s address before the process binding it exists (harness.rs)

Building the binary it drives

blockwatcher_binary builds the real binary once per test process and reuses the cached path afterward (harness.rs). It builds into a separate, nested target directory rather than the outer cargo test invocation’s own one, and nested_target_dir‘s doc comment explains why in cost terms: the two builds “resolve features differently and always will,” since the gate’s --all-features run turns on blockwatcher-portsfakes and testing features for everything downstream of it, while “the binary under test… is built with default features.” Sharing one directory would mean “every gate run pays a near-full rebuild of the workspace — tens of minutes, attributed to a test that appears to hang because the cost is borne by cargo inside it” (harness.rs:164-186). The build itself, inside blockwatcher_binary, runs on a blocking thread via spawn_blocking, specifically so a mock node serving a scenario from the same async runtime keeps answering the process under test while the nested cargo build runs to completion in the background (harness.rs).

The anvil-only scenarios

tests/e2e/anvil.rs hand-assembles the one contract every scenario in this crate that needs a real chain installs via anvil_setCode: runtime bytecode that copies its calldata into memory and logs it under one topic, because, as the module comment states, “this workspace carries no Solidity toolchain” (anvil.rs:46-60):

#![allow(unused)]
fn main() {
fn emitter_runtime(topic: &str) -> String {
    format!("0x3660006000377f{topic}366000a100")
}
}

Both headline scenarios mine blocks themselves, deliberately, because “anvil produces no blocks on its own” (anvil.rs:31-36): emit sends a transaction and polls until it lands in a block, mining along the way, never assuming inclusion on the send response alone; confirm mines until the latest block clears the confirmation barrier a Ping log’s own block needs to become eligible (anvil.rs). The second scenario, sigkill_and_restart_loses_nothing, is the one place in this crate that distinguishes a resumed process from a rescanned one: it kills the first process with SIGKILL rather than SIGTERM (no drain, no final checkpoint), then asserts that the first event delivered before the kill is never delivered a second time by the successor, since a rescan from the seed’s start block would repeat it and a resume from the stored checkpoint cannot (anvil.rs).

Serialization and the shared deadline

Every wait in this crate goes through the same bounded poll, harness::until, which quotes the tail of a captured process’s stderr into its panic message on timeout, since “every way one of these waits fails… is legible only there” (harness.rs:75-99, doc comment). Its budget is a package-wide BLOCKWATCHER_E2E_DEADLINE_SECS environment override on top of a 120-second default, because, in the function’s own words, “a laptop also running a build or a backup can starve a booting child past any default that is still short enough to fail a real regression promptly” (harness.rs:42-66).

shutdown.rs’s scenario and auth.rs’s scenario both take the harness::serial() guard before spawning anything (harness.rs). shutdown.rs’s own call site is the one that explains why, in a comment immediately above the call: “Serialized before anything else: the exit window below is measured in seconds on purpose, so this scenario least tolerates sharing the machine with another booting child” (shutdown.rs:78-80). auth.rs’s own call to serial() (auth.rs) carries no such comment of its own; it takes the same guard, backed by the same serial() function, without stating its own reason inline. anvil.rs’s scenarios take neither: nothing about their own assertions depends on a tight wall-clock margin the way shutdown.rs’s drain-deadline scenario does.

Neighbours

blockwatcher-e2e declares no [dependencies] at all. In [dev-dependencies] only:

  • blockwatcher-evm-testkit: MockNode, SimChain, mock_node, for the scenarios (shutdown.rs, stdout_purity.rs) that need a scripted chain but not a real one; see blockwatcher-evm-testkit § Neighbours for that crate’s side of the same edge
  • alloy-primitives: keccak256, for harness.rs’s ping_topic0 and the emitter contract’s runtime bytecode
  • axum: the recording webhook server Receiver runs
  • reqwest: every JSON-RPC and HTTP call this crate’s scenarios make, including the anvil scenarios’ own hand-rolled JSON-RPC client (this crate does not depend on blockwatcher-rpc)
  • serde_json: every config, seed, and JSON-RPC payload this crate builds or parses
  • tempfile: a fresh scenario directory per test
  • tokio (macros/rt-multi-thread/net/sync/time features): the async runtime every scenario and mock node runs on

No crate in the workspace depends on blockwatcher-e2e (per the dependency table, where its row has no incoming edge and an empty outgoing one): it is a leaf in both directions, driving the binary from outside rather than being driven by anything.

Reading the source

  1. lib.rs: the one-paragraph reason this crate’s library target is empty and stays that way.
  2. tests/e2e/main.rs: the mod declarations that root every scenario file, in file order.
  3. tests/e2e/harness.rs: ping_topic0, EMITTER, word (the shared fixture identity every scenario’s Ping event uses); deadline and until (the shared wait); serial; blockwatcher_binary and build_blockwatcher (the nested build); reserve_addr, write_file, ChainSeed, write_chain_seed (the shared seed); Scenario, spawn_blockwatcher, and its methods; Receiver; and anvil_available_or_skip last, since it is the one piece of this file a contributor without foundry installed hits first.
  4. tests/e2e/anvil.rs: the module doc comment, then emitter_runtime, start_anvil, and the shared emit/confirm/emit_and_confirm helpers, before either #[tokio::test] function.
  5. tests/e2e/auth.rs, tests/e2e/shutdown.rs, tests/e2e/stdout_purity.rs: each is short enough to read in one pass; each states its own headline assertion in its function’s doc comment before the test body.

Testing strategy

blockwatcher’s tests are layered by what they run against, and each layer answers a question the layers around it cannot: a unit layer proves a crate’s own logic against an in-memory stand-in for whatever port it depends on; a module layer proves a real module’s wire-level or storage-level behavior against a real (if local and disposable) backend; and a black-box end-to-end layer proves the assembled binary against a real chain. None of the three substitutes for either of the others, and none of them is optional: Architecture decisions § A trait is only trustworthy if something fake proves it states the rule the first layer exists to satisfy, and the crates this page is about (blockwatcher-testkit, blockwatcher-evm-testkit, blockwatcher-stellar-testkit, blockwatcher-e2e) are the shared scaffolding the second and third layers are built on.

This page does not restate what a fake, a mock, or a harness actually does: blockwatcher-ports documents the fakes and testing features, and the crate pages above document their own harnesses in full. This page’s job is to say which layer each of them belongs to, and how CI reaches every layer.

Key takeaways

  • Tests are layered by what they run against: unit tests prove a crate’s own logic against an in-memory fake, module tests prove a real module against a real local backend, and end-to-end tests prove the assembled binary against a real chain.
  • None of the three layers substitutes for the others, and none is optional.
  • blockwatcher-testkit, blockwatcher-evm-testkit, blockwatcher-stellar-testkit, and blockwatcher-e2e are the shared scaffolding the second and third layers are built on.

The three layers

Each layer proves something the layers around it cannot:

flowchart TD
    unit["unit tests<br/>fake per port:<br/>FakeSource, MemoryStorage..."] --> module["module tests<br/>real backend, run locally:<br/>mock rpc node, sqlite file"]
    module --> e2e["end to end tests<br/>real binary, real chain:<br/>anvil, blockwatcher-e2e"]

Unit tests: one fake per port

Every port trait blockwatcher-ports declares ships with an in-memory implementation behind its fakes feature: FakeSource, FakeDecoder, FakeMatcher, FakeSink, MemoryStorage, and FlakyStorage (a fault-injecting wrapper around MemoryStorage), each registered through the same ModuleRegistry contract a real module uses. A crate that needs to drive a port without linking any real module enables this feature on its blockwatcher-ports dev-dependency: blockwatcher-api, blockwatcher-core, blockwatcher-evm, blockwatcher-expr, blockwatcher-sinks, blockwatcher-storage, and the blockwatcher binary itself all do, each in [dev-dependencies] only. This is the fakes-per-port rule in its mechanical form: a change to a port’s shape that the matching fake cannot satisfy is a signal the port’s own design needs another look before anything downstream of it does.

blockwatcher-ports also declares a testing feature, gated behind which mockall::automock generates mocks for every port trait (MockSource, MockDecoder, MockMatcher, MockSink) and every storage facet (MockResourceStore, MockCheckpointStore, MockDeadLetterStore, MockPauseStore, MockDeliveryJournal, MockGateStore) (crates/blockwatcher-ports/src/{decoder,matcher,sink,source,storage}.rs, each carrying #[cfg_attr(feature = "testing", mockall::automock)] on its trait declaration). No workspace crate’s Cargo.toml enables that feature on its own blockwatcher-ports dependency: every port substitution anywhere in this workspace’s tests goes through a hand-written fakes implementation instead. The testing feature and its generated mocks still compile, and still get exercised, under the check job’s cargo test --workspace --all-features and the feature-powerset job’s combinatorial build (see How CI runs each layer below); they exist as available unit-level substitution infrastructure that this workspace’s own test suites have not, so far, needed to reach for over a hand-written fake.

Module tests: real backends, run locally

One level up from a port fake, a module’s own tests prove its wire-level or storage-level behavior against something that behaves like the real backend it talks to, not against another abstraction:

  • blockwatcher-storage’s tests/contract.rs runs blockwatcher-testkit’s exercise_storage_contract against the ports fake, its own in-memory module, and its sqlite module over a real file on disk, in turn, so all three are proven equivalent by one shared behavioral contract rather than by shared code. See blockwatcher-testkit § The storage contract.
  • blockwatcher-evm‘s own tests/*.rs and several of its src/ modules’ #[cfg(test)] blocks run the evm-rpc and evm-mempool sources against blockwatcher-evm-testkit’s scripted JSON-RPC and WebSocket mock node and its SimChain fixture, which answers real wire shapes (including reorgs) rather than a chain-agnostic port’s in-memory stand-in. evm-subgraph tests drive a scripted GraphQL HTTP host in source/subgraph/mock.rs (axum, [dev-dependencies] only) rather than extending that testkit. The point of this layer is proving the decode and retry logic against realistic responses, which a Source-level fake has no wire format to get wrong in the first place.
  • blockwatcher-stellar’s #[cfg(test)] in source/run.rs runs the stellar-rpc source against blockwatcher-stellar-testkit’s scripted JSON-RPC mock node, which answers real stellar-rpc wire shapes (getHealth, getEvents, getTransactions) rather than a chain-agnostic port’s in-memory stand-in.

End-to-end: the real binary, against a real chain

blockwatcher-e2e is the one layer that drives the assembled blockwatcher binary itself, as a spawned process, and asserts only on what an operator can observe from outside it. The scenarios in tests/e2e/anvil.rs need a real anvil node rather than any mock, because their assertions are about what a real node does with a transaction, a block, and a confirmation barrier, not about what a scripted handler was told to answer.

How CI runs each layer

.github/workflows/ci.yml declares these jobs, and each one exercises a different slice of the layers above:

JobWhat it runsWhich layer(s)
checkcargo fmt --all --check; cargo check --workspace --locked (default features); cargo clippy --workspace --all-features --all-targets; provisions anvil via foundry-rs/foundry-toolchain@v1.9.1, then cargo test --workspace --all-features with BLOCKWATCHER_E2E_REQUIRE_ANVIL: "1"; ./scripts/check-dep-graph.sh; ./scripts/check-release-version.test.sh; ./scripts/changelog-release-notes.test.shAll three: cargo test --workspace --all-features is the one command that runs every unit test, every module test, and every blockwatcher-e2e scenario (anvil-backed ones required, not skippable) in one pass
windows-checkcargo check --workspace --locked onlyNone: compiles every crate on Windows but runs no tests at all; its own comment states why blockwatcher-e2e in particular is excluded from anything beyond a compile check here: “e2e needs anvil and stays on the Linux job”
feature-powersetcargo hack check --workspace --feature-powerset --depth 2 --lockedNone directly: this job only compiles pairwise feature combinations (including blockwatcher-portsfakes and testing toggled independently), catching a combination that fails to build; it runs no test binary
wikimdbook build docs/wikiNone: this job builds the wiki you are reading, not the workspace’s Rust tests

A vX.Y.Z tag runs .github/workflows/release.yml instead of this table. Its preflight job re-runs the check job’s overlapping steps (including the foundry pin and BLOCKWATCHER_E2E_REQUIRE_ANVIL) plus scripts/check-release-version.sh; it does not repeat windows-check or feature-powerset. The publish job then reflows the matching changelog section into GitHub Release notes, attaches a Linux archive, and pushes both Docker images to GHCR. See CONTRIBUTING.md.

Only the check job actually runs tests, and it runs all of them in one cargo test --workspace --all-features invocation: there is no separate CI job per layer, because the layering above is a property of what each test is written to run against, not of how CI schedules them. The BLOCKWATCHER_E2E_REQUIRE_ANVIL: "1" environment variable on that one step is what turns blockwatcher-e2e’s anvil-backed scenarios from a locally-friendly skip (an absent anvil prints a marker and returns early, so a contributor without foundry installed stays unblocked on everything else) into a hard failure in CI, where anvil was just provisioned and its absence would otherwise be silent. See blockwatcher-e2e § Staying out of the default build for why blockwatcher-e2e is still a full participant in cargo test --workspace despite shipping no production code at all.

Consistency with the rest of the wiki

This page intentionally does not restate what already has a canonical home elsewhere in the wiki:

Claims and their tests

blockwatcher-e2e’s black-box scenarios double as a claims-verification suite: every scenario that asserts a documented guarantee carries a /// Claims: doc comment naming the IDs it holds up, and Claims and their tests is the traceability matrix those IDs resolve against, one row per behavioral claim the wiki makes, unit or e2e or both. Run the whole thing locally with scripts/verify-claims.sh, which checks anvil is on PATH and then runs the same anvil-required, all-scenario pass CI runs on manual dispatch and on pull requests to main that touch CHANGELOG.md (release-bump PRs).

Claims and their tests

Every behavioral guarantee the concepts and reference pages make, mapped to the test that holds it up. IDs are stable, new claims get new IDs. Tests written against this page name the claims they verify in a /// Claims: doc comment, so grep -rn "/// Claims:" crates cross-checks this page against reality.

Assurance vocabulary:

  • unit: an in-process test path (crate, file, and test name, or a shared suite when several tests together prove the claim).
  • e2e: a black-box scenario in crates/blockwatcher-e2e (the real binary, operator surfaces only).
  • by design, not live-tested: in-process coverage is judged the right instrument for this claim; the sentence after the colon is the recorded reason.
  • gap: no test proves this claim anywhere yet, unit or e2e. Each gap row names the crate a follow-up unit test belongs in.

A handful of appendix rows cover a small numbered range under one shared description rather than one row per number. This page lists every ID the range spans in that row’s ID column, so each individual ID is still greppable on its own, but keeps the row and its Claim sentence merged, since the underlying scenario and test set is genuinely one thing being proven, not several unrelated ones.

Progress and checkpoints

IDClaimDocumented inAssurance
PROG-1The checkpoint advances only past the longest contiguous fully-done prefix, never further.Delivery guaranteesunit: blockwatcher-core/src/progress.rs::checkpoint_advances_only_past_fully_completed_prefix
e2e: sigkill_and_restart_loses_nothing
PROG-2Fully done means delivered or dead-lettered, nothing else counts toward the prefix.Delivery guaranteesunit: blockwatcher-core/src/pipeline/sink_worker.rs::successful_match_delivery_is_journaled, ::dead_letter_recording_is_what_advances_the_checkpoint
PROG-3A dropped completion guard stalls its prefix forever, with no timeout and no fabricated completion.Delivery guaranteesunit: blockwatcher-core/src/progress.rs::a_dropped_guard_stalls_the_checkpoint
PROG-4A stalled prefix is visible from outside as in_flight_events in status.Delivery guaranteesunit: blockwatcher-core/src/progress.rs::in_flight_tracks_registration_and_completion
PROG-5An event that produces zero matches still completes at dispatch, and the cursor advances past it.Delivery guaranteesunit: blockwatcher-core/src/pipeline/processor.rs suite (event-dispatch and checkpoint-advance tests)
PROG-6A raw event registers with Progress exactly once, after dispatch, never before.Delivery guaranteesunit: blockwatcher-core/src/pipeline/processor.rs::dispatches_one_work_item_per_action_and_advances_checkpoint_on_completion, ::persist_failure_does_not_skip_begin
PROG-7A cursor persists only once every guard it depends on has completed.Delivery guaranteesunit: blockwatcher-core/src/pipeline/processor.rs::checkpoint_advances_to_the_second_event_only_after_both_complete_in_order
e2e: sigkill_and_restart_loses_nothing
PROG-8The checkpoint writer refuses to persist a regression against what is already stored.Delivery guaranteesunit: blockwatcher-core/src/pipeline/checkpoint_writer.rs::a_mid_run_regression_is_refused_counted_and_never_persisted
PROG-9Misrouted events (a raw event carrying another network’s id) are dropped and counted, never re-attributed.Delivery guaranteesunit: blockwatcher-core/src/pipeline/processor.rs::a_raw_event_for_a_different_network_is_counted_and_dropped_not_re_attributed, ::a_misrouted_event_is_exported_not_only_dropped
PROG-10The monitor set is snapshotted once per event, so a hot swap landing mid-flight can never split one event’s processing across two monitor sets.Delivery guaranteesunit: blockwatcher-core/src/pipeline/processor.rs::a_swap_landing_between_two_monitors_of_the_same_event_cannot_split_its_processing

At-least-once delivery

IDClaimDocumented inAssurance
ALO-1A crash can repeat work already in flight, but it can never skip it.Delivery guaranteese2e: sigkill_and_restart_loses_nothing
ALO-2On resume, the persisted checkpoint is the only truth the successor trusts.Delivery guaranteesunit: blockwatcher-core/tests/engine/acceptance.rs::crash_resume_does_not_redeliver_or_skip
e2e: sigkill_and_restart_loses_nothing
ALO-3MatchId::derive is deterministic across process lifetimes, so a successor mints the same ids a crashed predecessor would have.Delivery guaranteesunit: blockwatcher-types/src/id.rs::match_id_re_derivation_from_the_same_content_is_stable
e2e: a_buffered_aggregate_window_is_re_emitted_after_a_crash, a_crash_between_gate_emit_and_delivery_is_swept_by_the_startup_drain
ALO-4A redelivered Retracted or Digest must dedupe on match id at the consumer, same as a Match.Delivery guaranteesunit: blockwatcher-sinks/src/lib.rs::canonical_body_retracted_is_tagged, ::canonical_body_digest_is_tagged
e2e: an_anvil_reorg_retracts_replaced_matches_before_replacements
ALO-5All three SinkEvent variants share the same retry and dead-letter path, no variant gets special treatment.Delivery guaranteesunit: blockwatcher-core/src/pipeline/sink_worker.rs::transient_failures_retry_with_doubling_backoff_then_succeed (Match), ::retracts_bypass_throttle (Retracted), ::digest_failure_dead_letters_each_match (Digest)
ALO-6The SinkEvent set is closed: nothing outside Match, Retracted, and Digest can reach a sink.Delivery guaranteesunit: compile-enforced by an exhaustive match over SinkEvent throughout blockwatcher-core/src/pipeline/sink_worker.rs, no dedicated runtime test needed
ALO-7Built-in sinks render each variant as tagged, canonical JSON.Delivery guaranteesunit: blockwatcher-sinks/src/lib.rs::canonical_body_match_is_tagged, ::canonical_body_retracted_is_tagged, ::canonical_body_digest_is_tagged
e2e: an_anvil_event_reaches_the_webhook_exactly_once (Match shape), an_anvil_reorg_retracts_replaced_matches_before_replacements (Retracted shape)

Backpressure

IDClaimDocumented inAssurance
BP-1Bounded channels never drop an item on the full side; a full channel blocks the producer instead.The pipelineunit: blockwatcher-core/tests/engine/acceptance.rs::backpressure_never_drops
by design, not live-tested: acceptance::backpressure_never_drops is the right instrument, live channel-capacity plumbing is unobservable without white-box hooks.
BP-2A slow sink eventually stalls the whole pipeline behind it, rather than the pipeline shedding work around it.The pipelineunit: blockwatcher-core/tests/engine/acceptance.rs::backpressure_never_drops
by design, not live-tested: same instrument, live channel-capacity plumbing is unobservable without white-box hooks.
BP-3Channel capacities come from configuration and are applied to every pipeline the same way.The pipelineby design, not live-tested: capacities are wired from config at pipeline construction; live channel-capacity plumbing is unobservable without white-box hooks, so acceptance::backpressure_never_drops is the instrument of record instead of a dedicated wiring test.
BP-4Graceful shutdown drains everything already queued before the process exits.The pipelineunit: blockwatcher-core/src/pipeline/mod.rs::a_graceful_root_cancel_still_delivers_everything_already_buffered_behind_a_slow_sink
e2e: sigterm_exits_two_within_the_drain_deadline_despite_a_wedged_sink

Dead letters

IDClaimDocumented inAssurance
DLQ-1Retry exhaustion always writes a dead letter, a delivery is never simply dropped.Delivery guaranteesunit: blockwatcher-core/src/pipeline/sink_worker.rs::retry_budget_exhaustion_dead_letters_with_attempt_count
e2e: throttled_deliveries_land_in_the_dead_letter_queue_not_nowhere (throttle path), dead_letters_survive_a_restart_on_sqlite (retry-exhaustion path)
DLQ-2A dead letter carries id, monitor, sink, cursor, attempt count, reason, and payload.Delivery guaranteesunit: blockwatcher-types/src/event.rs::dead_letter_serde_round_trips
e2e (partial): dead_letters_survive_a_restart_on_sqlite
DLQ-3The delivery guard only completes once the dead letter has durably landed in storage.Delivery guaranteesunit: blockwatcher-core/src/pipeline/sink_worker.rs::dead_letter_recording_is_what_advances_the_checkpoint
DLQ-4A dead-letter write that fails retries forever on backoff rather than giving up.Delivery guaranteesunit: blockwatcher-core/src/pipeline/sink_worker.rs::dead_letter_recording_is_what_advances_the_checkpoint (FlakyStorage-backed retry)
DLQ-5A dead-lettered Match stays in the delivery journal; a dead-lettered Retracted does not.Delivery guaranteesunit: blockwatcher-core/src/pipeline/sink_worker.rs::dead_lettered_match_is_journaled (Match half; no test isolates the dead-lettered Retracted half, flagged as a follow-up)
DLQ-6A permanent error dead-letters immediately, with no retry budget spent first.Delivery guaranteesunit: blockwatcher-core/src/pipeline/sink_worker.rs::permanent_failure_dead_letters_on_first_attempt
DLQ-7Transient failures back off with doubling delay, bounded above.Delivery guaranteesunit: blockwatcher-core/src/pipeline/sink_worker.rs::transient_failures_retry_with_doubling_backoff_then_succeed
DLQ-8A dead letter’s reason always leads with its ErrorClass prefix (transient:, permanent:, and so on).Delivery guaranteesunit: blockwatcher-core/src/pipeline/sink_worker.rs::retry_budget_exhaustion_dead_letters_with_attempt_count
e2e: dead_letters_survive_a_restart_on_sqlite
DLQ-9Listing dead letters pages in insertion order and is a pure read with no side effect.Delivery guaranteesunit: blockwatcher-testkit/src/storage_contract/dead_letters.rs::dead_letters_keep_order_and_page (run per backend via blockwatcher-storage/tests/contract.rs)
DLQ-10A replay that succeeds deletes the letter; a replay that exhausts its budget bumps the attempt count in place and answers 502.HTTP API referenceunit: blockwatcher-core/tests/engine/control.rs::replay_dead_letter_failure_updates_attempts (exhaustion half)
e2e: a_dead_letter_replays_through_the_api_and_is_deleted_on_success (success half)
DLQ-11A dead letter with no stored payload refuses replay with 422.HTTP API referenceunit: blockwatcher-core/tests/engine/control.rs::replay_dead_letter_without_payload_is_invalid_resource
by design, not live-tested: pinned in blockwatcher-api wire tests, NO_REPLAY_RETRACTED in particular requires manufacturing a dead-lettered retract live, which costs more than it proves.
DLQ-12A dead-lettered Retracted refuses replay with 422.HTTP API referenceunit: blockwatcher-core/tests/engine/control.rs::replay_dead_letter_retracted_payload_is_invalid_resource
by design, not live-tested: pinned in blockwatcher-api wire tests, NO_REPLAY_RETRACTED in particular requires manufacturing a dead-lettered retract live, which costs more than it proves.
DLQ-13Discard, single or bulk with filters, counts what it removed and never delivers anything.HTTP API referenceunit: blockwatcher-testkit/src/storage_contract/dead_letters.rs::dead_letters_bulk_discard_combines_filters_and_scopes_by_pipeline (run per backend via blockwatcher-storage/tests/contract.rs)
e2e: bulk_discard_filters_and_counts
DLQ-14Identity for replay is the pair (match_id, sink); an ambiguous match across sinks answers 409.HTTP API referenceunit: blockwatcher-core/tests/engine/control.rs::replay_dead_letter_ambiguous_match_id_is_conflict
by design, not live-tested: pinned in blockwatcher-api wire tests, which already exercise the HTTP framing a live duplicate would add.
DLQ-15Legacy bare-Match dead-letter rows from before the identity schema still load.Delivery guaranteesunit: blockwatcher-storage/src/sqlite.rs::a_version_five_database_migrates_to_the_dead_letter_identity_schema
by design, not live-tested: pinned in blockwatcher-api wire tests, which already exercise the HTTP framing a live duplicate would add.
DLQ-16A sqlite-backed dead-letter queue survives a restart; a memory-backed one does not.Delivery guaranteese2e: dead_letters_survive_a_restart_on_sqlite (sqlite half; the memory-backend half is not separately asserted anywhere, flagged as a follow-up)
DLQ-17The retention cap prunes the oldest letters in-write, and a zero-valued cap is refused at boot.Delivery guaranteesunit: blockwatcher-testkit/src/storage_contract/dead_letters.rs::dead_letters_retention_prunes_the_oldest_and_reports_what_it_dropped (run per backend via blockwatcher-storage/tests/contract.rs), blockwatcher-core/src/config.rs::validate_refuses_a_zero_or_unstoreable_dead_letter_retention
by design, not live-tested: exhaustively pinned in-process at both write and boot, a live duplicate adds fidelity to nothing but HTTP framing, which api_semantics.rs already exercises.
DLQ-18dead_letter_count in status is read fresh from storage each time, and answers null rather than a fabricated number when storage will not answer.Delivery guaranteesunit: blockwatcher-core/tests/engine/control.rs::status_reports_a_durable_dead_letter_count_independent_of_the_volatile_counter, ::status_reports_unknown_rather_than_fabricated_when_storage_will_not_answer

Throttle

IDClaimDocumented inAssurance
THR-1A match over the throttle budget dead-letters as permanent: throttled:, never silently dropped.Delivery scenariosunit: blockwatcher-core/src/pipeline/sink_worker.rs::throttle_suppresses_excess_and_dead_letters_it
e2e: throttled_deliveries_land_in_the_dead_letter_queue_not_nowhere
THR-2Throttled letters are listable and replayable exactly like any other dead letter.Delivery scenariose2e: replaying_a_throttled_letter_bypasses_the_still_open_window; structurally covered by the generic replay path in blockwatcher-core/tests/engine/control.rs, which carries no throttle-specific branch
THR-3Replaying a throttled letter bypasses the window it was originally suppressed under.Delivery scenariosunit: blockwatcher-core/tests/engine/control.rs::replaying_a_throttled_letter_bypasses_the_still_exhausted_window
e2e: replaying_a_throttled_letter_bypasses_the_still_open_window
THR-4Only a delivered match spends throttle budget; a suppressed or failed one does not.Delivery scenariosunit: blockwatcher-core/src/pipeline/sink_worker.rs::throttle_suppresses_excess_and_dead_letters_it, ::throttle_counts_every_suppressed_match_not_every_suppressed_delivery
THR-5A Retracted skips throttle accounting entirely, in both directions.Delivery scenariosunit: blockwatcher-core/src/pipeline/sink_worker.rs::a_successful_retract_does_not_spend_an_open_throttle_window, ::retracts_bypass_throttle
THR-6The throttle window is scoped per (network, sink), never global across the process.Delivery scenariosunit: blockwatcher-core/tests/engine/acceptance.rs::a_throttle_window_is_scoped_per_network_not_per_sink_id
THR-7The throttle window anchors at the first admitted delivery, not at configuration time.Delivery scenariosunit: blockwatcher-core/src/pipeline/throttle.rs::the_window_belongs_to_the_first_delivery_it_admits
e2e (in passing): a_throttle_window_resets_on_restart
THR-8The throttle window is runtime-only state; a restart opens a fresh one.Delivery scenariose2e: a_throttle_window_resets_on_restart (no dedicated unit test isolates this; candidate: blockwatcher-core/src/pipeline/throttle.rs)
THR-9A zero-valued throttle setting is refused both at write and at boot.Delivery scenariosunit: blockwatcher-types/src/resource.rs::throttle_validate_rejects_zero_max_deliveries, ::throttle_validate_rejects_zero_window_ms
write: blockwatcher-core/src/control/writes.rs::put_sink_rejects_a_zero_valued_throttle
boot: blockwatcher-core/tests/engine/boot.rs::a_zero_valued_sink_throttle_fails_boot_before_anything_spawns
HTTP: blockwatcher-api/tests/api/resources.rs::write_time_policy_bounds_are_422_invalid_resource
THR-10Omitted throttle fields default to 60 deliveries per 60000ms.Delivery scenariosunit: blockwatcher-types/src/resource.rs::throttle_defaults_and_partial_config
THR-11A digest spends exactly one delivery of the throttle budget, however many matches it folded.Delivery scenariosunit: blockwatcher-core/src/pipeline/sink_worker.rs::a_digest_spends_one_delivery_of_the_throttle_budget
THR-12A throttle-refused digest dead-letters each of its member matches individually.Delivery scenariosunit: blockwatcher-core/src/pipeline/sink_worker.rs::throttle_counts_every_suppressed_match_not_every_suppressed_delivery

Aggregation

IDClaimDocumented inAssurance
AGG-1Only Match buffers for aggregation; a Retracted is never held.Delivery scenariosunit: blockwatcher-core/src/pipeline/sink_worker.rs::retract_flushes_before_delivering
AGG-2There are exactly four flush triggers: max_batch, the window deadline, a retract, and drain on shutdown.Delivery scenariosunit: blockwatcher-core/src/pipeline/sink_worker.rs::digest_flushes_at_max_batch, ::digest_flushes_at_window_deadline, ::retract_flushes_before_delivering, ::drain_flushes_the_buffer
e2e (max_batch): three_matches_in_one_window_arrive_as_one_digest_through_the_template
AGG-3A retract flushes the buffer first; it never overtakes buffered matches.Delivery scenariosunit: blockwatcher-core/src/pipeline/sink_worker.rs::retract_flushes_before_delivering
AGG-4A single-item flush renders as a plain Match, not a one-member Digest.Delivery scenariosunit: blockwatcher-core/src/pipeline/sink_worker.rs::single_item_flush_is_a_plain_match
AGG-5A multi-item flush renders as exactly one Digest.Delivery scenariosunit: blockwatcher-core/src/pipeline/sink_worker.rs::digest_flushes_at_max_batch
e2e: three_matches_in_one_window_arrive_as_one_digest_through_the_template
AGG-6Buffered matches stall the checkpoint by design, visible as in_flight_events until the window flushes.Delivery scenariosunit: blockwatcher-core/src/pipeline/sink_worker.rs::checkpoint_stalls_while_buffered
e2e (observable): a_buffered_aggregate_window_is_re_emitted_after_a_crash
AGG-7A crash drops buffered completion guards; the replay after restart re-delivers, and consumer-side dedupe by id yields the full set.Delivery scenariosunit: blockwatcher-core/src/pipeline/sink_worker.rs::hard_cancel_drops_buffered_guards
e2e: a_buffered_aggregate_window_is_re_emitted_after_a_crash
AGG-8No aggregation window state is persisted; everything a restart needs is re-derived.Delivery scenariose2e: a_buffered_aggregate_window_is_re_emitted_after_a_crash
AGG-9Digest members journal at their own cursor, in order, before the digest’s completion guards resolve.Delivery scenariosunit: blockwatcher-core/src/pipeline/sink_worker.rs::digest_flushes_at_max_batch, ::a_digest_interrupted_mid_journal_does_not_complete_its_guard
AGG-10Digest exhaustion dead-letters each member individually, in order.Delivery scenariosunit: blockwatcher-core/src/pipeline/sink_worker.rs::digest_failure_dead_letters_each_match
AGG-11A digest member replays through the dead-letter API as a single ordinary match, not as part of a digest.Delivery scenariosunit: blockwatcher-core/tests/engine/control.rs replay suite (no test isolates the digest-member case specifically)
AGG-12Aggregation bounds (max_batch, window_ms) are enforced both at write and at boot.Delivery scenariosunit: blockwatcher-core/src/control/writes.rs::put_sink_rejects_an_out_of_range_aggregate, blockwatcher-core/tests/engine/boot.rs::a_zero_valued_sink_aggregate_fails_boot_before_anything_spawns
HTTP: blockwatcher-api/tests/api/resources.rs::write_time_policy_bounds_are_422_invalid_resource
AGG-13Retry backoff bounds are validated both at write and at boot.Delivery scenariosunit: blockwatcher-core/src/control/writes.rs and blockwatcher-core/tests/engine/boot.rs retry-bound refusal tests
HTTP: blockwatcher-api/tests/api/resources.rs::write_time_policy_bounds_are_422_invalid_resource

Gates

IDClaimDocumented inAssurance
GATE-1A quiet hit (no fire) still registers cleanly, leaving zero outstanding work behind.Gatesunit: blockwatcher-core/src/pipeline/gate.rs::journal_held_gauge_tracks_the_persisted_length
GATE-2A gate hit persists before its completion guard resolves.Gatesunit: blockwatcher-core/src/pipeline/gate.rs::retain_persists_the_journal_including_this_hit
GATE-3A failed persist stalls the guard; it never silently skips the hit.Gatesunit: blockwatcher-core/src/pipeline/gate.rs::a_failed_persist_evicts_the_entry_so_memory_never_leads_storage, ::a_failed_commit_leaves_the_journal_intact_and_emits_nothing
GATE-4Invalidate prunes gate hits with cursor strictly greater than from, and nothing at or before it.Gatesunit: blockwatcher-testkit/src/storage_contract/gate_hits.rs::prune_gate_hits_deletes_only_cursor_after_from, ::prune_does_not_delete_cursor_at_or_before_from; blockwatcher-core/tests/engine/invalidate.rs::invalidate_prunes_gate_hits_strictly_after_from, ::invalidate_from_tip_keeps_gate_hit_at_or_before_from
GATE-5Emit metadata (last_emit_cursor) is cleared only when its cursor is strictly greater than from.Gatesunit: blockwatcher-core/tests/engine/invalidate.rs::invalidate_from_later_cursor_keeps_max_once_cooldown_when_journal_is_empty, ::invalidate_clears_cooldown_when_emit_cursor_is_after_from_even_if_journal_is_empty
GATE-6Gate replay is at-least-once, and the ids it mints are deterministic across a crash.Gatesunit: blockwatcher-core/src/pipeline/gate.rs::a_failed_commit_leaves_the_journal_intact_and_emits_nothing (nothing partially committed), blockwatcher-core/src/pipeline/processor.rs::a_gated_emission_dispatches_with_its_outbox_id_per_sink (durable per-sink ids)
GATE-7A max_once discard is not a dead letter; it is a quiet, counted no-op.Gatesunit: blockwatcher-core/src/pipeline/gate.rs::discard_persists_without_the_named_indices; blockwatcher-gates/src/max_once.rs::second_hit_in_window_discards_this_row
GATE-8A threshold gate resets its journal on fire; leftover hits past the fired count stay held.Gatesunit: blockwatcher-gates/src/threshold.rs::three_hits_in_window_emit_oldest_three, ::leftovers_after_emit_are_the_tail
GATE-9A gate module returns indices into the journal; only core mints the resulting match ids.Gatesunit: blockwatcher-gates/src/threshold.rs::three_hits_in_window_emit_oldest_three proves the module returns bare indices, and blockwatcher-core/src/pipeline/gate.rs::an_emit_atomically_consumes_the_journal_and_records_one_row_per_sink proves core creates one outbox row per sink from them; Match::new minting the match id itself happens in apply_gate_memory (blockwatcher-core/src/pipeline/gate.rs:263-268, no dedicated unit test isolates that call)
e2e: a_gated_monitor_survives_a_restart_and_delivers_its_digest is the behavioral proof that engine-minted ids reach the wire
GATE-10Gate windows are measured in block.timestamp, never wall-clock time.Gatesunit: blockwatcher-gates/src/threshold.rs::block_timestamp_int_is_accepted, ::block_timestamp_wrong_type_is_refused; blockwatcher-gates/src/max_once.rs equivalent schema tests
GATE-11A gate journal over its cap drops the oldest entries, and counts what it dropped.Gatesunit: blockwatcher-core/src/pipeline/gate.rs::journal_over_cap_counts_dropped
GATE-12A hit with no resolvable event timestamp is skipped and counted, never inserted untimestamped.Gatesunit: blockwatcher-core/src/pipeline/gate.rs::missing_timestamp_is_quiet_and_does_not_insert
GATE-13An invalid on_hit decision from a gate module discards only that hit, and is counted.Gatesunit: blockwatcher-core/src/pipeline/gate.rs::an_invalid_gate_decision_discards_only_this_hit proves an older hit survives while only the newest is dropped, ::an_invalid_gate_decision_counts_on_the_exported_metric proves blockwatcher_gate_decision_invalid_total increments through the live path; the fallback was previously fronted by a debug assert that made it untestable. The assert is now removed so the documented tolerance is exercised directly
GATE-14A schema with no block.timestamp field refuses a time-based gate at write.Gatesunit: blockwatcher-gates/src/max_once.rs::missing_block_timestamp_in_schema_is_refused, blockwatcher-gates/src/threshold.rs::missing_block_timestamp_in_schema_is_refused; blockwatcher-core/src/control/writes.rs::put_monitor_rejects_a_gate_on_a_source_without_event_timestamps
GATE-15A gate on a mempool source is refused, and a mempool source under a gated monitor is refused, in both directions.Gatesunit: blockwatcher-core/src/control/writes.rs::put_monitor_rejects_a_gate_on_a_source_without_event_timestamps, ::put_monitor_accepts_a_gate_on_a_source_with_event_timestamps
e2e: a_gated_monitor_on_a_mempool_source_is_refused_in_both_directions
GATE-16A hot swap of a gate’s config does not migrate its held journal; the holds reset.Gatesunit: blockwatcher-core/src/control/writes.rs::put_monitor_clears_gate_hits_when_gate_config_changes, ::put_monitor_keeps_gate_hits_when_gate_is_unchanged
e2e (in passing): a_pending_emission_outlives_its_deleted_monitor
GATE-17Sqlite-backed gate holds survive a restart.Gatese2e: a_gated_monitor_survives_a_restart_and_delivers_its_digest
GATE-18A dry-run against a gated monitor is inert: it never writes to the held journal.Gatesunit: blockwatcher-core/tests/engine/control.rs::test_monitor_holds_an_in_window_hit_without_writing_storage
GATE-19The dry-run TestReport shape distinguishes no-match, rejected-predicate, and held/fired outcomes.Gatesunit: blockwatcher-core/tests/engine/control.rs::test_monitor_dry_runs_supplied_payloads_without_touching_the_pipeline, ::test_monitor_flags_an_unselected_event_as_no_match_not_a_rejected_predicate
GATE-20Out-of-range gate configuration (window, count) answers 422 at write.Gatesunit: blockwatcher-gates/src/threshold.rs::count_one_is_refused_at_compile, ::window_ms_zero_and_over_one_day_are_refused, blockwatcher-gates/src/max_once.rs::window_ms_zero_and_over_one_day_are_refused
by design, not live-tested: exhaustively pinned in-process at both write and boot, a live duplicate adds fidelity to nothing but HTTP framing, which api_semantics.rs already exercises.
GATE-21An unknown gate module name answers 422 naming the module catalog.Gatesunit: blockwatcher-core/src/control/writes.rs::put_monitor_rejects_unknown_gate_module_at_write_lookup
by design, not live-tested: exhaustively pinned in-process at both write and boot, a live duplicate adds fidelity to nothing but HTTP framing, which api_semantics.rs already exercises.
GATE-22A monitor carries at most one gate; the resource schema has no way to express two.Gatesby design, not live-tested: structurally enforced by the type Monitor.gate: Option<ModuleSel>, so a second gate cannot be constructed to test against
GATE-23A gate emit is exactly one storage transaction: journal consumed and outbox rows written atomically.Gatesunit: blockwatcher-core/src/pipeline/gate.rs::an_emit_atomically_consumes_the_journal_and_records_one_row_per_sink
e2e (in passing): a_crash_between_gate_emit_and_delivery_is_swept_by_the_startup_drain
GATE-24A gate envelope change or gated monitor delete whose pipeline bring-up fails is returned as an error (HTTP non-2xx); the stored row may already have landed, and /status reports the network abandoned.HTTP API reference, Resourcesunit: blockwatcher-core/tests/engine/control.rs::a_gate_change_whose_restart_finds_an_unknown_sink_module_is_an_error, ::a_gate_state_wipe_whose_bring_up_fails_leaves_the_pipeline_stopped, ::a_gate_envelope_change_refuses_the_wipe_after_a_quiesce_timeout, ::a_delete_that_orphans_gate_state_is_wiped_before_a_recreate_adopts_it; blockwatcher-api/tests/api/resources.rs::a_gate_change_whose_restart_fails_is_not_2xx

Gate outbox

IDClaimDocumented inAssurance
OBX-1Emit writes exactly one outbox row per sink, inside the same transaction that consumes the gate journal.Gatesunit: blockwatcher-testkit/src/storage_contract/outbox.rs::commit_gate_emit_replaces_hits_writes_meta_and_creates_one_row_per_sink; blockwatcher-core/src/pipeline/gate.rs::an_emit_atomically_consumes_the_journal_and_records_one_row_per_sink
OBX-2An outbox row is held until its delivery outcome is durable, then deleted.Gatesunit: blockwatcher-testkit/src/storage_contract/outbox.rs::delete_gate_outbox_removes_one_row_and_absent_ids_succeed; blockwatcher-core/src/pipeline/sink_worker.rs::a_delivered_outbox_item_deletes_its_row_after_journaling, ::a_dead_lettered_outbox_item_deletes_its_row_too, ::outbox_delete_failure_retries_and_stalls_until_it_lands
OBX-3A crash between a gate emit and its delivery outcome loses nothing.Gatese2e: a_crash_between_gate_emit_and_delivery_is_swept_by_the_startup_drain
OBX-4The startup drain sweeps up any outbox rows a crash left pending.Gatese2e: a_crash_between_gate_emit_and_delivery_is_swept_by_the_startup_drain
OBX-5A pending emission outlives the deletion of its own monitor or network; delivery still completes.Gatesunit: blockwatcher-core/src/pipeline/sink_worker.rs::a_guardless_outbox_item_delivers_and_settles_without_a_checkpoint
e2e: a_pending_emission_outlives_its_deleted_monitor
OBX-6Invalidate prunes outbox rows whose cursor is strictly after from, reorged-out members are dropped.Gatesunit: blockwatcher-testkit/src/storage_contract/outbox.rs::prune_gate_outbox_after_drops_only_matches_strictly_after_from; blockwatcher-core/tests/engine/invalidate.rs::invalidate_prunes_outbox_rows_strictly_after_from
OBX-7A permanent gate-outbox prune error during invalidate does not retry until cancel and does not wedge control-plane writes.HTTP API referenceunit: blockwatcher-core/tests/engine/invalidate.rs::a_permanent_outbox_prune_during_invalidate_does_not_hang_control_plane_writes

Delivery journal

IDClaimDocumented inAssurance
JRN-1A delivery journals before its completion guard resolves.Delivery guaranteesunit: blockwatcher-core/src/pipeline/sink_worker.rs::journal_write_failure_stalls_the_checkpoint
JRN-2A successful retract forgets its journaled delivery; a dead-lettered retract keeps the row.Delivery guaranteesunit: blockwatcher-core/tests/engine/invalidate.rs::dead_lettered_retract_keeps_the_journal_row
e2e (in passing): an_anvil_reorg_retracts_replaced_matches_before_replacements
JRN-3journal_depth prunes the delivery journal in-write, not as a separate job.Delivery guaranteesunit: blockwatcher-testkit/src/storage_contract/deliveries.rs::delivery_journal_records_lists_forgets_and_prunes (run per backend via blockwatcher-storage/tests/contract.rs)
e2e (observable pruning): a_journal_gap_is_loud_not_silent
JRN-4There is no prune API and no switch to disable journal pruning.Delivery guaranteesby design, not live-tested: an absence claim with no disable switch to test; verified by the resource route table in crates/blockwatcher-api/src, which carries no such endpoint or config toggle
JRN-5A journal gap (an invalidate reaching past what the journal retained) is counted and logged loudly, never silent.Delivery guaranteesunit: blockwatcher-core/tests/engine/invalidate.rs::journal_gap_is_counted_when_invalidate_cursor_is_older_than_retained_window
e2e: a_journal_gap_is_loud_not_silent
JRN-6Deliveries at or below the invalidate cursor duplicate on redelivery; they are never retracted.Delivery guaranteesunit: blockwatcher-core/tests/engine/invalidate.rs::invalidated_source_retracts_then_offers_replacement_matches

Reorg and invalidation

IDClaimDocumented inAssurance
RRG-1A deep invalidate travels through a typed control path (SourceExitKind::Invalidated), not an ad-hoc signal.Delivery guaranteesunit: blockwatcher-core/tests/engine/invalidate.rs::pause_after_matches_still_rewinds_on_invalidate
e2e: an_anvil_reorg_retracts_replaced_matches_before_replacements
RRG-2A shallow fork, strictly inside the confirmation window, emits no Retracted at all.Delivery guaranteese2e: a_shallow_fork_emits_no_retract
RRG-3Invalidate runs its seven-step sequence in order: pause, retract, forget, rewind, resume, and so on.Delivery guaranteesunit: blockwatcher-core/tests/engine/invalidate.rs::invalidated_source_retracts_then_offers_replacement_matches
e2e (observable steps): an_anvil_reorg_retracts_replaced_matches_before_replacements
RRG-4An unrecovered retract failure blocks rewind and blocks the pipeline from restarting.Delivery guaranteesunit: blockwatcher-core/tests/engine/invalidate.rs::retract_failure_does_not_rewind_checkpoint
RRG-5A rewind is strictly backward, or it is refused and counted, never a silent no-op forward.Delivery guaranteesunit: blockwatcher-core/tests/engine/invalidate.rs::invalidate_never_advances_the_stored_checkpoint, ::invalidate_with_no_stored_checkpoint_writes_none
RRG-6Retracts finish, per sink, before any post-restart replacement match reaches that same sink.Delivery guaranteesunit: blockwatcher-core/tests/engine/invalidate.rs::two_sinks_each_see_retracts_before_replacement_matches
e2e: an_anvil_reorg_retracts_replaced_matches_before_replacements
RRG-7Retracts are scoped to the sinks the invalidated monitors actually spawned, and are tombstoned after the delete that caused them.Delivery guaranteesunit: blockwatcher-core/tests/engine/invalidate.rs::delete_invalidate_retracts_only_that_networks_sinks
RRG-8Sibling networks are untouched by one network’s invalidate.Delivery guaranteesunit: blockwatcher-core/tests/engine/invalidate.rs::delete_invalidate_retracts_only_that_networks_sinks
RRG-9In-flight work on the pipeline being invalidated is dropped outright, never silently skipped as if it succeeded.Delivery guaranteesunit: blockwatcher-core/tests/engine/invalidate.rs::invalidation_refuses_to_prune_retract_or_rewind_after_a_quiesce_timeout, blockwatcher-core/tests/engine/boot.rs::shutdown_reports_a_pipeline_aborted_when_a_permanent_storage_outage_blocks_the_drain_deadline
RRG-10The EVM source invalidates from either a proven ancestor block or the tracked-window edge, whichever applies.Chain-agnosticismunit: blockwatcher-evm/tests/reorg_and_failover.rs::a_reorg_beyond_confirmations_invalidates_from_the_proven_fork, ::a_resume_past_the_tracked_window_invalidates_from_below_the_oldest_tracked_height
RRG-11The block tracker keeps clamp(2 * confirmations, 8, 64) blocks; a deeper reorg invalidates from the deepest provable point.Chain-agnosticismunit: blockwatcher-evm/src/source/rpc/chain.rs::cap_is_clamped_between_8_and_64; deepest-provable behavior covered by the EVM invalidation tests cited in the row above
RRG-12The retract pass ignores throttle and aggregation entirely; a retract is never suppressed or buffered.Delivery guaranteesunit: blockwatcher-core/src/pipeline/sink_worker.rs::retracts_bypass_throttle, ::retract_flushes_before_delivering; RetractSink in blockwatcher-core/src/engine/invalidate/mod.rs carries no throttle or aggregate fields at all, bypass by construction
RRG-13Core never names a chain, even in the reorg and invalidation path.Chain-agnosticismby design, not live-tested: enforced by an architecture fitness check (scripts/check-dep-graph.sh, a per-crate allowlist plus a transitive denylist scan) rather than a runtime test, architectural by construction

Lifecycle

IDClaimDocumented inAssurance
LIF-1Every drain is bounded by drain_deadline_ms; nothing waits forever.The pipelineunit: blockwatcher-core/tests/engine/escalation.rs::restart_quiesces_storage_after_an_escalated_drain_before_reading_the_checkpoint
e2e: sigterm_exits_two_within_the_drain_deadline_despite_a_wedged_sink
LIF-2Missing the drain deadline escalates to a hard abort rather than hanging.The pipelineunit: blockwatcher-core/tests/engine/escalation.rs::restart_refused_after_quiesce_timeout_leaves_the_network_abandoned
e2e: sigterm_exits_two_within_the_drain_deadline_despite_a_wedged_sink
LIF-3Escalation to abort is loud (logged) and counted (metric), never silent.The pipelinee2e: sigterm_exits_two_within_the_drain_deadline_despite_a_wedged_sink (stderr names the missed deadline; no test separately pins the metric incrementing)
LIF-4Process exit codes are 0 (clean), 1 (config/boot failure), 2 (aborted pipeline), or 64 (usage error).The pipelineunit: blockwatcher/src/run.rs::exit_code_tests::clean_drain_exits_zero, ::aborted_pipelines_exit_two
e2e: a_port_conflict_is_a_boot_failure_with_exit_1, check_validates_like_boot_and_never_echoes_secrets (exit 1)
by design, not live-tested: exit 64 is pinned only as a constant in blockwatcher/src/cli.rs::tests::usage_exit_is_sysexits_ex_usage_and_help_names_it, no test drives the real process to that exit code, and the drain/abort scenarios above already cover the live-observable exits
LIF-5, LIF-6, LIF-7, LIF-8, LIF-9, LIF-10The quiesce budget, its refusal ladder, and owed-debt accounting behave as a coherent whole across a restart.The pipelineunit: blockwatcher-core/tests/engine/escalation.rs::restart_quiesces_storage_after_an_escalated_drain_before_reading_the_checkpoint, ::restart_refused_after_quiesce_timeout_leaves_the_network_abandoned, ::a_refused_quiesce_stays_owed_until_one_completes
by design, not live-tested: requires a deterministically wedgeable storage backend, the FlakyStorage/DetachingCheckpointStorage-backed in-process suite is the designed instrument, the live suite covers the escalation exit path (existing shutdown.rs).
LIF-11Workers and pipeline instances share their topology according to the documented rules, not ad hoc.The pipelineunit: blockwatcher-core/tests/engine/escalation.rs::put_monitor_naming_a_previously_unreferenced_sink_delivers_through_it_and_keeps_checkpointing, ::a_swap_that_leaves_the_sink_set_unchanged_still_never_restarts_the_pipeline
LIF-12source.status uses a fixed vocabulary, including abandoned for a refused restart.The pipelineunit: blockwatcher-core/src/status.rs::source_status_view_serializes_as_an_internally_tagged_enum; blockwatcher-core/tests/engine/escalation.rs::restart_refused_after_quiesce_timeout_leaves_the_network_abandoned (abandoned reached at runtime)
LIF-13Process stdout carries only canonical match JSON lines, nothing else.The pipelinee2e: stdout_carries_only_canonical_match_json_lines
LIF-14A refused DELETE does not clear that resource’s pause row.HTTP API referenceunit: blockwatcher-core/tests/engine/deletes.rs::a_stale_delete_does_not_clear_a_paused_monitor; blockwatcher-api/tests/api/ops.rs::a_stale_monitor_delete_does_not_clear_pause
LIF-15Pause or resume of one monitor is not blocked by an unrelated spec on the same chain that no longer compiles.HTTP API referenceunit: blockwatcher-core/tests/engine/control.rs::pause_monitor_succeeds_when_an_unrelated_spec_no_longer_compiles

HTTP API

IDClaimDocumented inAssurance
API-1Auth answers 401 for a missing or unknown token, and 403 for a token too weak in scope.HTTP API referenceunit: blockwatcher-api/tests/api/auth.rs::everything_else_is_401_without_or_with_a_wrong_bearer_token, blockwatcher-api/tests/api/scope.rs::every_registered_method_enforces_its_minimum_scope (403)
e2e: the_api_rejects_bad_tokens_and_never_leaks_the_good_one (401), the_scope_ladder_holds_over_real_http (403)
API-2Token comparison is constant-time, and the bearer scheme match is case-insensitive.HTTP API referenceunit: blockwatcher-api/src/auth/mod.rs::token_comparison_accepts_equal_and_rejects_unequal_and_prefixes, ::the_scheme_is_case_insensitive_and_only_bearer_carries_a_credential; blockwatcher-api/tests/api/auth.rs::the_bearer_scheme_is_matched_case_insensitively
API-3/health is exempt from auth ahead of the router, not by a route-level bypass.HTTP API referenceunit: blockwatcher-api/tests/api/auth.rs::health_answers_without_a_token, ::no_registered_path_and_no_unknown_path_answers_without_a_token
e2e (implicit): the_api_rejects_bad_tokens_and_never_leaks_the_good_one
API-4env: secrets resolve fresh per request; they are never cached and never logged.HTTP API referenceunit: blockwatcher-types/src/secret.rs::resolve_reads_the_variable_at_call_time_and_errors_name_the_variable_never_a_value, blockwatcher-api/tests/api/auth.rs::api_token_debug_never_carries_the_secret, ::each_labelled_token_authenticates_and_an_unknown_one_does_not
e2e: the_api_rejects_bad_tokens_and_never_leaks_the_good_one
API-5, API-6, API-7, API-8, API-9The PUT/DELETE ETag lifecycle: create returns 201 with an ETag, a repeat create is 409, a conditional update with the right ETag is 200, a stale If-Match is 412 with actual_version, and delete needs If-Match (428 without, 412 stale, 204 correct).HTTP API referenceunit: blockwatcher-api/tests/api/resources.rs::monitor_crud_round_trips_with_versions, ::network_sink_and_spec_routes_answer_the_same_shapes
e2e: etag_lifecycle_create_conflict_update_delete
API-10A collection GET is a bare list; it never carries a version or ETag per item.HTTP API referenceunit: blockwatcher-api/tests/api/resources.rs::each_family_listing_answers_with_only_its_own_records, ::monitor_crud_round_trips_with_versions
by design, not live-tested: pinned in blockwatcher-api wire and resource tests.
API-11Deleting a sink a monitor still references answers 422 still_referenced.HTTP API referenceunit: blockwatcher-api/tests/api/resources.rs::an_unparseable_monitor_is_deletable_and_names_itself_in_a_refusal
e2e: etag_lifecycle_create_conflict_update_delete
API-12Server errors (5xx) always answer a fixed, generic body; nothing internal leaks onto the wire.HTTP API referenceunit: blockwatcher-api/src/error.rs::internal_errors_never_leak_their_message_to_the_wire, ::the_error_body_is_the_pinned_wire_shape; blockwatcher-api/tests/api/resources.rs::reading_an_unparseable_record_is_500_not_422
by design, not live-tested: pinned in blockwatcher-api wire tests, a live duplicate adds fidelity to nothing but HTTP framing the unit suite already pins exactly.
API-13The scope ladder (read < operate < admin) is enforced on every registered route.HTTP API referenceunit: blockwatcher-api/src/auth/scope.rs::read_is_less_than_operate_is_less_than_admin, blockwatcher-api/tests/api/scope.rs::every_registered_method_enforces_its_minimum_scope, ::a_read_token_cannot_create_a_sink_and_an_admin_token_is_not_forbidden
e2e: the_scope_ladder_holds_over_real_http
API-14Monitor pause and resume persist, independent of whether the pipeline is currently running.HTTP API referenceunit: blockwatcher-api/tests/api/ops.rs::monitor_and_network_pause_and_resume_persist_regardless_of_pipeline_state, ::pause_stops_matching_and_resume_restores_it
e2e: pause_persists_across_a_restart
API-15Network pause and resume persist, and a paused network is never spawned on restart.HTTP API referenceunit: blockwatcher-api/tests/api/ops.rs::network_pause_reports_paused_and_resume_restores_live, ::monitor_and_network_pause_and_resume_persist_regardless_of_pipeline_state
e2e: pause_persists_across_a_restart
API-16paused_monitors answers null, not a fabricated list, when storage will not answer.HTTP API referenceunit: blockwatcher-core/tests/engine/control.rs::status_reports_unknown_rather_than_fabricated_when_storage_will_not_answer
e2e (shape, in passing): pause_persists_across_a_restart
API-17Pause views (monitor vs network) diverge only when a failed republish leaves them out of sync.HTTP API referenceunit: blockwatcher-api/tests/api/ops.rs::pause_views_diverge_when_a_paused_network_leaves_nothing_to_publish_to
API-18Skip requires pause first (409 otherwise), refuses to rewind, and honors tip semantics.HTTP API referenceunit: blockwatcher-api/tests/api/ops.rs::network_skip_shapes_conflict_refusal_and_success, ::network_skip_to_tip_uses_confirmed_tip, ::a_tip_lookup_that_cannot_answer_is_a_503_that_writes_nothing; blockwatcher-core/tests/engine/control.rs::skip_network_refuses_when_not_paused, ::skip_network_refuses_rewind, ::skip_network_tip_unsupported_refuses
e2e: skip_and_checkpoint_survive_network_recreation
API-19Checkpoint delete answers 409 while the network resource still exists; deleting the network keeps the checkpoint rather than discarding it.HTTP API referenceunit: blockwatcher-api/tests/api/ops.rs::checkpoint_reset_is_409_while_network_exists_then_204_then_404
e2e: skip_and_checkpoint_survive_network_recreation
API-20checkpoint_provenance (the module that wrote a checkpoint) is enforced with 409 on a mismatched module swap.HTTP API referenceunit: blockwatcher-api/tests/api/ops.rs::a_network_put_switching_modules_over_a_checkpoint_is_409; blockwatcher-core/tests/engine/control.rs::put_network_refuses_a_module_switch_over_an_existing_checkpoint, blockwatcher-core/tests/engine/boot.rs::boot_refuses_a_checkpoint_written_by_a_different_module
by design, not live-tested: pinned in blockwatcher-api wire and resource tests.
API-21, API-22, API-23Dry-run has an enforced payload cap, and its report semantics (no-match vs error vs held) are pinned.HTTP API referenceunit: blockwatcher-api/tests/api/wire.rs::test_endpoint_body_matches_the_pinned_literal, blockwatcher-api/tests/api/ops.rs::a_dry_run_over_too_many_payloads_is_refused_naming_the_limit, ::test_endpoint_dry_runs_without_delivering, ::a_predicate_that_errors_evaluating_is_counted_not_read_as_no_match, ::a_payload_no_selector_wanted_is_reported_as_no_match
by design, not live-tested: pinned in blockwatcher-api wire tests, a live duplicate adds fidelity to nothing but HTTP framing the unit suite already pins exactly.
API-24Network operations (skip, pause, checkpoint delete) act directly on storage; they never replay ingestion to get there.HTTP API referenceunit: blockwatcher-core/tests/engine/control.rs::skip_network_cursor_forward_while_paused (patches checkpoint directly, no rescan); composite evidence from the pause, resume, pause-divergence, and skip tests cited above
API-25Reads stay answerable while a write is in flight; nothing blocks the whole API on one mutation.HTTP API referenceunit: blockwatcher-core/tests/engine/control.rs::a_read_stays_answerable_while_a_write_is_wedged
API-26POST /specs/{id}/from-chain is Admin, uses If-Match like PUT, maps importer Invalid to 422 invalid_resource and Unavailable to 503, a missing network to 422 missing_reference, and a corrupt stored network through ApiError::stored (500).HTTP API referenceunit: blockwatcher-api/tests/api/spec_import.rs suite; blockwatcher-api/tests/api/scope.rs::every_registered_method_enforces_its_minimum_scope
API-27Write-time InvalidResource (throttle / aggregate / retry bounds, and any other request-body validation the engine raises before persist) is HTTP 422 invalid_resource, never 500 internal. ApiError::stored remains 500 only for a stored row that will not deserialize.HTTP API reference, Resourcesunit: blockwatcher-api/tests/api/resources.rs::write_time_policy_bounds_are_422_invalid_resource
API-28Skip to an absolute cursor moves the source module’s own cold-start field, whatever that module names it, and core never writes one.HTTP API referenceunit: blockwatcher-core/tests/engine/control.rs::skip_network_cursor_patches_a_ledger_sources_own_start_field, ::skip_network_refuses_a_source_with_no_cold_start; blockwatcher-stellar/src/registry.rs::set_start_writes_start_ledger_and_refuses_a_cursor_past_u32; blockwatcher-evm/src/registry.rs::evm_sources_declare_their_own_cold_start_and_tip
API-29GET /catalog advertises per source module whether it can report a confirmed tip, and skip to tip refuses exactly the modules it reports cannot.HTTP API referenceunit: blockwatcher-api/tests/api/catalog.rs::catalog_advertises_confirmed_tip_per_source, ::skip_to_tip_is_refused_for_a_source_the_catalog_reports_has_no_tip; blockwatcher-core/tests/engine/control.rs::skip_network_tip_unsupported_refuses; blockwatcher-stellar/src/registry.rs::stellar_rpc_reports_no_confirmed_tip

Dashboard

IDClaimDocumented inAssurance
DASH-1The companion HTTP listener binds before ui-ingest is registered. Until that sink exists, the published port answers 503 with a waiting page (Retry-After: 3), never a connection reset. The Host allowlist still answers 403, and /ingest still requires its header once ready.The dashboard, Dockerunit: ui/server/tests/ready.rs::a_not_ready_listener_answers_503_instead_of_resetting, ::a_foreign_host_is_still_403_while_not_ready, ::ingest_still_requires_its_header_once_ready

Stellar spec import

IDClaimDocumented inAssurance
STEL-IMP-1Fetch materializes SAC to {catalog: stellar-asset} and WASM to {wasm} that compile_spec accepts; G-addresses are Invalid; JSON-RPC -32602..=-32600 is Invalid; other RPC failure is Unavailable without echoing http; a WASM code lookup pins the instance endpoint.Stellarunit: blockwatcher-stellar/src/spec_from_rpc.rs::sac_instance_materializes_catalog, ::wasm_instance_with_code_materializes_wasm, ::g_address_is_invalid, ::rpc_status_500_is_unavailable, ::jsonrpc_invalid_params_is_invalid, ::wasm_code_lookup_pins_the_instance_endpoint
STEL-IMP-2import_spec_from_chain persists only after a successful importer call; missing importer is UnsupportedChain listing importer chains; missing decoder is UnsupportedChain listing decoder chains before the importer is called; a missing network is MissingReference; a network that changes during fetch is Conflict.HTTP API referenceunit: blockwatcher-core/tests/engine/spec_import.rs::import_writes_the_importer_payload_through_put_spec, ::import_without_importer_lists_importer_chains, ::import_does_not_write_on_importer_failure, ::import_without_decoder_lists_decoder_chains_and_does_not_call_importer, ::import_missing_network_is_missing_reference, ::import_conflicts_when_network_changes_during_fetch
STEL-IMP-3CLI spec-import writes a Spec JSON file check would accept, and a no-stellar build refuses at run with UnsupportedChain listing an empty importer set.Configuration referenceunit: blockwatcher/tests/spec_import.rs::spec_import_writes_stellar_asset_catalog_spec, ::spec_import_refuses_when_stellar_is_not_compiled_in; blockwatcher/src/cli.rs::spec_import_requires_network_address_and_id
STEL-SRC-1A configured start_ledger below oldestLedger on first health is refused; a checkpoint below that window clamps with a retention gap, the same as a mid-run floor advance.Stellarunit: blockwatcher-stellar/src/source/run.rs::start_ledger_before_oldest_is_permanent, ::checkpoint_before_oldest_clamps_on_first_health, ::retention_floor_after_degraded_clamps

evm-mempool

IDClaimDocumented inAssurance
MEM-1The mempool source keeps a watermark-only checkpoint; a restart loses whatever was still pending.Delivery guaranteesunit: blockwatcher-evm/tests/mempool_loop.rs::a_restart_loses_pending_work_by_design
MEM-2A pending transaction seen again mints a new id; dedupe happens on transaction hash, not on the minted id.Delivery guaranteesunit: blockwatcher-evm/tests/mempool_loop.rs::a_re_seen_pending_call_gets_a_new_arrival_cursor
MEM-3The mempool source never produces SourceOutcome::Invalidated.Delivery guaranteesunit: by construction, mempool/run.rs::run() never constructs that variant; no dedicated runtime test
MEM-4SourceCaps.event_timestamps is false for mempool sources and true for evm-rpc and evm-subgraph.Delivery guaranteesunit: blockwatcher-core/src/compile.rs::compile_rejects_a_gate_when_the_source_provides_no_event_timestamps, ::compile_accepts_a_gate_when_the_source_provides_event_timestamps; blockwatcher-evm/src/source/subgraph/run.rs::caps_declare_event_timestamps
e2e: a_gated_monitor_on_a_mempool_source_is_refused_in_both_directions, a_gated_monitor_survives_a_restart_and_delivers_its_digest

evm-subgraph

IDClaimDocumented inAssurance
SUB-1Completeness is whatever the deployed subgraph indexed. InterestSet only narrows GraphQL _in fetching; it never changes matching. A monitor for an address the subgraph never stored never fires.EVMunit: blockwatcher-evm/src/source/subgraph/query.rs::empty_interest_omits_in_filters, ::nonempty_interest_adds_in_filters_and_requests_transactions; blockwatcher-evm/src/source/subgraph/run.rs::empty_interest_still_emits_indexed_logs
SUB-2Cannot query field for logs / transactions / _meta publishes a named Degraded whose reason names the schema mismatch. It is not an empty successful window.EVM, Troubleshootingunit: blockwatcher-evm/src/source/subgraph/run.rs::schema_mismatch_publishes_degraded_and_does_not_advance; blockwatcher-evm/src/source/subgraph/fetch.rs::a_verdict_the_pool_retried_to_exhaustion_keeps_its_class
SUB-3Disagreement of _meta.hash or a row blockHash against a height this run already sent through ctx.events returns SourceOutcome::Invalidated { from } with rpc’s end-of-block secondary. In-window disagreement against unsent work retries in place. The tracker is (number, hash) only — not rpc RecentChain (no parentHash on Log).EVMunit: blockwatcher-evm/src/source/subgraph/run.rs::already_emitted_hash_disagreement_invalidates_from_end_of_block, ::in_window_disagreement_retries_without_invalidate; blockwatcher-evm/src/source/subgraph/resume.rs::disagreement_with_no_agreeing_ancestor_invalidates_from_below_oldest, ::disagreement_invalidates_from_the_highest_agreeing_height, ::a_head_below_tracked_heights_refutes_them; blockwatcher-evm/src/source/subgraph/emit.rs::mixed_hashes_in_one_block_are_detected
SUB-4Kinded cursors match rpc packing: transactions then logs, pack_secondary identical.EVMunit: blockwatcher-evm/src/source/subgraph/run.rs::emits_transactions_then_logs_with_rpc_kinded_cursors; blockwatcher-evm/src/source/subgraph/fetch.rs::pages_with_id_gt_until_a_short_page

Boot

IDClaimDocumented inAssurance
BOOT-1validate_and_build refuses stored rows that are no longer valid, at boot.Architecture decisionsunit: blockwatcher-core/tests/engine/boot.rs::an_unregistered_sink_module_fails_boot_before_anything_spawns, ::validate_rejects_what_start_rejects_with_the_same_error; blockwatcher/tests/boot.rs::a_seed_with_an_unknown_sink_module_aborts_boot_and_stores_nothing
BOOT-2A malformed secret shape is refused at boot, without ever echoing its value.Architecture decisionsunit: blockwatcher/src/config.rs::a_literal_auth_token_is_refused_pointing_at_the_reference_form, ::a_pasted_token_is_refused_without_the_message_carrying_it, blockwatcher-storage/src/postgres/mod.rs::tests::a_literal_url_is_refused_pointing_at_the_reference_form_without_echoing_it
e2e (in passing): check_validates_like_boot_and_never_echoes_secrets
BOOT-3Memory-backed storage persists nothing across a restart.Architecture decisionsunit: blockwatcher/tests/boot.rs::a_boot_on_the_non_persistent_store_warns_that_nothing_survives_a_restart
by design, not live-tested: memory storage’s non-persistence is definitional, a live scenario would only re-prove what the type itself already guarantees.
BOOT-4Listeners bind before the engine starts; a port conflict is a boot failure, not a hang.Architecture decisionse2e: a_port_conflict_is_a_boot_failure_with_exit_1
BOOT-5blockwatcher check validates exactly like boot does, and echoes no secret.Architecture decisionse2e: check_validates_like_boot_and_never_echoes_secrets
BOOT-6A populated store is resumed on restart, not re-seeded over.Architecture decisionse2e: sigkill_and_restart_loses_nothing

Extending blockwatcher

Every axis of behavior blockwatcher has is a module behind a port trait, and every module follows the same mechanical steps to become selectable. This page walks through those steps against a module that already ships: the log sink (crates/blockwatcher-sinks/src/log.rs), the smallest real module in the workspace. It is a worked example of the general recipe, not a special case: the same steps apply to a new source, decoder, matcher, gate, or storage backend.

The steps are the whole of the mechanism, but they are not the whole of the work: which trait a module implements decides what it actually has to guarantee, and those guarantees differ sharply from port to port. What each port asks of a module is the per-port half, one section per port, and Extending to a new network covers the one change that spans two ports at once.

This page does not restate what already has a canonical home elsewhere: Modules and trade-offs covers what a module is and how the catalog resolves a name at boot; Conventions § Adding a module is the closed checklist a change must not skip; Architecture decisions § Modules are how an operator makes trade-offs and § The cost-of-change budget state the binding rule and the number a change is judged against; Testing strategy covers the three test layers in full; blockwatcher-ports § Key types and traits tabulates every trait and compiled artifact the ports define. This page’s job is narrower: walk one real module through every step, then state what each port asks of the module behind it.

Key takeaways

  • Every axis of behavior is a module behind a port trait, and every module follows the same mechanical steps to become selectable.
  • The steps are identical for all six ports; this page walks the log sink through them as a worked example, then covers per-port obligations separately.
  • What differs per port is the contract, not the mechanism: cursor ordering for Source, chain-keyed selection for Decoder, bounded compilation for Matcher, an engine-owned journal for Gate, engine-owned retry for Sink, and a shared behavioural contract for Storage.
  • A new network on a chain family a build already carries is configuration and touches no code; a new chain family is a new crate implementing Source and Decoder, and optionally a write-time SpecImporter so POST /specs/{id}/from-chain can fetch that family’s spec.
  • Modules and trade-offs and Architecture decisions already state what a module is and the binding rule; this page’s job is narrower: one real module through every step, plus each port’s own obligations.

The steps, in order

flowchart LR
    port["implement<br/>the port trait"] --> config["define the<br/>config type"]
    config --> catalog["register in<br/>the catalog"]
    catalog --> allowlist["allowlist entry<br/>(if a new crate<br/>or dependency)"]
    allowlist --> tests["tests against the<br/>fake / example config"]

1. Implement the port trait

A module implements exactly one port trait. Sink is one method:

#![allow(unused)]
fn main() {
#[cfg_attr(feature = "testing", mockall::automock)]
#[async_trait]
pub trait Sink: Send + Sync {
    /// Deliver one event. The ENGINE owns retry/backoff/dead-letter policy;
    /// the sink reports classified errors and does not retry internally.
    async fn deliver(&self, event: &SinkEvent) -> Result<(), SinkError>;
}
}

(crates/blockwatcher-ports/src/sink.rs)

LogSink (crates/blockwatcher-sinks/src/log.rs) implements it in the most direct way a sink can: deliver writes one line to stdout and returns whatever the write reports:

#![allow(unused)]
fn main() {
#[async_trait]
impl Sink for LogSink {
    async fn deliver(&self, event: &SinkEvent) -> Result<(), SinkError> {
        self.deliver_to(event, &mut tokio::io::stdout()).await
    }
}
}

(log.rs)

A compile-time assertion right next to the struct, rather than a comment, is what actually proves the trait is implemented:

#![allow(unused)]
fn main() {
const _: fn() = || {
    fn assert_port<T: Sink>() {}
    assert_port::<LogSink>();
};
}

(log.rs:23-26)

Whatever error a module’s own logic produces has to become the port’s own error type at the point of contact, never a foreign one handed further in (the same rule Architecture decisions § Foreign shapes get translated once, at the door states in general). LogSink classifies a std::io::Error into SinkError::Delivery with an explicit ErrorClass, and its own doc comment argues the specific choice rather than asserting it:

#![allow(unused)]
fn main() {
/// A broken pipe will not reopen itself, so a reader could argue this
/// should classify as `Permanent`. `Transient` is the safer default: the
/// engine's retry budget bounds how long a truly dead consumer costs, while
/// a wrong `Permanent` classification dead-letters a match that a
/// momentarily blocked consumer (a slow reader, a full OS pipe buffer)
/// would have accepted on the next attempt. Losing a match to an
/// under-classified retry budget is not possible; losing one to an
/// over-eager `Permanent` is.
fn transient(e: std::io::Error) -> SinkError {
    SinkError::Delivery {
        message: format!("stdout write failed: {e}"),
        class: ErrorClass::Transient,
    }
}
}

(log.rs:64-77)

2. Define the config type

A module’s config is a private serde-deserialized struct with deny_unknown_fields, reachable only through the module’s own factory, never a public type another crate could reach into directly. log takes none, and still enforces the rule on an empty shape, so an unrecognized key is a boot-time typo rather than a silent no-op:

#![allow(unused)]
fn main() {
#[derive(serde::Deserialize, Default)]
#[serde(deny_unknown_fields, default)]
struct Config {}
}

(log.rs:86-88)

3. Register a name and a factory, right beside the code

A module declares its own selectable name and how to build itself through blockwatcher-ports’ one registration contract, ModuleRegistry (crates/blockwatcher-ports/src/registry.rs): a NAME constant, a Factory type alias (one of the *Factory aliases beside each port trait), and a factory() that returns a function from opaque JSON config to a constructed Arc<dyn Port>. log’s registration is the whole of its public surface beyond LogSink itself:

#![allow(unused)]
fn main() {
impl ModuleRegistry for Registry {
    const NAME: &'static str = "log";
    type Factory = SinkFactory;
    fn factory() -> Self::Factory {
        |config| {
            Box::pin(async move {
                let Config {} =
                    serde_json::from_value(config).map_err(|e| SinkError::InvalidConfig {
                        message: format!("invalid log config: {e}"),
                    })?;
                Ok(Arc::new(LogSink) as Arc<dyn Sink>)
            })
        }
    }
}
}

(log.rs:102-116)

A crate that ships more than one module in a family folds every registration into one get_all() enumeration, and that enumeration, not the trait impl, is what actually makes a module selectable: a module absent from it can never be selected by config, however completely it implements Sink. Adding log to blockwatcher-sinks’ family enumeration is one entry:

#![allow(unused)]
fn main() {
pub fn get_all() -> Vec<(&'static str, SinkFactory)> {
    vec![
        (crate::log::Registry::NAME, crate::log::Registry::factory()),
        (
            crate::webhook::Registry::NAME,
            crate::webhook::Registry::factory(),
        ),
        (
            crate::script::Registry::NAME,
            crate::script::Registry::factory(),
        ),
    ]
}
}

(crates/blockwatcher-sinks/src/registry.rs:9-21)

A source family’s enumeration is the same idea with a different payload: the catalog needs the module’s facts, not only its factory, so the entries are descriptors:

#![allow(unused)]
fn main() {
pub fn get_all() -> Vec<SourceModule> {
    vec![SourceModule::of::<MyRegistry>()]
}
}

4. Register in the composition root’s catalog

get_all() only enumerates a family within one crate; a running binary still has to fold every family’s enumeration into one ModuleCatalog (crates/blockwatcher-core/src/catalog.rs). The composition façade blockwatcher-embed does that once, per family, feature-gated so what a build can select is exactly what it linked. The blockwatcher binary calls the same build_catalog; it must not keep a second registrar.

#![allow(unused)]
fn main() {
#[cfg(feature = "sinks")]
for (name, factory) in blockwatcher_sinks::registry::sinks::get_all() {
    catalog.register_sink(name, factory)?;
}
}

(crates/blockwatcher-embed/src/catalog.rs)

A source fold hands the descriptor through, rather than a name and a factory alone:

#![allow(unused)]
fn main() {
catalog.register_source(SourceModule::of::<MyRegistry>())?;
}

Another module joining a family a build already carries (a new sink beside the ones a build already registers, webhook, script, and log) touches only step 3 above: its crate’s own get_all() gains one entry, and this composition-root fold picks it up automatically on the next build, because the fold iterates the enumeration rather than naming each module by hand. Only a brand-new family joining the binary for the first time (a new chain family’s sources and decoders, or a first module behind a port this binary has never carried before) needs a new block here, and, if it arrives in its own crate, a new feature flag named after the family in crates/blockwatcher-embed/Cargo.toml and crates/blockwatcher/Cargo.toml (blockwatcher (binary) § Feature-flag wiring covers the flag wiring itself).

5. Update the dependency gate’s allowlist, when the change needs it

scripts/check-dep-graph.sh enforces every crate’s allowed dependencies by name; Architecture decisions § How the dependency gate turns rules into a mechanical check covers its two-layer allowlist-plus-denylist mechanics in full, so this page only states when adding a module actually touches it: a module added to a crate whose allowlist already covers every dependency that module needs (as log does; it adds no dependency blockwatcher-sinks’s existing ALLOW_BLOCKWATCHER_SINKS entry does not already carry) touches nothing here at all. The script’s allowlist needs an edit only when the change also introduces something new to the dependency graph: a brand-new module crate needs its own ALLOW_BLOCKWATCHER_<CRATE> entry (and a FAMILY_EXEMPT_BLOCKWATCHER_<CRATE> entry if it pulls a denylisted family such as a chain SDK or an HTTP client on purpose) and its name added to ALLOW_BLOCKWATCHER_EMBED, the crate build_catalog lives in. A module added to an existing crate needs an edit only if it brings a dependency that crate did not already declare. The binary’s ALLOW_BLOCKWATCHER entry is not that list: chain-family crates, expr, and sinks reach the process through embed, not as direct dependencies of blockwatcher.

6. Add tests against the port’s fake, or the family’s own contract

Which test infrastructure a new module tests against depends on what already exists for its port:

  • Every port ships an in-memory fake behind blockwatcher-portsfakes feature (FakeSink, FakeSource, FakeDecoder, FakeMatcher, MemoryStorage), registered through the same ModuleRegistry contract a real module uses. Something else that needs a sink without linking yours reaches for FakeSink instead, the same way blockwatcher-sinks’s own dev-dependency on blockwatcher-core exercises a real sink through blockwatcher-core’s actual engine and sink-worker machinery rather than a reimplemented harness. Storage additionally has a shared behavioral contract, exercise_storage_contract (blockwatcher-testkit § The storage contract), that blockwatcher-storage’s own module and the ports fake both run against, so a new storage module is proven equivalent to the existing ones by the same contract rather than by shared code; no other port has an equivalent shared runner. Source has the next closest thing, blockwatcher-testkit’s recv and stop (blockwatcher-testkit § Driving a source): not a contract over a whole backend, but deadline-bounded drivers that fail a source which stops emitting or ignores its cancellation token instead of hanging the suite.
  • Every module crate proves its own registered modules construct from their own documented example config, in one self-verifying test per crate. blockwatcher-sinks’ version is sinks_get_all_constructs_every_entry_from_its_documented_example_config (registry.rs): it reads each module’s registry_examples/<name>.json and runs the module’s factory against it, so the enumeration in step 3 and the example config in a module’s own rustdoc header cannot drift apart. Adding a module means adding one match arm here alongside its own registry_examples/<name>.json.
  • A module’s own #[cfg(test)] block proves what is specific to it: log proves the emitted line is the canonical body plus one newline, in the_emitted_line_is_the_canonical_body_newline_terminated (log.rs), that concurrent deliveries never interleave a line, in concurrent_large_deliveries_never_interleave_their_lines (log.rs), and that an unrecognized config key is rejected as SinkError::InvalidConfig rather than silently ignored, in factory_rejects_unknown_config_fields_as_invalid_config (log.rs).

Testing strategy’s layering is what these three map onto: the fake and the family-completeness test are unit-layer proof that construction and substitution work; a module’s own test suite that exercises something wire-level or storage-level (a real sqlite file, a scripted mock node) is the module layer above it.

What each port asks of a module

Steps 1 through 6 do not change from port to port. What a module has to guarantee does, and the guarantees are not symmetric: Sink is one method with one rule, while Source carries an ordering invariant nothing checks at compile time and Storage has the widest surface of the six. Each port’s own trait file in blockwatcher-ports is the authority, and its doc comments argue each rule rather than assert it; the sections below are the contributor’s map of what to satisfy and which shipped module to read as a reference.

The obligations worth listing here are the ones a compiler cannot enforce. A module that violates one of them still builds, still registers, and still runs, so each is stated with the failure it produces.

Source

  • Trait (crates/blockwatcher-ports/src/source.rs): run is required. scan (bounded history fetch, behind a monitor dry run’s fetch) and confirmed_tip (the confirmed head for a cold start) both default to SourceError::Unsupported, so a streaming-only source implements neither and stays a fully valid source.
  • Reference modules: the family’s source modules (chain families).
  • Selected on: a network resource’s source.module.
  • Test infrastructure: FakeSource, plus blockwatcher-testkit’s recv and stop for driving a running source under a deadline.

A source module declares SourceModuleFacts, whose defaults refuse, so a module with no confirmed tip and no cold start writes nothing, and a module with either states it beside its own code.

Obligations run carries beyond its signature:

  • Non-decreasing cursor order, from one loop. The processor’s checkpoint tracker treats arrival order on ctx.events as cursor order. A source that reorders events, or that shares the sender across concurrent producers, publishes a corrupt checkpoint rather than failing, which is the silent loss this project ranks worst. A single source-owned loop satisfies this by construction; nothing else does.
  • The source owns the cursor mapping. Core needs only ordering and serialization and never interprets primary or secondary (crates/blockwatcher-types/src/cursor.rs). How a family packs those numbers is on the family page. A new source picks its own mapping and documents it, because that mapping is what journal_depth, lag reporting, and rewind ranges are all denominated in.
  • send().await is the backpressure. ctx.events is bounded; a source that drops rather than awaits turns a slow pipeline into missing matches.
  • Cancellation is a normal end. ctx.cancel firing means return SourceOutcome::Ended: not an error, not an invalidate, not a panic.
  • A rewind is a value, not an error. SourceOutcome::Invalidated { from } is how a source reports that everything past from was on a dead fork, and the engine handles it as a control path (Delivery guarantees covers the retract pass it triggers). A source over a feed with no finality, such as a mempool, never produces it.
  • ctx.interest narrows fetching, never matching. Hints are eventually consistent with the monitor set, never synchronised with it, so a source that used a hint to decide what matched would drop matches for monitors added inside that window. The pipeline decides what matched. referenced_fields being None means unknown, which is why an empty set and an absent set are different values.
  • ctx.status is what an operator sees. Live, CatchingUp, and their optional head cursor are what the control plane reports lag against the pipeline’s checkpoint; a source that never publishes status reports no lag rather than no problem.
  • Checkpoint.source_state is module-private. Nothing else reads it. The engine stamps Checkpoint.module and refuses to resume a checkpoint written by a different source module, because a cursor is only meaningful in its writer’s units. A source over a feed that cannot replay from a stored position must document, as its own trade-off, what its checkpoints mean.

Decoder

  • Trait (crates/blockwatcher-ports/src/decoder.rs): chain, compile_spec, compile, and decode are required. interest defaults to no hints and merge_interest defaults to unioning addresses and signatures.
  • Reference module: the family’s decoder (chain families).
  • Selected on: nothing. This is the one port an operator never names.
  • Test infrastructure: FakeDecoder, plus a golden-file suite in the family crate.

A decoder is chosen by chain rather than by name, and that changes steps 2 and 3 for it specifically. At boot the engine constructs one instance of every registered decoder module, each with an empty config object, indexes them by whatever chain() returns, and refuses boot with EngineError::DuplicateChainDecoder when two modules claim the same ChainKind (validate_and_build, crates/blockwatcher-core/src/engine/boot.rs). ChainKind is an open string set (crates/blockwatcher-types/src/id.rs), never an enum, so a new family adds a value and touches no existing type. Because every decoder is constructed from {}, a decoder has no useful per-module config: define the empty Config with deny_unknown_fields anyway, exactly as log does in step 2.

Obligations:

  • Translate once, at the door. CompiledSpec::new(schemas, inner) splits a chain artifact into the chain-agnostic SchemaSet the engine, API, and matcher work from, and a module-private inner only this family downcasts. Nothing chain-shaped may travel further in (Chain-agnosticism § Core must not know chains).
  • compile resolves only the specs its selectors name. specs is the whole chain’s set, and a RawSelector whose spec is None is not licence to read every spec on the chain. The control plane depends on this: deleting a spec no stored monitor names skips the pipeline restart a changed spec forces, precisely because no compiled set can contain it. A decoder that resolved more than it named would keep serving an artifact built from a deleted spec.
  • Same event name, two field sets, is a decision the decoder must make. The port deliberately leaves it open: union the declarations or reject the ambiguity, but a dropped declaration must not be invisible. FakeDecoder keeps the first and discards the second, which is a test-double convenience, not a pattern to imitate.
  • Declared field names must match the shapes decoded. Predicate compilation matches a schema field name flat, so "order.maker" is one name, while evaluation traverses the decoded event (order then maker, digit segments indexing arrays). A decoder that declares a dotted name and emits it as a literal key ships monitors that compile cleanly and never fire. A flat digit-bearing declaration is the same trap from the other side: args.path.0 already type-checks through a declared path: Array(T) by derivation, and declaring "path.0" as well shadows that derivation, type-checking the predicate against one type while the event delivers another. Every decoder’s golden-file suite therefore has to pair each dotted or digit-bearing declaration with the event shape it decodes to, so the contract is executable per module rather than prose.
  • decode output order is deterministic. Match identity derives from each event’s index in that output, so a reordering changes ids and breaks downstream deduplication. A payload this decoder recognizes as malformed is DecodeOutcome::undecodable(), which the engine counts; a payload no selector wanted is no_match(), which is not a failure.
  • Filling chain_specific obliges overriding merge_interest. The default drops it, because only the decoder that produced an erased payload knows how to combine two of them. A decoder that populates it and does not override loses every hint past the first monitor, silently.

Matcher

  • Trait (crates/blockwatcher-ports/src/matcher.rs): compile and matches are required. explain defaults to Explanation::Unsupported and referenced_fields to None, so a matcher that supports neither is still fully valid.
  • Reference module: expr (blockwatcher-expr, and Predicates and the expression language for the language it implements).
  • Selected on: [engine].matcher in blockwatcher.toml, instance-wide. One matcher runs per process, and EngineConfig has no overall default because it refuses to guess which. A control surface that recompiles a monitor compiles against the running instance, never a fresh one, because a predicate compiled against a different instance is not guaranteed to mean the same thing to the one evaluating it.
  • Test infrastructure: FakeMatcher, plus blockwatcher-expr’s property tests (crates/blockwatcher-expr/src/proptests) as the model for a language module: round-trip, integer edge cases, three-valued logic, and a no-panic sweep.

Obligations:

  • compile must bound its own work. The predicate source is operator-controlled. The engine bounds the length it hands in, but a bounded input can still spell unbounded work, and a compile that can be driven to exhaust a stack or heap is a write-path denial of service. This is the obligation most easily missed by a recursive-descent parser written without an explicit depth limit.
  • A missing field is Ok(false), never an error. matches is the hot path, and the third truth value is unknown rather than failure.
  • referenced_fields spells paths as the predicate source spells them (tx.gas, args.order.maker). None means this matcher cannot introspect itself, which forces every caller to assume any field may be read. That is why None is the default: it is the answer that cannot cause a source to skip work a monitor depended on.

Sink

The worked example above, in full. In summary:

  • Trait (crates/blockwatcher-ports/src/sink.rs): deliver is the only method, with no defaulted ones.
  • Reference modules: log, webhook, script (crates/blockwatcher-sinks/).
  • Selected on: a sink resource’s module.
  • Test infrastructure: FakeSink.
  • Obligations: the engine owns retry, backoff, and dead-lettering, so a sink that retries internally is a bug; it reports a classified SinkError::Delivery and returns. Both SinkEvent::Match and SinkEvent::Retracted are at-least-once, so a sink must be idempotent on match_id in both directions (Delivery guarantees).

Gate

  • Trait (crates/blockwatcher-ports/src/gate.rs): compile, on_hit, default no-op on_invalidate.
  • Reference modules: threshold, max_once (crates/blockwatcher-gates/).
  • Selected on: a monitor resource’s gate.module.
  • Test infrastructure: PassthroughGate (always Emit([this])).
  • Obligations: the engine owns the journal, so a module that opens storage is a bug. On invalidate the engine already prunes gate_hits with cursor > from; a module must not drain-all in on_invalidate. P11: two real modules plus the fake already satisfy the port; a third module is a new crate, not a core enum arm. See Gates.

Storage

  • Trait (crates/blockwatcher-ports/src/storage.rs): the widest of the six, covering resource CRUD under optimistic concurrency, checkpoints, dead letters, and the bounded delivery journal. Only put_batch is defaulted, as a plain loop over put.
  • Reference modules: memory and sqlite (blockwatcher-storage, whose § The Storage port contract is the canonical statement of the rules below).
  • Selected on: [storage] in blockwatcher.toml, instance-wide.
  • Test infrastructure: the only port with a shared behavioural contract, exercise_storage_contract. A new backend runs exactly the sequence memory, sqlite, and the ports fake already run, which is what proves it equivalent to them without sharing code. FlakyStorage wraps a backend to inject failures on top.

Obligations:

  • Optimistic concurrency, never a silent overwrite. expected_version: None creates and conflicts if the id exists; Some(v) updates and conflicts on mismatch, NotFound when absent. A create returns version 1. A rejected write must leave state untouched, which the contract checks directly.
  • list and list_checkpoints are unpaginated by contract. Resource and checkpoint counts are operator-scale, not event-scale, so a backend must not invent paging that callers would silently truncate against.
  • record_delivery prunes inside the same write. Rows whose cursor.primary falls strictly behind cursor.primary.saturating_sub(journal_depth) are dropped as part of recording, and re-recording a match id replaces its row. A backend that prunes lazily lets the journal grow without bound (Delivery guarantees § The delivery journal).
  • Round-trip values and cursors beyond a typed column’s range. Cursors are u64 and decoded values carry arbitrary-precision integers, so a backend built on signed 64-bit columns has to say how it stores them; the contract exercises exactly this.
  • Overriding put_batch is what atomicity means here. The default is correct everywhere and atomic nowhere: a mid-batch failure leaves earlier entries written. A backend with a native transaction overrides it, and the one caller that needs the guarantee, the seed path, says so in its own docs.

Extending to a new network

Two different changes both get called “adding a network”, and only one of them is code. Which one applies depends entirely on whether the chain’s family already has a decoder in the build.

flowchart TD
    q{"is a Decoder for this<br/>chain already registered?"}
    q -->|yes| conf["configuration only:<br/>Network + Spec resources"]
    q -->|no| fam["new chain family:<br/>new crate with a Source<br/>and a Decoder"]
    conf --> nothing["no crate, no feature flag,<br/>no allowlist entry"]
    fam --> steps["steps 1 to 6, twice,<br/>plus a feature flag<br/>and dep-gate entries"]

from-chain on that config-only path exists only when the family’s crate already registered a SpecImporter; adding a Network does not invent one.

A new network on a chain family the build already carries

A network on a chain family the build already carries is not a contribution at all. It is a Network resource naming a source module the build already registers and that module’s own endpoints, plus Spec resources whose chain matches that decoder. Nothing is compiled, no module is written, no allowlist changes, and the running instance picks it up through the control plane (Your first monitor walks the writes; Resources § The kinds and their fields is the field reference). This is the case that should cover most chains, and it is the payoff for the decoder boundary: the chain differs, the code does not. Whether POST /specs/{id}/from-chain is available for those specs is a property of the family crate already in the build, not of the new Network (HTTP API § Spec import).

A new chain family

A family blockwatcher has no decoder for is where code is written, and it is the one change that spans two ports at once: at minimum one Decoder claiming a new ChainKind and one Source producing raw events for it. Everything above the decoder, every predicate, the matcher, every sink, and all of storage, works against it unmodified, because none of it was ever written against any one family’s types to begin with. Write-time spec fetch is not a seventh catalog port: it is optional, keyed by ChainKind, and lives beside the family crate rather than on Decoder.

Beyond running steps 1 through 6 for each of the two modules, a new family touches:

  • One new crate, blockwatcher-<family>, holding both modules plus that family’s own sources::get_all() (Vec<SourceModule>) and decoders::get_all() enumerations. Copy an existing family crate’s layout: separate source/ and decoder/ trees, one registry module, one registry_examples/<name>.json per registered module. The decoder stays synchronous and RPC-free; chain RPC belongs in the source, and in an optional SpecImporter if the family wants from-chain.
  • A ChainKind value, returned by the decoder’s chain() and written on every Spec for that family. A new string, not a new variant, so no existing type changes. That string is not the source module’s name: a family can register stellar-rpc as the source module while decoder.chain() and every spec still say stellar.
  • A cursor mapping, chosen by the source and documented as its own trade-off, because journal_depth, lag reporting, and invalidate ranges are all denominated in it. Resume and finality are part of the same choice: whether the source hash-walks on reconnect, trusts cursor.primary, and whether it ever emits Invalidated. Write that down on the family page; do not assume the EVM source’s answers.
  • SourceCaps, declared by the source so the engine knows which canonical fields this family actually fills. If the family never has a reliable wall-clock, omit event_timestamps rather than inventing block.timestamp. If decoded events carry a chain_specific object, implement merge_interest so a later PUT of a selector can still compile against running sources.
  • Normalization onto the canonical value tree, so a decoded field on the new family is the same Value an existing predicate already reads (Chain-agnosticism § One value model, every chain).
  • Two registration blocks in crates/blockwatcher-embed/src/catalog.rs, one for the family’s sources and one for its decoders, feature-gated together, plus a feature flag named after the family in both crates/blockwatcher-embed/Cargo.toml and crates/blockwatcher/Cargo.toml, where the binary’s flag forwards to embed’s. This is the “brand-new family” case step 4 names. The binary crate must not take a production dependency on the family crate; operators reach the family through embed’s catalog fold.
  • An optional third fold in crates/blockwatcher-embed/src/importers.rs when the family implements SpecImporter. The family’s spec_importers::get_all() is keyed by ChainKind, not by source module name; embed folds that map and core looks up by the spec’s chain. A family can ship without this: EVM has none, and from-chain then returns chain_not_supported. That third file is an explain-why against the cost-of-change budget, not a seventh ModuleRegistry port (Modules and trade-offs).
  • Dependency-gate entries: an ALLOW_BLOCKWATCHER_<FAMILY> list of the crate’s direct dependencies, a FAMILY_EXEMPT_BLOCKWATCHER_<FAMILY> entry for the chain SDK it deliberately pulls, and the crate’s name added to ALLOW_BLOCKWATCHER_EMBED. Do not add the family crate to the binary’s ALLOW_BLOCKWATCHER list. The exemption relaxes only the transitive family denylist, never the direct allowlist, and it relaxes it only for the families named: the forbidden edge from a chain SDK into the core ring stays blocked (Chain-agnosticism § The CI gate that proves it).
  • Errors that name endpoint names, never URLs, on any path that talks to chain RPC (source fetch, importer, pool). The HTTP surface already translates those into operator-facing codes; leaking a node URL into a log or a 503 body is a family-crate bug, not an API one.
  • A golden-file suite pairing every schema declaration the decoder produces with the event shape it decodes to, which is the per-module form of the field-name contract above. A source that talks RPC also needs blockwatcher-testkit HTTP fixtures rather than a live node in CI.
  • Documentation: a crate page under Workspace map and an operator family page with a catalog row per module, so the new modules are visible to an operator choosing between them. How one family filled the optional importer, resume, and address-shape choices belongs on that family page, not here. Documented operator-facing behavior also needs a row in What we claim.

A second source for a family that already has a decoder, such as a different acquisition strategy, is not this case. It is the cheap case from step 3: one new file plus one entry in that crate’s existing sources::get_all().

Documenting trade-offs where a reader will see them

Architecture decisions § Modules are how an operator makes trade-offs states the rule: a module writes down its own trade-offs where a reader choosing between modules will actually see them, not in a document separate from the code. log’s crate-level doc comment does exactly that, right above the code it documents (log.rs): zero external dependencies and zero configuration make it the cheapest way to smoke-test a pipeline end to end, delivery is only as durable as whatever consumes stdout, there is no acknowledgement beyond the write succeeding, and a composition root that selects it must route diagnostics elsewhere or an interleaved non-JSON line breaks a consumer’s parse.

The registration function’s own doc comment, on Registry (log.rs), carries the second half of the same idea, pointing a reader at LogSink itself for the trade-offs above and explaining why the example below it cannot go stale: the example is include_str!-ed straight from the module’s own registry_examples/log.json, the same file the family-completeness test in step 6 reads, so the text a reader sees in rendered documentation and the text a test actually constructs from cannot diverge:

#![allow(unused)]
fn main() {
#[doc = concat!("```json\n", include_str!("registry_examples/log.json"), "```")]
pub struct Registry;
}

(log.rs:99-100)

Modules and trade-offs § The module catalog is where every shipped module’s trade-off, log’s included, is collected into one exhaustive table; this page does not repeat that table.

The cost-of-change budget

Architecture decisions § The cost-of-change budget states the number precisely: adding a new module should touch one new file (or one new crate) plus one registration line in the composition root, and nothing else. Reading the steps above against that budget:

  • A module added to a family a build already carries (another sink beside the ones a build already registers) is the cheap case: one new file (the module itself) plus one line in that crate’s own get_all(). It touches no core crate, no composition-root file, and no allowlist entry unless the new module’s own dependencies demand one.
  • A brand-new family joining the binary for the first time is the one case that also touches the composition façade: two registration blocks in crates/blockwatcher-embed/src/catalog.rs (sources and decoders), and, if the family lives in a new crate, one new ALLOW_BLOCKWATCHER_<CRATE> entry plus the crate’s own name added to ALLOW_BLOCKWATCHER_EMBED. The binary does not take a production dependency on the family crate, so that name does not join ALLOW_BLOCKWATCHER. A SpecImporter is a third fold, in crates/blockwatcher-embed/src/importers.rs; that extra file is the explain-why the budget asks for, not a reason to skip the importer. This is the “one registration line” the budget names, and a change that needs more than that is expected to explain why, the same way a size-budget overrun is a prompt to ask a question rather than an automatic rejection.

Using a module once it is compiled in

Everything above is the contributor side: getting a module into a binary. An operator who already has that binary selects the module by name, in whichever resource or instance-config section that module’s port family is configured on:

Port familySelected onCovered in
Sourcea network resource’s source.moduleResources § The kinds and their fields
Decoderautomatic, from a spec’s chainResources § The kinds and their fields
Sinka sink resource’s moduleResources § The kinds and their fields
Matcher[engine].matcher in blockwatcher.toml, instance-wideConfiguration reference § [engine]
Gatea monitor resource’s gate.moduleGates
Storage[storage] in blockwatcher.toml, instance-wideConfiguration reference § [storage]

Every one of these is the same envelope: a module name plus that module’s own opaque config object. A name the running binary never registered, whether because of a typo or because the build simply was not compiled with that module’s crate or feature flag, is refused at write or boot time with the list of names this particular build actually carries, never a superset the workspace merely contains somewhere else (Resources § Write-time validation covers the refusal path for resources in full; blockwatcher (binary) § Feature-flag wiring covers exactly which registrations a feature flag removes).

Architecture decisions

This page is the system invariants: the shape the workspace must keep, and why a change that would alter that shape is either wrong or a written amendment of the invariant itself. How a pull request lands (checklists, comments, claims process, size numbers, CI) lives on Conventions. That page is not a second copy of these decisions; it is the guardrail that keeps a change from quietly violating them.

The contributor-facing rulebook these pages restate is a local, project-internal document that this wiki does not link to or quote. A reader never needs the original to understand why the workspace is shaped the way it is, or what a change must not forget.

Four goals sit above every individual rule, and each rule below traces back to one of them:

  1. A monitor that quietly misses an event has failed at its one job, so correctness of delivery outranks everything else.
  2. Adding the next chain, source, or sink should cost about what adding the first one did, measured concretely as how many files a contributor has to touch.
  3. One way to solve a given problem, applied everywhere it recurs, beats a clever alternative used once: a codebase with a single learnable shape stays cheap to extend long after the person who wrote a rule has moved on.
  4. No wasted decode or eval on the hot path, and never at the cost of the three above.

Key takeaways

  • This page is the system invariants. Conventions is what a change must not forget. Neither page is a copy of the other.
  • Four goals sit above every rule: correctness of delivery outranks everything else, adding the next module should cost about what the first one did, one way to solve a recurring problem beats a clever alternative used once, and efficiency never trades away the three above.
  • Every rule below is a hard constraint: a change that violates one is either wrong or the rule itself needs to be amended in writing first.

The binding rules

Each of the following is treated as a hard constraint, not a guideline: a change that violates one is either wrong or the rule itself needs to be amended in writing before the change lands.

Chain knowledge stays out of the core

blockwatcher-types, blockwatcher-ports, blockwatcher-core, and blockwatcher-api (the core ring) may never depend on a chain SDK, whether directly or through some other dependency’s own dependency tree. Everything that knows a specific chain’s wire format lives in a module crate instead. This is not policed by a human remembering to check; scripts/check-dep-graph.sh reads each crate’s actual manifest and its full transitive dependency tree in CI and fails the build the moment a forbidden package family shows up anywhere in a core crate’s closure. The script is built as an allowlist of what a crate may depend on, not a blocklist of what it may not: a blocklist only ever catches the specific package someone thought to write down in advance, while an allowlist rejects anything unrecognized by default, including a brand-new chain SDK nobody has heard of yet.

There is exactly one way behavior varies

When blockwatcher needs pluggable behavior, it reaches for one mechanism: a Rust trait that can be called through a dyn pointer, implemented by whichever module wants to provide that behavior. Nothing in core ever branches on which chain it is talking to. A chain-specific value crosses from a module into core as an opaque payload carrying a tag naming which chain family produced it, and only that same family’s own module code is allowed to open the payload back up. Core just carries it through. Wherever blockwatcher’s vocabulary needs a name that a chain family invents for itself (an event kind, a namespace inside a predicate), that name is an open slot a family fills in on its own rather than a fixed list core would have to grow for every new chain. Vocabulary that core itself owns completely and reasons about exhaustively (the canonical value model, the error-retry categories, and what a sink may receive — today Match or Retracted { match_id } under one dyn Sink / deliver) stays a closed set on purpose, because matching over it exhaustively is exactly what keeps that vocabulary safe to extend. A new kind of delivery must earn a closed variant here, not arrive as an untyped side channel.

Modules are how an operator makes trade-offs

Every configurable piece of behavior (a source, a decoder, a matcher, a gate, a sink, a storage backend) is a module: it implements exactly one trait, declares its own name and how to build itself right next to its own code (never in a table shared across modules that could fall out of sync), validates its own configuration and rejects anything it does not recognize, and writes down its own trade-offs (cost, latency, how complete its coverage is) where a reader choosing between modules will actually see them. There is exactly one registration path for a module to become selectable; a second, faster-feeling way to wire one in is treated as a defect the moment it appears, because letting two registration patterns coexist is how a codebase quietly stops having one learnable shape.

A module’s capabilities have two homes, and the split is which question they answer

SourceCaps is instance level: it is read from a constructed source and answers what this source, built from this config, guarantees. Monitor writes consult it, which is why a gate is refused against a feed whose events carry no block.timestamp.

SourceModuleCaps is module level: it answers what is true of the module before any config exists, which is what lets GET /catalog advertise a capability without constructing anything. A catalog that had to build a source to describe it would need credentials and network access to answer a read.

A fact belongs at module level when a catalog must answer it, and at instance level when the answer can depend on config. A fact must not live in both.

A trait is only trustworthy if something fake proves it

Every trait behind which behavior varies ships with an in-memory implementation that is exercised in CI the same way a real implementation would be. These fakes are not test convenience; they are the standing proof that a trait’s shape leaks nothing chain-specific or backend-specific: a change to a trait that a fake cannot satisfy is a signal that the trait’s design needs another look before anything else about the change gets reviewed.

Foreign shapes get translated once, at the door

Each place blockwatcher’s code meets something foreign (a chain’s contract artifact, a chain’s native value encoding, an SDK’s own error type) converts it into blockwatcher’s own vocabulary at that single point of contact, inside the module that owns the boundary, and nothing further downstream ever has to re-learn the foreign shape. A contract artifact becomes a chain-agnostic compiled schema once, at write time, inside the deciding module’s own decode step. A chain’s native value becomes a canonical value model entry inside the same module’s decode function, which is why there is never more than one implementation of value comparison or evaluation regardless of how many chains blockwatcher supports. An SDK’s error type becomes the relevant port’s own error enum inside that module’s adapter code. Core is never handed a raw SDK error type to interpret.

Data moves forward, on purpose, and never silently

Stages inside a running pipeline hand work to each other over fixed-capacity queues, and a stage that produces faster than the next one can keep up simply waits: this is backpressure, and it replaces every form of “drop the newest thing because the buffer is full.” Any channel whose job is to carry something that must be delivered may never be a lossy one; a lossy channel is reserved for observability taps whose drops are themselves counted and which never carry anything load-bearing. Delivery is guaranteed at least once: the position a pipeline resumes from after a restart only moves past an event once every consequence of that event has either reached its destination or been recorded as undeliverable, so a crash can produce a repeated notification, never a missing one, and every notification carries an identifier a consumer can use to recognize a repeat. An undo is the same class of work as an apply: Retracted shares the delivery helper, retry budget, dead-letter write, and completion guard that Match uses — not a second retry stack and not a best-effort log line. Where a later invalidate must name what was already delivered, that fact is journaled before the completion guard completes; retention of that journal is bounded, and anything an invalidate can no longer reach is counted and logged rather than dropped quietly. A gate Retain/Discard completes with outstanding 0 and does not stall that prefix; only an Emit journals a delivery. On invalidate the engine prunes gate_hits by cursor (cursor > from), then retracts already-emitted matches as today. The operational sequence (drain, prune, retract, rewind, restart) and the retention knob live in Delivery guarantees, not as a second pattern beside this one. See Gates for the sixth port’s axis: when a predicate-true hit becomes a delivery.

Anything short of success is a value, not a crash

States like “nothing new yet,” “running behind,” or “given up after retrying” are ordinary return values an operator can query through the status API, not Err results that vanish into a log line. Anything that does require operator attention carries enough structured detail (what failed, where, how badly) to act on without reproducing the failure by hand; only truly incidental internal failures get away with a plain string. Every fallible call’s result is used somehow: handled, returned, or logged with enough context to matter; discarding a result silently is treated the same as never having called it. A positively detected deep invalidate is the same kind of value: the source returns it from run as a typed outcome, not a SourceError, and core never names a chain, a reorg, or a confirmation depth — only the invalidate. The module that talks to the chain decides when a fork is deep enough and translates at its own boundary. How the engine drains, retracts, and rewinds after that outcome is part of Delivery guarantees.

The system checks itself before it starts, and never takes its neighbors down

Nothing gets constructed at boot until every piece of configuration for every module (including one that will not actually be used) is confirmed valid, and until every persisted resource is confirmed to still compile against its schema. Any failure at this stage aborts the process with a message that lists the valid alternatives rather than leaving the operator to guess. Once running, a module that starts failing degrades its own corner of the system loudly (counted errors, visible status) and never brings down the modules around it, and never fails in a way nothing logs or counts.

Anything reusable is compiled once, ahead of the event that needs it

A predicate, a selector, or any other user-supplied specification is parsed, type-checked, and turned into a ready-to-run artifact the moment it is written (through the API or at boot from a seed file), never while an event is actually flowing through the pipeline. A typo in one of these becomes an immediate, specific rejection at write time rather than a monitor that silently never fires. The path a live event actually travels does the minimum possible work: run the already-compiled selector, run the already-compiled predicate, nothing else.

Dependencies always point toward the vocabulary, never toward the engine

A module or library crate may depend on the vocabulary crates (blockwatcher-types, blockwatcher-ports) but never on blockwatcher-core itself, which is what keeps every module usable from some other binary entirely, not only from blockwatcher’s own composition root. A module crate that cannot be linked from a second binary without pulling in the engine has leaked engine into the module. blockwatcher-embed is the standing in-tree consumer of that property: it folds a catalog without being the blockwatcher binary. The glue crates (blockwatcher and blockwatcher-embed) are the exception that may depend on blockwatcher-core: they assemble a process, they are not modules. The vocabulary crates in turn depend on almost nothing: blockwatcher-types pulls in serialization support and nothing that assumes an async runtime, a web framework, or a chain SDK. A crate that genuinely needs a heavy dependency is the one crate that carries it, rather than spreading it into something shared. Re-exporting another crate’s items is always explicit, never a blanket glob, so a reader can see exactly what a crate’s public surface actually is.

Nothing gets built before two real users need it

An abstraction earns a place in the codebase once there are two genuine implementations behind it, or one implementation plus a fake standing in for a second design that has actually been thought through, not because some earlier, unrelated project happened to have that feature. When it is unclear whether something is needed yet, the default is to leave it out; the module system exists specifically so a genuine need can be added later without redesigning anything.

Every signal names what produced it

A metric, a log line, or a status readout always carries enough identity (which module, which pipeline, which upstream endpoint) that a reader can act on it without cross-referencing something else first. Reporting only that something is failing, without saying which module or which endpoint, does not count as observability. Anything the system drops, caps, or truncates is counted and logged rather than disappearing quietly.

A port is surrounded by exactly three kinds of code

A port is a primitive effect. The only code allowed around one is a decision (answers a question from cheap local state and returns a verdict; no I/O, no port trait, no completion guard, no checkpoint), an effect (one port call, classified), or a driver (ordering, guard and checkpoint lifetime, retry, metrics, and invariants that span stages: one driver per port per pipeline stage). Logic belongs in the outermost layer that can hold it. A decision that cannot be tested with an injected clock and no I/O is not a decision. Which layer a file is must be obvious from its imports; the mechanical import limits are on Conventions.

Operator-facing features are one of two classes

A feature is either a transparent optimization (the observable event stream is identical on or off: bloom screening, header batching, receipt concurrency, endpoint selection) or a semantic policy (it changes what the operator sees: throttle, aggregation, retry and dead-letter). The classes never share a mechanism. A third-party optimization cannot be accepted unreviewed, because its whole safety is a losslessness proof someone must verify. A third-party semantic policy can, because its blast radius is bounded when the driver keeps the guards. This split is enforced by review, not by a gate: no mechanical test distinguishes an optimization that is genuinely lossless from one whose author believed it was. What each class owes (proof, interlock, defaults) is on Conventions. A feature that cannot say which class it is has not been designed.

A published guarantee is a claim

A sentence the wiki, rustdoc, or operator surface makes as a guarantee is a claim, not a hope. There is one claims matrix. Shipping the sentence without a home on that matrix is not allowed; adding the test later is not a claim. The triple that lands in the same change, and the matrix itself, are on Conventions and Claims.

Instance config and resources are different planes

Instance config parameterizes construction (file or env, boot-time, immutable at runtime). Resources are storage-backed, API-mutable, and hot-reloaded. The two are never mixed: a boot-time choice does not become a hot-reloaded resource by accident, and a resource does not silently act as instance config. Field-level strictness is on Conventions.

Growth past a size budget is a question, not an automatic rule break

When a file, a crate, the core ring, or a port trait’s method count gets large, the question is whether a concept is missing that would make the code smaller again. An overrun is allowed once that question has an answer. A test suite that pins down a genuinely hard-won invariant is judged by whether it still reads as proof of one concept, not by its line count. The numbers, the production-line definition, and the registry that records an unanswered overrun are on Conventions.

The cost-of-change budget

The clearest number in the whole rulebook is this one: adding a new module should touch one new file (or one new crate) plus one registration line in the composition root, and nothing else. Bumping a chain SDK’s version should force a rebuild of one crate (the module crate that chain family lives in), not the rest of the workspace. Both numbers are targets a reviewer checks a change against, not hard gates a script enforces; a change that touches more files than that budget suggests is expected to explain why, in the same way a size budget overrun is a prompt to ask a question rather than an automatic rejection.

How the dependency gate turns rules into a mechanical check

Every Rust crate in the repository goes through the same two-layer check on every commit, whether or not it belongs to the workspace:

flowchart TD
    crate["a crate in any Rust tree"] --> direct{"every direct dependency<br/>on this crate's own allowlist?"}
    direct -->|"no"| fail["ci fails,<br/>naming crate and package"]
    direct -->|"yes"| tree{"full transitive tree<br/>free of denylisted families,<br/>or exempted?"}
    tree -->|"no"| fail
    tree -->|"yes"| pass["ci passes"]

Most of the rules above would be easy to state and easy to drift away from without something outside code review holding them in place. scripts/check-dep-graph.sh is that something, and it runs on every commit in CI. It works in two layers:

  1. A direct-dependency allowlist, per crate. For every crate in the repository — a workspace member, or a crate with its own [workspace] and its own lockfile — the script names exactly which other crates and external packages it may declare in [dependencies]. A crate with no entry in this list fails outright: there is no default “allow unless denied,” so a brand-new crate that forgets to declare its intended shape fails loudly instead of inheriting whatever the crate next to it was allowed. Sitting outside the workspace is not a way to skip the question: a separate lockfile changes where versions are pinned, not whether the dependency was reviewed. The exception is a tree nothing consumes — a cargo-fuzz harness driving the crate beside it — which is still named so it cannot appear unnoticed, but is not asked what it exports, since it exports nothing.
  2. A transitive family denylist, checked against the crate’s full dependency tree. Even a dependency the allowlist approves might pull in a chain SDK or an HTTP stack of its own, so the script also walks each crate’s entire resolved dependency tree (via cargo tree, across every feature) looking for package-name prefixes belonging to forbidden families: chain SDKs, HTTP clients, storage drivers, web frameworks. A crate gets an exemption from one specific family only when its own direct allowlist already approved that exact family on purpose (the crate that talks to sqlite is allowed to have sqlite show up in its tree; nothing else is exempted for it).

Layered together, these two checks are what let this wiki describe the workspace’s rings as verified facts rather than a design intention: a core crate cannot quietly grow a chain dependency without a CI failure naming exactly which crate and which package tripped the check, and a module crate cannot quietly become a second way to depend on the engine, because “may this crate depend on blockwatcher-core” is itself one of the questions the allowlist answers.

The same script also has two crate-shaped rules beyond the two general layers: it checks by name that nothing outside [dev-dependencies] depends on the test-scaffolding crates (blockwatcher-testkit, blockwatcher-evm-testkit, blockwatcher-stellar-testkit), since an ordinary allowlist miss would only catch that accidentally; and it gives one crate (the one whose entire purpose is driving black-box tests against the real binary) a deliberately empty allowlist, so that crate fails the check the moment it declares any production dependency at all, by construction rather than by someone remembering to keep it empty.