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 [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_block to 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 match id. 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.status and every block.* field are always absent. Nothing here has a receipt or a mined block to read them from.
  • tx.index is 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 real transactionIndex it 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-rpc network.

.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.