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

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.