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: theCursortypecursors.rs’s pin constructsblockwatcher-evm:EvmEndpoint, the typepool.rs’s builders parameterize a realPoolwith, and the reason this crate depends onblockwatcher-evmrather than the other way aroundblockwatcher-rpc:Pool,PoolConfig,EndpointConfig,Priority, the connection-pool typessingle_endpoint_poolconstructs a real instance ofaxum(wsfeature): the HTTP server bothmock_nodeandmock_ws_nodebind, and the WebSocket upgradews_mock.rsspeaksalloy-primitives:keccak256, the hash function every block and transaction hash insim_chain.rsderives fromreqwest: the HTTP clientpool.rs’sevm_endpointconfiguresserde_json: the JSON-RPC request/response shapes every module in this crate buildstokio(net/rt-multi-thread/sync/timefeatures): the listener and the broadcast channel behindMockWsNode’s directives run onurl: parsing every node’s bound address
Key takeaways
blockwatcher-evm-testkitis a scripted JSON-RPC and WebSocket mock node, an in-memory chain, and the cursor-packing pinblockwatcher-evm’s own tests share, so no test needs a real Ethereum client.- It is consumed exclusively via
[dev-dependencies]; it depends onblockwatcher-evmwhileblockwatcher-evmdev-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-rpcorevm-mempoolsource runs unmodified on the other, meeting over the same wire protocol.
Responsibilities
- Runs a scripted JSON-RPC node on an ephemeral loopback port:
mock_nodetakes a handler closure over(method, params)and answers whateverMockReplyit returns, so a test swaps behavior by the closure it passes rather than by editing this crate (lib.rs). - Runs a scripted
eth_subscribeWebSocket node the same way:mock_ws_nodeanswers any first message of a connection as a successful subscription, then forwards whatever a test pushes throughMockWsNodeaseth_subscriptionnotifications (ws_mock.rs). - Provides
SimChain, a mutable in-memory chain amock_nodehandler can serve: deterministic, salt-derived block and transaction hashes, andreorg_from/reorg_tofor producing a fork mid-test (sim_chain.rs). - Provides
single_endpoint_poolandevm_endpoint, so a test that needs a realPool<EvmEndpoint>pointed at a mock node builds one in one call instead of assemblingPoolConfig/EndpointConfig/Priorityitself (pool.rs). - Provides
log_cursorandtx_cursor, a hand-mirrored copy of theevm-rpcsource’s cursor-packing formula, kept deliberately separate from the productionpack_secondaryfunction 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-ports’ fakes 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
| Name | Kind | Role |
|---|---|---|
mock_node | async fn | Starts a scripted HTTP JSON-RPC node on an ephemeral port, returning once it accepts connections (lib.rs) |
MockNode | struct | The running node’s handle; url is where to point an endpoint, and dropping it aborts the listener task (lib.rs) |
MockReply | enum | One scripted answer: Result, Error, Status, RawBody (a literal status and body, sent verbatim), or Hang (accepted, never answered) (lib.rs) |
CallLog | struct | Records every call a handler observed, method and params, in order (lib.rs) |
SimChain | struct | A 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_node | async fn | Starts a scripted WebSocket eth_subscribe node (ws_mock.rs) |
MockWsNode | struct | The running WS node’s handle: push_hash, push_raw, close_connection, go_silent, pings_received (ws_mock.rs) |
SOLO_ENDPOINT | const | The name every single-endpoint pool this crate builds gives its one endpoint (pool.rs) |
evm_endpoint | fn | An EvmEndpoint dialing a given URL with only a 2-second request timeout configured (pool.rs) |
single_endpoint_pool | fn | A real Pool<EvmEndpoint> of exactly one high-priority, unlimited endpoint (pool.rs) |
log_cursor, tx_cursor | fn | The 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, ¶ms) {
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-typesblockwatcher-evmblockwatcher-rpcaxum(wsfeature)alloy-primitivesreqwestserde_jsontokio(net/rt-multi-thread/sync/timefeatures)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 ofMockNode,MockReply,CallLog,SimChain,mock_ws_node,log_cursor,tx_cursor,evm_endpoint, andsingle_endpoint_pool, imported inside#[cfg(test)]modules acrossregistry.rs,source/mempool/pending.rs,source/rpc/{emit,fixtures,run,scan}.rs(fixtures.rs’ssource_againsttakes a&MockNodedirectly),ws.rs, and thetests/*.rsintegration suites (the dependency cycle this creates back ontoblockwatcher-evmis exactly the dev-only edge this page’s introduction names)blockwatcher(binary): a realPool<EvmEndpoint>for tests that need theevmfeature’s real construction path rather than a fake (tests/boot.rs,tests/escalation_seam.rs)blockwatcher-e2e:MockNode,SimChain,mock_nodedirectly, 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’sanvil-backed scenarios, which use a real node instead
Reading the source
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.mock_node,MockNode,MockReply,handle_request(lib.rs): the HTTP JSON-RPC mock at the center of the crate.CallLog(lib.rs): the call-recording type several integration suites assert against.sim_chain.rs:SimChain’s fields, thenwith_blocks,extend,reorg_from,reorg_to,add_log,add_tx,head,hash_of, andhandler, in that order; the module doc comment names every liberty the handler takes before the handler itself does.ws_mock.rs:MockWsNode’s fields and methods, thenmock_ws_nodeandserve_connection; readgo_silentlast, since it is the one path through this file that deliberately never touches the socket again.cursors.rs, thenpool.rs: the smallest files in the crate, each read in one pass alongside its own module doc comment’s rationale.