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

blockwatcher-evm-testkit

blockwatcher-evm-testkit is a scripted JSON-RPC and WebSocket mock node, an in-memory chain to serve it from, and the cursor-packing pin and pool builder blockwatcher-evm’s own tests share, so that no test in that crate, or in the blockwatcher binary, or in blockwatcher-e2e’s in-process scenarios has to run against a real Ethereum client. Its own lib.rs doc comment states the shape this crate’s dependency direction takes: “Consumed exclusively via [dev-dependencies]: nothing here ever reaches a production binary. It depends on blockwatcher-evm while blockwatcher-evm dev-depends on it — a cycle cargo permits precisely because the edge back is a dev edge” (crates/blockwatcher-evm-testkit/src/lib.rs:4-8). See Workspace map § Verifying the rings for how scripts/check-dep-graph.sh checks the dev-dependency-only half of that by name, the same way it checks blockwatcher-testkit.

The harness and the system under test line up like this: a scripted node on one side, a real evm-rpc or evm-mempool source on the other, meeting over the same wire protocol a real node would speak:

flowchart LR
    simchain["SimChain<br/>in-memory chain"] --> mocknode["mock_node<br/>scripted json-rpc"]
    mockws["MockWsNode<br/>scripted directives"] --> wsnode["mock_ws_node<br/>scripted eth_subscribe"]
    mocknode --> pool["single_endpoint_pool<br/>real Pool of EvmEndpoint"]
    pool --> src1["evm-rpc source<br/>blockwatcher-evm"]
    wsnode --> src2["evm-mempool source<br/>blockwatcher-evm"]
    src1 --> test["test asserts on<br/>decoded events, cursors"]
    src2 --> test

Its production dependencies:

  • blockwatcher-types: the Cursor type cursors.rs’s pin constructs
  • blockwatcher-evm: EvmEndpoint, the type pool.rs’s builders parameterize a real Pool with, and the reason this crate depends on blockwatcher-evm rather than the other way around
  • blockwatcher-rpc: Pool, PoolConfig, EndpointConfig, Priority, the connection-pool types single_endpoint_pool constructs a real instance of
  • axum (ws feature): the HTTP server both mock_node and mock_ws_node bind, and the WebSocket upgrade ws_mock.rs speaks
  • alloy-primitives: keccak256, the hash function every block and transaction hash in sim_chain.rs derives from
  • reqwest: the HTTP client pool.rs’s evm_endpoint configures
  • serde_json: the JSON-RPC request/response shapes every module in this crate builds
  • tokio (net/rt-multi-thread/sync/time features): the listener and the broadcast channel behind MockWsNode’s directives run on
  • url: parsing every node’s bound address

Key takeaways

  • blockwatcher-evm-testkit is a scripted JSON-RPC and WebSocket mock node, an in-memory chain, and the cursor-packing pin blockwatcher-evm’s own tests share, so no test needs a real Ethereum client.
  • It is consumed exclusively via [dev-dependencies]; it depends on blockwatcher-evm while blockwatcher-evm dev-depends back on it, a cycle cargo permits because the edge back is dev-only.
  • A scripted node stands in for a real node on one side, and a real evm-rpc or evm-mempool source runs unmodified on the other, meeting over the same wire protocol.

Responsibilities

  • Runs a scripted JSON-RPC node on an ephemeral loopback port: mock_node takes a handler closure over (method, params) and answers whatever MockReply it returns, so a test swaps behavior by the closure it passes rather than by editing this crate (lib.rs).
  • Runs a scripted eth_subscribe WebSocket node the same way: mock_ws_node answers any first message of a connection as a successful subscription, then forwards whatever a test pushes through MockWsNode as eth_subscription notifications (ws_mock.rs).
  • Provides SimChain, a mutable in-memory chain a mock_node handler can serve: deterministic, salt-derived block and transaction hashes, and reorg_from/reorg_to for producing a fork mid-test (sim_chain.rs).
  • Provides single_endpoint_pool and evm_endpoint, so a test that needs a real Pool<EvmEndpoint> pointed at a mock node builds one in one call instead of assembling PoolConfig/EndpointConfig/Priority itself (pool.rs).
  • Provides log_cursor and tx_cursor, a hand-mirrored copy of the evm-rpc source’s cursor-packing formula, kept deliberately separate from the production pack_secondary function it mirrors (cursors.rs).
  • Provides CallLog, which records every JSON-RPC call a mock node’s handler observed, method and params rather than just method, so a test can assert not only call order but which range or block a specific call actually named (lib.rs).

