Skip to content

feat(api): add pending transaction support in the eth subscription API#6941

Open
akaladarshi wants to merge 4 commits into
mainfrom
akaladarshi/add-pending-tx-eth-subscribe
Open

feat(api): add pending transaction support in the eth subscription API#6941
akaladarshi wants to merge 4 commits into
mainfrom
akaladarshi/add-pending-tx-eth-subscribe

Conversation

@akaladarshi
Copy link
Copy Markdown
Collaborator

@akaladarshi akaladarshi commented Apr 20, 2026

Summary of changes

Changes introduced in this pull request:

  • Adds support for the pending transaction API
  • Refactor the existing subscription API's to use stream instead of creating extra channel to sending data to the sink

Reference issue to close (if applicable)

Closes #6031

Other information and links

Change checklist

  • I have performed a self-review of my own code,
  • I have made corresponding changes to the documentation. All new code adheres to the team's documentation standards,
  • I have added tests that prove my fix is effective or that my feature works (if possible),
  • I have made sure the CHANGELOG is up-to-date. All user-facing changes should be reflected in this document.

Outside contributions

  • I have read and agree to the CONTRIBUTING document.
  • I have read and agree to the AI Policy document. I understand that failure to comply with the guidelines will lead to rejection of the pull request.

Summary by CodeRabbit

  • New Features

    • Added Ethereum pubsub support for pendingTransactions, logs, and newHeads (live subscription notifications).
  • Refactor

    • Reworked subscription/stream handling to route and forward subscription streams more reliably.
  • Tests

    • Added integration tests exercising eth_subscribe scenarios and broadcast stream behavior.
  • Documentation

    • Updated CHANGELOG entry noting pendingTransactions support.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 20, 2026

Walkthrough

Refactors eth_subscribe to stream-based handlers (newHeads/logs/pendingTransactions), adds a broadcast->Stream adapter and tests, promotes a tx-hash helper, removes obsolete chain helpers, moves dead-code allows to narrower locations, and adds RPC subscription tests and a changelog entry.

Changes

ETH Pubsub Stream-based Refactoring

Layer / File(s) Summary
Broadcast stream infrastructure
src/utils/broadcast/mod.rs, src/utils/broadcast/tests.rs
New subscription_stream utility wraps Tokio broadcast receivers into pinned async streams, drops/logs lagged events, and includes tests validating termination and lag handling.
ETH helper function visibility
src/rpc/methods/eth.rs
eth_tx_hash_from_signed_message is promoted from private to pub(crate) for use by the pendingTransactions subscription flow.
ETH pubsub stream-based subscription handlers
src/rpc/methods/eth/pubsub.rs
EthPubSub::subscribe now dispatches to spawn_new_heads, spawn_logs, and spawn_pending_transactions; adds pipe_stream_to_sink to serialize and forward items to the JSON-RPC subscription sink.
Chain RPC helper cleanup
src/rpc/methods/chain.rs
Removes new_heads and logs helper functions and their JoinHandle/imports as they are replaced by the pubsub stream handlers.
Dead code suppression refinement
src/message_pool/msgpool/events.rs, src/message_pool/msgpool/msg_pool.rs, src/message_pool/msgpool/pending_store.rs
Relocates #[allow(dead_code)] from enum/method-level to only the specific Remove variant and removes redundant allow attributes from subscribe/subscribe_to_updates.
eth_subscribe tests and tooling
src/tool/subcommands/api_cmd/stateful_tests.rs
Adds WebSocket EthSubStream helper, subscription lifecycle helpers, and three eth_subscribe integration tests: newHeads, pendingTransactions, and logs; registers them in create_tests.
Changelog update
CHANGELOG.md
Documents that eth_subscribe now supports pendingTransactions under "Forest unreleased → Added".

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • ChainSafe/forest#5749: Introduced chain::new_heads/chain::logs subscription helpers that this PR removes and replaces with stream-based handlers.
  • ChainSafe/forest#6402: Related changes to newHeads output formatting (EthBlock conversion) connected to the newHeads pipeline refactor.
  • ChainSafe/forest#6965: Prior movement of #[allow(dead_code)] around MpoolUpdate that this PR refines.

