fix: normalize pending block tags in eth_getLogs filters to latest - #340
fix: normalize pending block tags in eth_getLogs filters to latest#340operagxoksana wants to merge 1 commit into
Conversation
10de00c to
f448ea4
Compare
osr21
left a comment
There was a problem hiding this comment.
Disclosure: I'm not affiliated with Circle — an external community contributor, not a maintainer. I have no write access to this repository, so the review state I set carries no merge authority and is advisory only. Please treat this as one contributor's technical assessment, and defer to Circle maintainers for the binding review. Rust findings below are source-review only (no
cargo/rustcin my environment); everything else is executed or probed live and labelled as such.
The premise is real and I verified it against the live network, but I think the response shape blocks this as written: null is not a representable answer for these six methods, and one of them sits on the hot path of every viem transaction.
What I verified and agree with
The gap is genuine and reachable today. Against https://rpc.testnet.arc.network (chainId 0x4cef52, head 0x399f129), the methods this PR adds all answer a "pending" tag right now:
eth_getTransactionCount [addr,"pending"] -> 0x0
eth_getBalance [addr,"pending"] -> 0xd14311c7eb2f752958d
eth_call [{to},"pending"] -> 0x
eth_estimateGas [{to},"pending"] -> 0x5f12
eth_getStorageAt [addr,"0x0","pending"] -> 0x0000...
The existing filter is confirmed live on the same endpoint, so this is a true gap in a deployed control rather than a theoretical one — eth_getBlockReceipts ["pending"] and eth_getBlockTransactionCountByNumber ["pending"] both return null, which is the middleware's null_response, not an upstream error.
The object keys are correct. I checked every signature against the pinned reth-rpc-eth-api (Cargo.lock → tag=v2.2.0, 88505c7), crates/rpc/rpc-eth-api/src/core.rs. All six bind block_number, and the positional indices in state_query_block_param_position match exactly, including eth_getStorageAt at index 2:
async fn balance(&self, address: Address, block_number: Option<BlockId>) -> RpcResult<U256>;
async fn storage_at(&self, address: Address, index: JsonStorageKey, block_number: Option<BlockId>) -> RpcResult<B256>;
async fn call(&self, request: TxReq, block_number: Option<BlockId>, state_overrides: ..., block_overrides: ...) -> RpcResult<Bytes>;BlockId is also the right type — it picks up the EIP-1898 object form ({"blockNumber":"pending"}) as well as string tags. And the existing test_enabled_allows_non_pending_methods case survives, because it passes [] for eth_getBalance/eth_call, so index 1 is absent and optional_next yields None.
Blocking: null is not a valid response for any of these six
Every method the filter covered before this PR returns an Option in reth, so null is the method's own canonical "not found" value:
async fn block_receipts(...) -> RpcResult<Option<Vec<R>>>;
async fn header_by_number(...) -> RpcResult<Option<H>>;
async fn uncle_by_block_number_and_index(...) -> RpcResult<Option<B>>;None of the six added here are optional — they are U256, Bytes, and B256. Returning null puts a value on the wire that the method's own schema cannot express, so clients don't degrade gracefully, they misparse.
I ran this against viem 2.52.2 with a transport stubbed to return JSON-RPC success with result: null:
getTransactionCount(pending) => THREW TypeError: Cannot convert null to a BigInt
getBalance(pending) => THREW TypeError: Cannot convert null to a BigInt
estimateGas(pending) => THREW EstimateGasExecutionError: An error occurred.
call(pending) => RESOLVED: {"data":null}
getCode(pending) => RESOLVED: null
getStorageAt(pending) => RESOLVED: null
Two distinct failure modes, both bad: a raw TypeError that names nothing an operator could act on, and — worse — three methods that silently succeed with a null payload.
The consequential one is eth_getTransactionCount. A pending nonce is how wallets pick the next nonce, and in viem it is not an edge case — it is the default write path:
_esm/actions/wallet/prepareTransactionRequest.js:250 blockTag: 'pending' <- every sendTransaction/writeContract without an explicit nonce
_esm/utils/nonceManager.js:76 blockTag: 'pending'
_esm/actions/wallet/prepareAuthorization.js:74 blockTag: 'pending' (EIP-7702)
_esm/actions/public/verifyHash.js:123 blockTag: 'pending' (ERC-6492)
filter_pending_txs defaults to true (node.rs:150, rpc_middleware.rs:109), so on a default-configured node this turns "send a transaction from a viem dapp" into TypeError: Cannot convert null to a BigInt. That is a much larger blast radius than the block-content methods the filter covered previously, none of which sit on a signing path.
Severity of the leak itself, calibrated honestly
Worth weighing against that cost: on the live node the exposure is currently nil in steady state. For an active address at head 0x399f129, pending and latest are byte-identical:
eth_getBalance pending=0x16c0a26072333adc08ba latest=0x16c0a26072333adc08ba same
eth_getTransactionCount pending=0x0 latest=0x0 same
That is consistent with eth_getBlockByNumber ["pending"] returning -32014 requested data not available — with no pending block, reth's state provider already falls back to latest. So the real exposure is the narrow, racy window your doc comment describes, when the consensus engine briefly publishes a proposed block. Real, worth closing, but not a standing disclosure — which argues for closing it in a way that costs clients nothing.
Suggested alternative: coerce pending → latest instead of nulling
Since reth already resolves pending state to latest whenever no pending block exists (demonstrated above), making that mapping explicit for these six methods would:
- close the transient window deterministically, which is the actual goal;
- leak nothing, since with pending transactions hidden by default a
pendingnonce could never have included other senders' transactions anyway; - keep every value schema-valid, so viem, ethers, and wallets keep working unchanged.
In other words the observable behaviour for clients stays exactly what it already is in the common case, and the pre-finalization read disappears. If you prefer to reject rather than coerce, an explicit JSON-RPC error — mirroring PENDING_TX_SUBSCRIPTION_ERROR_CODE in the subscription path — is still far better than null, because at least it surfaces an actionable message instead of a TypeError deep inside a client library. What I'd avoid is null, which is the one option that is both invalid per schema and silent for three of the six.
This is a product call as much as a technical one, so I'd defer to maintainers on which of the two to take.
Coverage gaps, if the intent is to close the class
From the same pinned core.rs, these also take a block parameter and remain uncovered after this PR:
| method | index | object key | note |
|---|---|---|---|
eth_getProof |
2 | block_number |
state proof at pending state |
eth_createAccessList |
1 | block_number |
|
eth_simulateV1 |
1 | block_number |
|
eth_getStorageValues |
1 | block_number |
|
eth_getAccount |
1 | block |
different key |
eth_getAccountInfo |
1 | block |
different key |
eth_feeHistory |
1 | newest_block |
BlockNumberOrTag, not BlockId |
eth_getUncleByBlockNumberAndIndex |
0 | number |
block-content class, missed by is_pending_block_method |
eth_getBlockAccessListByBlockNumber |
0 | number |
same |
eth_getBlockAccessListRaw |
0 | block |
same |
Calibrating that down: eth_getProof, eth_createAccessList, and eth_getHeaderByNumber all return -32601 method not supported on the public endpoints I probed, so they are not exposed there. That is provider namespace configuration though, not something arc-node enforces, so a self-hosted node with the eth namespace enabled would still expose them. The block and newest_block keys are worth noting because a mechanical copy of the block_number entry would silently miss them — your helper returns the key per method, which is exactly the right shape to extend.
Smaller points
- Named-param key list is narrower than its neighbour.
extract_param_atis called with&[key]only, while the adjacenteth_getBlockReceiptsbranch passes bothBLOCK_ID_OBJECT_KEY_SNAKEandBLOCK_ID_OBJECT_KEY_CAMEL. jsonrpsee binds snake-case proc-macro field names, soblockNumbershouldn't occur — but the file already chose to be defensive one branch above, and the inconsistency will read as an oversight later. Either add the camel variant or drop a comment saying why it isn't needed here. - Changelog. Repo convention covers this exact class — v0.8.0 carries "[EL] Complete the pending-block RPC filter…" under
### Fixes. This PR adds no entry, and given the client-visible impact above it may warrant aBREAKING_CHANGES.mdnote too, depending on which response strategy you land on. - Match guards are unnecessary.
const &strvalues are valid match patterns, soETH_GET_BALANCE_METHOD => Some((1, "block_number")),works directly without them if m == …guard. - Test gap. The new tests cover array form only. Given
extract_param_athas a distinct object-params branch, an object-form case ({"address":"0x…","block_number":"pending"}) and an EIP-1898 case ([addr, {"blockNumber":"pending"}]) would pin the two paths that are easiest to regress.
Structurally the change is sound — the helper is the right abstraction, the indices and keys are right, and the doc comment on filter_pending_txs was kept in sync. My concern is only the value it returns.
|
Thanks for the detailed review and for testing this on testnet. You're right returning null isn't correct for these methods and can break clients like viem. I'll fix the response and update the tests accordingly. |
osr21
left a comment
There was a problem hiding this comment.
Disclosure: I'm not affiliated with Circle — I'm an external community contributor, not a maintainer, and I have no write access to this repository. Any review state I set is advisory only and carries no merge authority; please defer to Circle maintainers for the binding review. Rust findings below are source-review only (no
cargo/rustcin my environment) and CI is authoritative; everything labelled live was executed againsthttps://rpc.testnet.arc.networktoday.
Thanks for the quick turnaround, and agreed on the direction.
One procedural note first: the head commit is still f448ea4b, the same tree I reviewed, and the re-review request arrived a few seconds after your comment — so there's nothing new for me to look at yet. I'm submitting this as a comment rather than a new state, so the existing changes-requested stands on its own until you push. Everything below is aimed at making that push land in one go.
Re-verified live today
The premise still holds on the deployed node, so nothing has moved under you:
eth_getBlockReceipts ["pending"] -> null (filter is live)
eth_getBlockTransactionCountByNumber ["pending"] -> null (filter is live)
eth_getBalance [addr,"pending"] -> 0x0
eth_getTransactionCount [addr,"pending"] -> 0x0
eth_getCode [addr,"pending"] -> 0x
eth_getStorageAt [addr,"0x0","pending"] -> 0x00…00
eth_call [{to},"pending"] -> 0x
eth_estimateGas [{to},"pending"] -> 0x5208
eth_getBalance [addr,{"blockNumber":"pending"}] -> 0x0 (EIP-1898 form also reaches it)
Two things worth deciding before you push
1. Which response shape. My recommendation is still coercing pending → latest rather than an error, because on this node they are already the same value in steady state, so coercion is invisible to clients while still closing the transient window. If you prefer to reject, use a distinct error code in the spirit of PENDING_TX_SUBSCRIPTION_ERROR_CODE (-32001) — viem surfaces that as a normal RpcError with your message intact, which is at least actionable. Either is defensible; null is the only option that is both schema-invalid and silent.
2. If you coerce, don't rebuild the Request. This one is easy to get wrong and the compiler won't catch it. In the pinned jsonrpsee (0.26.0, workspace Cargo.toml:142), Request::params is a public field:
pub struct Request<'a> {
pub jsonrpc: TwoPointZero,
pub id: Id<'a>,
pub method: Cow<'a, str>,
pub params: Option<Cow<'a, RawValue>>,
#[serde(skip)]
pub extensions: Extensions,
}so rewriting the tag is an assignment to req.params on an owned Request — but the obvious-looking Request::owned(method, new_params, id) constructor sets extensions: Extensions::new(), silently dropping whatever the server put there. Mutate the field in place and forward the same request; don't reconstruct it.
Also worth pinning in tests if you go this route: the rewrite has to handle the string tag, the EIP-1898 object form ({"blockNumber":"pending"}), and the absent-param case (leave it alone — reth already defaults to latest).
New finding: eth_getLogs is the same class, and this PR doesn't reach it
This is the one I'd most like you to consider, because it changes the shape of the helper. eth_getLogs accepts pending in its filter object and is not covered by is_pending_block_method or by state_query_block_param_position. Live, it resolves pending to head + 1, which makes it visibly racy:
head=60660590 {"fromBlock":"pending","toBlock":"pending"} -> 50 logs, all from block 60660591
head=60660595 {"fromBlock":"pending","toBlock":"pending"} -> error -32602
"block range extends beyond current head block: requested 60660596, head 60660595"
head=60660599 {"fromBlock":"pending","toBlock":"pending"} -> 10 logs, all from block 60660601
Two consequences:
- It belongs in the same bucket as the six you added —
eth_getLogsreturnsVec<Log>, not anOption, sonullis unrepresentable there too. Confirmed against viem 2.52.2 with a transport stubbed to returnresult: null:TypeError: Cannot read properties of null (reading 'map'). - The helper can't express it.
state_query_block_param_positionreturns "one index, one key", butgetLogscarries two tags (fromBlock,toBlock) nested one level inside the object at index 0. If the intent is to close the class rather than six specific methods, that's a third param shape, alongside "first positional" and "nth positional".
Also live: eth_feeHistory ["0x1","pending",[]] returns real data (oldestBlock: 0x39d9bf7), so that gap from my earlier list is reachable too. By contrast eth_getProof, eth_createAccessList, and eth_simulateV1 all answer -32601 method not supported on the public endpoint — that's provider namespace configuration, not something arc-node enforces, so a self-hosted node with the full eth namespace still exposes them.
Two smaller things I can now be concrete about
Skipping positional params allocates on the hottest path. extract_param_at advances the sequence with let _: Option<serde_json::Value> = seq.optional_next()…, which builds a full Value tree for each skipped argument. For eth_call and eth_estimateGas the skipped argument is the transaction object, calldata included, and eth_call is typically the busiest method on the node. ParamsSequence::optional_next is bounded T: Deserialize<'a> — a borrowed lifetime, not DeserializeOwned (jsonrpsee-types 0.26.0, src/params.rs) — so you can skip with Option<&serde_json::value::RawValue> and get a borrow of the raw slice instead of a parsed tree. No behavioural change; the outer T: DeserializeOwned bound on the function is unaffected.
The object-params branch can't be validated live, so unit tests have to carry it. Through the public endpoint, by-name params are rejected before dispatch — -32700 parse error for every method I tried, including "params": {} on eth_blockNumber. I can't tell from outside whether that's the gateway or the node itself, so I'm not claiming the branch is dead code, only that nothing on that path is reachable for live verification. It makes the object-form test case from my earlier review more load-bearing than it looked.
Still open from the earlier review, unchanged: the camel-case key asymmetry against the adjacent eth_getBlockReceipts branch, the missing CHANGELOG entry (v0.8.0 has a precedent line for exactly this filter under ### Fixes), the unnecessary m if m == … match guards, and the array-only test coverage.
Happy to re-review once the fix is pushed.
|
Pushed the fix pending-state queries now reject with an explicit error (-32002) instead of null, and coverage extends to eth_getLogs and eth_feeHistory as you found. Also fixed the filter_pending_txs guard concern, removed the unnecessary match guards, added the CHANGELOG entry, and added object-form + asymmetric eth_getLogs test cases. Ready for re-review. |
osr21
left a comment
There was a problem hiding this comment.
Disclosure: I'm not affiliated with Circle — I'm an external community contributor, not a maintainer, and I have no write access to this repository. This is one contributor's technical assessment, advisory only; a "request changes" from me blocks nothing and Circle maintainers decide. Rust findings here are source-review only (I can't compile the tree); CI is authoritative. Live probes were run against the public
https://rpc.testnet.arc.io/endpoint.
Thanks for turning this around quickly — the mechanical review items are all addressed: null is gone, the filter_pending_txs gate is right (the layer is only wrapped at rpc_middleware.rs:185, so the new check inherits it), the match guards are gone, the CHANGELOG entry is there, the skip in extract_param_at now uses borrowed &RawValue instead of an owned Value tree, and object-form params have unit coverage.
I have to request changes anyway, because verifying the new behaviour turned up something I got wrong in my last review, and it invalidates most of this PR's scope.
I was wrong, and so is the premise: pending already resolves to latest
My previous review argued about how to answer these six methods (null vs. error vs. coercion) and never checked whether they were broken at all. They aren't. Live, against an account with a non-zero nonce:
nonce(pending) = 0x1e8 nonce(latest) = 0x1e8
balance(pending) = 0x8b291213354eb52 balance(latest) = 0x8b291213354eb52
eth_call([{to:0x0}, "pending"]) -> 0x
Reth already coerces pending → latest for these, which is exactly the coercion I suggested. The middleware doesn't need to implement it, and this PR replaces a correct answer with an error.
This contradicts the repo's own conformance suite
crates/test/checks/src/mev.rs is not neutral on this — it documents the fallback as the intended, MEV-safe behaviour:
//! - **Pending state fallback** — state methods with `"pending"` tag match `"latest"`
and check_node asserts it for precisely the six methods this PR now rejects — eth_getBalance, eth_getTransactionCount, eth_getCode, eth_getStorageAt, eth_call, eth_estimateGas (mev.rs:385-423), via check_pending_eq_latest. It's exported from the crate as check_pending_state (checks/src/lib.rs:42).
This PR touches two files and neither is that one. As it stands, merging makes the project's own MEV conformance check fail against a default-configured node. That check is also the argument against needing this change: pending == latest leaks no mempool state, so rejection buys nothing for the threat model the filter exists to serve.
It breaks the canonical nonce fetch — including this repo's own tooling
eth_getTransactionCount(addr, "pending") is how essentially every client gets the next nonce. viem 2.52.2, default write path, against a stub that behaves like this PR:
=== baseline (pending allowed) ===
sendTransaction OK
eth_getTransactionCount calls: ["pending"]
=== with this PR (pending nonce -> -32002) ===
sendTransaction FAILED: Requested resource not available.
details: queries against pending block state are not allowed
eth_getTransactionCount calls: ["pending"]
viem doesn't fall back to latest; the send just fails. Note this is the same class of breakage as the null version — my earlier point wasn't "null specifically is wrong", it was "don't interfere with this call", and I didn't state that clearly enough.
Two in-repo callers break the same way against a node with the default flag:
crates/spammer/src/generator.rs:986—eth_getTransactionCount(address, "pending"), deliberately, per its doc comment at :675 ("to skip nonces already accepted by the pool")crates/quake/src/rpc/mod.rs:185— same call inget_transaction_count
eth_feeHistory isn't broken either
eth_feeHistory ["0x1","pending",[]] ->
{"baseFeePerGas":["0x4a817c800","0x4a817c800"], "gasUsedRatio":[0.0267...],
"oldestBlock":"0x39ed2aa", "reward":[[]]}
Real data, live. Same story as the six: no need to intercept.
eth_getLogs is the one genuine bug, and I'd still not reject it
You were right to pick this up from my last review — it reproduces. Three identical back-to-back calls with {"fromBlock":"pending","toBlock":"pending"}:
1) {"error":{"code":-32602,"message":"block range extends beyond current head block:
requested 60740330, head 60740329"}}
2) {"result":[{"address":"0xffff...fffe", ...}]}
3) {"result":[{"address":"0xffff...fffe", ...}]}
pending resolves to head + 1 here, not to latest, so the same request non-deterministically errors or returns the next block's logs depending on where the head is. That's a real inconsistency worth fixing.
But rejecting it makes eth_getLogs the only method on the node where pending is an error rather than an alias for latest — three different behaviours across the RPC surface (null for block-content, error for logs, fallback for state). Clamping fromBlock/toBlock pending → latest in the middleware would make it consistent with what reth already does everywhere else, keep mev.rs's stated model intact, and fix the flip-flop. If maintainers prefer an error, that's a defensible call — but then it's a deliberate API decision that needs a BREAKING_CHANGES note, not a ### Fixes line.
Suggested scope
Drop the six state-query methods and eth_feeHistory entirely (state_query_block_param_position, the ETH_FEE_HISTORY_METHOD branch, and their tests), keep only the eth_getLogs handling, and reword the CHANGELOG entry to match. That removes extract_param_at's positional-skip path along with the two heaviest-traffic methods (eth_call, eth_estimateGas) from the middleware's hot path, which also disposes of the perf concern from my last review.
Smaller notes
-32002is EIP-1474 "Resource unavailable", which viem surfaces asRequested resource not available.— reasonable, and it doesn't collide with the-32004/-32005already used incrates/evm-node/src/rpc/common.rs. Worth knowing that-32002is also what MetaMask uses for "request already pending", so wallet-side logs may read oddly.- The
## [Unreleased]heading is new toCHANGELOG.md— every prior entry arrived through a release sync commit rather than being staged unreleased. Worth a maintainer confirming the release tooling tolerates it. - The comment on
state_query_block_param_positionsays "only the snake_case key is tried here" and justifies it, which is the asymmetry I raised — good, that answers it. If that block goes away with the scope reduction, theeth_getLogsfilterkey deserves the same one-line justification.
Happy to re-check anything here if you think a probe was mis-set-up — I'd rather be corrected twice than have this land on the wrong premise.
eth_getLogs accepts "pending" in its filter's fromBlock/toBlock, but resolves it to head + 1 instead of latest — unlike every other RPC method that accepts a block tag, which Reth already resolves pending to latest for. This is inconsistent and non-deterministic (the same request can error or return the next block's logs depending on where head is at request time). Coerces fromBlock/toBlock from "pending" to "latest" in the filter object before forwarding, mutating req.params in place rather than rebuilding the Request via Request::owned(...), which would drop whatever the server attached to req.extensions.
4a206e9 to
7934eb6
Compare
osr21
left a comment
There was a problem hiding this comment.
Disclosure: I'm not affiliated with Circle — I'm an external community contributor, not a maintainer, and I have no write access to this repository. Any review state I set is advisory only and carries no merge authority; please defer to Circle maintainers for the binding review. Rust findings below are source-review only (I can't compile the tree); CI is authoritative. Live probes were run today against
https://rpc.testnet.arc.io.
This addresses my last review completely, and the rewrite is the right call. Clearing my changes-requested.
The two blocking objections are resolved
1. The six state-query methods are gone from the diff. I grepped the patch for extract_param_at, state_query_block_param_position, eth_getBalance and eth_estimateGas — the only remaining hit is a comment. That was the substance of my last review and it's fully addressed.
2. The conformance conflict is gone with it. crates/test/checks/src/mev.rs asserts pending == latest for exactly those six methods (check_pending_eq_latest, the state_methods table). Nothing in this PR touches that surface now, so the MEV suite is unaffected. I re-verified the underlying behaviour live against an account with a real nonce rather than a zero one, since comparing zeros proves nothing:
addr 0x8d42b8fd2c96c042ba04c58e24876bd3a9c9ec8a
eth_getTransactionCount pending 0x305 latest 0x305
eth_getBalance pending 0x3fd85e5f44727b94f latest 0x3fd85e5f44727b94f
eth_getCode pending 0x latest 0x
Reth resolves those itself; leaving them alone is correct.
The remaining premise holds — eth_getLogs really is the exception
I re-checked this properly. My earlier attempt compared eth_blockNumber and eth_getLogs across separate round-trips, which measures nothing at a 500ms block time — head moves underneath you. Batching all four calls into one JSON-RPC request:
run 1: head_before=61053297 head_after=61053297 pending=[] latest=[]
run 2: head_before=61053305 head_after=61053305 pending=[61053306] latest=(rate limited)
run 3: head_before=61053314 head_after=61053314 pending=[61053314] latest=[61053314]
Run 2 is the money shot: pending returned logs from head + 1 within a single batch where head provably did not move. Run 3 returned head. So the behaviour is genuinely non-deterministic depending on whether a proposed block exists at request time, which matches the doc comment you wrote. Good — that's a real inconsistency and worth normalizing.
The implementation looks right to me. Mutating req.params in place rather than going through Request::owned(...) is the correct choice and the comment explaining why extensions would be lost is worth keeping. blockHash-form filters are untouched (no fromBlock/toBlock present → no-op), which is right since they're mutually exclusive with a range. The from_value::<BlockNumberOrTag> guard also correctly declines to rewrite an EIP-1898 object, which eth_getLogs wouldn't accept anyway. The tests that assert the rewritten params rather than just a non-error response are exactly the right shape, and I like that test_get_logs_coercion_rewrites_params_in_place pins that address survives.
Please fix before merge: the title and description now describe a different PR
The title is still "extend pending-block RPC filter to state-query methods" and the body still says:
eth_getBalance,eth_getTransactionCount,eth_getCode,eth_getStorageAt,eth_callandeth_estimateGas… a newextract_param_at()helper is added to locate it.
None of that is in the diff any more, and the direction is arguably inverted — the PR's whole point is now that those methods don't need handling. If this lands as a squash merge the commit message becomes the permanent record, so it'd bake in a description of work that isn't there. The CHANGELOG entry, by contrast, is accurate. Something like "fix: normalize pending block tags in eth_getLogs filters to latest" would match.
Three things worth a maintainer's judgement
1. The coercion inherits --arc.expose-pending-txs, and arguably shouldn't. coerce_pending_get_logs is called from intercept_or_forward, which only exists inside NoPendingTransactionsRpcMiddleware, and layer() only wraps that when filter_pending_txs is true. So a node started with --arc.expose-pending-txs keeps the head+1 behaviour.
There's a fair argument that this is intended — the operator asked to see pending state. But I'd push back gently: head+1 isn't pending state, it's a block that doesn't exist yet, and the observable result is either the next block's logs or -32602 "block range extends beyond current head block" depending on timing. That's non-determinism rather than exposure, and it's the one thing the flag's documentation doesn't promise. Since every other method resolves pending → latest regardless of the flag, gating this one makes the flag change log semantics as a side effect. Worth an explicit decision either way rather than falling out of where the function happens to live.
2. eth_newFilter takes the same Filter type and isn't covered. eth_newFilter({"fromBlock":"pending","toBlock":"pending"}) followed by eth_getFilterLogs reaches the same resolution path through a different door, so the inconsistency this PR removes is still reachable. On the public endpoint it answers -32601, but that's provider namespace configuration rather than node policy — a self-hosted node with the filter namespace enabled will expose it. Not necessarily this PR's job, but if the goal is "the RPC surface is consistent about pending", it's a gap; if the scope is deliberately just eth_getLogs, saying so in the doc comment would stop the next person re-opening it.
3. Consider a mev.rs case for it. The suite already documents pending-state behaviour as a conformance property. A getLogs assertion would lock in the new behaviour the same way, and would have caught the head+1 discrepancy originally. Given the non-determinism shown above it'd need a few samples rather than a single call.
Minor
coerce_filter_pending_fieldsdoesfield.clone()beforefrom_value.BlockNumberOrTag::deserialize(&*field)would avoid cloning, though for a short tag string it's negligible.coerce_pending_get_logsparses the full params into aserde_json::Valueon everyeth_getLogscall and throws the tree away when nothing is pending — which is the overwhelmingly common case on the busiest method indexers use. A cheap "does the raw params text containpendingat all" pre-check before parsing would skip the allocation almost always. I haven't measured this and filters are usually small, so treat it as a suggestion rather than a finding — but largetopics/addressOR-arrays do show up in indexer traffic.
Nothing above is a correctness problem in the code as written, so I'm approving. The title/description fix is the only thing I'd genuinely want done before it lands.
|
done |
Confirmed, and my approval stands — no need to re-review. The head SHA is unchanged ( Two small things, neither of which needs a new push on its own: The title has three leading spaces — On To restate so nothing reads as a blocker: the three items from my review — the |
|
I understood you, I corrected the title |
|
Nothing much to add, just have to ping maintainers to get this completed. |
This change fixes inconsistent pending handling in eth_getLogs by normalizing fromBlock and toBlock from pending to latest. Without this, pending could resolve to a proposed block beyond the current chain head, causing either unexpected logs or a block range extends beyond current head error depending on timing. The change keeps the existing behavior for other block tags and blockHash filters, and adds coverage for positional and object-form requests.