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

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.