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