Not this crate’s job: implementing evm-rpc or evm-mempool themselves (blockwatcher-evm owns both; this crate only mocks the wire they speak to), running anything against a real EVM node (blockwatcher-e2e‘s anvil-backed scenarios are the only place in the workspace that does; see blockwatcher-e2e), or standing in for a chain-agnostic port directly (blockwatcher-portsfakes feature does that; this crate mocks one family’s wire protocol underneath a real module, not the port trait above it).

Key types and functions

NameKindRole
mock_nodeasync fnStarts a scripted HTTP JSON-RPC node on an ephemeral port, returning once it accepts connections (lib.rs)
MockNodestructThe running node’s handle; url is where to point an endpoint, and dropping it aborts the listener task (lib.rs)
MockReplyenumOne scripted answer: Result, Error, Status, RawBody (a literal status and body, sent verbatim), or Hang (accepted, never answered) (lib.rs)
CallLogstructRecords every call a handler observed, method and params, in order (lib.rs)
SimChainstructA mutable chain of contiguous, gapless blocks; with_blocks, extend, reorg_from, reorg_to, add_log, add_tx, head, hash_of, and handler (sim_chain.rs)
mock_ws_nodeasync fnStarts a scripted WebSocket eth_subscribe node (ws_mock.rs)
MockWsNodestructThe running WS node’s handle: push_hash, push_raw, close_connection, go_silent, pings_received (ws_mock.rs)
SOLO_ENDPOINTconstThe name every single-endpoint pool this crate builds gives its one endpoint (pool.rs)
evm_endpointfnAn EvmEndpoint dialing a given URL with only a 2-second request timeout configured (pool.rs)
single_endpoint_poolfnA real Pool<EvmEndpoint> of exactly one high-priority, unlimited endpoint (pool.rs)
log_cursor, tx_cursorfnThe hand-mirrored cursor for an emitted log or transaction (cursors.rs)

The mock JSON-RPC node

mock_node’s handler sees each request’s method and params and returns a MockReply; handle_request (lib.rs:136-167) turns that into an HTTP response:

#![allow(unused)]
fn main() {
async fn handle_request(
    State(handler): State<Arc<RequestHandler>>,
    Json(body): Json<Value>,
) -> Response {
    let method = body
        .get("method")
        .and_then(Value::as_str)
        .unwrap_or_default();
    let id = body.get("id").cloned().unwrap_or(json!(1));
    let params = body.get("params").cloned().unwrap_or(Value::Null);

    match handler(method, &params) {
        MockReply::Result(result) => {
            Json(json!({"jsonrpc": "2.0", "id": id, "result": result})).into_response()
        }
        MockReply::Error { code, message } => Json(json!({
            "jsonrpc": "2.0",
            "id": id,
            "error": {"code": code, "message": message},
        }))
        .into_response(),
        MockReply::Status(status) => StatusCode::from_u16(status)
            .expect("test-provided status code is a valid HTTP status")
            .into_response(),
        MockReply::RawBody { status, body } => (
            StatusCode::from_u16(status).expect("test-provided status code is a valid HTTP status"),
            body,
        )
            .into_response(),
        MockReply::Hang => std::future::pending::<Response>().await,
    }
}
}

RawBody and Hang exist for exactly the shapes Result, Error, and Status cannot express: a body missing both result and error, one carrying both, a well-formed JSON-RPC error wrapped in a non-2xx status, and a node that accepts the connection and never answers at all, so the caller’s own timeout, not the mock server, is what ends that call.

SimChain: a mutable, reorg-capable chain

