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 [Subgraph]

examples/source-evm-subgraph-monitor/ is the evm-subgraph twin of examples/source-evm-rpc-monitor: same contract, same usdc-erc20 spec file, same events: ["Transfer"] monitor, same sink shape — watching confirmed USDC Transfer logs, but polling a GraphQL host this example deploys instead of eth_getLogs on an archive node. This page gives it the same file-by-file tour, but leads with what’s different, because what’s different here isn’t cosmetic. How to deploy subgraph/ and run the process is the example README; this page is what each file is for.

source-evm-subgraph-monitor/
├── .env.example
├── blockwatcher.toml
├── setup.sh
├── subgraph/
│   ├── schema.graphql
│   ├── subgraph.yaml
│   └── src/mapping.ts
└── resources/
    ├── networks/sepolia.json
    ├── specs/usdc-erc20.json
    ├── sinks/log-sink.json
    └── monitors/usdc-sepolia-transfers.json

What’s different from watching confirmed events over RPC

evm-subgraph trades archive-node completeness for an already-indexed range. Every fact below is established in more depth on EVM § evm-subgraph; this is the short version an operator needs before running this example:

  • Completeness is whatever the mapping stored, not the chain. A monitor for an address or event the AssemblyScript never wrote never fires. InterestSet _in filters only narrow the GraphQL fetch; they cannot widen coverage.
  • You must deploy subgraph/ first. The source POSTs at a query URL that already implements Log, Transaction, and _meta. A GraphQL host missing those fields yields named Degraded (schema mismatch), not empty success. Deploy subgraph/ (or any host that implements that schema).
  • The head is the subgraph’s indexed tip, read from _meta { block { number hash } }, already trailing the chain by indexing delay. confirmations therefore defaults to 0 in this example (rpc’s is 12).
  • Cursors and envelopes match rpc. Kinded secondary packing, 0x JSON the evm decoder already reads, SourceCaps.event_timestamps: true. Switching this network’s module from evm-rpc to evm-subgraph does not change what a Transfer selector compiles against.
  • The seed ids collide with the rpc example. Both use network sepolia and monitor usdc-sepolia-transfers. They are alternative trees, not two pipelines you seed into one storage; the mempool twin avoids that by using sepolia-mempool.

.env.example

SEPOLIA_SUBGRAPH_URL=https://YOUR_HOST/subgraphs/name/usdc-sepolia-logs
# SEPOLIA_SUBGRAPH_TOKEN=Bearer YOUR_API_KEY
BLOCKWATCHER_API_TOKEN=local-test-token

Two required variables where the rpc example also needed two, but the URL is a GraphQL query endpoint, not JSON-RPC. Nothing under resources/ ever holds SEPOLIA_SUBGRAPH_URL’s actual value: sepolia.json names the variable, because a hosted query URL is often the credential. YOUR_HOST is the placeholder setup.sh refuses, the same job YOUR_API_KEY does on the rpc twin.

SEPOLIA_SUBGRAPH_TOKEN is optional and commented out. If the host requires a bearer token, set the full Authorization header value (including Bearer ) and add header_secrets on the endpoint row (shown with the network file below). The token stays in .env; the resource only names the variable.

blockwatcher.toml

[api]
enabled = true
listen = "127.0.0.1:8080" # curl in this README. Dashboard: 9080; companion takes 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 = {} }

Identical in keys to source-evm-rpc-monitor’s instance config: nothing here is GraphQL-specific. The comment on listen is the one extra line — The dashboard needs :8080 for the companion, so a local UI attach moves the engine to 9080 before cargo run.

setup.sh

Same job as the rpc example’s script — stamp an absolute start_block just behind a live head — but the head it asks for is the subgraph’s indexed tip, not Sepolia’s eth_blockNumber:

head_num=$(curl -fsS "$SEPOLIA_SUBGRAPH_URL" \
  "${curl_headers[@]}" \
  -d '{"query":"{ _meta { block { number } } }"}' \
  | jq -re '.data._meta.block.number')
start=$(( head_num - 20 ))
if [ "$start" -lt 0 ]; then
  start=0
fi

It also refuses a still-placeholder YOUR_HOST, a non-http(s) scheme, and a host that does not answer _meta (the error text says to deploy subgraph/ first: that failure is the same schema-mismatch class a running source would publish as Degraded). If SEPOLIA_SUBGRAPH_TOKEN is set, the _meta POST sends it as Authorization. The floor at 0 is what a subgraph whose indexed head is still below 20 needs: bash arithmetic would otherwise write a negative start_block.

Shipped sepolia.json has start_block: 0 on purpose so a clone does not carry an operator-stamped height. Running with 0 still works; it is a long catching_up wait if the mapping indexed from genesis.

resources/networks/sepolia.json

{
  "id": "sepolia",
  "chain": "evm",
  "source": {
    "module": "evm-subgraph",
    "config": {
      "start_block": 0,
      "endpoints": [
        {
          "name": "primary",
          "url_secret": "env:SEPOLIA_SUBGRAPH_URL",
          "priority": "high",
          "rate_limit": { "rps": 10 }
        }
      ],
      "confirmations": 0
    }
  }
}