Suggested reviewers

  • sudo-shashank
  • LesnyRumcajs
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding pending transaction support to the eth subscription API, which is the primary feature introduced in this PR.
Linked Issues check ✅ Passed The PR fulfills both objectives from linked issue #6031: implements PendingTransaction subscription support in the EthSubscribe API [#6031] and includes integration tests validating the feature [#6031].
Out of Scope Changes check ✅ Passed All changes are scoped to implementing pending transaction support and refactoring subscriptions to use streams; no unrelated modifications are present.
Docstring Coverage ✅ Passed Docstring coverage is 84.62% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch akaladarshi/add-pending-tx-eth-subscribe
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch akaladarshi/add-pending-tx-eth-subscribe

Warning

Review ran into problems

🔥 Problems

Stopped waiting for pipeline failures after 30000ms. One of your pipelines takes longer than our 30000ms fetch window to run, so review may not consider pipeline-failure results for inline comments if any failures occurred after the fetch window. Increase the timeout if you want to wait longer or run a @coderabbit review after the pipeline has finished.


Comment @coderabbitai help to get the list of available commands and usage tips.

@akaladarshi akaladarshi force-pushed the akaladarshi/add-pending-tx-eth-subscribe branch from c221f29 to f4b122e Compare April 23, 2026 08:01
@akaladarshi akaladarshi added the RPC requires calibnet RPC checks to run on CI label Apr 23, 2026
@akaladarshi akaladarshi force-pushed the akaladarshi/add-pending-tx-eth-subscribe branch from f4b122e to 6f51115 Compare May 20, 2026 08:17
@akaladarshi akaladarshi marked this pull request as ready for review May 21, 2026 09:57
@akaladarshi akaladarshi requested a review from a team as a code owner May 21, 2026 09:57
@akaladarshi akaladarshi requested review from hanabi1224 and sudo-shashank and removed request for a team May 21, 2026 09:57
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/rpc/methods/eth/pubsub.rs`:
- Around line 126-128: The stream currently iterates only changes.applies so
reverted tipsets are ignored; update the flat_map over
subscription_stream(head_rx) to emit both applies and reverts (e.g., pair each
tipset with a boolean or enum indicating is_revert) instead of only
changes.applies, then adjust the subsequent filter_map closure that binds ts to
accept that (tipset, is_revert) and produce log removal events for reverts and
normal events for applies; target the subscription_stream/head_rx pipeline and
the closure capturing ts in pubsub.rs so reorg-driven log removals are emitted.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 023ea685-f51d-4356-8476-c6eea9c54e3d

📥 Commits

Reviewing files that changed from the base of the PR and between dba0196 and 6f51115.

📒 Files selected for processing (7)
  • src/message_pool/msgpool/events.rs
  • src/message_pool/msgpool/msg_pool.rs
  • src/rpc/methods/chain.rs
  • src/rpc/methods/eth.rs
  • src/rpc/methods/eth/pubsub.rs
  • src/utils/broadcast/mod.rs
  • src/utils/broadcast/tests.rs
💤 Files with no reviewable changes (2)
  • src/message_pool/msgpool/msg_pool.rs
  • src/rpc/methods/chain.rs

Comment thread src/rpc/methods/eth/pubsub.rs Outdated
Comment on lines +126 to +128
let stream = subscription_stream(head_rx)
.flat_map(|changes| futures::stream::iter(changes.applies))
.filter_map(move |ts| {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle revert tipsets in logs subscriptions.

On Line 127, the stream only iterates changes.applies, so reverted tipsets are dropped and reorg-driven log removals cannot be emitted.

💡 Suggested fix
-    let stream = subscription_stream(head_rx)
-        .flat_map(|changes| futures::stream::iter(changes.applies))
+    let stream = subscription_stream(head_rx)
+        .flat_map(|changes| {
+            futures::stream::iter(
+                changes
+                    .reverts
+                    .into_iter()
+                    .chain(changes.applies.into_iter()),
+            )
+        })
         .filter_map(move |ts| {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/rpc/methods/eth/pubsub.rs` around lines 126 - 128, The stream currently
