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
_infilters only narrow the GraphQL fetch; they cannot widen coverage. - You must deploy
subgraph/first. The source POSTs at a query URL that already implementsLog,Transaction, and_meta. A GraphQL host missing those fields yields namedDegraded(schema mismatch), not empty success. Deploysubgraph/(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.confirmationstherefore defaults to0in this example (rpc’s is12). - Cursors and envelopes match rpc. Kinded
secondarypacking,0xJSON theevmdecoder already reads,SourceCaps.event_timestamps: true. Switching this network’s module fromevm-rpctoevm-subgraphdoes not change what aTransferselector compiles against. - The seed ids collide with the rpc example. Both use network
sepoliaand monitorusdc-sepolia-transfers. They are alternative trees, not two pipelines you seed into one storage; the mempool twin avoids that by usingsepolia-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"].