Skip to content

Commit a0fd4c4

Browse files
will pankiewiczclaude
andcommitted
Merge origin/main into fix/issue-59-txpool-eviction-rpc
Resolve conflicts between PR #71 (pending_tx_broadcast/mempool_broadcast subscriptions) and PR #72 (txpool eviction + RPC). Both feature sets are retained: server structs carry txpool, pending_tx_broadcast, and mempool_broadcast fields; the transaction pool gains event broadcasting alongside eviction logic. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2 parents f240348 + 6b54991 commit a0fd4c4

14 files changed

Lines changed: 1002 additions & 26 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
# PR #71: Pending Transaction & Mempool Subscription Support
2+
3+
## Problem
4+
5+
Before this change, Kora nodes had no way for external clients to receive
6+
real-time notifications about transaction lifecycle events. Wallets, block
7+
explorers, and monitoring tools had to poll `eth_getTransactionByHash` or
8+
`eth_getTransactionReceipt` in a loop to discover when a transaction was
9+
accepted, included in a block, or evicted from the mempool. This created
10+
unnecessary RPC load and introduced latency between events and their
11+
observation.
12+
13+
## Solution
14+
15+
This PR adds two WebSocket/SSE subscription endpoints that push transaction
16+
lifecycle events to connected clients:
17+
18+
1. **`eth_subscribe("newPendingTransactions")`** -- standard Ethereum
19+
subscription that notifies clients whenever a new transaction enters the
20+
mempool. Supports an optional `{ "fullTx": true }` parameter to receive
21+
the full `RpcTransaction` object instead of just the hash.
22+
23+
2. **`kora_subscribe("mempool")`** -- Kora-specific subscription that streams
24+
the full mempool lifecycle for every transaction: `TxAdded` (accepted into
25+
the pool), `TxIncluded` (finalized in a block), and `TxEvicted` (removed
26+
without inclusion, with a human-readable reason).
27+
28+
Both subscriptions use `tokio::sync::broadcast` channels so that multiple
29+
WebSocket clients can subscribe independently without blocking the main
30+
transaction processing pipeline.
31+
32+
## How It Works
33+
34+
### Event Flow
35+
36+
```
37+
eth_sendRawTransaction
38+
--> EthApiImpl::broadcast_pending_tx()
39+
--> PendingTxEvent::Added (eth_subscribe consumers)
40+
--> MempoolEvent::TxAdded (kora_subscribe consumers)
41+
42+
Block finalized
43+
--> FinalizedReporter::report()
44+
--> publish_mempool_inclusions()
45+
--> MempoolEvent::TxIncluded (kora_subscribe consumers)
46+
47+
Transaction replaced / removed from pool
48+
--> TransactionPool::remove_with_reason()
49+
--> MempoolEvent::TxEvicted (kora_subscribe consumers)
50+
```
51+
52+
### Channel Architecture
53+
54+
- `PendingTxEventSender` (`broadcast::Sender<PendingTxEvent>`) -- carries
55+
Ethereum-standard pending transaction notifications (hash or full tx).
56+
- `MempoolEventSender` (`broadcast::Sender<MempoolEvent>`) -- carries
57+
Kora-specific mempool lifecycle events with richer metadata.
58+
- Both channels are created in the runner, wired into the RPC server and the
59+
`FinalizedReporter`, and passed through to the subscription module.
60+
61+
## Breaking Changes
62+
63+
None. All new types and endpoints are additive. Existing RPC methods and
64+
behavior are unchanged. The `TransactionPool` API gains a new
65+
`remove_with_reason()` method while the original `remove()` continues to work
66+
unchanged (it delegates to `remove_with_reason` with reason `"removed"`).
67+
68+
## Migration Notes
69+
70+
- Node operators do not need to change configuration. Subscriptions are
71+
available automatically when RPC is enabled.
72+
- If the RPC is not configured, the broadcast channels are `None` and no events
73+
are emitted (zero overhead).
74+
75+
## Files Modified
76+
77+
### `crates/node/domain/Cargo.toml`
78+
- Added `serde` feature to `alloy-primitives` for serializing `Address`, `B256`,
79+
and `U256` inside `MempoolEvent`.
80+
81+
### `crates/node/domain/src/events.rs`
82+
- Added `MempoolEvent` enum with three variants: `TxAdded`, `TxIncluded`, and
83+
`TxEvicted`.
84+
- `MempoolEvent` derives `Serialize`/`Deserialize` with `camelCase` serde
85+
renaming for JSON-RPC compatibility.
86+
- Added `mempool_event_serde_roundtrip` unit test.
87+
88+
### `crates/node/domain/src/lib.rs`
89+
- Re-exported `MempoolEvent` from the crate root.
90+
91+
### `crates/node/rpc/Cargo.toml`
92+
- Added `kora-domain` dependency (needed for `MempoolEvent` type in
93+
subscriptions).
94+
95+
### `crates/node/rpc/src/subscription.rs` (new file)
96+
- `PendingTxEvent` / `PendingTxInfo` -- types for Ethereum-standard pending
97+
transaction notifications.
98+
- `subscription_module()` -- builds the `RpcModule` with `eth_subscribe` and
99+
`kora_subscribe` handlers.
100+
- `pending_tx_channel()` / `mempool_event_channel()` -- factory functions for
101+
broadcast channels with default capacities (2048 / 4096).
102+
- `recv_broadcast()` -- helper that handles `Lagged` errors by skipping missed
103+
events and logging a warning.
104+
- Tests covering hash-only subscriptions, full-tx subscriptions, Kora mempool
105+
subscriptions, and lagged-receiver recovery.
106+
107+
### `crates/node/rpc/src/eth.rs`
108+
- `EthApiImpl` gains optional `pending_tx_broadcast` and `mempool_broadcast`
109+
fields, set via builder methods `with_pending_tx_broadcast()` and
110+
`with_mempool_broadcast()`.
111+
- `broadcast_pending_tx()` sends both `PendingTxEvent::Added` and
112+
`MempoolEvent::TxAdded` after a transaction is accepted via
113+
`eth_sendRawTransaction`.
114+
- Tests verify that broadcasts fire on acceptance and do not fire when the raw
115+
transaction fails to decode.
116+
117+
### `crates/node/rpc/src/server.rs`
118+
- `RpcServer` and `JsonRpcServer` gain `pending_tx_broadcast` and
119+
`mempool_broadcast` fields with corresponding builder methods.
120+
- The subscription module is merged into the RPC module at startup.
121+
- Debug impl updated to show broadcast channel presence.
122+
123+
### `crates/node/rpc/src/lib.rs`
124+
- Re-exports the new subscription types and channel factory functions from the
125+
crate root.
126+
127+
### `crates/node/reporters/src/lib.rs`
128+
- `FinalizedReporter` gains an optional `mempool_broadcast` field set via
129+
`with_mempool_broadcast()`.
130+
- `publish_mempool_inclusions()` iterates finalized block transactions and sends
131+
`MempoolEvent::TxIncluded` for each one.
132+
- Unit test verifies `TxIncluded` events are emitted with correct block
133+
number and hash.
134+
135+
### `crates/node/runner/src/runner.rs`
136+
- Creates `pending_tx_broadcast` and `mempool_broadcast` channels when RPC is
137+
configured.
138+
- Wires both channels into the `RpcServer` and the `FinalizedReporter`.
139+
140+
### `crates/node/txpool/Cargo.toml`
141+
- Added `tokio` dependency with `sync` feature for `broadcast::Sender`.
142+
143+
### `crates/node/txpool/src/config.rs`
144+
- No functional change; a blank line was added for consistency.
145+
146+
### `crates/node/txpool/src/pool.rs`
147+
- `TransactionPool` gains an optional `events: Option<broadcast::Sender<MempoolEvent>>`
148+
field and a `new_with_events()` constructor.
149+
- `add()` emits `MempoolEvent::TxEvicted` (reason: `"replaced"`) when a
150+
transaction at the same nonce is displaced, followed by `MempoolEvent::TxAdded`
151+
for the new transaction.
152+
- `remove_with_reason()` emits `MempoolEvent::TxEvicted` with a caller-supplied
153+
reason string.
154+
- `remove()` delegates to `remove_with_reason()` with reason `"removed"`.
155+
- `tx_added_event()` helper constructs `MempoolEvent::TxAdded` from an
156+
`OrderedTransaction`.
157+
- Tests cover: `TxAdded` on insert, `TxEvicted` on replacement, `TxEvicted` on
158+
remove, and custom eviction reasons.
159+
160+
### `Cargo.lock`
161+
- Updated to reflect the new `kora-domain` dependency from `kora-rpc`.
162+
163+
## Testing
164+
165+
The following test cases cover the subscription functionality:
166+
167+
**Domain events (`kora-domain`)**
168+
- `mempool_event_serde_roundtrip` -- verifies `MempoolEvent::TxAdded`
169+
serializes to JSON with camelCase field names and deserializes back
170+
identically.
171+
172+
**RPC subscriptions (`kora-rpc`)**
173+
- `eth_pending_subscription_receives_hash` -- subscribes to
174+
`newPendingTransactions` and verifies the hash is received.
175+
- `eth_pending_subscription_receives_full_tx` -- subscribes with `fullTx: true`
176+
and verifies the full `RpcTransaction` object is received.
177+
- `kora_mempool_subscription_receives_event` -- subscribes to `kora_subscribe("mempool")`
178+
and verifies a `MempoolEvent::TxIncluded` event is received.
179+
- `broadcast_receiver_skips_lagged_events` -- verifies the `recv_broadcast`
180+
helper correctly recovers from a lagged receiver by skipping to the latest
181+
available message.
182+
183+
**RPC broadcast integration (`kora-rpc`)**
184+
- `eth_send_raw_transaction_broadcasts_after_acceptance` -- verifies that
185+
`eth_sendRawTransaction` emits both `PendingTxEvent` and `MempoolEvent`
186+
after successful validation.
187+
- `invalid_raw_transaction_does_not_broadcast` -- verifies that a malformed
188+
transaction does not emit any broadcast events.
189+
190+
**Transaction pool events (`kora-txpool`)**
191+
- `pool_broadcasts_tx_added_on_insert` -- verifies `MempoolEvent::TxAdded` is
192+
emitted when a transaction is added to the pool.
193+
- `pool_broadcasts_replaced_transaction_as_evicted` -- verifies that replacing
194+
a transaction emits `TxEvicted` for the old transaction followed by `TxAdded`
195+
for the new one.
196+
- `pool_remove_broadcasts_tx_evicted` -- verifies `remove()` emits `TxEvicted`
197+
with reason `"removed"`.
198+
- `pool_remove_with_reason_broadcasts_custom_reason` -- verifies
199+
`remove_with_reason()` emits `TxEvicted` with the caller-supplied reason.
200+
201+
**Reporter integration (`kora-reporters`)**
202+
- `publish_mempool_inclusions_broadcasts_tx_included` -- verifies that
203+
`publish_mempool_inclusions()` emits `TxIncluded` with the correct block
204+
number and block hash for each transaction in a finalized block.

