Quickstart
Get one blockwatcher pipeline running end to end: watching a live testnet contract and printing every occurrence it matches as JSON. This page assumes you already have a rough sense of what a network, a monitor, and a sink are; see What is blockwatcher? first if not. This page is about getting bytes moving; Your first monitor explains each piece in more depth, and the Concepts pages explain why the pieces are shaped the way they are.
The steps below all feed one running pipeline:
flowchart LR
seed["seed/<br/>network, spec, sink, monitor"] --> check["blockwatcher check ./seed"]
check -->|"ok: N networks..."| run
cfg["blockwatcher.toml"] --> run["blockwatcher --config ... --seed ..."]
seed --> run
run --> pipe["running pipeline"]
pipe --> out["stdout: one match per line"]
Prerequisites
- A Rust toolchain at or above the workspace minimum,
rust-version = "1.85". Tagged releases publish a Linux x86_64 archive and GHCR images (see Installation and building); building from source is the path this walkthrough uses. - An RPC endpoint URL for an EVM testnet. The example below uses Sepolia; a free key from Infura, Alchemy, or dRPC works.
From a clone of the repository, build the release binary once:
cargo build --release -p blockwatcher
The binary lands at target/release/blockwatcher.
1. Write an instance config
blockwatcher has two separate configuration planes. Instance config (blockwatcher.toml)
is read once at process boot and controls process wiring: the API listener,
the storage backend, engine tunables. It is not where you say what to
watch; that’s a separate plane, managed at runtime (see
Configuration later in this wiki). A minimal
instance config:
[api]
enabled = true
listen = "127.0.0.1:8080"
[[auth.tokens]]
label = "operator"
scope = "admin"
secret = "env:BLOCKWATCHER_API_TOKEN"
[storage]
module = "sqlite"
config = { path = "blockwatcher.db" }
Each token’s secret is a reference (env:BLOCKWATCHER_API_TOKEN), never a
literal token: boot refuses, naming the variable, if it is unset. GET /health answers without a token; everything else requires one.
2. Write a seed directory
blockwatcher loads its resources (what to watch, decode, and deliver) from a directory of JSON files on first boot only. Once the store holds anything at all, seeding is skipped and the resources plane is managed exclusively through the REST API from then on. A minimal seed needs one of each resource kind, one file per resource, sorted into kind subdirectories that the loader recognizes by name:
seed/
├── networks/sepolia.json
├── specs/usdc-erc20.json
├── sinks/log-sink.json
└── monitors/usdc-sepolia-transfers.json
networks/sepolia.json: where to look and how to ingest. start_block is
an absolute block number rather than head-relative, so a restart resumes from
exactly this point instead of silently skipping whatever passed while the
process was down:
{
"id": "sepolia",
"chain": "evm",
"source": {
"module": "evm-rpc",
"config": {
"start_block": 9000000,
"endpoints": [
{ "name": "primary", "url_secret": "env:SEPOLIA_RPC_URL" }
]
}
}
}
Set start_block near the chain’s current head: a value from months ago
means a long wait before the first match arrives.
specs/usdc-erc20.json: the decode artifact. For "chain": "evm" this is a
Solidity ABI fragment list; the decoder compiles it once into a schema
everything downstream works from:
{
"id": "usdc-erc20",
"chain": "evm",
"payload": [
{
"type": "event",
"name": "Transfer",
"anonymous": false,
"inputs": [
{ "name": "from", "type": "address", "indexed": true },
{ "name": "to", "type": "address", "indexed": true },
{ "name": "value", "type": "uint256", "indexed": false }
]
}
]
}
sinks/log-sink.json: one JSON line per match to stdout, no configuration
of its own:
{
"id": "log-sink",
"module": "log",
"config": {},
"retry": { "max_attempts": 1, "initial_backoff_ms": 100, "max_backoff_ms": 1000 }
}
monitors/usdc-sepolia-transfers.json: ties the others together: watch
this address on sepolia, decode Transfer against usdc-erc20, send every
match to log-sink:
{
"id": "usdc-sepolia-transfers",
"network": "sepolia",
"selectors": [
{
"addresses": ["0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"],
"spec": "usdc-erc20",
"events": ["Transfer"]
}
],
"predicate": "args.value > 0",
"actions": ["log-sink"]
}
3. Validate offline, then run
export SEPOLIA_RPC_URL=https://sepolia.infura.io/v3/YOUR_KEY
./target/release/blockwatcher check ./seed
check takes only a directory (it never reads blockwatcher.toml), but it
constructs every module the seed references, including resolving each
url_secret, so SEPOLIA_RPC_URL has to be set (to a real http/https
URL: the check inspects its scheme, it does not need the endpoint to be
reachable). BLOCKWATCHER_API_TOKEN isn’t needed yet; nothing about check touches
the instance config that reads it.
A passing check prints a one-line summary to stdout and exits 0:
ok: 1 networks, 1 specs, 1 sinks, 1 monitors
Now export the API token and run for real:
export BLOCKWATCHER_API_TOKEN=quickstart-token
./target/release/blockwatcher --config ./blockwatcher.toml --seed ./seed
--seed only loads resources into a store that has nothing in it yet; once
it has run once, blockwatcher.db is authoritative and the seed directory is
ignored on every later boot.
What success looks like
Startup lines and warnings go to stderr; stdout carries nothing but
tagged sink-event JSON ("type": "match", "type": "retracted", or
"type": "digest" for a sink configured with aggregate), one line per
delivery: nothing else is ever mixed in, so a consumer piping stdout into a
parser never has to filter anything out. Within a few seconds of a matching
Transfer on Sepolia, a line like this appears:
{
"type": "match",
"id": "a3f8b21c9d...",
"monitor": "usdc-sepolia-transfers",
"network": "sepolia",
"event": {
"kind": "event",
"name": "Transfer",
"fields": {
"map": {
"args": {
"map": {
"from": { "address": "0xff30fb28e1794bb91d5bceb7d66b731d0c61af8e" },
"to": { "address": "0x7a3f2b16924f0e5c8f6a1c3d9e0b5a2f8c4d7e1a" },
"value": { "uint": "13000000" }
}
},
"tx": {
"map": {
"hash": { "bytes": "0x5f71ab..." },
"index": { "uint": "26" },
"status": { "uint": "1" }
}
},
"block": {
"map": {
"number": { "uint": "11424039" },
"hash": { "bytes": "0x9ab2cd..." },
"timestamp": { "uint": "1785929472" }
}
},
"log": {
"map": {
"address": { "address": "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238" },
"index": { "uint": "54" }
}
}
}
},
"cursor": { "primary": 11424039, "secondary": 54 }
}
}
Every scalar is tagged with its own type (address, uint, bytes), which
is why a 256-bit integer like value survives as an exact decimal string
rather than a lossy JSON number. USDC has 6 decimals, so 13000000 is 13
USDC. The top-level id is deterministic: the same occurrence always derives
the same id, which is what lets a consumer recognize a redelivered
match after a restart as a duplicate
rather than as new activity.
Next: Your first monitor walks through this same shape of deployment file by file, and shows how to narrow what it matches.