Monitoring ERC-20 transfers [Mempool]
examples/source-mempool-monitor/ is the evm-mempool twin of
examples/source-rpc-monitor: same contract, same
usdc-erc20 spec file, same sink shape: watching pending calls to
USDC’s transfer function instead of confirmed Transfer events. This
page gives it the same file-by-file tour, but leads with what’s
different, because what’s different here isn’t cosmetic.
What’s different from watching confirmed events
evm-mempool trades completeness for latency: every fact below is
already established in more depth on Selectors § The position
problem
and Delivery guarantees § The evm-mempool
exception; this is
the short version an operator needs before running this example:
- The cursor is a per-run arrival counter, not a chain position.
There is no
start_blockto stamp (a pending transaction has no place in the chain yet), and a restart’s fresh checkpoint starts the counter over, so it can never be replayed across a process boundary. - Dedupe on the transaction’s own
hash, never on the matchid. The same pending call seen again after a restart, or even twice within one run if it gets mined mid-hydration, is minted a different arrival number and therefore a different id. tx.statusand everyblock.*field are always absent. Nothing here has a receipt or a mined block to read them from.tx.indexis usually absent, but not always. A call can get mined between the pending-tx notification and the hydration lookup that fetches its full data: the copy that comes back then carries a realtransactionIndexit didn’t have a moment earlier.- This is why this source falls outside at-least-once delivery. A crash can lose whatever was pending during the gap; nothing resumes it.
- Resuming replays nothing: there’s no history behind a paused mempool
feed to catch up on, unlike a paused
evm-rpcnetwork.
.env.example
SEPOLIA_WS_URL=wss://sepolia.infura.io/ws/v3/YOUR_API_KEY
SEPOLIA_RPC_URL=https://sepolia.infura.io/v3/YOUR_API_KEY
BLOCKWATCHER_API_TOKEN=local-test-token
Three variables where the confirmed-events example needed two. The new
one, SEPOLIA_WS_URL, is what newPendingTransactions is subscribed
over: it must be ws:///wss://, never the http(s) scheme
SEPOLIA_RPC_URL uses. The two are usually the same provider project’s
websocket and HTTP endpoints; SEPOLIA_RPC_URL here does a different job
than in the confirmed-events example: not scanning blocks, but hydrating
each pending hash the subscription reports into a full transaction via
eth_getTransactionByHash. That lookup happens for every hash the
subscription hands over, ahead of anything the monitor’s selector or
predicate would otherwise rule out. So spend tracks the chain’s overall
mempool arrival rate, not how many of those pending calls end up
matching this example’s monitor.
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 = {} }
Identical, key for key, to source-rpc-monitor’s instance config: the
[api]/[metrics]/[storage]/[engine] sections have nothing
evm-mempool-specific about them; every difference between the two
examples lives in the network resource below, not in the process’s own
plumbing.
setup.sh
Unlike the confirmed-events example’s setup.sh, there is no
start_block to compute or stamp here: nothing pending has a chain
position for a script to look up. What it does instead: confirm .env
exists and both URLs are set, confirm SEPOLIA_WS_URL actually uses a
ws/wss scheme and SEPOLIA_RPC_URL an http/https one (catching
the two swapped, a URL still holding the YOUR_API_KEY placeholder, or
either one obviously mistyped), and make one real eth_blockNumber call
against the HTTP endpoint to prove it answers. It cannot exercise the
WebSocket endpoint itself, since only blockwatcher’s own boot ever dials that
one, so the script’s own final message says as much rather than
implying a check it didn’t perform.
resources/networks/sepolia-mempool.json
{
"id": "sepolia-mempool",
"chain": "evm",
"source": {
"module": "evm-mempool",
"config": {
"ws_url_secret": "env:SEPOLIA_WS_URL",
"endpoints": [
{
"name": "primary",
"url_secret": "env:SEPOLIA_RPC_URL",
"priority": "high",
"rate_limit": { "rps": 10 }
}
],
"reconnect_ms": 1000,
"idle_policy": {
"ping_after_ms": 30000,
"pong_deadline_ms": 10000
}
}
}
}
source.module: "evm-mempool" is the one field that changes which raw
material a selector on this network can ever see. See Selectors § The
source.
ws_url_secret names the subscription endpoint, resolved the same
env:NAME way as every other secret reference in this config; endpoints
is the same EndpointDef pool shape evm-rpc uses, here doing hydration
calls rather than log/block scans. Three keys have no counterpart in the
confirmed-events example’s network config at all: reconnect_ms is the
delay before redialing after the WebSocket connection drops;
idle_policy.ping_after_ms is how long the connection can go without a frame
before blockwatcher sends its own ping to check it’s still alive;
idle_policy.pong_deadline_ms is how long it then waits for the pong before
declaring the connection dead and reconnecting. When any of the three
losses above happens, GET /status reports source.status: degraded
with a reason string that’s the full, human-readable error (e.g. no frame arrived within 10s of an idle ping; the connection is presumed half-open for a pong that never came, per crates/blockwatcher-evm/src/ws.rs),
not a short code; SourceStatusView passes that string through
verbatim (crates/blockwatcher-core/src/status.rs). The short codes
dial_failed/stream_closed/transport_error/idle_timeout exist on a
different surface entirely: they’re the reason label on the
blockwatcher_evm_mempool_reconnects_total counter (and the matching field in
this source’s own tracing logs), one increment per subscription lost.
That’s the surface to alert on for a flapping connection, since a status
snapshot only ever shows whichever state is current, and a reconnect
that lands before the next scrape reads as healthy either way. Absent
from this file entirely, and refused if added: start_block. There is
no chain position for a pending transaction to resume from, so the
config has nothing to name.
resources/specs/usdc-erc20.json
Same id, same two events as the confirmed-events example’s spec, plus
three function fragments this example actually uses:
{
"type": "function",
"name": "transfer",
"stateMutability": "nonpayable",
"inputs": [
{ "name": "to", "type": "address" },
{ "name": "amount", "type": "uint256" }
],
"outputs": [ { "name": "", "type": "bool" } ]
}
(approve and transferFrom follow the same shape in the actual file,
with their own parameter lists.) A function fragment’s inputs are its
call arguments, decoded from the transaction’s calldata rather than a
log’s topics or data, which is why functions selectors have nothing
to do with indexed, a concept that only means something for an event’s
log encoding. stateMutability and outputs are recorded from the ABI
but don’t affect decoding or matching; only inputs, alongside the
function’s own name (hashed into its first-four-bytes selector), matter
to what a functions selector can dispatch on. This spec is not the same
file as the confirmed-events example’s: it’s a separate copy under this
example’s own resources/, extended with the three function fragments
above that copy doesn’t carry, but it keeps the same id and the same
two event fragments unchanged. That’s the pattern worth reaching for
whenever you want both a confirmed audit trail and an early pending-call
signal off the same contract: one evm-rpc network and one evm-mempool
network, each with its own copy of a spec that started identical and
only grows the fragments the mempool side actually needs, rather than
each deployment maintaining two divergent ABI descriptions of the same
contract from scratch.
resources/monitors/usdc-sepolia-pending-transfers.json
{
"id": "usdc-sepolia-pending-transfers",
"network": "sepolia-mempool",
"selectors": [
{
"addresses": ["0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"],
"spec": "usdc-erc20",
"functions": ["transfer"]
}
],
"predicate": "args.amount > 0",
"actions": ["log-sink"]
}
The one selector-body difference from the confirmed-events monitor:
functions: ["transfer"] where that one had events: ["Transfer"]. This
isn’t a style choice: an events selector would still compile cleanly
against this network, since compilation only ever validates a selector
against its spec, never against which source module the network happens
to run, but it could never produce a single match here: evm-mempool
never emits a log at any point in its lifetime for such a selector to
catch. args.amount
here is transfer’s own second parameter (named amount, not value,
because that’s what this ABI fragment calls it); it is unrelated to
args.value, the Transfer event’s third parameter the other
example’s predicate reads. Both predicates read as “keep every nonzero
transfer,” just against two different decoded shapes.
resources/sinks/log-sink.json
Identical to the confirmed-events 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.
When mempool watching is worth it
This source earns its place when the thing you care about is someone
tried to call this function, and minutes or even seconds of latency
change the value of knowing: flagging a large pending transfer before
it’s mined, watching for a specific address’s activity the moment it
hits a node’s mempool, anything where “usually right, occasionally wrong,
but fast” beats “always right, but a block or twelve confirmations
later.” It earns its place a lot less (arguably not at all) anywhere
the record has to be complete or auditable: billing off matched events,
anything feeding a ledger, anything where a transaction that gets dropped
or replaced after this source already delivered it would leave a
consumer holding a phantom event with no retraction ever coming. The
evm-rpc twin of this example is the one to reach for whenever that
completeness matters more than the head start.
Variations
Widen the functions watched. The spec already declares approve and
transferFrom alongside transfer; add them to the selector’s
functions list to catch pending approvals and delegated transfers too:
"functions": ["transfer", "approve", "transferFrom"]
Exclude a known address instead of just thresholding the amount.
args.amount > 0 only rules out the zero-amount edge case; combine it
with tx.from (always present on a functions selector, mined or not)
to drop calls from an address you already know about (say, a market
maker’s hot wallet that transfers constantly and would otherwise dominate
the feed):
args.amount > 1_000e6 && tx.from != 0xF977814e90dA44bFA03b6295A0616a897441aceC
Split the two endpoints across different provider projects.
ws_url_secret and endpoints[].url_secret are independent secret
references: nothing requires them to name variables from the same
provider account. Pointing the subscription at one provider and the
hydration pool at another spreads load and removes a single provider
outage as a way to lose the feed entirely; it costs nothing but a second
env:NAME variable and a second free-tier key.