Skip to main content

signet_bundle/send/
bundle.rs

1//! Signet bundle types.
2use alloy::{
3    consensus::{
4        transaction::{Recovered, SignerRecoverable},
5        TxEnvelope,
6    },
7    eips::{eip2718::Eip2718Result, Decodable2718},
8    primitives::{Address, Bytes, TxHash, B256},
9    rlp::Buf,
10    rpc::types::mev::EthSendBundle,
11};
12use serde::{Deserialize, Serialize};
13use trevm::{
14    inspectors::{Layered, TimeLimit},
15    revm::{inspector::NoOpInspector, Database},
16    BundleError,
17};
18
19use crate::{BundleRecoverError, RecoverError, RecoveredBundle};
20
21/// The inspector type required by the Signet bundle driver.
22pub type BundleInspector<I = NoOpInspector> = Layered<TimeLimit, I>;
23
24/// Bundle of transactions for `signet_sendBundle`.
25///
26/// The Signet bundle contains the following:
27///
28/// - A standard [`EthSendBundle`] with the transactions to simulate.
29/// - Host transactions to be included in the host bundle.
30///
31/// This is based on the flashbots `eth_sendBundle` bundle. See [their docs].
32///
33/// [their docs]: https://docs.flashbots.net/flashbots-auction/advanced/rpc-endpoint
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36pub struct SignetEthBundle {
37    /// The bundle of transactions to simulate. Same structure as a Flashbots [`EthSendBundle`] bundle.
38    #[serde(flatten)]
39    pub bundle: EthSendBundle,
40
41    /// Host transactions to be included in the host bundle.
42    #[serde(default, skip_serializing_if = "Vec::is_empty")]
43    pub host_txs: Vec<Bytes>,
44}
45
46impl SignetEthBundle {
47    /// Creates a new [`SignetEthBundle`] from an existing [`EthSendBundle`].
48    pub const fn new(bundle: EthSendBundle, host_txs: Vec<Bytes>) -> Self {
49        Self { bundle, host_txs }
50    }
51
52    /// Decomposes the [`SignetEthBundle`] into its parts.
53    pub fn into_parts(self) -> (EthSendBundle, Vec<Bytes>) {
54        (self.bundle, self.host_txs)
55    }
56
57    /// Returns the transactions in this bundle.
58    pub const fn txs(&self) -> &[Bytes] {
59        self.bundle.txs.as_slice()
60    }
61
62    /// Returns the host transactions in this bundle.
63    pub const fn host_txs(&self) -> &[Bytes] {
64        self.host_txs.as_slice()
65    }
66
67    /// Get a mutable reference to the host transactions.
68    pub const fn host_txs_mut(&mut self) -> &mut Vec<Bytes> {
69        &mut self.host_txs
70    }
71
72    /// Return an iterator over decoded transactions in this bundle.
73    pub fn decode_txs(&self) -> impl Iterator<Item = Eip2718Result<TxEnvelope>> + '_ {
74        self.txs().iter().map(|tx| TxEnvelope::decode_2718(&mut tx.chunk()))
75    }
76
77    /// Return an iterator over decoded host transactions in this bundle.
78    ///
79    /// This may be empty if no host transactions were included.
80    pub fn decode_host_txs(&self) -> impl Iterator<Item = Eip2718Result<TxEnvelope>> + '_ {
81        self.host_txs.iter().map(|tx| TxEnvelope::decode_2718(&mut tx.chunk()))
82    }
83
84    /// Return an iterator over recovered transactions in this bundle. This
85    /// iterator may include errors.
86    pub fn recover_txs(
87        &self,
88    ) -> impl Iterator<Item = Result<Recovered<TxEnvelope>, BundleRecoverError>> + '_ {
89        self.decode_txs().enumerate().map(|(index, res)| match res {
90            Ok(tx) => {
91                tx.try_into_recovered().map_err(|err| BundleRecoverError::new(err, false, index))
92            }
93            Err(err) => Err(BundleRecoverError::new(err, false, index)),
94        })
95    }
96
97    /// Return an iterator over recovered host transactions in this bundle. This
98    /// iterator may include errors.
99    pub fn recover_host_txs(
100        &self,
101    ) -> impl Iterator<Item = Result<Recovered<TxEnvelope>, BundleRecoverError>> + '_ {
102        self.decode_host_txs().enumerate().map(|(index, res)| match res {
103            Ok(tx) => {
104                tx.try_into_recovered().map_err(|err| BundleRecoverError::new(err, true, index))
105            }
106            Err(err) => Err(BundleRecoverError::new(err, true, index)),
107        })
108    }
109
110    /// Create a [`RecoveredBundle`] from this bundle by decoding and recovering
111    /// all transactions, taking ownership of the bundle.
112    pub fn try_into_recovered(self) -> Result<RecoveredBundle, BundleRecoverError> {
113        if self.txs().is_empty() {
114            return Err(BundleRecoverError::new(RecoverError::EmptyBundle, false, 0));
115        }
116
117        let txs = self.recover_txs().collect::<Result<Vec<_>, _>>()?;
118
119        let host_txs = self.recover_host_txs().collect::<Result<Vec<_>, _>>()?;
120
121        Ok(RecoveredBundle {
122            txs,
123            host_txs,
124            block_number: self.bundle.block_number,
125            min_timestamp: self.bundle.min_timestamp,
126            max_timestamp: self.bundle.max_timestamp,
127            reverting_tx_hashes: self.bundle.reverting_tx_hashes,
128            replacement_uuid: self.bundle.replacement_uuid,
129            dropping_tx_hashes: self.bundle.dropping_tx_hashes,
130            refund_percent: self.bundle.refund_percent,
131            refund_recipient: self.bundle.refund_recipient,
132            refund_tx_hashes: self.bundle.refund_tx_hashes,
133            extra_fields: self.bundle.extra_fields,
134        })
135    }
136
137    /// Create a [`RecoveredBundle`] from this bundle by decoding and recovering
138    /// all transactions, cloning other fields as necessary.
139    pub fn try_to_recovered(&self) -> Result<RecoveredBundle, BundleRecoverError> {
140        self.clone().try_into_recovered()
141    }
142
143    /// Return an iterator over the signers of the transactions in this bundle.
144    /// The iterator yields `Option<(TxHash, Address)>` for each transaction,
145    /// where `None` indicates that the signer could not be recovered.
146    ///
147    /// Computing this may be expensive, as it requires decoding and recovering
148    /// the signer for each transaction. It is recommended to memoize the
149    /// results
150    pub fn signers(&self) -> impl Iterator<Item = Option<(TxHash, Address)>> + '_ {
151        self.txs().iter().map(|tx| {
152            TxEnvelope::decode_2718(&mut tx.chunk())
153                .ok()
154                .and_then(|envelope| envelope.recover_signer().ok().map(|s| (*envelope.hash(), s)))
155        })
156    }
157
158    /// Return an iterator over the signers of the transactions in this bundle,
159    /// skipping any transactions where the signer could not be recovered.
160    pub fn signers_lossy(&self) -> impl Iterator<Item = (TxHash, Address)> + '_ {
161        self.signers().flatten()
162    }
163
164    /// Returns the block number for this bundle.
165    pub const fn block_number(&self) -> u64 {
166        self.bundle.block_number
167    }
168
169    /// Returns the minimum timestamp for this bundle.
170    pub const fn min_timestamp(&self) -> Option<u64> {
171        self.bundle.min_timestamp
172    }
173
174    /// Returns the maximum timestamp for this bundle.
175    pub const fn max_timestamp(&self) -> Option<u64> {
176        self.bundle.max_timestamp
177    }
178
179    /// Returns the reverting tx hashes for this bundle.
180    pub const fn reverting_tx_hashes(&self) -> &[B256] {
181        self.bundle.reverting_tx_hashes.as_slice()
182    }
183
184    /// Returns the replacement uuid for this bundle.
185    pub const fn replacement_uuid(&self) -> Option<&str> {
186        let Some(uuid) = &self.bundle.replacement_uuid else { return None };
187
188        Some(uuid.as_str())
189    }
190
191    /// Checks if the bundle is valid at a given timestamp.
192    pub fn is_valid_at_timestamp(&self, timestamp: u64) -> bool {
193        let min_timestamp = self.min_timestamp().unwrap_or(0);
194        let max_timestamp = self.max_timestamp().unwrap_or(u64::MAX);
195
196        (min_timestamp..=max_timestamp).contains(&timestamp)
197    }
198
199    /// Checks if the bundle is valid at a given block number.
200    pub const fn is_valid_at_block_number(&self, block_number: u64) -> bool {
201        self.bundle.block_number == block_number
202    }
203
204    /// Decode and validate the transactions in the bundle.
205    pub fn decode_and_validate_txs<Db: Database>(
206        &self,
207    ) -> Result<Vec<TxEnvelope>, BundleError<Db>> {
208        // Decode and validate the transactions in the bundle
209        let txs = self
210            .decode_txs()
211            .collect::<Result<Vec<_>, _>>()
212            .map_err(|err| BundleError::TransactionDecodingError(err))?;
213
214        if txs.iter().any(|tx| tx.is_eip4844()) {
215            return Err(BundleError::UnsupportedTransactionType);
216        }
217
218        Ok(txs)
219    }
220
221    /// Decode and validate the host transactions in the bundle.
222    pub fn decode_and_validate_host_txs<Db: Database>(
223        &self,
224    ) -> Result<Vec<TxEnvelope>, BundleError<Db>> {
225        self.decode_host_txs()
226            .collect::<Result<Vec<_>, _>>()
227            .map_err(|err| BundleError::TransactionDecodingError(err))
228    }
229}
230
231#[cfg(test)]
232mod test {
233    use super::*;
234
235    #[test]
236    fn send_bundle_ser_roundtrip() {
237        let bundle = SignetEthBundle::new(
238            EthSendBundle {
239                txs: vec![b"tx1".into(), b"tx2".into()],
240                block_number: 1,
241                min_timestamp: Some(2),
242                max_timestamp: Some(3),
243                reverting_tx_hashes: vec![B256::repeat_byte(4), B256::repeat_byte(5)],
244                replacement_uuid: Some("uuid".to_owned()),
245                ..Default::default()
246            },
247            vec![b"host_tx1".into(), b"host_tx2".into()],
248        );
249
250        let serialized = serde_json::to_string(&bundle).unwrap();
251        let deserialized: SignetEthBundle = serde_json::from_str(&serialized).unwrap();
252
253        assert_eq!(bundle, deserialized);
254    }
255
256    #[test]
257    fn send_bundle_ser_roundtrip_no_host_no_fills() {
258        let bundle = SignetEthBundle::new(
259            EthSendBundle {
260                txs: vec![b"tx1".into(), b"tx2".into()],
261                block_number: 1,
262                min_timestamp: Some(2),
263                max_timestamp: Some(3),
264                reverting_tx_hashes: vec![B256::repeat_byte(4), B256::repeat_byte(5)],
265                replacement_uuid: Some("uuid".to_owned()),
266                ..Default::default()
267            },
268            vec![],
269        );
270
271        let serialized = serde_json::to_string(&bundle).unwrap();
272        let deserialized: SignetEthBundle = serde_json::from_str(&serialized).unwrap();
273
274        assert_eq!(bundle, deserialized);
275    }
276
277    #[test]
278    fn test_deser_bundle_no_host_no_fills() {
279        let json = r#"
280        {"txs":["0x747831","0x747832"],"blockNumber":"0x1","minTimestamp":2,"maxTimestamp":3,"revertingTxHashes":["0x0404040404040404040404040404040404040404040404040404040404040404","0x0505050505050505050505050505050505050505050505050505050505050505"],"replacementUuid":"uuid"}"#;
281
282        let deserialized: SignetEthBundle = serde_json::from_str(json).unwrap();
283
284        assert!(deserialized.host_txs.is_empty());
285    }
286
287    /// Generate test vectors for TypeScript SDK.
288    ///
289    /// Run with: `cargo t -p signet-bundle -- --ignored --nocapture`
290    #[test]
291    #[ignore]
292    fn generate_eth_bundle_vectors() {
293        use alloy::primitives::Address;
294
295        let vectors = vec![
296            (
297                "minimal",
298                SignetEthBundle::new(
299                    EthSendBundle {
300                        txs: vec![b"\x02\xf8test_tx_1".into()],
301                        block_number: 12345678,
302                        ..Default::default()
303                    },
304                    vec![],
305                ),
306            ),
307            (
308                "with_timestamps",
309                SignetEthBundle::new(
310                    EthSendBundle {
311                        txs: vec![b"\x02\xf8test_tx_1".into()],
312                        block_number: 12345678,
313                        min_timestamp: Some(1700000000),
314                        max_timestamp: Some(1700003600),
315                        ..Default::default()
316                    },
317                    vec![],
318                ),
319            ),
320            (
321                "with_reverting_hashes",
322                SignetEthBundle::new(
323                    EthSendBundle {
324                        txs: vec![b"\x02\xf8test_tx_1".into(), b"\x02\xf8test_tx_2".into()],
325                        block_number: 12345678,
326                        reverting_tx_hashes: vec![B256::repeat_byte(0xab), B256::repeat_byte(0xcd)],
327                        ..Default::default()
328                    },
329                    vec![],
330                ),
331            ),
332            (
333                "with_host_txs",
334                SignetEthBundle::new(
335                    EthSendBundle {
336                        txs: vec![b"\x02\xf8rollup_tx".into()],
337                        block_number: 12345678,
338                        ..Default::default()
339                    },
340                    vec![b"\x02\xf8host_tx_1".into(), b"\x02\xf8host_tx_2".into()],
341                ),
342            ),
343            (
344                "full_bundle",
345                SignetEthBundle::new(
346                    EthSendBundle {
347                        txs: vec![b"\x02\xf8tx_1".into(), b"\x02\xf8tx_2".into()],
348                        block_number: 12345678,
349                        min_timestamp: Some(1700000000),
350                        max_timestamp: Some(1700003600),
351                        reverting_tx_hashes: vec![B256::repeat_byte(0xef)],
352                        dropping_tx_hashes: vec![B256::repeat_byte(0x11)],
353                        refund_percent: Some(90),
354                        refund_recipient: Some(Address::repeat_byte(0x22)),
355                        refund_tx_hashes: vec![B256::repeat_byte(0x33)],
356                        ..Default::default()
357                    },
358                    vec![b"\x02\xf8host_tx".into()],
359                ),
360            ),
361            (
362                "replacement_bundle",
363                SignetEthBundle::new(
364                    EthSendBundle {
365                        txs: vec![b"\x02\xf8replacement_tx".into()],
366                        block_number: 12345678,
367                        replacement_uuid: Some("550e8400-e29b-41d4-a716-446655440000".to_owned()),
368                        ..Default::default()
369                    },
370                    vec![],
371                ),
372            ),
373        ];
374
375        let output: Vec<_> = vectors
376            .into_iter()
377            .map(|(name, bundle)| {
378                serde_json::json!({
379                    "name": name,
380                    "bundle": bundle,
381                })
382            })
383            .collect();
384
385        println!("// SignetEthBundle vectors\n{}", serde_json::to_string_pretty(&output).unwrap());
386    }
387}