Every block’s hash is block_hash’s keccak256(number ++ salt) (sim_chain.rs), and every block’s parentHash is read from whatever the previous block’s hash happens to be at read time, never recomputed from the formula. That is what lets reorg_from and reorg_to change the salt for a suffix of the chain and get back an internally consistent set of hashes for exactly that suffix, while the shared prefix keeps its original hashes untouched (sim_chain.rs, module doc comment). reorg_from replaces every block from a given number up with a same-height, different-salt fork; reorg_to is the shape that can shorten or lengthen the chain instead, up to a target length (sim_chain.rs). SimChain::handler answers exactly the methods eth_blockNumber, eth_getBlockByNumber, eth_getLogs, and eth_getTransactionReceipt, and panics on any other, “since nothing in this family’s source implementation calls anything else” (sim_chain.rs:265-269).

SimChain’s own module doc comment lists the mock liberties a test must not lean on:

Deliberate mock liberties a test must not lean on: `SimChain`'s handler
answers `eth_getBlockByNumber` for numeric block tags only (a real
client's `"latest"`/`"finalized"` tags panic it); its
`eth_getLogs` enforces no range or result-size cap — range-limit behavior
is driven by a handler injecting explicit error replies — and matches
only topic position 0; logs carry no `removed` field; and a log's
`transactionHash` is its block's hash, not a real transaction's.
Transactions added via `SimChain::add_tx` carry a fixed zero `value` —
there is no parameter for it at all — and none of a real transaction's
other fields (`gas`, `nonce`, `type`, `chainId`, `gasPrice`/
`maxFeePerGas`, the `v`/`r`/`s` signature fields, `accessList`, and so
on); a test needing those must construct transaction JSON directly
rather than going through a chain. Receipts from
`eth_getTransactionReceipt` carry only `status` and positional fields
(`transactionHash`, `blockNumber`, `blockHash`) — no gas usage, logs,
`contractAddress`, or any of a real receipt's other fields.

(crates/blockwatcher-evm-testkit/src/lib.rs:21-36)

MockWsNode: a scripted mempool endpoint

ws_mock.rs exists for blockwatcher-evm’s mempool tests: its own module doc comment states the shape of its handshake: it “answers the first JSON request of every connection as a successful eth_subscribe, then forwards whatever hashes the test pushes as eth_subscription notifications” (ws_mock.rs:1-7). lib.rs’s own module doc comment names the liberties that handshake takes: the mock answers any first message this way, “without checking the method name or the subscribed event,” and the subscription id it hands back is always the literal "0x1", never a freshly generated one; only ever one subscription exists per connection at all, so eth_unsubscribe and a second eth_subscribe on the same socket are not handled, silently ignored the same as any other message once the first reply is sent (lib.rs:38-44). close_connection severs the current socket without stopping the listener, so the next dial gets a fresh subscription (ws_mock.rs); go_silent stops touching the socket at all: no reads, no writes, not even the automatic pong axum’s WebSocket would otherwise send, which leaves exactly the half-open shape a vanished peer leaves behind (ws_mock.rs). pings_received lets a test observe that a keepalive ping arrived without the mock ever having to answer it itself: axum auto-answers pings, so this counter only proves the idle clock never fired.

The cursor-packing pin

cursors.rs’s own module doc comment states why log_cursor and tx_cursor exist as hand-written functions rather than calls into production code: “a test asserting the wire contract must not verify the packer with itself, or a bug in pack_secondary and a matching bug here would cancel out and the test would still pass. Keep this hand-written, even though it is tempting to ‘simplify’ into a call to the real function — that temptation is exactly what would silence the pin” (cursors.rs:1-16).

#![allow(unused)]
fn main() {
/// The cursor an emitted log carries.
pub fn log_cursor(block: u64, log_index: u64) -> Cursor {
    Cursor::new(block, (1u64 << 32) | log_index)
}

/// The cursor an emitted transaction carries — see [`log_cursor`].
pub fn tx_cursor(block: u64, tx_index: u64) -> Cursor {
    Cursor::new(block, tx_index)
}
}

Both encode the same rule the evm-rpc source packs an occurrence’s kind into the secondary field’s high bits by: transactions are kind 0, logs are kind 1, so both kinds share one non-decreasing stream per block.

single_endpoint_pool: a real pool over a mock node

pool.rs’s own module doc comment explains why this helper lives in a library crate rather than in each test binary’s own common module: “a pub item in a private mod common of a test binary is dead code the moment one binary stops using it,” while “a pub item in a library crate is never dead code,” which is what lets every consuming suite share exactly the parts it needs (pool.rs:1-9). single_endpoint_pool builds a real blockwatcher_rpc::Pool<EvmEndpoint> of exactly one high-priority, unlimited endpoint named SOLO_ENDPOINT, pointed at a caller-supplied URL:

#![allow(unused)]
fn main() {
pub fn single_endpoint_pool(url: url::Url) -> Pool<EvmEndpoint> {
    Pool::new(
        PoolConfig::default(),
        vec![(
            EndpointConfig {
                name: SOLO_ENDPOINT.into(),
                priority: Priority::High,
                rate_limit: None,
            },
            evm_endpoint(url),
        )],
    )
    .expect("a single healthy endpoint always constructs a pool")
}
}

blockwatcher-evm’s tests/mempool_loop.rs and tests/common.rs import it directly; blockwatcher-evm’s src/source/rpc/scan.rs instead defines its own local, differently-shaped evm_endpoint/single_endpoint_pool pair for its own module tests rather than importing this one. This crate depends on blockwatcher-rpc directly (not only transitively through blockwatcher-evm), for exactly this function; see blockwatcher-rpc § Neighbours for that crate’s account of the same edge.

Neighbours

blockwatcher-evm-testkit depends on, in production:

  • blockwatcher-types
  • blockwatcher-evm
  • blockwatcher-rpc
  • axum (ws feature)
  • alloy-primitives
  • reqwest
  • serde_json
  • tokio (net/rt-multi-thread/sync/time features)
  • url

The following crates depend on it, every edge under [dev-dependencies] only (per the dependency table, and checked by name per Workspace map § Verifying the rings):

  • blockwatcher-evm: every one of MockNode, MockReply, CallLog, SimChain, mock_ws_node, log_cursor, tx_cursor, evm_endpoint, and single_endpoint_pool, imported inside #[cfg(test)] modules across registry.rs, source/mempool/pending.rs, source/rpc/{emit,fixtures,run,scan}.rs (fixtures.rs’s source_against takes a &MockNode directly), ws.rs, and the tests/*.rs integration suites (the dependency cycle this creates back onto blockwatcher-evm is exactly the dev-only edge this page’s introduction names)
  • blockwatcher (binary): a real Pool<EvmEndpoint> for tests that need the evm feature’s real construction path rather than a fake (tests/boot.rs, tests/escalation_seam.rs)
  • blockwatcher-e2e: MockNode, SimChain, mock_node directly, for its in-process scenarios (tests/e2e/shutdown.rs, tests/e2e/stdout_purity.rs) that need a scripted chain but not a real one; see blockwatcher-e2e § Neighbours for the contrast with that crate’s anvil-backed scenarios, which use a real node instead

Reading the source

  1. lib.rs’s module doc comment: the dependency-cycle rationale, the ephemeral-port binding rule, and the full list of deliberate mock liberties, before any of the code that implements them.
  2. mock_node, MockNode, MockReply, handle_request (lib.rs): the HTTP JSON-RPC mock at the center of the crate.
  3. CallLog (lib.rs): the call-recording type several integration suites assert against.
  4. sim_chain.rs: SimChain’s fields, then with_blocks, extend, reorg_from, reorg_to, add_log, add_tx, head, hash_of, and handler, in that order; the module doc comment names every liberty the handler takes before the handler itself does.
  5. ws_mock.rs: MockWsNode’s fields and methods, then mock_ws_node and serve_connection; read go_silent last, since it is the one path through this file that deliberately never touches the socket again.
  6. cursors.rs, then pool.rs: the smallest files in the crate, each read in one pass alongside its own module doc comment’s rationale.