
{"id":206519,"date":"2026-07-30T15:02:30","date_gmt":"2026-07-30T15:02:30","guid":{"rendered":"https:\/\/mycryptomania.com\/?p=206519"},"modified":"2026-07-30T15:02:30","modified_gmt":"2026-07-30T15:02:30","slug":"the-lifecycle-of-an-ethereum-rpc-call-from-alloy-to-reth","status":"publish","type":"post","link":"https:\/\/mycryptomania.com\/?p=206519","title":{"rendered":"The Lifecycle of an Ethereum RPC Call: From Alloy to Reth"},"content":{"rendered":"<h3>The Lifecycle of an Ethereum RPC Request: From Alloy to\u00a0Reth<\/h3>\n<p>let logs = provider.get_logs(&amp;filter).await?;<\/p>\n<p>While using Alloy, I kept wondering what a single line like the one above actually does. So I read the relevant parts of Alloy and Reth and wrote down what I\u00a0found.<\/p>\n<p>We follow one call, eth_getLogs, over HTTP, from the typed Filter in your process through Reth\u2019s log lookup and back to a Vec&lt;Log&gt;. The Alloy side of this path is shared by many ordinary request and response RPC calls, while the log search inside Reth is specific to eth_getLogs. Along the way, we see where the JSON is created, when the request is sent, and how the node finds matching\u00a0logs.<\/p>\n<p>Reading Rust and having used Alloy before is enough to follow along. We trace Alloy\u2019s default provider implementation down to individual poll calls, then follow the main range query path inside Reth. The code is pinned to <a href=\"https:\/\/github.com\/alloy-rs\/alloy\/tree\/ffaf1da70ef3bc2f85b792257b67fce7b49999ca\">Alloy 2.1.1 at <\/a><a href=\"https:\/\/github.com\/alloy-rs\/alloy\/tree\/ffaf1da70ef3bc2f85b792257b67fce7b49999ca\">ffaf1da7<\/a> and <a href=\"https:\/\/github.com\/paradigmxyz\/reth\/tree\/f969bd4de34657bdea11e98c17a9fd132850d5c3\">Reth 2.4.1 at <\/a><a href=\"https:\/\/github.com\/paradigmxyz\/reth\/tree\/f969bd4de34657bdea11e98c17a9fd132850d5c3\">f969bd4d<\/a>. In some code blocks, unrelated parts have been omitted to keep the focus on the path being discussed.<\/p>\n<h3>Overview<\/h3>\n<p>Let\u2019s start with an overview of what is happening in the process, so we can dive deeper later and understand the details. An Ethereum RPC call is, at its core, sending one JSON-RPC 2.0 message and receiving one in response. The node is a separate program. You can\u2019t call its functions directly; you can only send it a message and wait for a reply. Alloy\u2019s whole job on your side is to hide that: it takes your typed Rust values, turns them into that JSON, sends it, waits, and turns the node\u2019s answer back into Rust types so you get a clean Vec&lt;Log&gt; instead of a blob of\u00a0text.<\/p>\n<p>The process overview is roughly\u00a0this:<\/p>\n<p><strong>Alloy serializes:<\/strong> your typed call becomes the request\u00a0JSON.<strong>Reth deserializes, processes, serializes:<\/strong> the request JSON becomes a Filter, the filter finds its logs, the logs become the response\u00a0JSON.<strong>Alloy deserializes:<\/strong> the response JSON becomes your Vec&lt;Log&gt;.<\/p>\n<p>Here is the call we are going to follow, with the request details filled\u00a0in:<\/p>\n<p>let filter = Filter::new()<br \/>    .address(address!(&#8220;A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48&#8221;))        \/\/ USDC<br \/>    .from_block(0x17d7840u64)<br \/>    .to_block(0x17d7842u64)<br \/>    .event_signature(keccak256(&#8220;Transfer(address,address,uint256)&#8221;));<\/p>\n<p>let logs = provider.get_logs(&amp;filter).await?;<\/p>\n<p>Alloy serializes that filter into JSON-RPC parameters so the server can understand what we need. The request looks like\u00a0this:<\/p>\n<p>{<br \/>  &#8220;method&#8221;: &#8220;eth_getLogs&#8221;,<br \/>  &#8220;params&#8221;: [<br \/>    {<br \/>      &#8220;fromBlock&#8221;: &#8220;0x17d7840&#8221;,<br \/>      &#8220;toBlock&#8221;: &#8220;0x17d7842&#8221;,<br \/>      &#8220;address&#8221;: &#8220;0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48&#8221;,<br \/>      &#8220;topics&#8221;: [<br \/>        &#8220;0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef&#8221;<br \/>      ]<br \/>    }<br \/>  ],<br \/>  &#8220;id&#8221;: 0,<br \/>  &#8220;jsonrpc&#8221;: &#8220;2.0&#8221;<br \/>}<\/p>\n<p>Reth answers in the same JSON-RPC shape, including the same id and carrying either a result or an error. A successful response contains an array of\u00a0logs:<\/p>\n<p>{<br \/>  &#8220;jsonrpc&#8221;: &#8220;2.0&#8221;,<br \/>  &#8220;id&#8221;: 0,<br \/>  &#8220;result&#8221;: [<br \/>    {<br \/>      &#8220;address&#8221;: &#8220;0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48&#8221;,<br \/>      &#8220;topics&#8221;: [&#8220;0xddf252ad&#8230;&#8221;, &#8220;0x&#8230;&#8221;, &#8220;0x&#8230;&#8221;],<br \/>      &#8220;data&#8221;: &#8220;0x&#8230;&#8221;,<br \/>      &#8220;blockHash&#8221;: &#8220;0x&#8230;&#8221;,<br \/>      &#8220;blockNumber&#8221;: &#8220;0x17d7840&#8221;,<br \/>      &#8220;blockTimestamp&#8221;: &#8220;0x&#8230;&#8221;,<br \/>      &#8220;transactionHash&#8221;: &#8220;0x&#8230;&#8221;,<br \/>      &#8220;transactionIndex&#8221;: &#8220;0x2&#8221;,<br \/>      &#8220;logIndex&#8221;: &#8220;0x5&#8221;,<br \/>      &#8220;removed&#8221;: false<br \/>    }<br \/>  ]<br \/>}<\/p>\n<p>JSONs are the only thing that travel between the two programs. Everything else is private to one side or the\u00a0other.<\/p>\n<h3>What the Filter\u00a0Says<\/h3>\n<p>Before following the code, let\u2019s open the filter itself, because it is the payload of the whole request. In Alloy, a Filter stores three\u00a0fields:<\/p>\n<p>\/\/ crates\/rpc-types-eth\/src\/filter.rs<br \/>pub struct Filter {<br \/>    pub block_option: FilterBlockOption,<br \/>    pub address: FilterSet&lt;Address&gt;,<br \/>    pub topics: [Topic; 4],<br \/>}<\/p>\n<p>The block_option says which blocks to search. It can be a range (fromBlock\/toBlock) or a specific blockHash. The address field says which contract address, or addresses, the logs must come from; if it is empty, logs from any address match. The topics array contains up to four topic filters; an empty slot matches anything. They are positional, so a filter can be valid while targeting the wrong topic\u00a0slot.<\/p>\n<p>Ethereum logs have up to four topics. For non-anonymous Solidity events, topics[0] is the hash of the event signature. For ERC-20 transfers, that is keccak256(&#8220;Transfer(address,address,uint256)&#8221;) -the 0xddf252ad&#8230; value in the request above. The remaining slots correspond to the indexed event arguments, in\u00a0order.<\/p>\n<p>event Transfer(address indexed from, address indexed to, uint256 value);<\/p>\n<p>The layout\u00a0is:<\/p>\n<p>topics[0] = keccak256(&#8220;Transfer(address,address,uint256)&#8221;)<br \/>topics[1] = from<br \/>topics[2] = to<br \/>data      = value<\/p>\n<p>value is not indexed, so it is not a topic. It lives in the log data, which means you cannot filter on it with eth_getLogs.<\/p>\n<p>The topic syntax is positional:<\/p>\n<p>{<br \/>  &#8220;topics&#8221;: [<br \/>    &#8220;0xddf252ad&#8230;&#8221;,<br \/>    null,<br \/>    &#8220;0x000000000000000000000000&#8230;&#8221;<br \/>  ]<br \/>}<\/p>\n<p>This means: topic0 must be the Transfer signature, topic1 can be anything, topic2 must be the given address, topic3 can be anything. Get a position wrong, and the query still succeeds. It just quietly matches nothing or the wrong\u00a0thing.<\/p>\n<p>Now we can look at what Alloy\u00a0does.<\/p>\n<h3>How Alloy Serializes and Sends the\u00a0Call<\/h3>\n<p>Remember what we are calling the function on: Provider. But what exactly is\u00a0that?<\/p>\n<p>provider.get_logs(&amp;filter).await?;<\/p>\n<p>It looks like a single function call, but several different jobs have to happen before the request reaches the node. Someone needs to provide the API you call, reuse the connection to the node, turn your function call into a JSON-RPC request, and send that request over the\u00a0network.<\/p>\n<p>Instead of putting all of these responsibilities into one place, Alloy splits them across four roles: Provider, RootProvider, RpcClient, and Transport:<\/p>\n<h3>Provider<\/h3>\n<p>The Provider is the object your application interacts with. Whenever you call methods like get_logs, get_balance, or send_transaction, you&#8217;re calling methods on a Provider.<\/p>\n<p>get_logs has a default implementation on the Provider trait: it obtains the shared RpcClient from the RootProvider and uses it to perform the\u00a0request.<\/p>\n<p>\/\/ crates\/provider\/src\/provider\/trait.rs<br \/>pub trait Provider&lt;N: Network = Ethereum&gt;: Send + Sync {<br \/>    \/\/\/ Returns the root provider.<br \/>    fn root(&amp;self) -&gt; &amp;RootProvider&lt;N&gt;;<\/p>\n<p>    \/\/ &#8230;<\/p>\n<p>    \/\/\/ Returns the RPC client used to send requests.<br \/>    #[inline]<br \/>    fn client(&amp;self) -&gt; ClientRef&lt;&#8216;_&gt; {<br \/>        self.root().client()<br \/>    }<\/p>\n<p>    \/\/ &#8230;<\/p>\n<p>    \/\/\/ Retrieves a [`Vec&lt;Log&gt;`] with the given [`Filter`].<br \/>    async fn get_logs(&amp;self, filter: &amp;Filter) -&gt; TransportResult&lt;Vec&lt;Log&gt;&gt; {<br \/>        self.client().request(&#8220;eth_getLogs&#8221;, (filter,)).await<br \/>    }<br \/>}<\/p>\n<p>One scope note before we continue: this article follows this default implementation. A provider may override get_logs entirely.<\/p>\n<h3>RootProvider<\/h3>\n<p>The RootProvider sits at the base of every provider stack, and for our call it does exactly one thing: it holds the shared RpcClient and hands out references to\u00a0it.<\/p>\n<p>\/\/ crates\/provider\/src\/provider\/root.rs<br \/>pub struct RootProvider&lt;N: Network = Ethereum&gt; {<br \/>    \/\/\/ The inner state of the root provider.<br \/>    pub(crate) inner: Arc&lt;RootProviderInner&lt;N&gt;&gt;,<br \/>}<\/p>\n<p>pub(crate) struct RootProviderInner&lt;N: Network = Ethereum&gt; {<br \/>    client: RpcClient,<br \/>    heart: OnceLock&lt;HeartbeatHandle&gt;,<br \/>    _network: PhantomData&lt;N&gt;,<br \/>}<\/p>\n<p>Three fields, and only the first matters to our call. client is the shared RpcClient, the next layer down. heart serves features like watching new blocks and waiting for transactions to be mined; a one-shot read like get_logs never touches it. And _network is a zero-sized PhantomData marker: it holds no data; it only pins the stack to one network&#8217;s types at compile\u00a0time.<\/p>\n<p>Providers point back at the same root, so they all share one client and, below it, one connection pool.<\/p>\n<h3>RpcClient<\/h3>\n<p>The RpcClient converts your Rust function call into a JSON-RPC request: it builds the request, assigns an id, hands the finished request to the transport, and converts the reply back into Rust\u00a0types.<\/p>\n<p>Notice what it does <em>not<\/em> know: the RpcClient knows nothing about Ethereum. Its only job is to speak JSON-RPC: build a request, attach an id, read a response. It holds the transport it sends through and the counter it draws ids from. (The full type also stores is_local andpoll_interval, plus a pubsub handle when that feature is enabled -none of which our call touches.)<\/p>\n<p>\/\/ crates\/rpc-client\/src\/client.rs<br \/>pub struct RpcClientInner {<br \/>    \/\/\/ The underlying transport.<br \/>    pub(crate) transport: BoxTransport,<br \/>    \/\/\/ The next request ID to use.<br \/>    pub(crate) id: AtomicU64,<br \/>    \/\/ &#8230;<br \/>}<\/p>\n<p>A note on that id: over HTTP, the reply is already tied to the request by the request\/response exchange itself -one POST, one response- and Alloy never even compares it; we\u2019ll watch it get discarded on the return trip. The id becomes load-bearing on batches and on multiplexed transports like WebSocket, where many requests share one connection and responses can arrive out of order; there, the id is what routes each response back to its\u00a0caller.<\/p>\n<h3>Transport<\/h3>\n<p>The Transport is the only layer that actually talks to the network. Its job: deliver an already-serialized JSON-RPC packet to the node and hand back the reply. It doesn&#8217;t know what eth_getLogs means, and it doesn&#8217;t know Ethereum. It moves\u00a0packets.<\/p>\n<p>\/\/ crates\/transport\/src\/boxed.rs<br \/>pub struct BoxTransport {<br \/>    inner: Box&lt;dyn CloneTransport&gt;,<br \/>}<\/p>\n<p>That is the whole reason it\u2019s four roles and not one. The Provider speaks Ethereum, the RpcClient speaks only JSON-RPC, the RootProvider sits between them holding the client they all share, and the Transport speaks only packets. Each role strips away one concern, and the call simply descends from the one that knows the most to the one that knows the\u00a0least.<\/p>\n<p>Now let\u2019s follow the call\u00a0down.<\/p>\n<h3>Alloy\u2019s Serialization<\/h3>\n<p>This is the overview of the function call sequence, and we will look at it in detail by reading the\u00a0code:<\/p>\n<p>Let\u2019s return to the function implementation we\u00a0call:<\/p>\n<p>    \/\/\/ Retrieves a [`Vec&lt;Log&gt;`] with the given [`Filter`].<br \/>    async fn get_logs(&amp;self, filter: &amp;Filter) -&gt; TransportResult&lt;Vec&lt;Log&gt;&gt; {<br \/>        self.client().request(&#8220;eth_getLogs&#8221;, (filter,)).await<br \/>    }<\/p>\n<p>self.client() borrows the shared client from the RootProvider -a ClientRef, which is simply a reference to the RpcClientInner we just\u00a0saw.<\/p>\n<p>request is the RpcClientInner \u2019s method, and does not send anything. It builds the request and wraps it in an RpcCall -a\u00a0future:<\/p>\n<p>\/\/ crates\/rpc-client\/src\/client.rs<br \/>\/\/\/ Prepares an [`RpcCall`].<br \/>\/\/\/<br \/>\/\/\/ This function reserves an ID for the request, however the request is not sent.<br \/>\/\/\/ To send a request, await the returned [`RpcCall`].<br \/>\/\/\/<br \/>\/\/\/ # Note<br \/>\/\/\/<br \/>\/\/\/ Serialization is done lazily. It will not be performed until the call is awaited.<br \/>pub fn request&lt;Params: RpcSend, Resp: RpcRecv&gt;(<br \/>    &amp;self,<br \/>    method: impl Into&lt;Cow&lt;&#8216;static, str&gt;&gt;,<br \/>    params: Params,<br \/>) -&gt; RpcCall&lt;Params, Resp&gt; {<br \/>    let request = self.make_request(method, params);<br \/>    RpcCall::new(request, self.transport.clone())<br \/>}<\/p>\n<p>\/\/ &#8230;<\/p>\n<p>pub fn make_request&lt;Params: RpcSend&gt;(<br \/>    &amp;self,<br \/>    method: impl Into&lt;Cow&lt;&#8216;static, str&gt;&gt;,<br \/>    params: Params,<br \/>) -&gt; Request&lt;Params&gt; {<br \/>    Request::new(method, self.next_id(), params)<br \/>}<\/p>\n<p>At this point, not a single byte has gone to the network. make_request produced a Request&lt;Params&gt;: the method name, the id reserved for this call, and the params, which are still the Rust values you built. That id is the 0 you saw in the request JSON above. Serialization has not happened\u00a0yet.<\/p>\n<p>After RpcCall::new, an RpcCall&lt;Params, Vec&lt;Log&gt;&gt;: a future holding the request, the transport, and the response type it will eventually deserialize into.<\/p>\n<h3>Sending the\u00a0Request<\/h3>\n<p>When you\u00a0.await the RpcCall, two poll functions come into play: an outer one on the RpcCall itself, and an inner one on its state machine. The outer drives the inner and then deserializes; the inner sends the request and waits for the\u00a0reply.<\/p>\n<p>The outer poll, in\u00a0full:<\/p>\n<p>\/\/ crates\/rpc-client\/src\/call.rs<br \/>fn poll(self: Pin&lt;&amp;mut Self&gt;, cx: &amp;mut task::Context&lt;&#8216;_&gt;) -&gt; task::Poll&lt;Self::Output&gt; {<br \/>    let this = self.get_mut();<br \/>    let resp = try_deserialize_ok(ready!(this.state.poll_unpin(cx)));<br \/>    Ready(resp.map(this.map.take().expect(&#8220;polled after completion&#8221;)))<br \/>}<\/p>\n<p>It is short because it does only two things. It polls the inner state machine (this.state) -ready! returns Pending early if the state isn&#8217;t finished- and once the state returns the raw response, try_deserialize_ok turns that raw JSON into the typed Vec&lt;Log&gt;; map then applies an optional conversion, the identity for get_logs. We&#8217;ll come back to the deserialize step on the return trip. First, the machine underneath.<\/p>\n<p>The inner poll is the state machine that sends the request and waits for the reply. In\u00a0full:<\/p>\n<p>\/\/ crates\/rpc-client\/src\/call.rs<br \/>fn poll(mut self: Pin&lt;&amp;mut Self&gt;, cx: &amp;mut task::Context&lt;&#8216;_&gt;) -&gt; task::Poll&lt;Self::Output&gt; {<br \/>    loop {<br \/>        match self.as_mut().project() {<br \/>            CallStateProj::Prepared { connection, request } =&gt; {<br \/>                if let Err(e) =<br \/>                    task::ready!(Service::&lt;RequestPacket&gt;::poll_ready(connection, cx))<br \/>                {<br \/>                    self.set(Self::Complete);<br \/>                    return Ready(RpcResult::Err(e));<br \/>                }<\/p>\n<p>                let request = request.take().expect(&#8220;no request&#8221;);<br \/>                if tracing::enabled!(tracing::Level::TRACE) {<br \/>                    trace!(?request, &#8220;sending request&#8221;);<br \/>                } else {<br \/>                    debug!(method=%request.meta.method, id=%request.meta.id, &#8220;sending request&#8221;);<br \/>                }<br \/>                let request = request.serialize();<br \/>                let fut = match request {<br \/>                    Ok(request) =&gt; {<br \/>                        trace!(request=%request.serialized(), &#8220;serialized request&#8221;);<br \/>                        connection.call(request.into())<br \/>                    }<br \/>                    Err(err) =&gt; {<br \/>                        trace!(?err, &#8220;failed to serialize request&#8221;);<br \/>                        self.set(Self::Complete);<br \/>                        return Ready(RpcResult::Err(TransportError::ser_err(err)));<br \/>                    }<br \/>                };<br \/>                self.set(Self::AwaitingResponse { fut });<br \/>            }<br \/>            CallStateProj::AwaitingResponse { fut } =&gt; {<br \/>                let res = match task::ready!(fut.poll(cx)) {<br \/>                    Ok(ResponsePacket::Single(res)) =&gt; Ready(transform_response(res)),<br \/>                    Err(e) =&gt; Ready(RpcResult::Err(e)),<br \/>                    Ok(ResponsePacket::Batch(_)) =&gt; {<br \/>                        Ready(RpcResult::Err(TransportErrorKind::custom_str(<br \/>                            &#8220;received batch response from single request&#8221;,<br \/>                        )))<br \/>                    }<br \/>                };<br \/>                self.set(Self::Complete);<br \/>                return res;<br \/>            }<br \/>            CallStateProj::Complete =&gt; {<br \/>                panic!(&#8220;Polled after completion&#8221;);<br \/>            }<br \/>        }<br \/>    }<br \/>}<\/p>\n<p>The machine has two points where it can pause: is the transport ready, and has the node replied? Over HTTP, only the second one ever actually pauses. Everything else is serialization and bookkeeping. Let\u2019s take the arms one by\u00a0one.<\/p>\n<h4>The Prepared\u00a0arm<\/h4>\n<p>The state machine starts here, and the arm does three things in order: ask the transport if it\u2019s ready, serialize the request, and hand it\u00a0over.<\/p>\n<p>First, the readiness check:<\/p>\n<p>\/\/ crates\/rpc-client\/src\/call.rs<br \/>if let Err(e) =<br \/>    task::ready!(Service::&lt;RequestPacket&gt;::poll_ready(connection, cx))<br \/>{<br \/>    self.set(Self::Complete);<br \/>    return Ready(RpcResult::Err(e));<br \/>}<\/p>\n<p>For an HTTP transport, this always returns\u00a0Ready:<\/p>\n<p>\/\/ crates\/transport-http\/src\/reqwest_transport.rs<br \/>fn poll_ready(&amp;mut self, _cx: &amp;mut task::Context&lt;&#8216;_&gt;) -&gt; task::Poll&lt;Result&lt;(), Self::Error&gt;&gt; {<br \/>    \/\/ `reqwest` always returns `Ok(())`.<br \/>    task::Poll::Ready(Ok(()))<br \/>}<\/p>\n<p>Second, serialization: where the request becomes\u00a0JSON.<\/p>\n<p>\/\/ crates\/rpc-client\/src\/call.rs &#8211; logging trimmed<br \/>let request = request.take().expect(&#8220;no request&#8221;);<br \/>let request = request.serialize();<br \/>let fut = match request {<br \/>    Ok(request) =&gt; connection.call(request.into()),<br \/>    Err(err) =&gt; {<br \/>        self.set(Self::Complete);<br \/>        return Ready(RpcResult::Err(TransportError::ser_err(err)));<br \/>    }<br \/>};<br \/>self.set(Self::AwaitingResponse { fut });<\/p>\n<p>What does serialize actually do? It\u2019s\u00a0short:<\/p>\n<p>\/\/ crates\/json-rpc\/src\/request.rs<br \/>pub fn serialize(self) -&gt; serde_json::Result&lt;SerializedRequest&gt; {<br \/>    let request = serde_json::value::to_raw_value(&amp;self)?;<br \/>    Ok(SerializedRequest { meta: self.meta, request })<br \/>}<\/p>\n<p>to_raw_value(&amp;self) hands the request to serde, which dispatches back to Request \u2018s own Serialize impl. That\u2019s where the envelope from the overview is assembled, field by\u00a0field:<\/p>\n<p>\/\/ crates\/json-rpc\/src\/request.rs<br \/>\/\/ manually implemented to avoid adding a type for the protocol-required<br \/>\/\/ `jsonrpc` field<br \/>impl&lt;Params&gt; Serialize for Request&lt;Params&gt;<br \/>where<br \/>    Params: RpcSend,<br \/>{<br \/>    fn serialize&lt;S&gt;(&amp;self, serializer: S) -&gt; Result&lt;S::Ok, S::Error&gt;<br \/>    where<br \/>        S: serde::Serializer,<br \/>    {<br \/>        let sized_params = std::mem::size_of::&lt;Params&gt;() != 0;<\/p>\n<p>        let mut map = serializer.serialize_map(Some(3 + sized_params as usize))?;<br \/>        map.serialize_entry(&#8220;method&#8221;, &amp;self.meta.method[..])?;<\/p>\n<p>        \/\/ Params may be omitted if it is 0-sized<br \/>        if sized_params {<br \/>            map.serialize_entry(&#8220;params&#8221;, &amp;self.params)?;<br \/>        }<\/p>\n<p>        map.serialize_entry(&#8220;id&#8221;, &amp;self.meta.id)?;<br \/>        map.serialize_entry(&#8220;jsonrpc&#8221;, &#8220;2.0&#8221;)?;<br \/>        map.end()<br \/>    }<br \/>}<\/p>\n<p>There they are: method, params, id, jsonrpc: &#8220;2.0&#8221;; the four fields of the request JSON from the overview, written out one entry at a time. Serializing params recursively serializes your Filter, which is how the typed addresses and topics become the hex strings on the\u00a0wire.<\/p>\n<p>Third, the handoff. connection.call(req.into()) passes the serialized packet to the transport and returns a future. The\u00a0.into() call wraps our single request in RequestPacket::Single. RequestPacket is the wire unit that can carry either a single request or a batch. But where does the returned future come\u00a0from?<\/p>\n<p>\/\/ crates\/transport-http\/src\/reqwest_transport.rs<br \/>impl Service&lt;RequestPacket&gt; for Http&lt;reqwest::Client&gt; {<br \/>    type Response = ResponsePacket;<br \/>    type Error = TransportError;<br \/>    type Future = TransportFut&lt;&#8216;static&gt;;<\/p>\n<p>    \/\/ poll_ready shown above<\/p>\n<p>    #[inline]<br \/>    fn call(&amp;mut self, req: RequestPacket) -&gt; Self::Future {<br \/>        let this = self.clone();<br \/>        let span = debug_span!(&#8220;ReqwestTransport&#8221;, url = %this.url);<br \/>        Box::pin(this.do_reqwest(req).instrument(span.or_current()))<br \/>    }<br \/>}<\/p>\n<p>self.clone() copies the transport handle so the future can own it (do_reqwest takes self by value and the clone is cheap, because a reqwest::Client is itself a handle to a shared connection pool). Then this.do_reqwest(req) <em>constructs<\/em> its future, and Box::pin wraps it into the type the state machine stores. The span\/instrument pair is observability, not control flow: it tags every log line the future emits with the transport&#8217;s URL. None of it changes how the request is\u00a0sent.<\/p>\n<p>What does not happen in call is any networking: async functions in Rust are lazy, so at this point not a single byte has moved. The boxed future is the fut the arm stores as it moves\u00a0on:<\/p>\n<p>self.set(Self::AwaitingResponse { fut });<\/p>\n<p>The POST only begins when the next arm polls it for the first\u00a0time.<\/p>\n<h4>The AwaitingResponse arm<\/h4>\n<p>\/\/ crates\/rpc-client\/src\/call.rs<br \/>let res = match task::ready!(fut.poll(cx)) {<br \/>    Ok(ResponsePacket::Single(res)) =&gt; Ready(transform_response(res)),<br \/>    Err(e) =&gt; Ready(RpcResult::Err(e)),<br \/>    Ok(ResponsePacket::Batch(_)) =&gt; {<br \/>        Ready(RpcResult::Err(TransportErrorKind::custom_str(<br \/>            &#8220;received batch response from single request&#8221;,<br \/>        )))<br \/>    }<br \/>};<br \/>self.set(Self::Complete);<\/p>\n<p>This is the second wait, and the real one: fut.poll(cx) is where the await truly waits on the network. The future stays Pending, the task yields so the runtime can run other work, and nothing happens until the node&#8217;s reply comes back. Most of that time belongs to the network and to\u00a0Reth.<\/p>\n<p>That first fut.poll(cx) is also what finally runs do_reqwest, a thin wrapper over\u00a0reqwest:<\/p>\n<p>\/\/ crates\/transport-http\/src\/reqwest_transport.rs &#8211; logging trimmed<br \/>async fn do_reqwest(self, req: RequestPacket) -&gt; TransportResult&lt;ResponsePacket&gt; {<br \/>    let resp = self<br \/>        .client<br \/>        .post(self.url)<br \/>        .json(&amp;req)<br \/>        .headers(req.headers())<br \/>        .send()<br \/>        .await<br \/>        .map_err(TransportErrorKind::custom)?;<br \/>    let status = resp.status();<\/p>\n<p>    \/\/ Unpack data from the response body. We do this regardless of<br \/>    \/\/ the status code, as we want to return the error in the body<br \/>    \/\/ if there is one.<br \/>    let body = resp.bytes().await.map_err(TransportErrorKind::custom)?;<\/p>\n<p>    if !status.is_success() {<br \/>        if let Some(response) = crate::json_rpc_error_response(&amp;body) {<br \/>            return Ok(response);<br \/>        }<\/p>\n<p>        return Err(TransportErrorKind::http_error(<br \/>            status.as_u16(),<br \/>            String::from_utf8_lossy(&amp;body).into_owned(),<br \/>        ));<br \/>    }<\/p>\n<p>    \/\/ Deserialize a Box&lt;RawValue&gt; from the body. If deserialization fails, return<br \/>    \/\/ the body as a string in the error. The conversion to String<br \/>    \/\/ is lossy and may not cover all the bytes in the body.<br \/>    serde_json::from_slice(&amp;body)<br \/>        .map_err(|err| TransportError::deser_err(err, String::from_utf8_lossy(&amp;body)))<br \/>}<\/p>\n<p>.json(&amp;req) writes the JSON body,\u00a0.post(url).send() performs the HTTP POST, resp.bytes() reads the reply. This is where the request finally leaves the process. While the inner poll sits Pending here, the bytes are on the network, and control has passed to\u00a0Reth.<\/p>\n<p>When the reply does arrive, the arm checks its shape. We sent a single request, so a batch reply would be a protocol violation; that\u2019s the Batch arm turning into an error. A transport failure, such as a refused connection or a timeout, surfaces as the Err arm. The normal case, Single, goes into transform_response, which turns the already-parsed reply into a Rust Result. This is the return trip. We\u2019ll walk through its code after Reth has done its\u00a0part.<\/p>\n<h4>The Complete\u00a0arm<\/h4>\n<p>CallStateProj::Complete =&gt; {<br \/>    panic!(&#8220;Polled after completion&#8221;);<br \/>}<\/p>\n<p>It\u2019s a contract, not logic: a Rust future should not be polled again after it has returned Ready.\u00a0.await respects this automatically.<\/p>\n<h3>Inside Reth<\/h3>\n<p>While Alloy waits in AwaitingResponse, the request is inside the node. This is the busiest of the three movements: Reth deserializes the request, does the actual work, and serializes the reply. We followed Alloy down to individual polls; here we zoom out one level and follow the main path\u00a0only.<\/p>\n<p>Here is the whole path in\u00a0outline:<\/p>\n<p><strong>Route.<\/strong> jsonrpsee parses the request and rebuilds the typed Filter, then calls the handler for eth_getLogs.<strong>Resolve.<\/strong> A filter names either one block hash or a range. A range may use tags like latest, so it becomes two concrete numbers that are checked against the chain head, pruned history, and the configured range\u00a0cap.<strong>Search.<\/strong> Cheap pass first: block headers carry a bloom filter (we will see what it is) that answers \u201ccould this block match?\u201d. Only the blocks that say yes get their receipts opened -from cache if recent, from disk if not- and only there does the exact match\u00a0run.<strong>Return.<\/strong> Matching logs are stamped with their block and transaction context and serialized back into the response\u00a0JSON.<\/p>\n<h3>Where the Request\u00a0Arrives<\/h3>\n<p>The HTTP request first reaches jsonrpsee; it is the JSON-RPC library Reth uses. It parses the JSON-RPC request and uses the method field to decide which handler receives the request. That mapping is declared by a\u00a0trait:<\/p>\n<p>\/\/ reth: crates\/rpc\/rpc-eth-api\/src\/filter.rs<br \/>#[cfg_attr(not(feature = &#8220;client&#8221;), rpc(server, namespace = &#8220;eth&#8221;))]<br \/>#[cfg_attr(feature = &#8220;client&#8221;, rpc(server, client, namespace = &#8220;eth&#8221;))]<br \/>pub trait EthFilterApi&lt;T: RpcObject&gt; {<br \/>    \/\/ &#8230;<\/p>\n<p>    \/\/\/ Returns logs matching given filter object.<br \/>    #[method(name = &#8220;getLogs&#8221;)]<br \/>    async fn logs(&amp;self, filter: Filter) -&gt; RpcResult&lt;Vec&lt;Log&gt;&gt;;<br \/>}<\/p>\n<p>The namespace eth and method name getLogs form eth_getLogs. The signature tells jsonrpsee what to do with the parameters: deserialize the request&#8217;s filter object into a Rust Filter, then call\u00a0logs.<\/p>\n<p>Reth imports that Filter, along with the Log type it will return, from alloy-rpc-types-eth -the same RPC type definitions Alloy uses on the client side. The two programs hold separate Rust values, but they agree on the JSON representation, so the filter Alloy serialized is reconstructed field by field inside the\u00a0node.<\/p>\n<p>With the conversion done, jsonrpsee calls Reth&#8217;s\u00a0handler:<\/p>\n<p>\/\/ reth: crates\/rpc\/rpc\/src\/eth\/filter.rs<br \/>#[async_trait]<br \/>impl&lt;Eth&gt; EthFilterApiServer&lt;RpcTransaction&lt;Eth::NetworkTypes&gt;&gt; for EthFilter&lt;Eth&gt;<br \/>where<br \/>    Eth: FullEthApiTypes + RpcNodeCoreExt + LoadReceipt + EthBlocks + &#8216;static,<br \/>{<br \/>    \/\/ &#8230;<\/p>\n<p>    \/\/\/ Handler for `eth_getLogs`<br \/>    async fn logs(&amp;self, filter: Filter) -&gt; RpcResult&lt;Vec&lt;Log&gt;&gt; {<br \/>        trace!(target: &#8220;rpc::eth&#8221;, &#8220;Serving eth_getLogs&#8221;);<br \/>        Ok(self.logs_for_filter(filter, self.inner.query_limits).await?)<br \/>    }<br \/>}<\/p>\n<p>From here on, Reth\u2019s code works with typed Rust values rather than JSON. The block selectors, the USDC address, and the Transfer topic are typed fields inside Filter\u00a0. The handler adds the node\u2019s configured query limits and forwards the request through one small\u00a0wrapper:<\/p>\n<p>async fn logs_for_filter(<br \/>    &amp;self,<br \/>    filter: Filter,<br \/>    limits: QueryLimits,<br \/>) -&gt; Result&lt;Vec&lt;Log&gt;, EthFilterError&gt; {<br \/>    self.inner.clone().logs_for_filter(filter, limits).await<br \/>}<\/p>\n<h3>Resolving the Block\u00a0Range<\/h3>\n<p>A filter can identify one block by hash or describe a range, and the inner logs_for_filter branches on\u00a0which.<\/p>\n<p>\/\/ reth: crates\/rpc\/rpc\/src\/eth\/filter.rs<br \/>async fn logs_for_filter(<br \/>    self: Arc&lt;Self&gt;,<br \/>    filter: Filter,<br \/>    limits: QueryLimits,<br \/>) -&gt; Result&lt;Vec&lt;Log&gt;, EthFilterError&gt; {<br \/>    match filter.block_option {<br \/>        FilterBlockOption::AtBlockHash(block_hash) =&gt; { \/* single-block path omitted *\/ }<br \/>        FilterBlockOption::Range { from_block, to_block } =&gt; {<br \/>            if from_block.is_some_and(|b| b.is_pending()) {<br \/>                \/* pending-block path omitted *\/<br \/>            }<\/p>\n<p>            let info = self.provider().chain_info()?;<br \/>            let start_block = info.best_number;<br \/>            let from = from_block<br \/>                .map(|num| self.provider().convert_block_number(num))<br \/>                .transpose()?<br \/>                .flatten();<br \/>            let to = to_block<br \/>                .map(|num| self.provider().convert_block_number(num))<br \/>                .transpose()?<br \/>                .flatten();<\/p>\n<p>            if let Some(t) = to &amp;&amp; t &gt; info.best_number {<br \/>                return Err(EthFilterError::BlockRangeExceedsHead {<br \/>                    requested: t, head: info.best_number,<br \/>                });<br \/>            }<br \/>            if let Some(f) = from &amp;&amp; f &gt; info.best_number {<br \/>                return Ok(Vec::new());<br \/>            }<\/p>\n<p>            let (from_block_number, to_block_number) =<br \/>                logs_utils::get_filter_block_range(from, to, start_block, info)?;<\/p>\n<p>            \/\/ Check if the requested range overlaps with pruned history (EIP-4444)<br \/>            let earliest_block = self.provider().earliest_block_number()?;<br \/>            if from_block_number &lt; earliest_block {<br \/>                return Err(EthApiError::PrunedHistoryUnavailable.into());<br \/>            }<\/p>\n<p>            self.get_logs_in_block_range(filter, from_block_number, to_block_number, limits)<br \/>                .await<br \/>        }<br \/>    }<br \/>}<\/p>\n<p>The block-hash branch already knows its block, so it fetches that block\u2019s receipts (cache first, storage on a miss), applies the same pruned-history check, and jumps straight to the exact-matching step every path eventually reaches. No range to resolve, no bloom\u00a0scan.<\/p>\n<p>The pending branch applies when fromBlock is pending, referring to a block that has not yet become part of the canonical chain. Reth uses the pending block it builds locally when one is available.<\/p>\n<p>Ours is the third branch, and it is the only one with work to do before the search can start. Its problem is that \u201ca range\u201d arrived in an unresolved form. Both bounds are optional, and each can be a tag like latest or earliest rather than a number. So it turns the unresolved range into two concrete integers. chain_info() supplies the current head. convert_block_number resolves each bound. A number stays a number, latest becomes the head, and earliest becomes the first block the node has. Then come the boundary rules. These are worth knowing as a user because they are asymmetric:<\/p>\n<p>A toBlock beyond the head is an error (BlockRangeExceedsHead), you asked for blocks that don&#8217;t exist\u00a0yet.A fromBlock beyond the head silently returns an empty Vec, the whole search starts past the known\u00a0chain.A range starting before the node\u2019s earliest stored block fails with PrunedHistoryUnavailable, rather than silently searching only the part that survived\u00a0pruning.An omitted bound is not what you\u2019d assume. get_filter_block_range fills a missing fromBlock with the current head, and a missing toBlock with the head too. A filter with no block bounds is a query over the latest block, not over all of\u00a0history.<\/p>\n<p>Our numeric bounds pass every check unchanged:<\/p>\n<p>from_block_number = 25_000_000<br \/>to_block_number   = 25_000_002<\/p>\n<p>The unresolved range is now exactly three concrete\u00a0blocks.<\/p>\n<h3>Range Limits and a Blocking\u00a0Task<\/h3>\n<p>The resolved range enters get_logs_in_block_range, which does two things: checks the guardrails, and moves the work somewhere it&#8217;s allowed to be\u00a0slow.<\/p>\n<p>\/\/ reth: crates\/rpc\/rpc\/src\/eth\/filter.rs<br \/>async fn get_logs_in_block_range(<br \/>    self: Arc&lt;Self&gt;,<br \/>    filter: Filter,<br \/>    from_block: u64,<br \/>    to_block: u64,<br \/>    limits: QueryLimits,<br \/>) -&gt; Result&lt;Vec&lt;Log&gt;, EthFilterError&gt; {<br \/>    trace!(target: &#8220;rpc::eth::filter&#8221;, from=from_block, to=to_block, ?filter, &#8220;finding logs in range&#8221;);<\/p>\n<p>    \/\/ perform boundary checks first<br \/>    if to_block &lt; from_block {<br \/>        return Err(EthFilterError::InvalidBlockRangeParams);<br \/>    }<\/p>\n<p>    if let Some(max_blocks_per_filter) =<br \/>        limits.max_blocks_per_filter.filter(|limit| to_block &#8211; from_block &gt; *limit)<br \/>    {<br \/>        return Err(EthFilterError::QueryExceedsMaxBlocks(max_blocks_per_filter));<br \/>    }<\/p>\n<p>    let (tx, rx) = oneshot::channel();<br \/>    let this = self.clone();<br \/>    self.task_spawner.spawn_blocking_task(async move {<br \/>        let res =<br \/>            this.get_logs_in_block_range_inner(&amp;filter, from_block, to_block, limits).await;<br \/>        let _ = tx.send(res);<br \/>    });<\/p>\n<p>    rx.await.map_err(|_| EthFilterError::InternalError)?<br \/>}<\/p>\n<p>The first two checks reject an inverted range and apply Reth\u2019s configured block-range limit before the scan\u00a0begins.<\/p>\n<p>The scan performs synchronous provider operations such as headers_range and receipts_by_block. Since these storage reads can block, Reth runs get_logs_in_block_range_inner in a separate blocking task instead of directly in the RPC handler. The worker sends its result through tx, while rx.await keeps the handler waiting until the result\u00a0arrives.<\/p>\n<h3>The Scan in Three\u00a0Phases<\/h3>\n<p>Everything from here until the worker returns happens inside get_logs_in_block_range_inner. The complete functional path is shown below; tracing-only logging has been\u00a0omitted.<\/p>\n<p>\/\/ reth: crates\/rpc\/rpc\/src\/eth\/filter.rs<br \/>async fn get_logs_in_block_range_inner(<br \/>    self: Arc&lt;Self&gt;,<br \/>    filter: &amp;Filter,<br \/>    from_block: u64,<br \/>    to_block: u64,<br \/>    limits: QueryLimits,<br \/>) -&gt; Result&lt;Vec&lt;Log&gt;, EthFilterError&gt; {<br \/>    let mut all_logs = Vec::new();<br \/>    let mut matching_headers = Vec::new();<\/p>\n<p>    \/\/ get current chain tip to determine processing mode<br \/>    let chain_tip = self.provider().best_block_number()?;<\/p>\n<p>    \/\/ first collect all headers that match the bloom filter for cached mode decision<br \/>    for (from, to) in<br \/>        BlockRangeInclusiveIter::new(from_block..=to_block, self.max_headers_range)<br \/>    {<br \/>        let headers = self.provider().headers_range(from..=to)?;<br \/>        let mut headers_iter = headers.into_iter().peekable();<\/p>\n<p>        while let Some(header) = headers_iter.next() {<br \/>            if !filter.matches_bloom(header.logs_bloom()) {<br \/>                continue;<br \/>            }<\/p>\n<p>            let current_number = header.number();<\/p>\n<p>            let block_hash = match headers_iter.peek() {<br \/>                Some(next_header) if next_header.number() == current_number + 1 =&gt; {<br \/>                    \/\/ Headers are consecutive, use the more efficient parent_hash<br \/>                    next_header.parent_hash()<br \/>                }<br \/>                _ =&gt; {<br \/>                    \/\/ Headers not consecutive or last header, calculate hash<br \/>                    header.hash_slow()<br \/>                }<br \/>            };<\/p>\n<p>            matching_headers.push(SealedHeader::new(header, block_hash));<br \/>        }<br \/>    }<\/p>\n<p>    \/\/ initialize the appropriate range mode based on collected headers<br \/>    let mut range_mode = RangeMode::new(<br \/>        self.clone(),<br \/>        matching_headers,<br \/>        from_block,<br \/>        to_block,<br \/>        self.max_headers_range,<br \/>        chain_tip,<br \/>    );<\/p>\n<p>    \/\/ iterate through the range mode to get receipts and blocks<br \/>    while let Some(ReceiptBlockResult { receipts, recovered_block, header }) =<br \/>        range_mode.next().await?<br \/>    {<br \/>        let num_hash = header.num_hash();<br \/>        append_matching_block_logs(<br \/>            &amp;mut all_logs,<br \/>            recovered_block<br \/>                .map(ProviderOrBlock::Block)<br \/>                .unwrap_or_else(|| ProviderOrBlock::Provider(self.provider())),<br \/>            filter,<br \/>            num_hash,<br \/>            &amp;receipts,<br \/>            false,<br \/>            header.timestamp(),<br \/>        )?;<\/p>\n<p>        \/\/ size check but only if range is multiple blocks, so we always return all<br \/>        \/\/ logs of a single block<br \/>        let is_multi_block_range = from_block != to_block;<br \/>        if let Some(max_logs_per_response) = limits.max_logs_per_response<br \/>            &amp;&amp; is_multi_block_range<br \/>            &amp;&amp; all_logs.len() &gt; max_logs_per_response<br \/>        {<br \/>            let retry_to_block =<br \/>                if num_hash.number == from_block { from_block } else { num_hash.number &#8211; 1 };<br \/>            return Err(EthFilterError::QueryExceedsMaxResults {<br \/>                max_logs: max_logs_per_response,<br \/>                from_block,<br \/>                to_block: retry_to_block,<br \/>            });<br \/>        }<br \/>    }<\/p>\n<p>    Ok(all_logs)<br \/>}<\/p>\n<p>The function has three\u00a0stages:<\/p>\n<p>Use header blooms to find candidate blocks (matching_headers).Fetch the receipts for each candidate (range_mode.next()).Check the real logs, build RPC Log values, and enforce the result limit (append_matching_block_logs and the check after\u00a0it).<\/p>\n<h3>Phase 1: The bloom\u00a0scan<\/h3>\n<p>The first stage starts with a problem of scale. Logs live inside transaction receipts, and since this is our first mention of receipts, it\u2019s worth saying what one is. A receipt is the record Ethereum produces for every executed transaction: whether it succeeded, how much gas it used, and -what matters here- the complete list of logs that transaction emitted. A block carries one receipt per transaction, so a busy block holds hundreds of them. Reading every receipt of every block in the range would work for our three blocks, but would be hopeless for the thousands a serious indexer asks for. The question phase 1 answers: can irrelevant blocks be eliminated without opening their receipts at\u00a0all?<\/p>\n<p>The block header makes that first elimination possible: it carries a 256-byte summary called the <strong>logs bloom<\/strong>. When a block is executed, the address and topics of every emitted log get hashed into this bloom filter. A bloom filter can answer one question, fast: \u201ccould this address or topic appear in this block\u2019s logs?\u201d It can produce false positives -\u201dmaybe\u201d when the truth is no- but never false negatives. If the bloom says our USDC address isn\u2019t in this block, it is not in this block, guaranteed, and the node never needs to open the block\u2019s receipts.<\/p>\n<p>So the scan walks headers, not receipts:<\/p>\n<p>\/\/ reth: crates\/rpc\/rpc\/src\/eth\/filter.rs &#8211; the bloom scan, in get_logs_in_block_range_inner<br \/>for (from, to) in BlockRangeInclusiveIter::new(from_block..=to_block, self.max_headers_range) {<br \/>    let headers = self.provider().headers_range(from..=to)?;<br \/>    let mut headers_iter = headers.into_iter().peekable();<\/p>\n<p>    while let Some(header) = headers_iter.next() {<br \/>        if !filter.matches_bloom(header.logs_bloom()) {<br \/>            continue;<br \/>        }<\/p>\n<p>        let current_number = header.number();<br \/>        let block_hash = match headers_iter.peek() {<br \/>            Some(next_header) if next_header.number() == current_number + 1 =&gt; {<br \/>                \/\/ Headers are consecutive, use the more efficient parent_hash<br \/>                next_header.parent_hash()<br \/>            }<br \/>            _ =&gt; {<br \/>                \/\/ Headers not consecutive or last header, calculate hash<br \/>                header.hash_slow()<br \/>            }<br \/>        };<\/p>\n<p>        matching_headers.push(SealedHeader::new(header, block_hash));<br \/>    }<br \/>}<\/p>\n<p>The whole decision for each block is one call, matches_bloom:<\/p>\n<p>\/\/ alloy: crates\/rpc-types-eth\/src\/filter.rs<br \/>pub fn matches_bloom(&amp;self, bloom: Bloom) -&gt; bool {<br \/>    self.address_bloom_filter().matches(bloom)<br \/>        &amp;&amp; self.topics_bloom_filter().iter().all(|topic_bloom| topic_bloom.matches(bloom))<br \/>}<\/p>\n<p>The address must be possible in the bloom, and every constrained topic position must be possible too. One negative result skips the block, so its receipts are never opened. A positive result only keeps the block as a candidate; the bloom cannot confirm that a matching log exists. And it cannot confirm <em>where<\/em> a topic sits: the bloom records that a value appears somewhere in the block\u2019s logs, not in which topic slot. So the positional check that phase 3 runs still has real work to\u00a0do.<\/p>\n<p>Two mechanical details are worth a note. First, batching. BlockRangeInclusiveIter slices the range into chunks of max_headers_range, so no single storage read is unbounded. Memory use is still not constant because every bloom-positive header is kept in matching_headers. Our request is one chunk of\u00a0three.<\/p>\n<p>Second, block hashes. Phase 2 fetches receipts by hash, so each surviving header has to leave the scan with its hash attached (SealedHeader::new). Computing one directly means keccak-hashing the whole header, which is the costly path. This is why the iterator is peekable: a header already stores its parent&#8217;s hash, so if the next header is consecutive, its parent_hash is exactly the current block&#8217;s hash,\u00a0free.<\/p>\n<p>The flip side of the bloom check is worth stating clearly: if the filter names no address and no topics, there is nothing to check; every bloom passes by default, and every block in the range falls through to phase 2, which is exactly why an unfiltered eth_getLogs over a wide range is such an expensive query to send a\u00a0node.<\/p>\n<p>Phase 1\u2019s output is matching_headers: every block whose bloom says the filter may match, each paired with its hash. For our three-block request, this vector can contain anywhere from zero to three headers. The source code tells us how candidates are selected, but only the actual chain data can tell us which of these three blooms\u00a0pass.<\/p>\n<h3>Phase 2: Fetching the\u00a0receipts<\/h3>\n<p>A bloom-positive header is only a candidate. The actual logs remain inside the block\u2019s receipts, so Reth must now obtain one receipt list for every candidate in matching_headers.<\/p>\n<p>To see why this step has two strategies, start with where a Reth node keeps its data. Everything the node retains lives in persistent storage on disk. On top of that, Reth keeps recently touched blocks and receipts in memory, most of them near the chain tip. Memory answers immediately and disk is far slower per block. So the same request for a block\u2019s receipts has a cheap answer for recent blocks and an expensive one for old ones, and it is worth choosing the access path explicitly.<\/p>\n<p>That choice is RangeMode. It performs no additional filtering; it only decides how receipt access is managed. Two\u00a0modes:<\/p>\n<p>\/\/ reth: crates\/rpc\/rpc\/src\/eth\/filter.rs<br \/>enum RangeMode&lt;<br \/>    Eth: RpcNodeCoreExt&lt;Provider: BlockIdReader, Pool: TransactionPool&gt;<br \/>        + EthApiTypes<br \/>        + LoadReceipt<br \/>        + EthBlocks<br \/>        + &#8216;static,<br \/>&gt; {<br \/>    \/\/\/ Use cache-based processing for recent blocks<br \/>    Cached(CachedMode&lt;Eth&gt;),<br \/>    \/\/\/ Use range-based processing for older blocks<br \/>    Range(RangeBlockMode&lt;Eth&gt;),<br \/>}CachedMode, for small ranges near the chain tip. Each candidate&#8217;s receipts are requested through the cache service (get_receipts_and_maybe_block): already in memory, returned immediately; on a miss, the cache service can still fall back to storage. If the full block is also cached, it comes back alongside the receipts.RangeBlockMode, for older or larger ranges, goes to storage. It checks the cache without forcing a fetch, then reads whatever&#8217;s missing straight from disk via self.provider().receipts_by_block(&#8230;). A very large candidate set is split across parallel blocking tasks that go straight to the provider, their results reassembled in block order so logs never come back out of sequence.<\/p>\n<p>Which mode runs is decided when the RangeMode is built. RangeMode::new computes two values from the resolved range: the number of blocks, and the distance from the range&#8217;s end to the chain tip. And passes them to should_use_cached_mode:<\/p>\n<p>\/\/ reth: crates\/rpc\/rpc\/src\/eth\/filter.rs<br \/>\/\/\/ Determines whether to use cached mode based on bloom filter matches and range size<br \/>const fn should_use_cached_mode(<br \/>    headers: &amp;[SealedHeader&lt;&lt;Eth::Provider as HeaderProvider&gt;::Header&gt;],<br \/>    block_count: u64,<br \/>    distance_from_tip: u64,<br \/>) -&gt; bool {<br \/>    \/\/ Headers are already filtered by bloom, so count equals length<br \/>    let bloom_matches = headers.len();<\/p>\n<p>    \/\/ Calculate adjusted threshold based on bloom matches<br \/>    let adjusted_threshold = Self::calculate_adjusted_threshold(block_count, bloom_matches);<\/p>\n<p>    block_count &lt;= adjusted_threshold &amp;&amp; distance_from_tip &lt;= adjusted_threshold<br \/>}<\/p>\n<p>The returned boolean selects the mode: true builds Cached, false builds Range (an empty candidate list goes to Range either\u00a0way).<\/p>\n<p>For our query, the decision is easy: three blocks sitting far behind the chain tip, so RangeMode::new picks RangeBlockMode. Iterating it determines how the rest of the phase proceeds. Each range_mode.next() delegates to RangeBlockMode::next, which groups consecutive candidate headers and chooses how to fetch their receipts. With only three candidates, the request stays on the sequential path: check the cache first, then load whatever&#8217;s missing through receipts_by_block. Whichever mode runs, next() hands back the same bundle, ReceiptBlockResult:<\/p>\n<p>\/\/ reth: crates\/rpc\/rpc\/src\/eth\/filter.rs<br \/>\/\/\/ Helper type for the common pattern of returning receipts, block and the original header that is<br \/>\/\/\/ a match for the filter.<br \/>struct ReceiptBlockResult&lt;P&gt;<br \/>where<br \/>    P: ReceiptProvider + BlockReader,<br \/>{<br \/>    \/\/\/ We always need the entire receipts for the matching block.<br \/>    receipts: Arc&lt;Vec&lt;ProviderReceipt&lt;P&gt;&gt;&gt;,<br \/>    \/\/\/ Block can be optional and we can fetch it lazily when needed.<br \/>    recovered_block: Option&lt;Arc&lt;reth_primitives_traits::RecoveredBlock&lt;ProviderBlock&lt;P&gt;&gt;&gt;&gt;,<br \/>    \/\/\/ The header of the block.<br \/>    header: SealedHeader&lt;&lt;P as HeaderProvider&gt;::Header&gt;,<br \/>}<\/p>\n<p>Two of the three fields are unconditional: the receipts and the header. The third, recovered_block, is present only when the full block was already in memory; otherwise it is None. That optional block is exactly what Phase 3 reaches for when it needs a matching log&#8217;s transaction hash. So whether it arrives as Some or None sets the cost of that\u00a0lookup.<\/p>\n<h3>Phase 3: Exact matching, and building your\u00a0Log<\/h3>\n<p>Every path has now converged: for each block that made it this far, get_logs_in_block_range_inner destructures each ReceiptBlockResult that next() returns -pulling out the recovered_block we just saw- and calls the append_matching_block_logs function:<\/p>\n<p>\/\/ reth: crates\/rpc\/rpc\/src\/eth\/filter.rs &#8211; the call in get_logs_in_block_range_inner<br \/>append_matching_block_logs(<br \/>    &amp;mut all_logs,<br \/>    recovered_block<br \/>        .map(ProviderOrBlock::Block)<br \/>        .unwrap_or_else(|| ProviderOrBlock::Provider(self.provider())),<br \/>    filter,<br \/>    num_hash,<br \/>    &amp;receipts,<br \/>    false,<br \/>    header.timestamp(),<br \/>)?;<\/p>\n<p>That second argument is the recovered_block from phase 2, mapped into an either-or: ProviderOrBlock::Block when the block came back in memory, ProviderOrBlock::Provider -a handle to storage- when it didn&#8217;t. The function needs it for a single field, and the variant it gets sets the price of filling that\u00a0field.<\/p>\n<p>Inside, the function is two nested loops for every receipt in the block, and every log in the\u00a0receipt:<\/p>\n<p>\/\/ reth: crates\/rpc\/rpc-eth-types\/src\/logs_utils.rs<\/p>\n<p>\/\/\/ Appends all matching logs of a block&#8217;s receipts.<br \/>\/\/\/ If the log matches, look up the corresponding transaction hash.<br \/>pub fn append_matching_block_logs&lt;P&gt;(<br \/>    all_logs: &amp;mut Vec&lt;Log&gt;,<br \/>    provider_or_block: ProviderOrBlock&lt;&#8216;_, P&gt;,<br \/>    filter: &amp;Filter,<br \/>    block_num_hash: BlockNumHash,<br \/>    receipts: &amp;[P::Receipt],<br \/>    removed: bool,<br \/>    block_timestamp: u64,<br \/>) -&gt; Result&lt;(), ProviderError&gt;<br \/>where<br \/>    P: BlockReader&lt;Transaction: SignedTransaction&gt;,<br \/>{<br \/>    if !filter.matches_block(&amp;block_num_hash) {<br \/>        return Ok(());<br \/>    }<\/p>\n<p>    \/\/ Tracks the index of a log in the entire block.<br \/>    let mut log_index: u64 = 0;<\/p>\n<p>    \/\/ Lazy loaded number of the first transaction in the block.<br \/>    \/\/ This is useful for blocks with multiple matching logs because it<br \/>    \/\/ prevents re-querying the block body indices.<br \/>    let mut loaded_first_tx_num = None;<\/p>\n<p>    \/\/ Iterate over receipts and append matching logs.<br \/>    for (receipt_idx, receipt) in receipts.iter().enumerate() {<br \/>        \/\/ The transaction hash of the current receipt.<br \/>        let mut transaction_hash = None;<\/p>\n<p>        for log in receipt.logs() {<br \/>            if filter.matches(log) {<br \/>                \/\/ if this is the first match in the receipt&#8217;s logs, look up the transaction hash<br \/>                if transaction_hash.is_none() {<br \/>                    transaction_hash = match &amp;provider_or_block {<br \/>                        ProviderOrBlock::Block(block) =&gt; {<br \/>                            block.body().transactions().get(receipt_idx).map(|t| *t.tx_hash())<br \/>                        }<br \/>                        ProviderOrBlock::Provider(provider) =&gt; {<br \/>                            let first_tx_num = match loaded_first_tx_num {<br \/>                                Some(num) =&gt; num,<br \/>                                None =&gt; {<br \/>                                    let block_body_indices = provider<br \/>                                        .block_body_indices(block_num_hash.number)?<br \/>                                        .ok_or(ProviderError::BlockBodyIndicesNotFound(<br \/>                                            block_num_hash.number,<br \/>                                        ))?;<br \/>                                    loaded_first_tx_num = Some(block_body_indices.first_tx_num);<br \/>                                    block_body_indices.first_tx_num<br \/>                                }<br \/>                            };<\/p>\n<p>                            \/\/ This is safe because Transactions and Receipts have the same<br \/>                            \/\/ keys.<br \/>                            let transaction_id = first_tx_num + receipt_idx as u64;<br \/>                            let transaction =<br \/>                                provider.transaction_by_id(transaction_id)?.ok_or_else(|| {<br \/>                                    ProviderError::TransactionNotFound(transaction_id.into())<br \/>                                })?;<\/p>\n<p>                            Some(*transaction.tx_hash())<br \/>                        }<br \/>                    };<br \/>                }<\/p>\n<p>                let log = Log {<br \/>                    inner: log.clone(),<br \/>                    block_hash: Some(block_num_hash.hash),<br \/>                    block_number: Some(block_num_hash.number),<br \/>                    transaction_hash,<br \/>                    \/\/ The transaction and receipt index is always the same.<br \/>                    transaction_index: Some(receipt_idx as u64),<br \/>                    log_index: Some(log_index),<br \/>                    removed,<br \/>                    block_timestamp: Some(block_timestamp),<br \/>                };<br \/>                all_logs.push(log);<br \/>            }<br \/>            log_index += 1;<br \/>        }<br \/>    }<br \/>    Ok(())<br \/>}<\/p>\n<p>Filter::matches is where the bloom\u2019s false positives die:<\/p>\n<p>\/\/ alloy: crates\/rpc-types-eth\/src\/filter.rs<br \/>pub fn matches(&amp;self, log: &amp;alloy_primitives::Log) -&gt; bool {<br \/>    if !self.matches_address(log.address) {<br \/>        return false;<br \/>    }<br \/>    self.matches_topics(log.topics())<br \/>}<\/p>\n<p>The address is checked against the address set, and each topic against its positional filter -the same positional semantics from the start of the\u00a0article.<\/p>\n<p>Once a log clears matches, the function looks up its transaction hash. This is where the two ProviderOrBlock variants diverge. If phase 2 delivered the block, the answer is an array index: the receipt_idx-th transaction&#8217;s hash. If not, storage answers in two\u00a0reads.<\/p>\n<p>Reth\u2019s database numbers every transaction it stores with one global sequence, and a block\u2019s transactions sit consecutively in it. block_body_indices fetches where the block&#8217;s transactions begin (first_tx_num), and first_tx_num + receipt_idx gives the transaction&#8217;s global number, which transaction_by_id resolves to the transaction itself.<\/p>\n<p>Both lookups are lazy: the hash is fetched only for the first matching log in a receipt (transaction_hash starts as None, and later matches in the same receipt reuse it), while first_tx_num is fetched once per block and reused across receipts.<\/p>\n<p>Once transaction_hash has been resolved, the log that passed matches becomes the RPC Log you will eventually receive. The inner loop adds it to all_logs with all_logs.push(Log {\u00a0\u2026 }). Every other field comes directly from values the loop already\u00a0holds:<\/p>\n<p>inner: the log as the contract emitted it; address, topics,\u00a0data.block_hash, block_number, block_timestamp: straight from values already in\u00a0hand.transaction_index: receipt_idx, because receipts are ordered the same way as the block\u2019s transactions.log_index: the log\u2019s absolute position among all logs in the block. This is why the counter increments outside the if. Non-matching logs still occupy positions.removed: false here; it turns true on the eth_subscribe(&#8220;logs&#8221;) path, where a reorg replays the logs of the blocks it\u00a0undid.<\/p>\n<p>After append_matching_block_logs returns, the worker applies the second configured limit, max_logs_per_response:<\/p>\n<p>\/\/ reth: crates\/rpc\/rpc\/src\/eth\/filter.rs<br \/>\/\/ size check but only if range is multiple blocks, so we always return all<br \/>\/\/ logs of a single block<br \/>let is_multi_block_range = from_block != to_block;<br \/>if let Some(max_logs_per_response) = limits.max_logs_per_response<br \/>    &amp;&amp; is_multi_block_range<br \/>    &amp;&amp; all_logs.len() &gt; max_logs_per_response<br \/>{<br \/>    let retry_to_block =<br \/>        if num_hash.number == from_block { from_block } else { num_hash.number &#8211; 1 };<br \/>    return Err(EthFilterError::QueryExceedsMaxResults {<br \/>        max_logs: max_logs_per_response,<br \/>        from_block,<br \/>        to_block: retry_to_block,<br \/>    });<br \/>}<\/p>\n<p>Three details define its behavior:<\/p>\n<p>It runs after each block, not each log, so a single block\u2019s matching logs are never truncated mid-block.A query for exactly one block is exempt entirely. The is_multi_block_range guard provides this exemption.On overflow, Reth returns a QueryExceedsMaxResults error rather than a partial vector. The error is unusually helpful because it carries from_block and the last block number that still fit. If the very first block already overflowed, it carries that block number instead. A retry can always fetch that block because single-block queries are exempt. In either case, the client is told exactly which smaller range to try\u00a0next.<\/p>\n<p>When the iterator runs dry, the loop ends and get_logs_in_block_range returns Ok(all_logs). For our request, every USDC Transfer log in blocks 25,000,000 through 25,000,002.<\/p>\n<h3>Returning Through the Call\u00a0Chain<\/h3>\n<p>When the scan finishes, the result travels back through the same call chain in reverse. Because the scan ran in a separate blocking task, its first hop is through the oneshot channel: the worker sends the result through tx, and the rx.await inside get_logs_in_block_range receives\u00a0it.<\/p>\n<p>From there, each function that awaited the next one resumes and returns the result upward: get_logs_in_block_range returns to the inner logs_for_filter, which returns through the forwarding wrapper and finally reaches the public logs handler. An error follows the same path as a successful Vec&lt;Log&gt;.<\/p>\n<p>Once the handler returns, Reth\u2019s method-specific work is complete. jsonrpsee turns the returned value into a JSON-RPC response and writes it to HTTP, sending the call back across the boundary. Somewhere on the other side, the future we left in AwaitingResponse is about to be polled one last\u00a0time.<\/p>\n<h3>How Alloy Deserializes the\u00a0Response<\/h3>\n<p>Now the third movement reverses the first: raw JSON becomes typed Rust\u00a0again.<\/p>\n<p>The reply arrives on the same HTTP connection, and we are still inside do_reqwest, parked since the POST:\u00a0.send().await resolves once the response headers arrive, and resp.bytes().await reads the\u00a0body:<\/p>\n<p>\/\/ crates\/transport-http\/src\/reqwest_transport.rs<br \/>async fn do_reqwest(<br \/>    self,<br \/>    req: RequestPacket,<br \/>) -&gt; TransportResult&lt;ResponsePacket&gt; {<br \/>    \/\/ request sending and status handling omitted<\/p>\n<p>    let body = resp.bytes().await.map_err(TransportErrorKind::custom)?;<\/p>\n<p>    serde_json::from_slice(&amp;body)<br \/>        .map_err(|err| TransportError::deser_err(<br \/>            err,<br \/>            String::from_utf8_lossy(&amp;body),<br \/>        ))<br \/>}<\/p>\n<p>What does that parse produce? Not your logs, not yet. from_slice returns a ResponsePacket-a thin wrapper that is either Single(Response) or Batch(Vec&lt;Response&gt;):<\/p>\n<p>\/\/ crates\/json-rpc\/src\/packet.rs<br \/>pub enum ResponsePacket&lt;Payload = Box&lt;RawValue&gt;, ErrData = Box&lt;RawValue&gt;&gt; {<br \/>    \/\/\/ A single response.<br \/>    Single(Response&lt;Payload, ErrData&gt;),<br \/>    \/\/\/ A batch of responses.<br \/>    Batch(Vec&lt;Response&lt;Payload, ErrData&gt;&gt;),<br \/>}<\/p>\n<p>We sent one request, so ours is Single. Here is the Response:<\/p>\n<p>\/\/ crates\/json-rpc\/src\/response\/mod.rs<br \/>pub struct Response&lt;Payload = Box&lt;RawValue&gt;, ErrData = Box&lt;RawValue&gt;&gt; {<br \/>    pub id: Id,<br \/>    pub payload: ResponsePayload&lt;Payload, ErrData&gt;,<br \/>}<\/p>\n<p>\/\/ crates\/json-rpc\/src\/response\/payload.rs<br \/>pub enum ResponsePayload&lt;Payload = Box&lt;RawValue&gt;, ErrData = Box&lt;RawValue&gt;&gt; {<br \/>    Success(Payload),<br \/>    Failure(ErrorPayload&lt;ErrData&gt;),<br \/>}<\/p>\n<p>The payload is classified as Success or Failure while the response is parsed. Its default type is Box&lt;RawValue&gt;, so serde validates the result JSON but leaves it as raw text. This is the first stage of deserialization. The response structure becomes typed, while the result remains\u00a0raw.<\/p>\n<p>The transport cannot convert the result into Vec&lt;Log&gt; because it does not know the expected response type. RpcCall does, and try_deserialize_ok performs the second stage. Batch requests and multiplexed transports use the response id for routing. This single HTTP path discards it without checking.<\/p>\n<p>This parsed ResponsePacket resolves the second wait -the fut.poll(cx) that had been sitting Pending in AwaitingResponse. From here, two transformations turn the raw reply into your Vec&lt;Log&gt;, undoing the two that built the request on the way\u00a0out.<\/p>\n<p><strong>First transformation:<\/strong> convert the response into a Result<strong>.<\/strong> Back in the inner poll, the resolved future hands the packet to transform_response:<\/p>\n<p>\/\/ crates\/rpc-client\/src\/call.rs<br \/>CallStateProj::AwaitingResponse { fut } =&gt; {<br \/>    let res = match task::ready!(fut.poll(cx)) {<br \/>        Ok(ResponsePacket::Single(res)) =&gt; Ready(transform_response(res)),<br \/>        Err(e) =&gt; Ready(RpcResult::Err(e)),<br \/>        Ok(ResponsePacket::Batch(_)) =&gt; {<br \/>            Ready(RpcResult::Err(TransportErrorKind::custom_str(<br \/>                &#8220;received batch response from single request&#8221;,<br \/>            )))<br \/>        }<br \/>    };<br \/>    self.set(Self::Complete);<br \/>    return res;<br \/>}<\/p>\n<p>And transform_response is a match on the enum we just\u00a0saw:<\/p>\n<p>\/\/ crates\/json-rpc\/src\/result.rs<br \/>pub fn transform_response&lt;T, E, ErrResp&gt;(response: Response&lt;T, ErrResp&gt;) -&gt; RpcResult&lt;T, E, ErrResp&gt;<br \/>where<br \/>    ErrResp: RpcRecv,<br \/>{<br \/>    match response {<br \/>        Response { payload: ResponsePayload::Failure(err_resp), .. } =&gt; {<br \/>            Err(RpcError::err_resp(err_resp))<br \/>        }<br \/>        Response { payload: ResponsePayload::Success(result), .. } =&gt; Ok(result),<br \/>    }<br \/>}<\/p>\n<p>transform_response converts a failure payload into a Rust Err carrying an RpcError; a successful one stays raw JSON as Box&lt;RawValue&gt;. The\u00a0.. pattern discards the response id. If Reth rejected the query, for example because the block range exceeded the node\u2019s limit, the caller\u2019s\u00a0? propagates that error. The inner state machine then sets itself to Complete. On success, it returns the raw result to the outer poll, where it is deserialized into Vec&lt;Log&gt;.<\/p>\n<p>Second transformation: parse the payload. We now return to the outer poll, the line we set aside in \u201cSending the Request\u201d:<\/p>\n<p>\/\/ crates\/rpc-client\/src\/call.rs<br \/>let resp = try_deserialize_ok(ready!(this.state.poll_unpin(cx)));<\/p>\n<p>try_deserialize_ok is where the raw JSON finally becomes the type you asked for. Here is the function in\u00a0full:<\/p>\n<p>\/\/ crates\/json-rpc\/src\/result.rs<br \/>pub fn try_deserialize_ok&lt;J, T, E, ErrResp&gt;(<br \/>    result: RpcResult&lt;J, E, ErrResp&gt;,<br \/>) -&gt; RpcResult&lt;T, E, ErrResp&gt;<br \/>where<br \/>    J: Borrow&lt;RawValue&gt; + &#8216;static,<br \/>    T: RpcRecv,<br \/>    ErrResp: RpcRecv,<br \/>{<br \/>    let json = result?;<\/p>\n<p>    \/\/ Fast path: the caller wants the already-owned `Box&lt;RawValue&gt;` back unchanged. Hand it<br \/>    \/\/ over directly, skipping the byte copy and full JSON validation scan that<br \/>    \/\/ `from_str::&lt;Box&lt;RawValue&gt;&gt;` would repeat over an already-validated value.<br \/>    if TypeId::of::&lt;J&gt;() == TypeId::of::&lt;Box&lt;RawValue&gt;&gt;()<br \/>        &amp;&amp; TypeId::of::&lt;T&gt;() == TypeId::of::&lt;Box&lt;RawValue&gt;&gt;()<br \/>    {<br \/>        \/\/ SAFETY: `J` and `T` are both `Box&lt;RawValue&gt;`, so this is a no-op reinterpretation.<br \/>        \/\/ `transmute_copy` stands in for the unstable `transmute_unchecked` (sizes are equal).<br \/>        let json = std::mem::ManuallyDrop::new(json);<br \/>        return Ok(unsafe { std::mem::transmute_copy::&lt;J, T&gt;(&amp;json) });<br \/>    }<\/p>\n<p>    let _guard = debug_span!(&#8220;deserialize_response&#8221;, ty=%std::any::type_name::&lt;T&gt;()).entered();<br \/>    let json = json.borrow().get();<br \/>    trace!(%json, &#8220;deserializing&#8221;);<br \/>    serde_json::from_str(json)<br \/>        .inspect(|response| trace!(?response, &#8220;deserialized&#8221;))<br \/>        .inspect_err(|err| trace!(?err, &#8220;failed to deserialize&#8221;))<br \/>        .map_err(|err| RpcError::deser_err(err, json))<br \/>}<\/p>\n<p>result? first propagates anything that has already gone wrong. A transport failure or an error response from the node exits here, and your\u00a0? sees it. What remains is the result payload. It is still raw JSON text that has been syntax-checked but not yet deserialized into Vec&lt;Log&gt;.<\/p>\n<p>Two lines turn that text into your logs. json.borrow().get() exposes the raw value as a plain &amp;str without parsing it. serde_json::from_str then reads it as the response type get_logs declared, the Vec&lt;Log&gt; carried by RpcCall. The transport could not perform this parse because only the caller knows the expected result type. The hex strings become typed addresses, hashes, and numbers inside the same shared Log type that Reth serialized.<\/p>\n<p>If the text refuses the type, the node has sent a shape that the struct cannot accept. In that case, deser_err attaches the failing JSON to the error. A deserialization failure from Alloy shows you the exact text it could not deserialize, which turns \u201csomething didn\u2019t parse\u201d into a bug report that writes\u00a0itself.<\/p>\n<p>(The fast path above serves callers that request Box&lt;RawValue&gt; directly. This is the raw, unparsed JSON. Alloy hands back the value it already owns. Our get_logs never takes this\u00a0path.)<\/p>\n<p>One step remains in the outer\u00a0poll:<\/p>\n<p>Ready(resp.map(this.map.take().expect(&#8220;polled after completion&#8221;)))<\/p>\n<p>map is a final conversion applied to the response. It provides a chance to reshape the response before it reaches you. get_logs does not need this conversion. The Vec&lt;Log&gt; is already the type you want, so its map is the identity function. The value passes through unchanged.<\/p>\n<p>The outer poll returns Ready, the\u00a0.await inside the default get_logs completes, and get_logs returns the Vec&lt;Log&gt; to its\u00a0caller.<\/p>\n<p>Run against a node, our three-block request returns 187 logs. Here is an example log from the\u00a0result:<\/p>\n<p>Log {<br \/>    inner: Log {<br \/>        address: 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48,<br \/>        data: LogData {<br \/>            topics: [<br \/>                0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef,<br \/>                0x000000000000000000000000000000000004444c5dc75cb358380d2e3de08a90,<br \/>                0x0000000000000000000000007f54f05635d15cde17a49502fedb9d1803a3be8a,<br \/>            ],<br \/>            data: 0x00000000000000000000000000000000000000000000000000000000017d0fc6,<br \/>        },<br \/>    },<br \/>    block_hash: Some(<br \/>        0xf398976165ca4756c77fc6b61111fa1102d431eb03082417ecce38b36308d728,<br \/>    ),<br \/>    block_number: Some(<br \/>        25000000,<br \/>    ),<br \/>    block_timestamp: Some(<br \/>        1777637363,<br \/>    ),<br \/>    transaction_hash: Some(<br \/>        0xd019ec20783bff41af6c78575e5f1bc4018775161d1e82f7913c2c30a8369d35,<br \/>    ),<br \/>    transaction_index: Some(<br \/>        8,<br \/>    ),<br \/>    log_index: Some(<br \/>        33,<br \/>    ),<br \/>    removed: false,<br \/>}<\/p>\n<h3>Closing the\u00a0Loop<\/h3>\n<p>At the call site, all of this comes back to the line we started\u00a0with:<\/p>\n<p>let logs = provider.get_logs(&amp;filter).await?;<\/p>\n<p>The path has the same shape in both directions. A typed Filter becomes JSON, crosses into another process, and becomes a typed Filter again. Reth then uses it to check block headers, load receipts, and match logs in stages. The result returns as JSON and becomes a typed Vec&lt;Log&gt;. Alloy builds and sends the call. Reth answers\u00a0it.<\/p>\n<p>A single\u00a0.await covers this whole path. It is not one hidden operation but a series of smaller steps. Once we can see those steps, eth_getLogs no longer looks like magic. A slow query, an empty result, a range-limit error, a transport failure, and a deserialization failure can arise at different points on the same trip. Now we know where to look for each\u00a0one.<\/p>\n<p><a href=\"https:\/\/medium.com\/coinmonks\/the-lifecycle-of-an-ethereum-rpc-call-from-alloy-to-reth-48f6ff25feef\">The Lifecycle of an Ethereum RPC Call: From Alloy to Reth<\/a> was originally published in <a href=\"https:\/\/medium.com\/coinmonks\">Coinmonks<\/a> on Medium, where people are continuing the conversation by highlighting and responding to this story.<\/p>","protected":false},"excerpt":{"rendered":"<p>The Lifecycle of an Ethereum RPC Request: From Alloy to\u00a0Reth let logs = provider.get_logs(&amp;filter).await?; While using Alloy, I kept wondering what a single line like the one above actually does. So I read the relevant parts of Alloy and Reth and wrote down what I\u00a0found. We follow one call, eth_getLogs, over HTTP, from the typed [&hellip;]<\/p>\n","protected":false},"author":0,"featured_media":206520,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[2],"tags":[],"class_list":["post-206519","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-interesting"],"_links":{"self":[{"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/posts\/206519"}],"collection":[{"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"replies":[{"embeddable":true,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=206519"}],"version-history":[{"count":0,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/posts\/206519\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/media\/206520"}],"wp:attachment":[{"href":"https:\/\/mycryptomania.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=206519"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=206519"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=206519"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}