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

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)"]
    sources["Sources<br/>evm-rpc · evm-mempool"]
    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.

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.

That separation is what a chain-agnostic decoder boundary actually buys: a second chain family arrives as a new 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 the first 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"] --> mod["module crate<br/>blockwatcher-evm"]
    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, but every family it 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.