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

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.