iterates only changes.applies so reverted tipsets are ignored; update the
flat_map over subscription_stream(head_rx) to emit both applies and reverts
(e.g., pair each tipset with a boolean or enum indicating is_revert) instead of
only changes.applies, then adjust the subsequent filter_map closure that binds
ts to accept that (tipset, is_revert) and produce log removal events for reverts
and normal events for applies; target the subscription_stream/head_rx pipeline
and the closure capturing ts in pubsub.rs so reorg-driven log removals are
emitted.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 21, 2026

Codecov Report

❌ Patch coverage is 14.81481% with 69 lines in your changes missing coverage. Please review.
✅ Project coverage is 64.17%. Comparing base (62c0289) to head (709de17).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/rpc/methods/eth/pubsub.rs 0.00% 69 Missing ⚠️
Additional details and impacted files
Files with missing lines Coverage Δ
src/message_pool/msgpool/msg_pool.rs 87.75% <ø> (ø)
src/message_pool/msgpool/pending_store.rs 97.08% <ø> (ø)
src/rpc/methods/chain.rs 57.93% <ø> (+2.28%) ⬆️
src/rpc/methods/eth.rs 65.68% <100.00%> (ø)
src/utils/broadcast/mod.rs 100.00% <100.00%> (ø)
src/rpc/methods/eth/pubsub.rs 0.00% <0.00%> (ø)

... and 8 files with indirect coverage changes


