Your first monitor
Quickstart got a pipeline running from files typed by hand.
This page walks the same ground more slowly, using the runnable example
shipped in the repository at examples/source-rpc-monitor/, and ends by changing
what the monitor matches and watching the behavior change. The Concepts
pages explain why each resource is shaped the way it is; this one is about
doing it once, end to end.
What’s in the example
Eight tracked files (.env isn’t one of them: you create it from
.env.example in a moment, and it’s git-ignored so your key never gets
committed):
examples/source-rpc-monitor/
├── .env.example
├── README.md
├── blockwatcher.toml
├── setup.sh
└── resources/
├── monitors/usdc-sepolia-transfers.json
├── networks/sepolia.json
├── sinks/log-sink.json
└── specs/usdc-erc20.json
Two of these aren’t blockwatcher resources at all: blockwatcher.toml is the instance
config covered in Quickstart, and
setup.sh is a one-off helper this page runs below: neither is read by
check or seeded into storage. The other four are exactly the seed
directory’s four resource kinds, one file each. Below, they’re discussed in
the order they reference each other (network, then spec, then sink, then
the monitor that ties all three together) rather than the tree’s
alphabetical order.
The four files wire together into one running pipeline like this:
flowchart LR
net["network<br/>sepolia.json"] --> mon
spec["spec<br/>usdc-erc20.json"] --> mon
sink["sink<br/>log-sink.json"] --> mon
mon["monitor<br/>usdc-sepolia-transfers.json"] --> pipe["running pipeline<br/>on sepolia"]
net --> pipe
resources/networks/sepolia.json names the evm-rpc source and tunes it
beyond the defaults: 12 confirmations before a block is trusted, a
starting eth_getLogs window of 1000 blocks growing to 5000, and a
30-second head-probe interval. None of these are required fields (the
quickstart network file left every one of them at its
default and still worked), but they’re realistic values for a testnet feed,
not just illustration.
resources/specs/usdc-erc20.json is a Solidity ABI fragment list covering
both Transfer and Approval events. Only Transfer is selected by the
monitor below, but nothing stops a spec from carrying more than one monitor
ever asks it to decode: the fragments a monitor doesn’t reference are
simply unused.
resources/sinks/log-sink.json selects the log sink with a max_attempts
of 1: one delivery attempt, dead-letter on failure, no retries. That’s a
reasonable choice for a sink with nothing to fail on other than a broken
stdout pipe.
resources/monitors/usdc-sepolia-transfers.json is the monitor: it watches
that one address on the sepolia network, decodes only Transfer against
usdc-erc20, keeps every occurrence where args.value > 0 (which is to say,
everything a Transfer event can carry: a zero-value transfer is legal but
rare), and sends every match to log-sink.
Running it
From the repository root:
cd examples/source-rpc-monitor
cp .env.example .env
Edit .env and replace YOUR_API_KEY in SEPOLIA_RPC_URL with a real
Sepolia endpoint (a free key from Infura, Alchemy, or dRPC works). The key
never gets written into any resource file: the network resource only names
the SEPOLIA_RPC_URL variable, and blockwatcher resolves it from its own
environment at construction time.
./setup.sh
set -a; . ./.env; set +a
setup.sh reads the current chain head over eth_blockNumber and rewrites
resources/networks/sepolia.json’s start_block to a value just behind it.
start_block is deliberately absolute, with no default: a head-relative
start would derive a different block on every restart and silently skip
whatever passed in between, which a monitor must never do. The trade-off is
that a fresh network needs a block number named for it, and naming one from
months ago means a long catching_up wait, visible on the GET /status
endpoint, before anything is delivered.
cargo run --bin blockwatcher -- check ./resources
cargo run --bin blockwatcher -- --config ./blockwatcher.toml --seed ./resources
check constructs every module the seed references (including the
evm-rpc source, which resolves SEPOLIA_RPC_URL), so it only passes with
the environment loaded. A pass prints:
ok: 1 networks, 1 specs, 1 sinks, 1 monitors
The run command boots the engine, seeds resources/ into the empty
blockwatcher.db it just created, and starts the pipeline. Diagnostics (the boot
line, warnings) go to stderr; stdout carries nothing but match JSON, one line
per delivery. Within a few seconds, matching transfers start arriving:
{"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":"20000000"}}},"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}}}
Pipe through jq to read it comfortably, or reduce to one line per transfer:
cargo run --bin blockwatcher -- --config ./blockwatcher.toml --seed ./resources \
| jq -r '.event.fields.map.args.map | "\(.from.address) → \(.to.address) \(.value.uint)"'
Narrowing what it matches
The seeded monitor’s predicate, args.value > 0, accepts every nonzero
transfer. Suppose you only care about large ones, say, anything over 1000
USDC (six decimals: 1_000e6). The monitor is already running and its store
already holds it, so re-seeding won’t touch it: --seed only loads into a
store that has nothing in it yet. The way to change a live monitor is the
same way you’d change any resource (the REST API), and the change takes
effect on the running pipeline immediately, no restart.
Get the monitor’s current version (ETag), then PUT it back with the
predicate changed:
export TOKEN=$BLOCKWATCHER_API_TOKEN # from .env
etag=$(curl -s -o /dev/null -w '%header{etag}' \
localhost:8080/monitors/usdc-sepolia-transfers \
-H "Authorization: Bearer $TOKEN")
curl -s -X PUT localhost:8080/monitors/usdc-sepolia-transfers \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-H "If-Match: $etag" \
-d '{
"id": "usdc-sepolia-transfers",
"network": "sepolia",
"selectors": [{
"addresses": ["0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"],
"spec": "usdc-erc20",
"events": ["Transfer"]
}],
"predicate": "args.value > 1_000e6",
"actions": ["log-sink"]
}'
If-Match carries the resource’s current version so the API can refuse a
write that would clobber a change made since you last read it; omitting it
on a PUT to a resource that already exists returns 409 Conflict
(already_exists), and a stale If-Match value returns 412 Precondition Failed (version_conflict). 1_000e6 is blockwatcher’s
token-decimal literal syntax (one thousand at six decimals), evaluated
against args.value as an arbitrary-precision integer, so the comparison is
exact even though value itself arrived as a decimal string, not a JSON
number.
The behavior change is immediate: transfers under 1000 USDC that used to
print a line stop appearing entirely, while nothing about the pipeline
restarts or re-scans: the predicate
is recompiled and hot-swapped into the running monitor set, and only events
decoded from here on are evaluated against the new one. Widen the predicate
back, or remove it entirely (an absent predicate matches everything the
selector decodes), the same way: another PUT with a fresh If-Match.
Next: The dashboard covers a web UI that does the same resource CRUD and log-watching this page just did by hand, and Concepts covers what the predicate language can express beyond a threshold. To require N transfers in a block-time window before alerting, see Gates.