Configuration reference
blockwatcher boots from exactly one instance configuration: an optional TOML
file layered with BLOCKWATCHER_* environment overrides, read once at process
start and immutable for the rest of the run
(crates/blockwatcher/src/config.rs). It covers only the process’s own
plumbing: the API listener, the metrics listener, the storage backend, and
the engine’s runtime tunables. Everything else (networks,
specs,
sinks, and
monitors) is resource
configuration: storage-backed, mutated through the
HTTP API or loaded once from a seed directory, and never read
from blockwatcher.toml.
InstanceConfig (crates/blockwatcher/src/config.rs) and every section
inside it reject an unrecognized field (#[serde(deny_unknown_fields)]), so a
typo’d key fails the boot rather than being silently ignored. Every section
also defaults, so an empty file (or no file at all, given at least one
recognized environment override) boots a working instance: an in-memory
store, no API, no metrics.
Loading a configuration
blockwatcher [--config <path>] [--seed <dir>]
Three layers, each one able to override the layer before it:
flowchart LR
def["InstanceConfig::default()"] --> file["--config file<br/>TOML, if given"]
file --> env["BLOCKWATCHER_* env vars<br/>win over both"]
env --> final["final InstanceConfig"]
load (config.rs) builds the configuration in three layers:
InstanceConfig::default(): every section’s own default.- The TOML file at
--config <path>, if given. A file that fails to read or parse refuses the boot naming the path, never the offending value. BLOCKWATCHER_*environment variables, which win over both of the above.
Nested keys use a double underscore: BLOCKWATCHER_API__LISTEN,
BLOCKWATCHER_STORAGE__MODULE. Only variables whose name
contains __ after the BLOCKWATCHER_ prefix are read as overrides
(env_provider, config.rs): a variable such as BLOCKWATCHER_API_TOKEN,
the environment variable a token’s secret = "env:BLOCKWATCHER_API_TOKEN" reference
names, has no __ in it and is never mistaken for a config key. The token
table itself is file-based: Figment numbered env keys cannot spell a
sequence, so [[auth.tokens]] rows are not overridden from the
environment.
Two aliases exist for one deprecation cycle: BLOCKWATCHER_API_LISTEN and
BLOCKWATCHER_METRICS_LISTEN, equivalent to BLOCKWATCHER_API__LISTEN and
BLOCKWATCHER_METRICS__LISTEN respectively, applied after everything else so they
still win when both an alias and its nested form are set
(apply_listen_aliases, config.rs).
Neither a --config path nor a single recognized BLOCKWATCHER_* override is
optional: at least one must be present, or the boot refuses with:
no configuration source: provide --config <path> or set BLOCKWATCHER_* variables
(e.g. BLOCKWATCHER_STORAGE__MODULE, BLOCKWATCHER_API__LISTEN)
This is deliberate: an empty file and “no configuration at all” would otherwise both boot the same all-defaults instance, and the second of those is far more likely to be a mistake than an intent.
[api]
The REST control plane. Disabled by default: a listener that appears without an operator having asked for one is a listener nobody remembered to put a token on.
| Key | Type | Default | Meaning |
|---|---|---|---|
enabled | bool | false | Serves the HTTP API on listen when true. |
listen | string (socket address) | "127.0.0.1:8080" | Address the API listener binds. Parsed at boot; a value that doesn’t parse as a socket address refuses naming [api].listen. |
GET /health on this listener is the only path that answers without a
bearer token. Compose healthchecks curl it with no header. Every other
path requires a token from [auth].
[auth]
The labelled bearer table. Required (and must be non-empty) when
[api].enabled is true. Each row is one credential: a label audit
records name, a scope (read, operate, or admin), and a secret
that is an env:NAME reference, never a literal. Boot refuses an empty
table, two rows that resolve to the same secret (naming the labels, not
the value), an unresolvable reference, or a secret that is not of the
env:NAME form.
read < operate < admin. The minimum is attached per MethodRouter,
not by path prefix: GET of a resource is read; pause/resume/replay
are operate; PUT/DELETE of networks, specs, sinks, and monitors are
admin. Creating a script sink is therefore not the same privilege as
reading the dashboard.
| Key | Type | Default | Meaning |
|---|---|---|---|
tokens | array of { label, scope, secret } | [] | The table. Empty when the API is disabled; refused when the API is enabled. |
[metrics]
The Prometheus scrape listener. Disabled by default for the same reason as
[api].
| Key | Type | Default | Meaning |
|---|---|---|---|
enabled | bool | false | Serves /metrics on listen when true. |
listen | string (socket address) | "127.0.0.1:9090" | Address the metrics listener binds. Parsed the same way as [api].listen. |
[storage]
Which storage module backs every
resource and every checkpoint and
dead letter. This section is a
ModuleSel (a module name plus that module’s own opaque config object), the
same envelope every other module family uses
(crates/blockwatcher-types/src/resource.rs).
| Key | Type | Default | Meaning |
|---|---|---|---|
module | string | "memory" | Which storage module to construct. |
config | object | {} | The named module’s own config. Shape depends on module. |
blockwatcher ships memory and sqlite storage modules. memory takes no
config at all: it persists nothing, which is the floor that lets an
instance boot with zero setup and never a durable deployment. sqlite
takes:
| Key | Type | Default | Meaning |
|---|---|---|---|
path | string | none (required) | The database file, or the literal ":memory:" for a database that dies with the process (fine for tests, not for anything meant to survive a restart). |
busy_timeout_ms | u64 | 5000 | How long a call waits on a lock an external reader holds before giving up. |
[engine]
The core pipeline’s own tunables, as an operator writes them
(EngineSection, crates/blockwatcher/src/config.rs). Every field here is
optional; an unset one is left out of the value core’s own deserializer sees
(engine_config, config.rs), so the default that applies is
whatever blockwatcher-core’s EngineConfig itself defaults to. This binary
never restates a number core owns. matcher is the one field this rule
doesn’t quite cover: core has no sane default matcher to fall back to on its
own, so when this key is unset the binary substitutes the
matcher module it was compiled
with (expr, when the expr feature is on) rather than leaving the boot to
fail on a genuinely absent value.
| Key | Type | Config default (unset) | Meaning |
|---|---|---|---|
event_channel_capacity | usize | 256 | Bound on the channel between a network’s source and its decode-and-match stage. |
sink_channel_capacity | usize | 64 | Bound on the channel feeding each sink worker. |
drain_deadline_ms | u64 | 10000 | How long a shutdown waits for in-flight matches to finish delivering before aborting the pipeline (exit code 2). |
journal_depth | u64 | 1024 | How many cursor-primary units of delivered Match ids the delivery journal retains (EVM: block numbers). Always on; there is no disable switch. Must sit well above the source’s confirmation window so a deep invalidate can still retract by id. Gaps past the window are counted, never silent. |
dead_letter_retention | u64, optional | unset (None) | How many dead letters one network keeps. Omitting the key keeps every letter, which grows without bound against a sink that keeps failing. A positive value is the cap: recording a letter drops the oldest beyond it in the same write, counted as blockwatcher_dead_letters_pruned_total and logged — a dropped letter can no longer be listed or replayed. 0 is refused at boot: zero is not a spelling of unbounded. A cap above i64::MAX is also refused. |
default_retry | table, see below | see below | Delivery retry policy used by any sink that doesn’t set its own retry. |
source_restart | table, see below | see below | Backoff policy for restarting a pipeline whose source exits on its own. |
matcher | table ({ module, config }) | compiled-in matcher | Which matcher module evaluates every predicate. Selected once for the whole instance, never per monitor. |
default_retry is the same DeliveryRetry shape a SinkDef’s
own retry field carries, validated at boot the same way a sink’s own
retry override is validated at write time and at boot:
| Key | Type | Default | Meaning |
|---|---|---|---|
max_attempts | u32 | 8 | Total delivery attempts, not retries after the first: 1 delivers once and dead-letters on failure; 0 is treated as 1. |
initial_backoff_ms | u64 | 200 | Delay before the second attempt. Above max_backoff_ms refuses: default_retry.initial_backoff_ms (30001) must be at most default_retry.max_backoff_ms (30000). |
max_backoff_ms | u64 | 30000 | Cap on the doubling backoff between attempts. |
source_restart governs the supervisor that restarts a pipeline whose source
task exits before its own cancellation fires, validated at boot on the same
terms:
| Key | Type | Default | Meaning |
|---|---|---|---|
initial_backoff_ms | u64 | 500 | Delay before the first restart attempt. Above max_backoff_ms refuses: source_restart.initial_backoff_ms (60001) must be at most source_restart.max_backoff_ms (60000). |
max_backoff_ms | u64 | 60000 | Cap on the doubling backoff between consecutive restarts. |
reset_after_ms | u64 | 300000 | How long a network must run without exiting again before its consecutive-exit count resets to zero. |
An invalid default_retry, source_restart, or dead_letter_retention
fails Engine::start with InvalidEngineConfig, naming the offending
field: process config, not a stored resource, so it carries no resource
kind or id the way a rejected sink policy does.
matcher is a ModuleSel like [storage]: a module name and that
module’s own config object. The shipped expr module takes no config.
Secrets: the env: indirection
A token’s secret is never a literal token: it is a reference of the form
"env:NAME", parsed by SecretRef (crates/blockwatcher-types/src/secret.rs) and
resolved by reading the named environment variable at the moment it’s needed,
never cached and never logged. A value that isn’t of that form refuses the
boot naming the required shape and never echoing what was actually written,
because the likeliest mistake feeding this key is pasting the real token
where a reference belongs. AuthTokenEntry’s own Debug implementation is
hand-written for the same reason: it renders secret as <redacted>
whenever one is set, so a panic message or a boot-failure log line can never
carry it (config.rs). When the API is enabled, boot also refuses a
label that is empty, a label used by more than one row, and a reference
that resolves to an empty string: audit names the label, and an empty
secret would start a listener that can never authenticate.
This indirection isn’t unique to the token table: it’s the general shape blockwatcher
uses anywhere a secret has to live in a config object: a webhook
sink’s url_secret field, and an
evm-rpc or
evm-mempool network’s endpoint
URLs, are all env:NAME references resolved the same way, per use, by the
same SecretRef type. The instance config surfaces those fields on
[[auth.tokens]], because they are the secrets this file itself carries;
every other env: reference lives in resource configuration (network,
sink), set through the API or a seed directory rather than blockwatcher.toml.
CLI flags
Parsed by hand, not through a flag-parsing crate, in parse
(crates/blockwatcher/src/cli.rs):
| Flag | Applies to | Meaning |
|---|---|---|
--config <path> | blockwatcher [run], prune-checkpoints | Instance configuration TOML. Optional for the default run command when BLOCKWATCHER_* env vars supply the whole instance config; required for prune-checkpoints. |
--seed <dir> | blockwatcher [run] | Seeds resources from <dir> on a first boot only: a store that already holds any resource is left untouched. |
--dry-run | prune-checkpoints | Lists orphaned checkpoint rows without deleting them. |
Three commands, no explicit subcommand name for the default one:
blockwatcher [--config <path>] [--seed <dir>] # boot and run
blockwatcher check <dir> # validate a seed directory
blockwatcher prune-checkpoints --config <path> [--dry-run]
blockwatcher --help # or -h
blockwatcher --version # or -V
check <dir> validates a seed directory against the modules actually
compiled into the binary and constructs every one of them, so every secret
a seed’s configs reference through env:NAME must be present in check’s
own environment, exactly as it would need to be at a real boot.
prune-checkpoints deletes checkpoint rows whose network resource is gone;
deleting a network deliberately leaves its checkpoint behind so a
re-created id can resume, and this command is the offline sweep for orphans
that will never come back.
Exit codes (cli.rs):
| Code | Meaning |
|---|---|
0 | A clean drain (or a stop during boot), or a check that passed. |
1 | A configuration, seed, or boot failure, or a check that refused. |
2 | A shutdown that aborted at least one pipeline at the drain deadline. |
64 | A command line this binary could not parse. |
RUST_LOG sets the log filter; unset, it defaults to info rather than
silence, so the shutdown report, seeding warnings, and restart notices are
still visible by default (init_tracing, crates/blockwatcher/src/lib.rs).
Every diagnostic goes to stderr, never stdout. Stdout, when running with no
[api] at all, is reserved for sink-event JSON (tagged type: match or
type: retracted).
Full annotated example
# blockwatcher instance configuration. Resources (networks, specs, sinks,
# monitors) are managed through the HTTP API or a --seed directory, never
# through this file.
[api]
enabled = true
listen = "0.0.0.0:8080"
[[auth.tokens]]
label = "operator"
scope = "admin"
# A reference, never the token itself: resolved from the environment at
# each request's auth check.
secret = "env:BLOCKWATCHER_API_TOKEN"
[metrics]
enabled = true
listen = "0.0.0.0:9090"
[storage]
module = "sqlite"
config = { path = "/data/blockwatcher.db", busy_timeout_ms = 5000 }
[engine]
event_channel_capacity = 256
sink_channel_capacity = 64
drain_deadline_ms = 10000
journal_depth = 1024
# omit dead_letter_retention to keep every letter; a positive value is the cap
default_retry = { max_attempts = 8, initial_backoff_ms = 200, max_backoff_ms = 30000 }
source_restart = { initial_backoff_ms = 500, max_backoff_ms = 60000, reset_after_ms = 300000 }
matcher = { module = "expr", config = {} }
Every value shown above under [engine] is also that key’s own default:
the section could be omitted entirely and the instance would boot
identically, apart from matcher, which still resolves to expr as long as
the binary was built with that feature.