diff --git a/crates/op-rbuilder/src/primitives/bundle.rs b/crates/op-rbuilder/src/primitives/bundle.rs index 073422a8e..d1a4fa7a3 100644 --- a/crates/op-rbuilder/src/primitives/bundle.rs +++ b/crates/op-rbuilder/src/primitives/bundle.rs @@ -3,16 +3,54 @@ use alloy_rpc_types_eth::erc4337::TransactionConditional; use reth_rpc_eth_types::EthApiError; use serde::{Deserialize, Serialize}; +/// Maximum number of blocks allowed in the block range for bundle execution. +/// +/// This constant limits how far into the future a bundle can be scheduled to +/// prevent excessive resource usage and ensure timely execution. When no +/// maximum block number is specified, this value is added to the current block +/// number to set the default upper bound. pub const MAX_BLOCK_RANGE_BLOCKS: u64 = 10; +/// A bundle represents a collection of transactions that should be executed +/// together with specific conditional constraints. +/// +/// Bundles allow for sophisticated transaction ordering and conditional +/// execution based on block numbers, flashblock numbers, and timestamps. They +/// are a key primitive in MEV (Maximal Extractable Value) strategies and block +/// building. +/// +/// # Validation +/// +/// The following validations are performed before adding the transaction to the +/// mempool: +/// - Block number ranges are valid (min ≤ max) +/// - Maximum block numbers are not in the past +/// - Block ranges don't exceed `MAX_BLOCK_RANGE_BLOCKS` (currently 10) +/// - There's only one transaction in the bundle +/// - Flashblock number ranges are valid (min ≤ max) #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct Bundle { + /// List of raw transaction data to be included in the bundle. + /// + /// Each transaction is represented as raw bytes that will be decoded and + /// executed in the specified order when the bundle conditions are met. #[serde(rename = "txs")] pub transactions: Vec, + /// Optional list of transaction hashes that are allowed to revert. + /// + /// By default, if any transaction in a bundle reverts, the entire bundle is + /// considered invalid. This field allows specific transactions to revert + /// without invalidating the bundle, enabling more sophisticated MEV + /// strategies. #[serde(rename = "revertingTxHashes")] pub reverting_hashes: Option>, + /// Minimum block number at which this bundle can be included. + /// + /// If specified, the bundle will only be considered for inclusion in blocks + /// at or after this block number. This allows for scheduling bundles for + /// future execution. #[serde( default, rename = "minBlockNumber", @@ -21,6 +59,11 @@ pub struct Bundle { )] pub block_number_min: Option, + /// Maximum block number at which this bundle can be included. + /// + /// If specified, the bundle will be considered invalid for inclusion in + /// blocks after this block number. If not specified, defaults to the + /// current block number plus `MAX_BLOCK_RANGE_BLOCKS`. #[serde( default, rename = "maxBlockNumber", @@ -29,6 +72,11 @@ pub struct Bundle { )] pub block_number_max: Option, + /// Minimum flashblock number at which this bundle can be included. + /// + /// Flashblocks are preconfirmations that are built incrementally. This + /// field along with `maxFlashblockNumber` allows bundles to be scheduled + /// for more precise execution. #[serde( default, rename = "minFlashblockNumber", @@ -37,6 +85,10 @@ pub struct Bundle { )] pub flashblock_number_min: Option, + /// Maximum flashblock number at which this bundle can be included. + /// + /// Similar to `minFlashblockNumber`, this sets an upper bound on which + /// flashblocks can include this bundle. #[serde( default, rename = "maxFlashblockNumber", @@ -45,7 +97,12 @@ pub struct Bundle { )] pub flashblock_number_max: Option, - // Not recommended because this is subject to the builder node clock + /// Minimum timestamp (Unix epoch seconds) for bundle inclusion. + /// + /// **Warning**: Not recommended for production use as it depends on the + /// builder node's clock, which may not be perfectly synchronized with + /// network time. Block number constraints are preferred for deterministic + /// behavior. #[serde( default, rename = "minTimestamp", @@ -53,7 +110,12 @@ pub struct Bundle { )] pub min_timestamp: Option, - // Not recommended because this is subject to the builder node clock + /// Maximum timestamp (Unix epoch seconds) for bundle inclusion. + /// + /// **Warning**: Not recommended for production use as it depends on the + /// builder node's clock, which may not be perfectly synchronized with + /// network time. Block number constraints are preferred for deterministic + /// behavior. #[serde( default, rename = "maxTimestamp", @@ -74,6 +136,9 @@ pub enum BundleConditionalError { MinGreaterThanMax { min: u64, max: u64 }, #[error("block_number_max ({max}) is a past block (current: {current})")] MaxBlockInPast { max: u64, current: u64 }, + /// To prevent resource exhaustion and ensure timely execution, bundles + /// cannot be scheduled more than `MAX_BLOCK_RANGE_BLOCKS` blocks into the + /// future. #[error( "block_number_max ({max}) is too high (current: {current}, max allowed: {max_allowed})" )] @@ -82,10 +147,15 @@ pub enum BundleConditionalError { current: u64, max_allowed: u64, }, + /// When no explicit maximum block number is provided, the system uses + /// `current_block + MAX_BLOCK_RANGE_BLOCKS` as the default maximum. This + /// error occurs when the specified minimum exceeds this default maximum. #[error( "block_number_min ({min}) is too high with default max range (max allowed: {max_allowed})" )] MinTooHighForDefaultRange { min: u64, max_allowed: u64 }, + #[error("flashblock_number_min ({min}) is greater than flashblock_number_max ({max})")] + FlashblockMinGreaterThanMax { min: u64, max: u64 }, } pub struct BundleConditional { @@ -144,6 +214,15 @@ impl Bundle { } } + // Validate flashblock number range + if let Some(min) = self.flashblock_number_min { + if let Some(max) = self.flashblock_number_max { + if min > max { + return Err(BundleConditionalError::FlashblockMinGreaterThanMax { min, max }); + } + } + } + Ok(BundleConditional { transaction_conditional: TransactionConditional { block_number_min, @@ -158,8 +237,18 @@ impl Bundle { } } +/// Result returned after successfully submitting a bundle for inclusion. +/// +/// This struct contains the unique identifier for the submitted bundle, which +/// can be used to track the bundle's status and inclusion in future blocks. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct BundleResult { + /// Transaction hash of the single transaction in the bundle. + /// + /// This hash can be used to: + /// - Track bundle inclusion in blocks + /// - Query bundle status + /// - Reference the bundle in subsequent operations #[serde(rename = "bundleHash")] pub bundle_hash: B256, } @@ -334,4 +423,79 @@ mod tests { assert_eq!(result.block_number_min, Some(999)); assert_eq!(result.block_number_max, Some(1010)); } + + #[test] + fn test_bundle_conditional_flashblock_min_greater_than_max() { + let bundle = Bundle { + flashblock_number_min: Some(105), + flashblock_number_max: Some(100), + ..Default::default() + }; + + let last_block = 1000; + let result = bundle.conditional(last_block); + + assert!(matches!( + result, + Err(BundleConditionalError::FlashblockMinGreaterThanMax { min: 105, max: 100 }) + )); + } + + #[test] + fn test_bundle_conditional_with_valid_flashblock_range() { + let bundle = Bundle { + flashblock_number_min: Some(100), + flashblock_number_max: Some(105), + ..Default::default() + }; + + let last_block = 1000; + let result = bundle.conditional(last_block).unwrap(); + + assert_eq!(result.flashblock_number_min, Some(100)); + assert_eq!(result.flashblock_number_max, Some(105)); + } + + #[test] + fn test_bundle_conditional_with_only_flashblock_min() { + let bundle = Bundle { + flashblock_number_min: Some(100), + ..Default::default() + }; + + let last_block = 1000; + let result = bundle.conditional(last_block).unwrap(); + + assert_eq!(result.flashblock_number_min, Some(100)); + assert_eq!(result.flashblock_number_max, None); + } + + #[test] + fn test_bundle_conditional_with_only_flashblock_max() { + let bundle = Bundle { + flashblock_number_max: Some(105), + ..Default::default() + }; + + let last_block = 1000; + let result = bundle.conditional(last_block).unwrap(); + + assert_eq!(result.flashblock_number_min, None); + assert_eq!(result.flashblock_number_max, Some(105)); + } + + #[test] + fn test_bundle_conditional_flashblock_equal_values() { + let bundle = Bundle { + flashblock_number_min: Some(100), + flashblock_number_max: Some(100), + ..Default::default() + }; + + let last_block = 1000; + let result = bundle.conditional(last_block).unwrap(); + + assert_eq!(result.flashblock_number_min, Some(100)); + assert_eq!(result.flashblock_number_max, Some(100)); + } } diff --git a/docs/eth_sendBundle.md b/docs/eth_sendBundle.md new file mode 100644 index 000000000..30b47630a --- /dev/null +++ b/docs/eth_sendBundle.md @@ -0,0 +1,148 @@ +# eth_sendBundle API Reference + +## Overview + +The `eth_sendBundle` method is a JSON-RPC endpoint that allows searchers to submit transactions with advanced execution control. Unlike regular transaction submission via `eth_sendTransaction`, bundles allow for the following features: +- **Execution Timing**: Specify exact block ranges, flashblock ranges, or timestamps when transactions should execute +- **Revert Protection**: Reverting transactions don't land on-chain so they don't cost any gas + +## Prerequisites + +The `eth_sendBundle` endpoint is only available when revert protection is enabled with the `--builder.enable-revert-protection` flag. + +## Bundle Structure (JSON-RPC params) + +```json +{ + "txs": ["0x..."], // Array of raw transaction bytes + "revertingTxHashes": ["0x..."], // Optional: transactions allowed to revert + "minBlockNumber": "0x1", // Optional: minimum block number + "maxBlockNumber": "0xa", // Optional: maximum block number + "minFlashblockNumber": "0x64", // Optional: minimum flashblock number + "maxFlashblockNumber": "0x68", // Optional: maximum flashblock number + "minTimestamp": 1640995200, // Optional: minimum timestamp (Unix epoch) + "maxTimestamp": 1640995800 // Optional: maximum timestamp (Unix epoch) +} +``` + +### Parameters + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `txs` | `string[]` | ✅ | Array of RLP-encoded transaction data (exactly one transaction) | +| `revertingTxHashes` | `string[]` | ❌ | Transaction hashes allowed to revert without failing the bundle | +| `minBlockNumber` | `number` | ❌ | Earliest block number for execution | +| `maxBlockNumber` | `number` | ❌ | Latest block number for execution | +| `minFlashblockNumber` | `number` | ❌ | Earliest flashblock iteration for execution | +| `maxFlashblockNumber` | `number` | ❌ | Latest flashblock iteration for execution | +| `minTimestamp` | `number` | ❌ | Earliest timestamp for execution (Unix epoch seconds) | +| `maxTimestamp` | `number` | ❌ | Latest timestamp for execution (Unix epoch seconds) | + +## Response + +```json +{ + "bundleHash": "0x..." // Transaction hash of the submitted transaction +} +``` + +## Validation Rules + +### Block Number Validation + +1. **Range Validity**: If both `minBlockNumber` and `maxBlockNumber` are specified, min ≤ max +2. **Past Block Protection**: `maxBlockNumber` must be greater than the current block number +3. **Range Limits**: Block ranges cannot exceed 10 blocks (`MAX_BLOCK_RANGE_BLOCKS`) +4. **Default Maximum**: If no `maxBlockNumber` is specified, defaults to `current_block + 10` + +### Flashblock Number Validation + +If both `minFlashblockNumber` and `maxFlashblockNumber` are specified, min ≤ max. + +### Transaction Constraints + +1. **Single Transaction**: Bundles must contain exactly one transaction +2. **Valid Format**: Transaction must be properly RLP-encoded + +### Timestamp Constraints (⚠️ Caution) + +Timestamp-based constraints depend on the builder node's clock and may not be perfectly synchronized with network time. Block number or flashblock number constraints are preferred. + +## Error Responses + +| Error | Description | Solution | +|-------|-------------|----------| +| `bundle must contain exactly one transaction` | Bundle has 0 or >1 transactions | Include exactly one transaction | +| `block_number_max (X) is a past block` | Max block is ≤ current block | Use future block number | +| `block_number_max (X) is too high` | Block range exceeds 10 blocks | Reduce block range | +| `flashblock_number_min (X) is greater than flashblock_number_max (Y)` | Invalid flashblock range | Ensure min ≤ max | +| `method not found` | Revert protection disabled | Enable revert protection | + +## Usage Examples + +### Basic Bundle Submission + +```bash +curl -X POST http://localhost:8545 \ + -H "Content-Type: application/json" \ + -d '{ + "method": "eth_sendBundle", + "params": [{ + "txs": ["0x02f86c0182..."], // Raw transaction bytes + "maxBlockNumber": 10 // Execute within next 10 blocks + }], + "id": 1, + "jsonrpc": "2.0" + }' +``` + +### Bundle with Revert Protection + +```bash +curl -X POST http://localhost:8545 \ + -H "Content-Type: application/json" \ + -d '{ + "method": "eth_sendBundle", + "params": [{ + "txs": ["0x02f86c0182..."], + "revertingTxHashes": ["0xabc123..."], // Allow this tx to revert + "minBlockNumber": 5, + "maxBlockNumber": 10 + }], + "id": 1, + "jsonrpc": "2.0" + }' +``` + +### Flashblock Bundle + +```bash +curl -X POST http://localhost:8545 \ + -H "Content-Type: application/json" \ + -d '{ + "method": "eth_sendBundle", + "params": [{ + "txs": ["0x02f86c0182..."], + "minFlashblockNumber": 100, // Flashblock 100 + "maxFlashblockNumber": 104 // Flashblock 104 + }], + "id": 1, + "jsonrpc": "2.0" + }' +``` + +## Monitoring and Debugging + +### Check Bundle Status +Use `eth_getTransactionReceipt` to check if your bundle was included: + +```bash +curl -X POST http://localhost:8545 \ + -H "Content-Type: application/json" \ + -d '{ + "method": "eth_getTransactionReceipt", + "params": ["0x..."], // Bundle hash (is also tx hash) from response + "id": 1, + "jsonrpc": "2.0" + }' +``` \ No newline at end of file