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.85. 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", "expr", "sinks"]
evm = ["blockwatcher-embed/evm"]
expr = ["blockwatcher-embed/expr"]
sinks = ["blockwatcher-embed/sinks"]
Each forwards onto the matching blockwatcher-embed feature, which is what
actually links the family’s 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.
| Feature | Off removes |
|---|---|
evm | Both EVM sources (evm-rpc, evm-mempool) and the evm decoder, the EVM module family that ships with blockwatcher (see Modules and trade-offs for how other chain families would plug in). A build without it has no source and no decoder registered at all, so it can watch nothing. |
expr | The expr matcher, the predicate engine that ships with blockwatcher (see Modules and trade-offs for how other matcher engines would plug in). A network can still be configured, but the engine’s [engine].matcher has nothing to select: booting refuses, naming expr as unavailable. |
sinks | All 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 (say, evm and sinks without expr, if a different matcher module
were added later) uses Cargo’s usual --no-default-features --features
combination:
cargo build --release -p blockwatcher --no-default-features --features evm,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:
| Code | Meaning |
|---|---|
0 | A clean drain (or a stop signal that arrived before boot finished), or a check that passed. |
1 | A configuration, seed, or boot failure, or a check that refused. |
2 | Shutdown reached its drain deadline with at least one pipeline still running work, and aborted it. |
64 | A 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.88-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.)
For a full local stack (blockwatcher plus the companion dashboard), docker compose from the repository root is the supported path; see
The dashboard.