arbos/retryables/
mod.rs

1use alloy_primitives::{keccak256, Address, B256, U256};
2use revm::Database;
3
4use arb_storage::{
5    initialize_queue, open_queue, Queue, Storage, StorageBackedAddress, StorageBackedAddressOrNil,
6    StorageBackedBigUint, StorageBackedBytes, StorageBackedUint64,
7};
8
9pub const RETRYABLE_LIFETIME_SECONDS: u64 = 7 * 24 * 60 * 60; // one week
10pub const RETRYABLE_REAP_PRICE: u64 = 58000;
11
12pub const TIMEOUT_QUEUE_KEY: &[u8] = &[0];
13pub const CALLDATA_KEY: &[u8] = &[1];
14
15// Storage offsets for Retryable fields.
16pub const NUM_TRIES_OFFSET: u64 = 0;
17pub const FROM_OFFSET: u64 = 1;
18pub const TO_OFFSET: u64 = 2;
19pub const CALLVALUE_OFFSET: u64 = 3;
20pub const BENEFICIARY_OFFSET: u64 = 4;
21pub const TIMEOUT_OFFSET: u64 = 5;
22pub const TIMEOUT_WINDOWS_LEFT_OFFSET: u64 = 6;
23
24/// Manages the collection of retryable tickets.
25pub struct RetryableState<D> {
26    retryables: Storage<D>,
27    pub timeout_queue: Queue<D>,
28}
29
30/// A single retryable ticket.
31pub struct Retryable<D> {
32    pub id: B256,
33    #[allow(dead_code)]
34    backing_storage: Storage<D>,
35    num_tries: StorageBackedUint64<D>,
36    from: StorageBackedAddress<D>,
37    to: StorageBackedAddressOrNil<D>,
38    callvalue: StorageBackedBigUint<D>,
39    beneficiary: StorageBackedAddress<D>,
40    calldata: StorageBackedBytes<D>,
41    timeout: StorageBackedUint64<D>,
42    timeout_windows_left: StorageBackedUint64<D>,
43}
44
45pub fn initialize_retryable_state<D: Database>(sto: &Storage<D>) -> Result<(), ()> {
46    initialize_queue(&sto.open_sub_storage(TIMEOUT_QUEUE_KEY))
47}
48
49pub fn open_retryable_state<D: Database>(sto: Storage<D>) -> RetryableState<D> {
50    let queue_sto = sto.open_sub_storage(TIMEOUT_QUEUE_KEY);
51    RetryableState {
52        timeout_queue: open_queue(queue_sto),
53        retryables: sto,
54    }
55}
56
57impl<D: Database> RetryableState<D> {
58    pub fn initialize(sto: &Storage<D>) -> Result<(), ()> {
59        initialize_retryable_state(sto)
60    }
61
62    pub fn open(sto: Storage<D>) -> Self {
63        open_retryable_state(sto)
64    }
65
66    /// Creates a new retryable ticket. The id must be unique.
67    pub fn create_retryable(
68        &self,
69        id: B256,
70        timeout: u64,
71        from: Address,
72        to: Option<Address>,
73        callvalue: U256,
74        beneficiary: Address,
75        calldata: &[u8],
76    ) -> Result<Retryable<D>, ()> {
77        let ret = self.internal_open(id);
78        ret.num_tries.set(0)?;
79        ret.from.set(from)?;
80        ret.to.set(to)?;
81        ret.callvalue.set(callvalue)?;
82        ret.beneficiary.set(beneficiary)?;
83        ret.calldata.set(calldata)?;
84        ret.timeout.set(timeout)?;
85        ret.timeout_windows_left.set(0)?;
86        self.timeout_queue.put(id)?;
87        Ok(ret)
88    }
89
90    /// Opens an existing retryable if it exists and hasn't expired.
91    pub fn open_retryable(
92        &self,
93        id: B256,
94        current_timestamp: u64,
95    ) -> Result<Option<Retryable<D>>, ()> {
96        let sto = self.retryables.open_sub_storage(id.as_slice());
97        let timeout_storage =
98            StorageBackedUint64::new(sto.state_ptr(), sto.base_key(), TIMEOUT_OFFSET);
99        let timeout = timeout_storage.get()?;
100        if timeout == 0 || timeout < current_timestamp {
101            return Ok(None);
102        }
103        Ok(Some(self.internal_open(id)))
104    }
105
106    /// Gets the size in bytes a retryable occupies in storage.
107    pub fn retryable_size_bytes(&self, id: B256, current_time: u64) -> Result<u64, ()> {
108        let retryable = self.open_retryable(id, current_time)?;
109        match retryable {
110            None => Ok(0),
111            Some(ret) => {
112                let size = ret.calldata_size()?;
113                let calldata_slots = 32 + 32 * words_for_bytes(size);
114                Ok(6 * 32 + calldata_slots)
115            }
116        }
117    }
118
119    /// Deletes a retryable and returns whether it existed.
120    /// Moves the escrow's entire balance to the beneficiary via the provided closures.
121    pub fn delete_retryable<F, G>(
122        &self,
123        id: B256,
124        mut transfer_fn: F,
125        mut balance_of: G,
126    ) -> Result<bool, ()>
127    where
128        F: FnMut(Address, Address, U256) -> Result<(), ()>,
129        G: FnMut(Address) -> U256,
130    {
131        let ret_storage = self.retryables.open_sub_storage(id.as_slice());
132        let timeout_val = ret_storage.get_by_uint64(TIMEOUT_OFFSET)?;
133        if timeout_val == B256::ZERO {
134            return Ok(false);
135        }
136
137        // Move escrowed funds to beneficiary.
138        let beneficiary_val = ret_storage.get_by_uint64(BENEFICIARY_OFFSET)?;
139        let escrow_address = retryable_escrow_address(id);
140        let beneficiary_address = Address::from_slice(&beneficiary_val[12..]);
141        let amount = balance_of(escrow_address);
142        transfer_fn(escrow_address, beneficiary_address, amount)?;
143
144        // Clear all storage slots.
145        let _ = ret_storage.set_by_uint64(NUM_TRIES_OFFSET, B256::ZERO);
146        let _ = ret_storage.set_by_uint64(FROM_OFFSET, B256::ZERO);
147        let _ = ret_storage.set_by_uint64(TO_OFFSET, B256::ZERO);
148        let _ = ret_storage.set_by_uint64(CALLVALUE_OFFSET, B256::ZERO);
149        let _ = ret_storage.set_by_uint64(BENEFICIARY_OFFSET, B256::ZERO);
150        let _ = ret_storage.set_by_uint64(TIMEOUT_OFFSET, B256::ZERO);
151        let _ = ret_storage.set_by_uint64(TIMEOUT_WINDOWS_LEFT_OFFSET, B256::ZERO);
152        let bytes_storage = StorageBackedBytes::new(ret_storage.open_sub_storage(CALLDATA_KEY));
153        bytes_storage.clear()?;
154        Ok(true)
155    }
156
157    /// Extends the lifetime of a retryable ticket.
158    pub fn keepalive(
159        &self,
160        ticket_id: B256,
161        current_timestamp: u64,
162        limit_before_add: u64,
163        _time_to_add: u64,
164    ) -> Result<u64, ()> {
165        let retryable = self.open_retryable(ticket_id, current_timestamp)?;
166        let retryable = retryable.ok_or(())?;
167        let timeout = retryable.calculate_timeout()?;
168        if timeout > limit_before_add {
169            return Err(());
170        }
171        self.timeout_queue.put(retryable.id)?;
172        retryable.increment_timeout_windows()?;
173        let new_timeout = timeout + RETRYABLE_LIFETIME_SECONDS;
174        // In Go, this also burns RetryableReapPrice gas.
175        Ok(new_timeout)
176    }
177
178    /// Tries to reap one expired retryable from the timeout queue.
179    pub fn try_to_reap_one_retryable<F, G>(
180        &self,
181        current_timestamp: u64,
182        mut transfer_fn: F,
183        mut balance_of: G,
184    ) -> Result<(), ()>
185    where
186        F: FnMut(Address, Address, U256) -> Result<(), ()>,
187        G: FnMut(Address) -> U256,
188    {
189        let id = self.timeout_queue.peek()?;
190        let id = match id {
191            None => return Ok(()),
192            Some(id) => id,
193        };
194
195        let ret_storage = self.retryables.open_sub_storage(id.as_slice());
196        let timeout_storage = StorageBackedUint64::new(
197            ret_storage.state_ptr(),
198            ret_storage.base_key(),
199            TIMEOUT_OFFSET,
200        );
201        let timeout = timeout_storage.get()?;
202
203        if timeout == 0 {
204            // Already deleted, discard queue entry.
205            let _ = self.timeout_queue.get()?;
206            return Ok(());
207        }
208
209        let windows_left_storage = StorageBackedUint64::new(
210            ret_storage.state_ptr(),
211            ret_storage.base_key(),
212            TIMEOUT_WINDOWS_LEFT_OFFSET,
213        );
214        let windows_left = windows_left_storage.get()?;
215
216        if timeout >= current_timestamp {
217            return Ok(());
218        }
219
220        // Retryable has expired or lost a lifetime window.
221        let _ = self.timeout_queue.get()?;
222
223        if windows_left == 0 {
224            // Fully expired — delete it.
225            self.delete_retryable(id, &mut transfer_fn, &mut balance_of)?;
226            return Ok(());
227        }
228
229        // Consume a window, delaying timeout by one lifetime.
230        timeout_storage.set(timeout + RETRYABLE_LIFETIME_SECONDS)?;
231        windows_left_storage.set(windows_left - 1)?;
232        Ok(())
233    }
234
235    /// Total number of pending retryables in the timeout queue.
236    pub fn queue_size(&self) -> Result<u64, ()> {
237        self.timeout_queue.size()
238    }
239
240    /// Walk the timeout queue and yield `(ticket_id, timeout_seconds)`
241    /// for each non-expired retryable. Expired tickets (those that
242    /// would be reaped by `try_to_reap_one_retryable` at
243    /// `current_time`) are skipped so callers see a faithful snapshot
244    /// of the live queue.
245    pub fn snapshot_queue(
246        &self,
247        current_time: u64,
248        max_entries: usize,
249    ) -> Result<Vec<(B256, u64)>, ()> {
250        let mut out = Vec::new();
251        self.timeout_queue.for_each(|id| {
252            if out.len() >= max_entries {
253                return Ok(());
254            }
255            match self.open_retryable(id, current_time)? {
256                Some(retryable) => {
257                    let timeout = retryable.calculate_timeout()?;
258                    out.push((id, timeout));
259                }
260                None => {
261                    // Expired/deleted — skip.
262                }
263            }
264            Ok(())
265        })?;
266        Ok(out)
267    }
268
269    fn internal_open(&self, id: B256) -> Retryable<D> {
270        let sto = self.retryables.open_sub_storage(id.as_slice());
271        let state = sto.state_ptr();
272        let base_key = sto.base_key();
273        Retryable {
274            id,
275            num_tries: StorageBackedUint64::new(state, base_key, NUM_TRIES_OFFSET),
276            from: StorageBackedAddress::new(state, base_key, FROM_OFFSET),
277            to: StorageBackedAddressOrNil::new(state, base_key, TO_OFFSET),
278            callvalue: StorageBackedBigUint::new(state, base_key, CALLVALUE_OFFSET),
279            beneficiary: StorageBackedAddress::new(state, base_key, BENEFICIARY_OFFSET),
280            calldata: StorageBackedBytes::new(sto.open_sub_storage(CALLDATA_KEY)),
281            timeout: StorageBackedUint64::new(state, base_key, TIMEOUT_OFFSET),
282            timeout_windows_left: StorageBackedUint64::new(
283                state,
284                base_key,
285                TIMEOUT_WINDOWS_LEFT_OFFSET,
286            ),
287            backing_storage: sto,
288        }
289    }
290}
291
292impl<D: Database> Retryable<D> {
293    pub fn num_tries(&self) -> Result<u64, ()> {
294        self.num_tries.get()
295    }
296
297    pub fn increment_num_tries(&self) -> Result<u64, ()> {
298        let current = self.num_tries.get()?;
299        let new_val = current + 1;
300        self.num_tries.set(new_val)?;
301        Ok(new_val)
302    }
303
304    pub fn beneficiary(&self) -> Result<Address, ()> {
305        self.beneficiary.get()
306    }
307
308    pub fn calculate_timeout(&self) -> Result<u64, ()> {
309        let timeout = self.timeout.get()?;
310        let windows = self.timeout_windows_left.get()?;
311        Ok(timeout + windows * RETRYABLE_LIFETIME_SECONDS)
312    }
313
314    pub fn set_timeout(&self, val: u64) -> Result<(), ()> {
315        self.timeout.set(val)
316    }
317
318    pub fn timeout_windows_left(&self) -> Result<u64, ()> {
319        self.timeout_windows_left.get()
320    }
321
322    fn increment_timeout_windows(&self) -> Result<u64, ()> {
323        let current = self.timeout_windows_left.get()?;
324        let new_val = current + 1;
325        self.timeout_windows_left.set(new_val)?;
326        Ok(new_val)
327    }
328
329    pub fn from(&self) -> Result<Address, ()> {
330        self.from.get()
331    }
332
333    pub fn to(&self) -> Result<Option<Address>, ()> {
334        self.to.get()
335    }
336
337    pub fn callvalue(&self) -> Result<U256, ()> {
338        self.callvalue.get()
339    }
340
341    pub fn calldata(&self) -> Result<Vec<u8>, ()> {
342        self.calldata.get()
343    }
344
345    pub fn calldata_size(&self) -> Result<u64, ()> {
346        self.calldata.size()
347    }
348
349    /// Constructs a retry transaction from this retryable's stored fields
350    /// combined with the provided runtime parameters.
351    pub fn make_tx(
352        &self,
353        chain_id: U256,
354        nonce: u64,
355        gas_fee_cap: U256,
356        gas: u64,
357        ticket_id: B256,
358        refund_to: Address,
359        max_refund: U256,
360        submission_fee_refund: U256,
361    ) -> Result<arb_alloy_consensus::tx::ArbRetryTx, ()> {
362        Ok(arb_alloy_consensus::tx::ArbRetryTx {
363            chain_id,
364            nonce,
365            from: self.from()?,
366            gas_fee_cap,
367            gas,
368            to: self.to()?,
369            value: self.callvalue()?,
370            data: self.calldata()?.into(),
371            ticket_id,
372            refund_to,
373            max_refund,
374            submission_fee_refund,
375        })
376    }
377}
378
379/// Computes the escrow address for a retryable ticket.
380pub fn retryable_escrow_address(ticket_id: B256) -> Address {
381    let mut data = Vec::with_capacity(16 + 32);
382    data.extend_from_slice(b"retryable escrow");
383    data.extend_from_slice(ticket_id.as_slice());
384    let hash = keccak256(&data);
385    Address::from_slice(&hash[12..])
386}
387
388/// Computes the submission fee for a retryable ticket.
389///
390/// Matches Nitro's `RetryableSubmissionFee`: `(1400 + 6 * len) * l1_base_fee`
391/// using big-integer arithmetic so length and product can't overflow.
392pub fn retryable_submission_fee(calldata_length: usize, l1_base_fee: U256) -> U256 {
393    let factor = U256::from(1400u64)
394        .saturating_add(U256::from(6u64).saturating_mul(U256::from(calldata_length as u128)));
395    l1_base_fee.saturating_mul(factor)
396}
397
398/// Rounds up byte count to number of 32-byte words.
399fn words_for_bytes(bytes: u64) -> u64 {
400    bytes.div_ceil(32)
401}