Monitoring ERC-20 transfers [RPC]
Your first monitor already walked
examples/source-rpc-monitor/ end to end: running it, reading the output,
narrowing a predicate through the API. This page takes a different pass
at the same directory: every file, reproduced in full, annotated field by
field, as a reference for what each line actually does rather than a
“do this next” narrative. Read that page first if you haven’t run the
example yet; read this one when you want to know exactly what you’re
looking at.
source-rpc-monitor/
├── .env.example
├── blockwatcher.toml
├── setup.sh
└── resources/
├── networks/sepolia.json
├── specs/usdc-erc20.json
├── sinks/log-sink.json
└── monitors/usdc-sepolia-transfers.json
.env.example
SEPOLIA_RPC_URL=https://sepolia.infura.io/v3/YOUR_API_KEY
BLOCKWATCHER_API_TOKEN=local-test-token
Two variables, both required. Nothing under resources/ ever holds
SEPOLIA_RPC_URL’s actual value: resources/networks/sepolia.json
below names the variable itself, and blockwatcher resolves it from its own
process environment the moment it needs it. That indirection exists
because the
URL usually is the credential: most providers put the API key
somewhere in the path or query string, so the file that would otherwise
carry it stays out of the resource entirely and out of anything that
might get committed. BLOCKWATCHER_API_TOKEN can be any string here: the
example’s blockwatcher.toml binds the API to 127.0.0.1 only, so there is no
network exposure to defend against, just a token the example’s own
curl commands need to match.
blockwatcher.toml
[api]
enabled = true
listen = "127.0.0.1:8080"
[[auth.tokens]]
label = "operator"
scope = "admin"
secret = "env:BLOCKWATCHER_API_TOKEN"
[metrics]
enabled = true
listen = "127.0.0.1:9090"
[storage]
module = "sqlite"
config = { path = "blockwatcher.db" }
[engine]
event_channel_capacity = 1000
sink_channel_capacity = 100
drain_deadline_ms = 5000
matcher = { module = "expr", config = {} }
Every section here is covered generally on the Configuration
reference; what’s worth noting about this instance
of it: [metrics] is turned on (unlike the Docker stack’s
config, which leaves it off), so curl localhost:9090/metrics works
alongside the API without any extra setup (see
Observability for what’s on that endpoint).
[storage].config.path is a bare relative filename, blockwatcher.db, so the
database lands next to wherever the process’s current directory is when
it starts: inside examples/source-rpc-monitor/ if you cd there first, per
the running instructions. [engine]’s three explicit values
(event_channel_capacity, sink_channel_capacity, drain_deadline_ms)
each differ from EngineConfig’s own defaults (256/64/10000
respectively, see Configuration reference); an empty
[engine] section would boot with those defaults instead.
setup.sh
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
[ -f .env ] || { echo "error: no .env — run: cp .env.example .env, then edit it" >&2; exit 1; }
set -a; . ./.env; set +a
: "${SEPOLIA_RPC_URL:?error: SEPOLIA_RPC_URL is unset in .env}"
case "$SEPOLIA_RPC_URL" in
*YOUR_API_KEY*) echo "error: SEPOLIA_RPC_URL still holds the placeholder YOUR_API_KEY" >&2; exit 1 ;;
http://*|https://*) ;;
*) echo "error: SEPOLIA_RPC_URL must be an http(s) URL, not a ${SEPOLIA_RPC_URL%%:*}: one" >&2; exit 1 ;;
esac
head_hex=$(curl -fsS "$SEPOLIA_RPC_URL" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \
| jq -re '.result') || { echo "error: the endpoint did not answer eth_blockNumber" >&2; exit 1; }
start=$(( head_hex - 20 ))
net=resources/networks/sepolia.json
tmp=$(mktemp)
jq --argjson b "$start" '.source.config.start_block = $b' "$net" > "$tmp" && mv "$tmp" "$net"
(Trimmed of its own comments and echo lines above, the repository copy
explains its own reasoning inline.) The script is three checks and one
mutation, in order: a .env file exists at all; the RPC URL is set,
isn’t still the literal placeholder, and has a scheme that’s actually
http(s) rather than, say, a stray ws:// pasted from the wrong
example; and a live eth_blockNumber call succeeds against it,
subtracted by 20 blocks and written into sepolia.json’s
source.config.start_block in place, using jq and a temp file so a
crash mid-write can never leave the resource file truncated. Nothing
about this is required for blockwatcher itself to run: it’s a convenience so
a fresh clone doesn’t need a human to look up a current Sepolia block
number by hand. Running the network file with its original
start_block: 11424310 still works if that block hasn’t rolled out of
the RPC provider’s retained history; it just costs a longer
catching_up wait if 11424310 is very old by the time you try it.
resources/networks/sepolia.json
{
"id": "sepolia",
"chain": "evm",
"source": {
"module": "evm-rpc",
"config": {
"start_block": 11424310,
"endpoints": [
{
"name": "primary",
"url_secret": "env:SEPOLIA_RPC_URL",
"priority": "high",
"rate_limit": { "rps": 10 }
}
],
"confirmations": 12,
"max_lag_blocks": 100,
"poll_interval_ms": 3000,
"logs_window": { "initial": 1000, "max": 5000 },
"probe_interval_ms": 30000
}
}
}
chain: "evm" selects the evm decoder family; source.module: "evm-rpc" selects confirmed-block scanning over evm-mempool‘s pending
stream (see Selectors § The source
for what that choice determines about what a selector here can ever
match). endpoints is a pool of one: name is this instance’s own label
for it (shows up in the blockwatcher_rpc_* metrics’ endpoint label, per
Observability);
priority: "high" matters once a second endpoint with a lower priority
is added: the pool prefers higher-priority candidates and only falls
back when they’re rate-limited or breaker-open; rate_limit.rps: 10
caps outbound calls to a level a free-tier provider key tolerates.
confirmations: 12 is how many blocks must sit on top of one before its
logs are trusted (deeper than the testnet’s own instant-final default,
a realistic value for demonstrating a real reorg-safety margin rather
than the bare minimum). max_lag_blocks: 100 is the threshold past which
source.status reports catching_up instead of live. poll_interval_ms: 3000 is how often the source checks for a new head. logs_window
governs how many blocks one eth_getLogs call spans while catching up:
starting at 1000 and growing to 5000 as the gap narrows. None of
these seven tuning keys are required: every one has its own default,
and the quickstart’s network file
carries none of them, but each is set here to a number an operator
running against a real testnet would actually pick, rather than a value
chosen only to demonstrate that the field exists.
resources/specs/usdc-erc20.json
{
"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 }
]
},
{
"type": "event",
"name": "Approval",
"anonymous": false,
"inputs": [
{ "name": "owner", "type": "address", "indexed": true },
{ "name": "spender", "type": "address", "indexed": true },
{ "name": "value", "type": "uint256", "indexed": false }
]
}
]
}
This is a Solidity ABI fragment list: the raw artifact evm’s decoder
compiles once, at write time, into the schema a selector and predicate
actually work against. indexed: true on from/to/owner/spender
means those parameters live in the log’s topics, not its data; value is
indexed: false because ERC-20’s Transfer/Approval standard puts the
transferred or approved amount in the log’s data word instead (a
deliberate ABI design choice this fragment merely records, not something
blockwatcher infers). anonymous: false on both means each event keeps its
normal topic0 event signature hash, which is what the decoder actually
dispatches on. Only Transfer is ever selected by this example’s
monitor; Approval sits in the spec unused, because nothing requires a
spec to carry only what one particular monitor asks for: a second
monitor on the same network watching approvals could reference this same
spec file without it changing at all.
resources/sinks/log-sink.json
{
"id": "log-sink",
"module": "log",
"config": {},
"retry": {
"max_attempts": 1,
"initial_backoff_ms": 100,
"max_backoff_ms": 1000
}
}
module: "log" puts each match on the process’s own stdout as a single
line of JSON, the shipped sink with the least that can go wrong at
delivery time, since there’s no network call and no credential to
resolve.
config: {} because the log sink takes no configuration at all.
retry.max_attempts: 1 means exactly one delivery attempt: on failure
(realistically, only a broken stdout pipe) the match dead-letters
immediately rather than retrying a failure mode backoff can’t fix.
initial_backoff_ms/max_backoff_ms are present but never exercised at
max_attempts: 1: they’d matter only if this sink retried, which it
doesn’t.
resources/monitors/usdc-sepolia-transfers.json
{
"id": "usdc-sepolia-transfers",
"network": "sepolia",
"selectors": [
{
"addresses": ["0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"],
"spec": "usdc-erc20",
"events": ["Transfer"]
}
],
"predicate": "args.value > 0",
"actions": ["log-sink"]
}
network: "sepolia" ties this monitor to the network resource above:
one network, always, per monitor. The one selector entry restricts to a
single contract address (Sepolia’s USDC), names usdc-erc20 as the spec
to decode against, and lists events: ["Transfer"]: with events
present and functions absent, selector compilation
fills only the events table with that one name and leaves the functions
table empty; Approval, though declared in the spec, is never decoded by
this selector because it was never named. In English, the predicate
args.value > 0 reads “keep every transfer that moved a nonzero amount”
, which in practice is nearly every one, since a zero-value Transfer is
legal ABI-wise but rare in the wild; it exists mainly to show that a
predicate is present and doing something, not to meaningfully filter this
particular feed. actions: ["log-sink"] sends every match to the one
sink above; a monitor with more sinks in its actions list would fan the
same match out to each of them independently.
Variations
Watch a different contract. The spec is generic ERC-20 (nothing in
usdc-erc20.json is USDC-specific), so pointing at a different token on
Sepolia only means a different addresses entry on the monitor (or a
second monitor entirely, if you want both watched at once). PUT the
existing monitor with a new address and a fresh If-Match:
export TOKEN=$BLOCKWATCHER_API_TOKEN
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": ["0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14"],
"spec": "usdc-erc20",
"events": ["Transfer"]
}],
"predicate": "args.value > 0",
"actions": ["log-sink"]
}'
(0xfFf9… is Sepolia’s canonical WETH9: any ERC-20 deployment works the
same way.)
A predicate with more than one clause. Your first
monitor
already showed a single threshold; the predicate
language supports combining conditions with
&&. To keep only large transfers that don’t originate from a
specific address (say, a known exchange hot wallet you want to exclude
from the feed):
args.value > 500e6 && args.from != 0xF977814e90dA44bFA03b6295A0616a897441aceC
Both operands type-check against the schema usdc-erc20 compiles:
args.value and args.from are both Transfer’s own declared
parameters (value a uint256, from an address). Excluding by
sender has to go through args.from here, not tx.from: a log-decoded
occurrence never carries tx.from/tx.to/tx.value at all (see the
selectors comparison table),
since nothing in a log’s own envelope holds them; only a functions
selector, decoding calldata rather than a log, has a tx.from to read.
Deliver to a webhook instead of the log sink. Add a second sink
resource using the webhook module (its url_secret follows the same
env:NAME indirection as the network’s RPC URL above), then point the
monitor’s actions at it instead of (or alongside) log-sink:
{
"id": "ops-webhook",
"module": "webhook",
"config": {
"url_secret": "env:OPS_WEBHOOK_URL",
"headers": { "X-Source": "blockwatcher-source-rpc-monitor" },
"timeout_ms": 10000
},
"retry": { "max_attempts": 5, "initial_backoff_ms": 200, "max_backoff_ms": 30000 }
}
PUT it to /sinks/ops-webhook, export OPS_WEBHOOK_URL in the same
shell blockwatcher runs in, then PUT the monitor again with
"actions": ["ops-webhook"]. A higher max_attempts than the log sink’s
1 makes sense here: an HTTP endpoint has real transient failure modes
(a momentary 503, a timeout) that a retry can actually recover from,
unlike a broken stdout pipe.