arb_rpc/
conditional_tx.rs

1//! `eth_sendRawTransactionConditional` — Arbitrum's conditional tx
2//! submission RPC.
3//!
4//! Lets a client attach predicates (block-number range, timestamp
5//! range, per-account storage roots / slot values) to a raw tx. The
6//! sequencer only accepts the tx if every predicate holds against
7//! current chain state. Used by MEV-aware clients to fail fast when
8//! a trade opportunity has already been consumed.
9
10use std::collections::HashMap;
11
12use alloy_primitives::{Address, B256, Bytes};
13use jsonrpsee::{
14    core::RpcResult,
15    proc_macros::rpc,
16    types::{ErrorObject, error::INVALID_PARAMS_CODE},
17};
18use serde::{Deserialize, Serialize};
19
20/// Per-account expected state:
21///   - Either an expected storage-root hash
22///   - Or a map of slot → expected value
23#[derive(Debug, Clone, Serialize, Deserialize, Default)]
24#[serde(untagged)]
25pub enum KnownAccountCondition {
26    /// Entire storage root must match.
27    RootHash(B256),
28    /// Specific storage slots must have the given values.
29    #[serde(rename_all = "camelCase")]
30    SlotValues(HashMap<B256, B256>),
31    #[default]
32    Empty,
33}
34
35/// Conditional options attached to a raw tx.
36#[derive(Debug, Clone, Default, Serialize, Deserialize)]
37#[serde(rename_all = "camelCase")]
38pub struct ConditionalOptions {
39    /// Per-account storage-root or slot-value requirements.
40    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
41    pub known_accounts: HashMap<Address, KnownAccountCondition>,
42    /// L1 block number must be ≥ this.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub block_number_min: Option<u64>,
45    /// L1 block number must be ≤ this.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub block_number_max: Option<u64>,
48    /// L2 block timestamp must be ≥ this.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub timestamp_min: Option<u64>,
51    /// L2 block timestamp must be ≤ this.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub timestamp_max: Option<u64>,
54}
55
56fn condition_rejected(reason: &str) -> ErrorObject<'static> {
57    ErrorObject::owned(
58        INVALID_PARAMS_CODE,
59        format!("conditional tx rejected: {reason}"),
60        None::<()>,
61    )
62}
63
64/// Check `(block_number_*, timestamp_*)` predicates against current
65/// chain state. Returns on the first predicate that fails.
66///
67/// Per-account storage checks are handled separately since they need
68/// provider access.
69pub fn check_simple_predicates(
70    opts: &ConditionalOptions,
71    current_l1_block: u64,
72    current_l2_timestamp: u64,
73) -> Result<(), ErrorObject<'static>> {
74    if let Some(min) = opts.block_number_min
75        && current_l1_block < min
76    {
77        return Err(condition_rejected("BlockNumberMin condition not met"));
78    }
79    if let Some(max) = opts.block_number_max
80        && current_l1_block > max
81    {
82        return Err(condition_rejected("BlockNumberMax condition not met"));
83    }
84    if let Some(min) = opts.timestamp_min
85        && current_l2_timestamp < min
86    {
87        return Err(condition_rejected("TimestampMin condition not met"));
88    }
89    if let Some(max) = opts.timestamp_max
90        && current_l2_timestamp > max
91    {
92        return Err(condition_rejected("TimestampMax condition not met"));
93    }
94    Ok(())
95}
96
97/// `eth_sendRawTransactionConditional` — registered on the `eth`
98/// namespace (not `arb_`). Node-level RPC module merger handles binding.
99#[rpc(server, namespace = "eth")]
100pub trait ConditionalTxApi {
101    /// Submit a signed raw tx with attached predicates. Returns the
102    /// tx hash on acceptance; error on predicate failure or pool
103    /// rejection.
104    #[method(name = "sendRawTransactionConditional")]
105    async fn send_raw_transaction_conditional(
106        &self,
107        raw_tx: Bytes,
108        options: ConditionalOptions,
109    ) -> RpcResult<B256>;
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    fn some_opts() -> ConditionalOptions {
117        ConditionalOptions {
118            block_number_min: Some(100),
119            block_number_max: Some(200),
120            timestamp_min: Some(1_700_000_000),
121            timestamp_max: Some(1_800_000_000),
122            known_accounts: HashMap::new(),
123        }
124    }
125
126    #[test]
127    fn none_all_accepts() {
128        let opts = ConditionalOptions::default();
129        assert!(check_simple_predicates(&opts, 0, 0).is_ok());
130    }
131
132    #[test]
133    fn block_number_min_rejects_below() {
134        let opts = some_opts();
135        let err = check_simple_predicates(&opts, 99, 1_750_000_000).unwrap_err();
136        assert!(err.message().contains("BlockNumberMin"));
137    }
138
139    #[test]
140    fn block_number_max_rejects_above() {
141        let opts = some_opts();
142        let err = check_simple_predicates(&opts, 201, 1_750_000_000).unwrap_err();
143        assert!(err.message().contains("BlockNumberMax"));
144    }
145
146    #[test]
147    fn timestamp_min_rejects_below() {
148        let opts = some_opts();
149        let err = check_simple_predicates(&opts, 150, 1_000).unwrap_err();
150        assert!(err.message().contains("TimestampMin"));
151    }
152
153    #[test]
154    fn timestamp_max_rejects_above() {
155        let opts = some_opts();
156        let err = check_simple_predicates(&opts, 150, 2_000_000_000).unwrap_err();
157        assert!(err.message().contains("TimestampMax"));
158    }
159
160    #[test]
161    fn inside_window_accepts() {
162        let opts = some_opts();
163        assert!(check_simple_predicates(&opts, 150, 1_750_000_000).is_ok());
164    }
165
166    #[test]
167    fn boundary_inclusive() {
168        let opts = some_opts();
169        assert!(check_simple_predicates(&opts, 100, 1_700_000_000).is_ok());
170        assert!(check_simple_predicates(&opts, 200, 1_800_000_000).is_ok());
171    }
172}