Continue to review full report in Codecov by Sentry.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 62c0289...709de17. Read the comment docs.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@akaladarshi akaladarshi marked this pull request as draft May 21, 2026 18:06
@akaladarshi akaladarshi force-pushed the akaladarshi/add-pending-tx-eth-subscribe branch from 6f51115 to 709de17 Compare May 22, 2026 10:16
@akaladarshi akaladarshi marked this pull request as ready for review May 22, 2026 10:17
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/tool/subcommands/api_cmd/stateful_tests.rs`:
- Around line 529-551: The current loops call next_subscription_payload(&mut
ws_stream, &subscription_id, Duration::from_secs(...)).await with a
per-notification timeout which resets on every unrelated event; change to use a
total deadline by recording let deadline = Instant::now() + TOTAL_DURATION (e.g.
120s for pending tx loop, 300s for logs loop) before the loop, then each
iteration compute remaining = deadline.saturating_duration_since(Instant::now())
and pass that remaining duration into next_subscription_payload; if
remaining.is_zero() or elapsed >= TOTAL_DURATION return an error (or bail) so
the test fails instead of hanging. Apply this same pattern to both the
pendingTransactions loop (using tx_hash, ws_stream, subscription_id) and the
logs loop (the one using 300s budget).
- Around line 182-188: The connect_ws helper currently forces the URL scheme to
"ws" and breaks secure endpoints; update the connect_ws function to derive the
websocket scheme from the rpc::Client's base URL (use "wss" when original scheme
is "https", "ws" when "http", and preserve existing "ws"/"wss" if present), call
url.set_scheme(...) with that derived scheme (handling set_scheme errors as
before), then set_path("/rpc/v1") and proceed to connect_async(url.as_str()) to
return the EthSubStream.
- Around line 596-606: The test currently treats the `eth_subscribe("logs")`
payload as an array; instead require a single object: check
`payload.is_object()` (not `is_array()`), deserialize into a single `LogView`
(not `Vec<LogView>`), remove the empty-array check, and update the matching
logic to compare the single `log`'s `transaction_hash` and `topics` against
`tx_hash` and `tx.topic` (the code around `payload`, `LogView`, `tx_hash`,
`tx.topic`, and the `matched` variable).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 73ae3705-5e85-4aec-9388-62e6528b206f

📥 Commits

Reviewing files that changed from the base of the PR and between 6f51115 and 709de17.

📒 Files selected for processing (10)
  • CHANGELOG.md
  • src/message_pool/msgpool/events.rs
  • src/message_pool/msgpool/msg_pool.rs
  • src/message_pool/msgpool/pending_store.rs
  • src/rpc/methods/chain.rs
  • src/rpc/methods/eth.rs
  • src/rpc/methods/eth/pubsub.rs
  • src/tool/subcommands/api_cmd/stateful_tests.rs
  • src/utils/broadcast/mod.rs
  • src/utils/broadcast/tests.rs
💤 Files with no reviewable changes (2)
  • src/message_pool/msgpool/pending_store.rs
  • src/rpc/methods/chain.rs
✅ Files skipped from review due to trivial changes (2)
  • CHANGELOG.md
  • src/message_pool/msgpool/msg_pool.rs

Comment on lines +182 to +188
async fn connect_ws(client: &rpc::Client) -> anyhow::Result<EthSubStream> {
let mut url = client.base_url().clone();
url.set_scheme("ws")
.map_err(|_| anyhow::anyhow!("failed to set scheme"))?;
url.set_path("/rpc/v1");
let (ws_stream, _) = connect_async(url.as_str()).await?;
Ok(ws_stream)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve wss:// for secure RPC endpoints.

This helper forces every base URL onto ws://, so the new subscription tests also fail against https:// RPC servers that require wss://.

💡 Suggested fix
 async fn connect_ws(client: &rpc::Client) -> anyhow::Result<EthSubStream> {
     let mut url = client.base_url().clone();
-    url.set_scheme("ws")
+    let ws_scheme = match url.scheme() {
+        "http" => "ws",
+        "https" => "wss",
+        scheme => anyhow::bail!("unsupported RPC URL scheme: {scheme}"),
+    };
+    url.set_scheme(ws_scheme)
         .map_err(|_| anyhow::anyhow!("failed to set scheme"))?;
     url.set_path("/rpc/v1");
     let (ws_stream, _) = connect_async(url.as_str()).await?;
     Ok(ws_stream)
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async fn connect_ws(client: &rpc::Client) -> anyhow::Result<EthSubStream> {
let mut url = client.base_url().clone();
url.set_scheme("ws")
.map_err(|_| anyhow::anyhow!("failed to set scheme"))?;
url.set_path("/rpc/v1");
let (ws_stream, _) = connect_async(url.as_str()).await?;
Ok(ws_stream)
async fn connect_ws(client: &rpc::Client) -> anyhow::Result<EthSubStream> {
let mut url = client.base_url().clone();
let ws_scheme = match url.scheme() {
"http" => "ws",
"https" => "wss",
scheme => anyhow::bail!("unsupported RPC URL scheme: {scheme}"),
};
url.set_scheme(ws_scheme)
.map_err(|_| anyhow::anyhow!("failed to set scheme"))?;
url.set_path("/rpc/v1");
let (ws_stream, _) = connect_async(url.as_str()).await?;
Ok(ws_stream)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tool/subcommands/api_cmd/stateful_tests.rs` around lines 182 - 188, The
connect_ws helper currently forces the URL scheme to "ws" and breaks secure
endpoints; update the connect_ws function to derive the websocket scheme from
the rpc::Client's base URL (use "wss" when original scheme is "https", "ws" when
"http", and preserve existing "ws"/"wss" if present), call url.set_scheme(...)
with that derived scheme (handling set_scheme errors as before), then
set_path("/rpc/v1") and proceed to connect_async(url.as_str()) to return the
EthSubStream.

Comment on lines +529 to +551
let outcome: anyhow::Result<()> = async {
loop {
let payload = next_subscription_payload(
&mut ws_stream,
&subscription_id,
Duration::from_secs(120),
)
.await?;
// a pending tx is a single hash string.
anyhow::ensure!(
payload.is_string(),
"pendingTransactions must yield a tx-hash string, got: {payload}"
);
let hash: EthHash = serde_json::from_value(payload)
.context("pendingTransactions payload is not an Eth hash")?;
// Identity: it must be the exact tx we just submitted.
if hash == tx_hash {
break;
}
}
Ok(())
}
.await;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use a total deadline, not a per-notification timeout.

Both loops reset the timeout after every unrelated event. On a busy network, that can hang the test indefinitely if the expected notification never arrives.

