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

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:

  1. InstanceConfig::default(): every section’s own default.
  2. 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.
  3. 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.

KeyTypeDefaultMeaning
enabledboolfalseServes the HTTP API on listen when true.
listenstring (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.

KeyTypeDefaultMeaning
tokensarray 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].

KeyTypeDefaultMeaning
enabledboolfalseServes /metrics on listen when true.
listenstring (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).

KeyTypeDefaultMeaning
modulestring"memory"Which storage module to construct.
configobject{}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:

KeyTypeDefaultMeaning
pathstringnone (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_msu645000How 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.

KeyTypeConfig default (unset)Meaning
event_channel_capacityusize256Bound on the channel between a network’s source and its decode-and-match stage.
sink_channel_capacityusize64Bound on the channel feeding each sink worker.
drain_deadline_msu6410000How long a shutdown waits for in-flight matches to finish delivering before aborting the pipeline (exit code 2).
journal_depthu641024How 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_retentionu64, optionalunset (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_retrytable, see belowsee belowDelivery retry policy used by any sink that doesn’t set its own retry.
source_restarttable, see belowsee belowBackoff policy for restarting a pipeline whose source exits on its own.
matchertable ({ module, config })compiled-in matcherWhich 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:

KeyTypeDefaultMeaning
max_attemptsu328Total delivery attempts, not retries after the first: 1 delivers once and dead-letters on failure; 0 is treated as 1.
initial_backoff_msu64200Delay 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_msu6430000Cap 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:

KeyTypeDefaultMeaning
initial_backoff_msu64500Delay 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_msu6460000Cap on the doubling backoff between consecutive restarts.
reset_after_msu64300000How 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):

FlagApplies toMeaning
--config <path>blockwatcher [run], prune-checkpointsInstance 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-runprune-checkpointsLists 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):

CodeMeaning
0A clean drain (or a stop during boot), or a check that passed.
1A configuration, seed, or boot failure, or a check that refused.
2A shutdown that aborted at least one pipeline at the drain deadline.
64A 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.