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
Valueenum; nothing past the decoder (a matcher, a gate, a sink) ever needs to know which chain produced it. Int/Uintare arbitrary-precision andAddressis canonical bytes, so no chain’s values lose precision or collide with another chain’s.blockwatcher-coredepends 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.shenforces 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
DecoderandSource; 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:
IntandUinthold 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 ani64without losing bits; aBigInt/BigUinthas 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, perValue’s own doc comment and thedecimalmodule’s serialization rules (value.rs). This is also why match ids hashValuetrees directly, inMatchId::derive’seat_valuestep (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.
Addressis 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 onValue::Addresscalls 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 toMapkeys: they are alwaysString, 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.