|
| 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. |
0 commit comments