crates/node/domain/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ commonware-cryptography.workspace = true
1818

1919
# Execution
2020
alloy-evm = { workspace = true, features = ["std"] }
21-
alloy-primitives.workspace = true
21+
alloy-primitives = { workspace = true, features = ["serde"] }
2222
alloy-consensus = { workspace = true, optional = true }
2323
alloy-eips = { workspace = true, optional = true }
2424
k256 = { workspace = true, optional = true }

crates/node/domain/src/events.rs

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
33
use std::sync::Arc;
44

5-
use alloy_evm::revm::primitives::B256;
5+
use alloy_primitives::{Address, B256, U256};
66
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded};
77
use parking_lot::Mutex;
8+
use serde::{Deserialize, Serialize};
89

910
use super::TxId;
1011
use crate::ConsensusDigest;
@@ -20,6 +21,43 @@ pub enum LedgerEvent {
2021
SeedUpdated(ConsensusDigest, B256),
2122
}
2223

24+
/// Transaction lifecycle events emitted by the mempool.
25+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
26+
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
27+
pub enum MempoolEvent {
28+
/// Transaction accepted into the mempool.
29+
TxAdded {
30+
/// Transaction hash.
31+
hash: B256,
32+
/// Sender address recovered from the transaction signature.
33+
from: Address,
34+
/// Recipient address, or `None` for contract creation.
35+
to: Option<Address>,
36+
/// Value transferred by the transaction.
37+
value: U256,
38+
/// Effective gas price used for ordering.
39+
gas_price: U256,
40+
/// Transaction nonce.
41+
nonce: u64,
42+
},
43+
/// Transaction included in a finalized block.
44+
TxIncluded {
45+
/// Transaction hash.
46+
hash: B256,
47+
/// Finalized block number.
48+
block_number: u64,
49+
/// Finalized block hash.
50+
block_hash: B256,
51+
},
52+
/// Transaction removed from the mempool without inclusion.
53+
TxEvicted {
54+
/// Transaction hash.
55+
hash: B256,
56+
/// Human-readable eviction reason.
57+
reason: String,
58+
},
59+
}
60+
2361
/// Pub-sub registry for ledger events.
2462
#[derive(Clone, Debug)]
2563
pub struct LedgerEvents {
@@ -55,7 +93,7 @@ impl Default for LedgerEvents {
5593

5694
#[cfg(test)]
5795
mod tests {
58-
use alloy_primitives::B256;
96+
use alloy_primitives::{Address, B256, U256};
5997
use commonware_cryptography::sha256::Digest;
6098

6199
use super::*;
@@ -96,7 +134,7 @@ mod tests {
96134
let tx_id = TxId(B256::repeat_byte(0x42));
97135
events.publish(LedgerEvent::TransactionSubmitted(tx_id));
98136

99-
let received = receiver.try_next().expect("channel open").expect("should receive event");
137+
let received = receiver.try_recv().expect("should receive event");
100138
if let LedgerEvent::TransactionSubmitted(id) = received {
101139
assert_eq!(id.0, B256::repeat_byte(0x42));
102140
} else {
@@ -113,8 +151,8 @@ mod tests {
113151
let tx_id = TxId(B256::repeat_byte(0x01));
114152
events.publish(LedgerEvent::TransactionSubmitted(tx_id));
115153

116-
let e1 = r1.try_next().expect("channel open").expect("r1 should receive");
117-
let e2 = r2.try_next().expect("channel open").expect("r2 should receive");
154+
let e1 = r1.try_recv().expect("r1 should receive");
155+
let e2 = r2.try_recv().expect("r2 should receive");
118156

119157
assert!(matches!(e1, LedgerEvent::TransactionSubmitted(_)));
120158
assert!(matches!(e2, LedgerEvent::TransactionSubmitted(_)));
@@ -132,4 +170,22 @@ mod tests {
132170
events.publish(LedgerEvent::SnapshotPersisted(digest));
133171
assert_eq!(events.listeners.lock().len(), 0);
134172
}
173+
174+
#[test]
175+
fn mempool_event_serde_roundtrip() {
176+
let event = MempoolEvent::TxAdded {
177+
hash: B256::repeat_byte(0x01),
178+
from: Address::repeat_byte(0x02),
179+
to: Some(Address::repeat_byte(0x03)),
180+
value: U256::from(1_000),
181+
gas_price: U256::from(1_000_000_000u64),
182+
nonce: 42,
183+
};
184+
185+
let json = serde_json::to_string(&event).expect("serialize mempool event");
186+
assert!(json.contains("\"type\":\"txAdded\""));
187+
assert!(json.contains("\"gasPrice\""));
188+
let parsed: MempoolEvent = serde_json::from_str(&json).expect("deserialize mempool event");
189+
assert_eq!(parsed, event);
190+
}
135191
}

crates/node/domain/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ mod commitment;
1111
pub use commitment::{AccountChange, StateChanges, StateChangesCfg};
1212

1313
mod events;
14-
pub use events::{LedgerEvent, LedgerEvents};
14+
pub use events::{LedgerEvent, LedgerEvents, MempoolEvent};
1515

1616
mod bootstrap;
1717
pub use bootstrap::{BootstrapConfig, BootstrapError};

0 commit comments

Comments
 (0)