source.module: "evm-subgraph" is the one field that changes which raw material a selector on this network can ever see. See Selectors § The source. url_secret is the same env:NAME shape rpc uses for SEPOLIA_RPC_URL. confirmations: 0 is the documented default for this source: the subgraph head already lags the chain. Absent here, and filled by defaults: logs_window, entity_page_size, poll_interval_ms, probe_interval_ms, max_lag_blocks. Rpc-only keys (receipts, bloom_screen, header_batch, …) are refused as unknown fields.

A host that wants a bearer token adds one object on the endpoint, still as a secret reference:

"header_secrets": { "authorization": "env:SEPOLIA_SUBGRAPH_TOKEN" }

Content-Type: application/json is always set at construct; do not put it in header_secrets.

resources/specs/usdc-erc20.json

Same id, same two event fragments as the rpc example’s spec (Transfer and Approval). This copy lives under this example’s own resources/; it is not a shared file. The subgraph mapping also handles both events (it stores a Log row for each, and a Transaction row when input is at least 4 bytes). The shipped monitor only names Transfer. Adding Approval to the selector is a one-field change here the same way it is on the rpc twin — if those rows are in the deployed subgraph, which for this example they are, because subgraph.yaml registers both handlers.

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"]
}

Identical to the rpc example’s monitor, including the selector body: events: ["Transfer"] and args.value (the event parameter, not a function’s amount). Compilation still validates the selector against the spec, never against which source module the network runs. What changes is whether the subgraph stored a Log for that address and topic0. An events selector against evm-subgraph is the supported shape; a functions selector also compiles, and this source will query transactions when some monitor watches functions, but only rows the mapping saved can match.

resources/sinks/log-sink.json

Identical to the rpc example’s, with the same module, same single-attempt retry policy, and the same reasoning: nothing about the log sink’s own behavior changes based on which source fed it a match.

subgraph/

This directory is the part the other ERC-20 examples do not have. The source does not compile it or talk to graph-node; an operator deploys it to Goldsky, The Graph, or a graph-node they run, then points SEPOLIA_SUBGRAPH_URL at the query URL. CI never hits a live host: tests use a scripted GraphQL mock.

schema.graphql

The host schema the source documents on the EVM family page, as this example actually ships it:

type Log @entity(immutable: true) {
  id: ID!
  address: Bytes!
  topic0: Bytes!
  topics: [Bytes!]!
  data: Bytes!
  blockNumber: BigInt!
  blockHash: Bytes!
  blockTimestamp: BigInt!
  transactionHash: Bytes!
  transactionIndex: BigInt!
  logIndex: BigInt!
}

type Transaction @entity(immutable: true) {
  id: ID!
  hash: Bytes!
  from: Bytes!
  to: Bytes
  input: Bytes!
  selector: Bytes!
  value: BigInt!
  status: BigInt!
  blockNumber: BigInt!
  blockHash: Bytes!
  blockTimestamp: BigInt!
  transactionIndex: BigInt!
}

No Block entity. to is nullable (a create). id is padded decimal ASCII so id_gt is lexicographic chain order: 12-digit block, 5-digit txIndex, 5-digit logIndex (000018000000-00003-00012).

subgraph.yaml

Sepolia, USDC 0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238, both Transfer and Approval with receipt: true (the mapping needs the receipt to copy log topics and tx.status). source.startBlock: 0 is a placeholder: set it to a recent Sepolia block (or USDC’s deployment block) before deploying. Genesis indexing is valid and slow; setup.sh cannot fix a mapping that has not caught up.

src/mapping.ts

Writes one Log per handled event with topic0 = topics[0], skips a log whose topics are empty, writes one Transaction per transaction the first time it is seen (keyed by padded block+txIndex), and skips transactions whose input is shorter than 4 bytes so selector is always present. Padding is pad(block, 12) — the same 12-digit block field the source’s id_gt paging assumes.

When subgraph watching is worth it

This source earns its place when the range you care about is already indexed (or cheap to index once) and eth_getLogs over that range is the expensive part: a long Sepolia catch-up, a dedicated logs subgraph you already run for other tools, a host whose query URL is the credential you want in env: rather than an archive-node key. It earns its place a lot less anywhere the mapping is narrower than the monitor: billing off “every Transfer of this token” when the subgraph only stored a subset is a silent miss, not a Degraded. The evm-rpc twin is the one to reach for when the chain itself is the completeness bound.

Variations

Watch Approval as well. The spec and the mapping already store it:

"events": ["Transfer", "Approval"]

Attach the dashboard. The engine and the UI companion both default to :8080. Move [api] listen to 9080, keep UI_INGEST_URL on the engine process, add a ui-ingest sink and name it in actions — the full sequence is The dashboard § Alongside a local example.

Deliver to a webhook instead of the log sink. Same as the rpc example: a webhook sink whose url_secret is another env:NAME, then "actions": ["ops-webhook"].