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

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.

FeatureOff removes
evmBoth 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.
exprThe 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.
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 (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:

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.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.