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

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 and evm-mempool sources and the EVM log decoder, built on blockwatcher-rpc and the alloy SDK.

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-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 and blockwatcher-evm-testkit are checked by name to confirm no crate outside [dev-dependencies] depends on either (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) 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, and blockwatcher-evm. Each either carries its own single-family exemption for the reason it exists (blockwatcher-storage for rusqlite, blockwatcher-sinks for reqwest, blockwatcher-evm for alloy and its transport crates, 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-evm-testkitblockwatcher-types, blockwatcher-evm, blockwatcher-rpc
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-storage, blockwatcher-gates, blockwatcher-expr, blockwatcher-evm, blockwatcher-sinks
blockwatcher (binary)blockwatcher-types, blockwatcher-ports, blockwatcher-core, blockwatcher-embed, blockwatcher-api, blockwatcher-metrics, blockwatcher-storage
blockwatcher-e2enone

That is 36 edges across 16 nodes, most of them 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 all 36 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), blockwatcher-evm’s direct edges to blockwatcher-types and blockwatcher-ports (reachable via blockwatcher-evm → blockwatcher-rpc), blockwatcher-embed’s direct edge to blockwatcher-core’s own dependencies, and four of the binary’s seven direct edges (blockwatcher-types, blockwatcher-ports, and blockwatcher-core are reachable via blockwatcher → blockwatcher-api; blockwatcher-storage is reachable via blockwatcher → blockwatcher-embed). No edge is added that is not in the table above; none of the 16 nodes or 36 edges 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]
  end

  subgraph glue["glue / test"]
    embed[blockwatcher-embed]
    bin[blockwatcher]
    testkit[blockwatcher-testkit]
    evmtestkit[blockwatcher-evm-testkit]
    e2e[blockwatcher-e2e]
  end

  ports --> types
  core_ --> ports
  api --> core_

  expr --> ports
  rpc --> ports
  storage --> ports
  sinks --> ports
  gates --> ports
  evm --> rpc

  evmtestkit --> evm
  testkit --> ports

  embed --> core_
  embed --> storage
  embed --> expr
  embed --> evm
  embed --> sinks
  embed --> gates

  bin --> api
  bin --> metrics
  bin --> embed

blockwatcher-metrics and blockwatcher-e2e 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), 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 those two crates).