💡 Suggested fix pattern
-            let outcome: anyhow::Result<()> = async {
-                loop {
-                    let payload = next_subscription_payload(
-                        &mut ws_stream,
-                        &subscription_id,
-                        Duration::from_secs(120),
-                    )
-                    .await?;
+            let outcome: anyhow::Result<()> = async {
+                let deadline = tokio::time::Instant::now() + Duration::from_secs(120);
+                loop {
+                    let remaining =
+                        deadline.saturating_duration_since(tokio::time::Instant::now());
+                    anyhow::ensure!(
+                        !remaining.is_zero(),
+                        "timeout waiting for matching pendingTransactions notification"
+                    );
+                    let payload =
+                        next_subscription_payload(&mut ws_stream, &subscription_id, remaining)
+                            .await?;
                     // a pending tx is a single hash string.
                     anyhow::ensure!(
                         payload.is_string(),
                         "pendingTransactions must yield a tx-hash string, got: {payload}"
                     );

Apply the same pattern to the logs loop with its 300-second budget.

Also applies to: 588-613

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tool/subcommands/api_cmd/stateful_tests.rs` around lines 529 - 551, The
current loops call next_subscription_payload(&mut ws_stream, &subscription_id,
Duration::from_secs(...)).await with a per-notification timeout which resets on
every unrelated event; change to use a total deadline by recording let deadline
= Instant::now() + TOTAL_DURATION (e.g. 120s for pending tx loop, 300s for logs
loop) before the loop, then each iteration compute remaining =
deadline.saturating_duration_since(Instant::now()) and pass that remaining
duration into next_subscription_payload; if remaining.is_zero() or elapsed >=
TOTAL_DURATION return an error (or bail) so the test fails instead of hanging.
Apply this same pattern to both the pendingTransactions loop (using tx_hash,
ws_stream, subscription_id) and the logs loop (the one using 300s budget).

Comment on lines +596 to +606
anyhow::ensure!(
payload.is_array(),
"logs must yield a JSON array, got: {payload}"
);
let logs: Vec<LogView> = serde_json::from_value(payload)
.context("logs payload is not a list of Eth logs")?;
anyhow::ensure!(!logs.is_empty(), "received an empty logs notification");
// Identity: a log carrying our event topic and `our` tx hash.
let matched = logs.iter().any(|log| {
log.transaction_hash == tx_hash && log.topics.contains(&tx.topic)
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

For Ethereum JSON-RPC eth_subscribewith"logs", is each eth_subscriptionnotification'sparams.result a single log object or an array of logs?

💡 Result:

For an Ethereum JSON-RPC eth_subscribe subscription with the "logs" type, each eth_subscription notification's params.result is a single log object, not an array of logs [1][2][3]. When the subscription triggers, the JSON-RPC server sends a notification message with a params field containing the subscription ID and the result [1][2][3]. The result field contains the specific log object that matched the filter criteria [1][2][3]. This structure allows clients to process each log individually as it is emitted by the network [4][5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== stateful_tests.rs (540-660) ==="
sed -n '540,660p' src/tool/subcommands/api_cmd/stateful_tests.rs

echo
echo "=== Search for LogView definition ==="
rg -n "struct LogView|type LogView|LogView" src/tool/subcommands/api_cmd/stateful_tests.rs

echo
echo "=== Search for payload parsing around logs subscription ==="
rg -n "eth_subscribe\\(\"logs\"\\)|payload\\.(is_array|is_object)|from_value\\(payload\\)|logs payload" src/tool/subcommands/api_cmd/stateful_tests.rs

Repository: ChainSafe/forest

Length of output: 5154


Update the eth_subscribe("logs") test to expect a single log object, not a JSON array.

For eth_subscribe with "logs", params.result is a single log object; this test currently requires payload.is_array() and deserializes into Vec<LogView>, making it non-spec-compliant and brittle.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tool/subcommands/api_cmd/stateful_tests.rs` around lines 596 - 606, The
test currently treats the `eth_subscribe("logs")` payload as an array; instead
require a single object: check `payload.is_object()` (not `is_array()`),
deserialize into a single `LogView` (not `Vec<LogView>`), remove the empty-array
check, and update the matching logic to compare the single `log`'s
`transaction_hash` and `topics` against `tx_hash` and `tx.topic` (the code
around `payload`, `LogView`, `tx_hash`, `tx.topic`, and the `matched` variable).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

RPC requires calibnet RPC checks to run on CI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add PendingTransaction support in the EthSubscribe API

1 participant