Phase 4.3. The last big eth_* method anyone cares about, and the one that unlocks The Graph, Dune, Blockscout, and every dApp that subscribes to events. Yesterday I shipped the read layer and the Snap. Today I shipped eth_getLogs and the filter methods that wrap it.
The fun part wasn't the matching logic (that's boring, it's the exact Ethereum spec: position-sensitive topic arrays, null-means-any, address lists, block ranges). The fun part is that I tested it against a real deployed contract emitting real events through the real VM — not a mock, not a stub, an actual contract I deployed in the test by signing a real tx with Dilithium3 and submitting it to an AsentumNode.
Here's the test contract:
({
init() {
emit('Hello', { msg: 'world' });
},
ping(n) {
emit('Ping', { count: String(n) });
return String(n);
},
});
Thirty lines of JavaScript. No ABI. No Solidity. No compiler. It's just a SES-sandboxed JS object with init and ping methods that emit structured events through the host API.
The test flow:
- Boot an
AsentumNode+RpcServeron an OS-assigned port - Build a deploy tx with the contract source as the
datafield, sign with the faucet's Dilithium3 key, submit - Produce a block → contract lands at
blake3(deployer || u64be(nonce))[:20] - Submit an
initcall tx → contract runs, emitsHelloevent, receipt captures it - Submit two
ping(n)calls → two morePingevents - Produce more blocks, now we have 3 events across 3 blocks
- Hit
POST /witheth_getLogsover HTTP from the test — purefetch(), no internal shortcuts - Assert the right logs come back
And here's the payoff:
✓ test/rpc/log-filter.test.ts (20 tests) 2ms
✓ test/rpc/filter-store.test.ts (7 tests) 3ms
✓ test/rpc/eth-logs.test.ts (9 tests) 839ms
Test Files 3 passed (3)
Tests 36 passed (36)
36 new tests for Phase 4.3. 20 unit tests cover the filter-matching logic (every corner of the Ethereum topic-filter spec: null means any, single string means exact, array means OR, position-sensitive both-must-match, insufficient-topics rejection). 7 unit tests cover the in-memory filter store (creation, uninstall, TTL eviction with injected clock, max-filter cap). 9 HTTP integration tests cover the end-to-end flow: deploy → init → ping → query.
The assertions that made me happy:
// topics[0] = blake3("Ping")
const pingHash = hex(blake3(new TextEncoder().encode('Ping')));
const logs = await jsonRpc('eth_getLogs', [
{ fromBlock: 'earliest', toBlock: 'latest', topics: [pingHash] },
]);
expect(logs.length).toBe(1);
expect(logs[0].topics[0]).toBe(pingHash);
This is a dApp indexer's code shape. Compute the event signature hash, pass it as a topic filter, get back only the matching events. Works today. Would work if you pointed it at The Graph if The Graph knew to use blake3(name) instead of keccak256(abiSig) — which is exactly the kind of tiny adapter the planned @asentum/sdk is going to hide.
the stateful filter dance
The other half of Phase 4.3 is the stateful filter API — eth_newFilter, eth_getFilterChanges, eth_uninstallFilter. This is the pattern dApps use for live event subscriptions: "create a filter, poll it, get only what's new since last time, discard when done." In classic Ethereum JSON-RPC style it's manual polling (no websockets), but if you want websockets you can layer them on top.
// Create a filter after block 1 has landed
const filterId = await jsonRpc('eth_newFilter', [
{ address: hex(contractAddr) },
]);
// Initially nothing new
expect(await jsonRpc('eth_getFilterChanges', [filterId])).toEqual([]);
// Ping the contract, produce a block
node.submitTransaction(buildCallTx(nonce, contractAddr, 11));
await node.produceBlockNow();
// Now one new log
const changes = await jsonRpc('eth_getFilterChanges', [filterId]);
expect(changes.length).toBe(1);
expect(changes[0].blockNumber).toBe('0x2');
// Polling again returns nothing — watermark advanced
expect(await jsonRpc('eth_getFilterChanges', [filterId])).toEqual([]);
The lastSeenBlock watermark inside the FilterStore is the only state the filter needs. Every poll advances it. Orphaned filters get evicted after 5 minutes of inactivity so the filter store can't grow unbounded if a dApp crashes mid-subscription.
The eth_newBlockFilter variant is even simpler — same watermark mechanism but it returns block hashes instead of logs:
$ curl http://127.0.0.1:8546/ -d '{"jsonrpc":"2.0","id":1,"method":"eth_newBlockFilter"}'
{"jsonrpc":"2.0","id":1,"result":"0x1"}
the safety cap I made sure to ship
One thing I want to flag: eth_getLogs does a linear scan over the block range. This is fine in dev and on a testnet with modest block counts, but a misconfigured dApp that asks for fromBlock: 0, toBlock: latest on a million-block chain would tie the node up. So I added a hard cap:
export const MAX_LOG_RANGE = 10_000n;
Matches what Infura and Alchemy use in their managed RPC offerings. Queries wider than that get rejected with a clean JSON-RPC error:
$ curl ... '{"method":"eth_getLogs","params":[{"fromBlock":"0x0","toBlock":"0x4000"}]}'
{"jsonrpc":"2.0","id":3,"error":{"code":-32602,"message":"log scan: requested range 16385 exceeds 10000-block limit"}}
The right long-term fix is a per-topic and per-contract-address index at the block-store level, so "give me all Transfer events emitted by this token contract across the last year" is O(hits) instead of O(blocks). That's a follow-up, not a Phase 4 item. For now the linear scan + hard cap is honest and safe.
Phase 4.3 done. 235 tests across all packages now. Next up: polish items — real eth_getCode bytes, eth_getBlockByHash with a proper hash index, eth_getStorageAt. Then Phase 4 is closed.
— milkie
