arbos/retryables/
mod.rs

1use alloy_primitives::{Address, B256, U256, keccak256};
2use arb_storage::{
3    Queue, Storage, StorageBackedAddress, StorageBackedAddressOrNil, StorageBackedBigUint,
4    StorageBackedBytes, StorageBackedUint64, StorageBackend, SystemStateBackend, initialize_queue,
5    open_queue,
6};
7use revm::Database;
8
9use crate::util::BalanceError;
10
11mod error;
12pub use error::RetryableError;
13
14pub const RETRYABLE_LIFETIME_SECONDS: u64 = 7 * 24 * 60 * 60; // one week
15pub const RETRYABLE_REAP_PRICE: u64 = 58000;
16
17const WINDOWS_LEFT_SLOAD_GAS: u64 = 800;
18
19pub const TIMEOUT_QUEUE_KEY: &[u8] = &[0];
20pub const CALLDATA_KEY: &[u8] = &[1];
21
22// Storage offsets for Retryable fields.
23pub const NUM_TRIES_OFFSET: u64 = 0;
24pub const FROM_OFFSET: u64 = 1;
25pub const TO_OFFSET: u64 = 2;
26pub const CALLVALUE_OFFSET: u64 = 3;
27pub const BENEFICIARY_OFFSET: u64 = 4;
28pub const TIMEOUT_OFFSET: u64 = 5;
29pub const TIMEOUT_WINDOWS_LEFT_OFFSET: u64 = 6;
30
31/// Manages the collection of retryable tickets.
32pub struct RetryableState<'a, D> {
33    retryables: Storage<'a, D>,
34    pub timeout_queue: Queue,
35    pub arbos_version: u64,
36}
37
38/// Outcome of a metered retryable lookup that may miss.
39pub enum LookupOutcome<T> {
40    Found(T),
41    NoTicket,
42}
43
44/// A metered lookup result: its outcome plus the extra storage-read gas the
45/// open consumed consulting the lifetime-window slot past the raw timeout.
46pub struct RetryableLookup<T> {
47    pub outcome: LookupOutcome<T>,
48    pub extra_gas: u64,
49}
50
51/// Outcome of a metered `cancel`: cleared (with the calldata size that was
52/// cleared), a miss, or an unauthorised caller.
53pub enum CancelOutcome {
54    Cleared {
55        calldata_size: u64,
56        beneficiary: Address,
57    },
58    NoTicket,
59    NotBeneficiary,
60}
61
62/// A metered `cancel` result: its outcome plus the extra storage-read gas.
63pub struct CancelLookup {
64    pub outcome: CancelOutcome,
65    pub extra_gas: u64,
66}
67
68/// A single retryable ticket.
69pub struct Retryable<'a, D> {
70    pub id: B256,
71    backing_storage: Storage<'a, D>,
72    num_tries: StorageBackedUint64,
73    from: StorageBackedAddress,
74    to: StorageBackedAddressOrNil,
75    callvalue: StorageBackedBigUint,
76    beneficiary: StorageBackedAddress,
77    calldata: StorageBackedBytes,
78    timeout: StorageBackedUint64,
79    timeout_windows_left: StorageBackedUint64,
80}
81
82pub fn initialize_retryable_state<D: Database>(sto: &Storage<'_, D>) -> Result<(), RetryableError> {
83    Ok(initialize_queue(&sto.open_sub_storage(TIMEOUT_QUEUE_KEY))?)
84}
85
86pub fn open_retryable_state<D>(sto: Storage<'_, D>, arbos_version: u64) -> RetryableState<'_, D> {
87    let queue_sto = sto.open_sub_storage(TIMEOUT_QUEUE_KEY);
88    RetryableState {
89        timeout_queue: open_queue(queue_sto),
90        retryables: sto,
91        arbos_version,
92    }
93}
94
95impl<'a, D> RetryableState<'a, D> {
96    pub fn open(sto: Storage<'a, D>, arbos_version: u64) -> Self {
97        open_retryable_state(sto, arbos_version)
98    }
99
100    /// Creates a new retryable ticket. The id must be unique.
101    pub fn create_retryable<B: StorageBackend>(
102        &self,
103        backend: &mut B,
104        id: B256,
105        timeout: u64,
106        from: Address,
107        to: Option<Address>,
108        callvalue: U256,
109        beneficiary: Address,
110        calldata: &[u8],
111    ) -> Result<Retryable<'a, D>, RetryableError> {
112        let ret = self.internal_open(id);
113        ret.num_tries.set(backend, 0)?;
114        ret.from.set(backend, from)?;
115        ret.to.set(backend, to)?;
116        ret.callvalue.set(backend, callvalue)?;
117        ret.beneficiary.set(backend, beneficiary)?;
118        ret.calldata.set(backend, calldata)?;
119        ret.timeout.set(backend, timeout)?;
120        ret.timeout_windows_left.set(backend, 0)?;
121        self.timeout_queue.put(backend, id)?;
122        Ok(ret)
123    }
124
125    /// Opens an existing retryable if it is still live.
126    ///
127    /// Past the raw timeout at ArbOS v60+, a kept-alive ticket survives while
128    /// its windowed timeout has not yet elapsed.
129    pub fn open_retryable<B: SystemStateBackend>(
130        &self,
131        backend: &mut B,
132        id: B256,
133        current_timestamp: u64,
134    ) -> Result<Option<Retryable<'a, D>>, RetryableError> {
135        let (retryable, _extra_gas) =
136            self.open_retryable_metered(backend, id, current_timestamp)?;
137        Ok(retryable)
138    }
139
140    /// Like [`open_retryable`], additionally returning the extra storage-read
141    /// gas the open consumed consulting the lifetime-window slot past the raw
142    /// timeout. Callers that meter storage access by hand fold this in.
143    pub fn open_retryable_metered<B: SystemStateBackend>(
144        &self,
145        backend: &mut B,
146        id: B256,
147        current_timestamp: u64,
148    ) -> Result<(Option<Retryable<'a, D>>, u64), RetryableError> {
149        let sto = self.retryables.open_sub_storage(id.as_slice());
150        let base_key = sto.base_key();
151        let timeout = StorageBackedUint64::new(base_key, TIMEOUT_OFFSET).get(backend)?;
152        if timeout == 0 {
153            return Ok((None, 0));
154        }
155        let mut extra_gas = 0;
156        if timeout < current_timestamp {
157            let mut effective_timeout = timeout;
158            if self.arbos_version >= arb_chainspec::arbos_version::ARBOS_VERSION_60 {
159                let windows_left =
160                    StorageBackedUint64::new(base_key, TIMEOUT_WINDOWS_LEFT_OFFSET).get(backend)?;
161                extra_gas = WINDOWS_LEFT_SLOAD_GAS;
162                effective_timeout =
163                    timeout.saturating_add(windows_left.saturating_mul(RETRYABLE_LIFETIME_SECONDS));
164            }
165            if effective_timeout < current_timestamp {
166                return Ok((None, extra_gas));
167            }
168        }
169        Ok((Some(self.internal_open(id)), extra_gas))
170    }
171
172    /// Gets the size in bytes a retryable occupies in storage.
173    pub fn retryable_size_bytes<B: SystemStateBackend>(
174        &self,
175        backend: &mut B,
176        id: B256,
177        current_time: u64,
178    ) -> Result<u64, RetryableError> {
179        let retryable = self.open_retryable(backend, id, current_time)?;
180        match retryable {
181            None => Ok(0),
182            Some(ret) => {
183                let size = ret.calldata_size(backend)?;
184                let calldata_slots = 32 + 32 * words_for_bytes(size);
185                Ok(6 * 32 + calldata_slots)
186            }
187        }
188    }
189
190    /// Deletes a retryable and returns whether it existed.
191    /// Moves the escrow's entire balance to the beneficiary via the provided closures.
192    pub fn delete_retryable<F, G, B>(
193        &self,
194        backend: &mut B,
195        id: B256,
196        mut transfer_fn: F,
197        mut balance_of: G,
198    ) -> Result<bool, RetryableError>
199    where
200        F: FnMut(Address, Address, U256) -> Result<(), BalanceError>,
201        G: FnMut(Address) -> U256,
202        B: StorageBackend,
203    {
204        let ret = self.internal_open(id);
205        let timeout = ret.timeout.get(backend)?;
206        if timeout == 0 {
207            return Ok(false);
208        }
209
210        let beneficiary_address = ret.beneficiary.get(backend)?;
211        let escrow_address = retryable_escrow_address(id);
212        let amount = balance_of(escrow_address);
213        transfer_fn(escrow_address, beneficiary_address, amount)?;
214
215        clear_ticket_fields(backend, &ret)?;
216        Ok(true)
217    }
218
219    /// Reads the effective timeout of an open retryable.
220    ///
221    /// Returns `NoTicketWithId` if the ticket does not exist or has expired.
222    pub fn get_timeout<B: SystemStateBackend>(
223        &self,
224        backend: &mut B,
225        ticket_id: B256,
226        current_timestamp: u64,
227    ) -> Result<RetryableLookup<u64>, RetryableError> {
228        let (opened, extra_gas) =
229            self.open_retryable_metered(backend, ticket_id, current_timestamp)?;
230        let outcome = match opened {
231            Some(ret) => LookupOutcome::Found(ret.calculate_timeout(backend)?),
232            None => LookupOutcome::NoTicket,
233        };
234        Ok(RetryableLookup { outcome, extra_gas })
235    }
236
237    /// Reads the beneficiary of an open retryable.
238    ///
239    /// Returns `NoTicketWithId` if the ticket does not exist or has expired.
240    pub fn get_beneficiary<B: SystemStateBackend>(
241        &self,
242        backend: &mut B,
243        ticket_id: B256,
244        current_timestamp: u64,
245    ) -> Result<RetryableLookup<Address>, RetryableError> {
246        let (opened, extra_gas) =
247            self.open_retryable_metered(backend, ticket_id, current_timestamp)?;
248        let outcome = match opened {
249            Some(ret) => LookupOutcome::Found(ret.beneficiary(backend)?),
250            None => LookupOutcome::NoTicket,
251        };
252        Ok(RetryableLookup { outcome, extra_gas })
253    }
254
255    /// Returns the calldata size of an open retryable, or zero if it has
256    /// expired. The lookup never fails the way `get_timeout` does so the
257    /// precompile can use it to size its gas reservation regardless of
258    /// liveness.
259    pub fn calldata_size_for<B: SystemStateBackend>(
260        &self,
261        backend: &mut B,
262        ticket_id: B256,
263        current_timestamp: u64,
264    ) -> Result<(u64, u64), RetryableError> {
265        let (opened, extra_gas) =
266            self.open_retryable_metered(backend, ticket_id, current_timestamp)?;
267        let size = match opened {
268            Some(ret) => ret.calldata_size(backend)?,
269            None => 0,
270        };
271        Ok((size, extra_gas))
272    }
273
274    /// Increments `num_tries` on an open retryable and returns the *previous*
275    /// value (i.e. the nonce used by the retry transaction).
276    ///
277    /// Returns `NoTicketWithId` if the ticket is missing or expired.
278    pub fn increment_num_tries_for<B: StorageBackend>(
279        &self,
280        backend: &mut B,
281        ticket_id: B256,
282        current_timestamp: u64,
283    ) -> Result<RetryableLookup<u64>, RetryableError> {
284        let (opened, extra_gas) =
285            self.open_retryable_metered(backend, ticket_id, current_timestamp)?;
286        let outcome = match opened {
287            Some(ret) => LookupOutcome::Found(ret.increment_num_tries(backend)? - 1),
288            None => LookupOutcome::NoTicket,
289        };
290        Ok(RetryableLookup { outcome, extra_gas })
291    }
292
293    /// Extends the lifetime of a retryable ticket.
294    pub fn keepalive<B: StorageBackend>(
295        &self,
296        backend: &mut B,
297        ticket_id: B256,
298        current_timestamp: u64,
299        limit_before_add: u64,
300        _time_to_add: u64,
301    ) -> Result<RetryableLookup<u64>, RetryableError> {
302        let (opened, extra_gas) =
303            self.open_retryable_metered(backend, ticket_id, current_timestamp)?;
304        let retryable = match opened {
305            Some(ret) => ret,
306            None => {
307                return Ok(RetryableLookup {
308                    outcome: LookupOutcome::NoTicket,
309                    extra_gas,
310                });
311            }
312        };
313        let timeout = retryable.calculate_timeout(backend)?;
314        if timeout > limit_before_add {
315            return Err(RetryableError::TimeoutTooFarFuture);
316        }
317        self.timeout_queue.put(backend, retryable.id)?;
318        retryable.increment_timeout_windows(backend)?;
319        Ok(RetryableLookup {
320            outcome: LookupOutcome::Found(timeout + RETRYABLE_LIFETIME_SECONDS),
321            extra_gas,
322        })
323    }
324
325    /// Verifies `caller` is the beneficiary of an open retryable and clears
326    /// the ticket's storage. Returns the calldata size that was cleared so
327    /// the precompile can derive its gas reservation.
328    ///
329    /// Returns `NoTicketWithId` for missing/expired tickets and
330    /// `NotBeneficiary` when the caller is unauthorised.
331    pub fn cancel<B: StorageBackend>(
332        &self,
333        backend: &mut B,
334        ticket_id: B256,
335        caller: Address,
336        current_timestamp: u64,
337    ) -> Result<CancelLookup, RetryableError> {
338        let (opened, extra_gas) =
339            self.open_retryable_metered(backend, ticket_id, current_timestamp)?;
340        let retryable = match opened {
341            Some(ret) => ret,
342            None => {
343                return Ok(CancelLookup {
344                    outcome: CancelOutcome::NoTicket,
345                    extra_gas,
346                });
347            }
348        };
349        let beneficiary = retryable.beneficiary(backend)?;
350        if caller != beneficiary {
351            return Ok(CancelLookup {
352                outcome: CancelOutcome::NotBeneficiary,
353                extra_gas,
354            });
355        }
356        let calldata_size = retryable.calldata_size(backend)?;
357        clear_ticket_fields(backend, &retryable)?;
358        Ok(CancelLookup {
359            outcome: CancelOutcome::Cleared {
360                calldata_size,
361                beneficiary,
362            },
363            extra_gas,
364        })
365    }
366
367    /// Tries to reap one expired retryable from the timeout queue.
368    pub fn try_to_reap_one_retryable<F, G, B>(
369        &self,
370        backend: &mut B,
371        current_timestamp: u64,
372        mut transfer_fn: F,
373        mut balance_of: G,
374    ) -> Result<(), RetryableError>
375    where
376        F: FnMut(Address, Address, U256) -> Result<(), BalanceError>,
377        G: FnMut(Address) -> U256,
378        B: StorageBackend,
379    {
380        let id = self.timeout_queue.peek(backend)?;
381        let id = match id {
382            None => return Ok(()),
383            Some(id) => id,
384        };
385
386        let ret_storage = self.retryables.open_sub_storage(id.as_slice());
387        let timeout_storage = StorageBackedUint64::new(ret_storage.base_key(), TIMEOUT_OFFSET);
388        let timeout = timeout_storage.get(backend)?;
389
390        if timeout == 0 {
391            self.timeout_queue.get(backend)?;
392            return Ok(());
393        }
394
395        let windows_left_storage =
396            StorageBackedUint64::new(ret_storage.base_key(), TIMEOUT_WINDOWS_LEFT_OFFSET);
397        let windows_left = windows_left_storage.get(backend)?;
398
399        if timeout >= current_timestamp {
400            return Ok(());
401        }
402
403        self.timeout_queue.get(backend)?;
404
405        if windows_left == 0 {
406            self.delete_retryable(backend, id, &mut transfer_fn, &mut balance_of)?;
407            return Ok(());
408        }
409
410        timeout_storage.set(backend, timeout + RETRYABLE_LIFETIME_SECONDS)?;
411        windows_left_storage.set(backend, windows_left - 1)?;
412        Ok(())
413    }
414
415    /// Total number of pending retryables in the timeout queue.
416    pub fn queue_size<B: SystemStateBackend>(
417        &self,
418        backend: &mut B,
419    ) -> Result<u64, RetryableError> {
420        Ok(self.timeout_queue.size(backend)?)
421    }
422
423    /// Walk the timeout queue and yield `(ticket_id, timeout_seconds)`
424    /// for each non-expired retryable.
425    pub fn snapshot_queue<B: SystemStateBackend>(
426        &self,
427        backend: &mut B,
428        current_time: u64,
429        max_entries: usize,
430    ) -> Result<Vec<(B256, u64)>, RetryableError> {
431        let ids: Vec<B256> = {
432            let mut collected = Vec::new();
433            self.timeout_queue
434                .for_each(backend, |id| -> Result<(), RetryableError> {
435                    collected.push(id);
436                    Ok(())
437                })?;
438            collected
439        };
440        let mut out = Vec::new();
441        for id in ids {
442            if out.len() >= max_entries {
443                break;
444            }
445            if let Some(retryable) = self.open_retryable(backend, id, current_time)? {
446                let timeout = retryable.calculate_timeout(backend)?;
447                out.push((id, timeout));
448            }
449        }
450        Ok(out)
451    }
452
453    fn internal_open(&self, id: B256) -> Retryable<'a, D> {
454        let sto = self.retryables.open_sub_storage(id.as_slice());
455        let base_key = sto.base_key();
456        let calldata_key = sto.open_sub_storage(CALLDATA_KEY).base_key();
457        Retryable {
458            id,
459            num_tries: StorageBackedUint64::new(base_key, NUM_TRIES_OFFSET),
460            from: StorageBackedAddress::new(base_key, FROM_OFFSET),
461            to: StorageBackedAddressOrNil::new(base_key, TO_OFFSET),
462            callvalue: StorageBackedBigUint::new(base_key, CALLVALUE_OFFSET),
463            beneficiary: StorageBackedAddress::new(base_key, BENEFICIARY_OFFSET),
464            calldata: StorageBackedBytes::new(calldata_key),
465            timeout: StorageBackedUint64::new(base_key, TIMEOUT_OFFSET),
466            timeout_windows_left: StorageBackedUint64::new(base_key, TIMEOUT_WINDOWS_LEFT_OFFSET),
467            backing_storage: sto,
468        }
469    }
470}
471
472impl<D: Database> RetryableState<'_, D> {
473    pub fn initialize(sto: &Storage<'_, D>) -> Result<(), RetryableError> {
474        initialize_retryable_state(sto)
475    }
476}
477
478fn clear_ticket_fields<D, B: StorageBackend>(
479    backend: &mut B,
480    ret: &Retryable<'_, D>,
481) -> Result<(), RetryableError> {
482    use arb_storage::ARBOS_STATE_ADDRESS;
483    let base_key = ret.backing_storage.base_key();
484    let key_slice: &[u8] = if base_key == B256::ZERO {
485        &[]
486    } else {
487        base_key.as_slice()
488    };
489    for offset in [
490        NUM_TRIES_OFFSET,
491        FROM_OFFSET,
492        TO_OFFSET,
493        CALLVALUE_OFFSET,
494        BENEFICIARY_OFFSET,
495        TIMEOUT_OFFSET,
496        TIMEOUT_WINDOWS_LEFT_OFFSET,
497    ] {
498        let slot = arb_storage::storage_key_map(key_slice, offset);
499        backend
500            .sstore(ARBOS_STATE_ADDRESS, slot, U256::ZERO)
501            .map_err(Into::into)?;
502    }
503    ret.calldata.clear(backend)?;
504    Ok(())
505}
506
507impl<D> Retryable<'_, D> {
508    pub fn num_tries<B: SystemStateBackend>(&self, backend: &mut B) -> Result<u64, RetryableError> {
509        Ok(self.num_tries.get(backend)?)
510    }
511
512    pub fn increment_num_tries<B: StorageBackend>(
513        &self,
514        backend: &mut B,
515    ) -> Result<u64, RetryableError> {
516        let current = self.num_tries.get(backend)?;
517        let new_val = current + 1;
518        self.num_tries.set(backend, new_val)?;
519        Ok(new_val)
520    }
521
522    pub fn beneficiary<B: SystemStateBackend>(
523        &self,
524        backend: &mut B,
525    ) -> Result<Address, RetryableError> {
526        Ok(self.beneficiary.get(backend)?)
527    }
528
529    pub fn calculate_timeout<B: SystemStateBackend>(
530        &self,
531        backend: &mut B,
532    ) -> Result<u64, RetryableError> {
533        let timeout = self.timeout.get(backend)?;
534        let windows = self.timeout_windows_left.get(backend)?;
535        Ok(timeout + windows * RETRYABLE_LIFETIME_SECONDS)
536    }
537
538    pub fn set_timeout<B: StorageBackend>(
539        &self,
540        backend: &mut B,
541        val: u64,
542    ) -> Result<(), RetryableError> {
543        Ok(self.timeout.set(backend, val)?)
544    }
545
546    pub fn timeout_windows_left<B: SystemStateBackend>(
547        &self,
548        backend: &mut B,
549    ) -> Result<u64, RetryableError> {
550        Ok(self.timeout_windows_left.get(backend)?)
551    }
552
553    fn increment_timeout_windows<B: StorageBackend>(
554        &self,
555        backend: &mut B,
556    ) -> Result<u64, RetryableError> {
557        let current = self.timeout_windows_left.get(backend)?;
558        let new_val = current + 1;
559        self.timeout_windows_left.set(backend, new_val)?;
560        Ok(new_val)
561    }
562
563    pub fn from<B: SystemStateBackend>(&self, backend: &mut B) -> Result<Address, RetryableError> {
564        Ok(self.from.get(backend)?)
565    }
566
567    pub fn to<B: SystemStateBackend>(
568        &self,
569        backend: &mut B,
570    ) -> Result<Option<Address>, RetryableError> {
571        Ok(self.to.get(backend)?)
572    }
573
574    pub fn callvalue<B: SystemStateBackend>(
575        &self,
576        backend: &mut B,
577    ) -> Result<U256, RetryableError> {
578        Ok(self.callvalue.get(backend)?)
579    }
580
581    pub fn calldata<B: SystemStateBackend>(
582        &self,
583        backend: &mut B,
584    ) -> Result<Vec<u8>, RetryableError> {
585        Ok(self.calldata.get(backend)?)
586    }
587
588    pub fn calldata_size<B: SystemStateBackend>(
589        &self,
590        backend: &mut B,
591    ) -> Result<u64, RetryableError> {
592        Ok(self.calldata.size(backend)?)
593    }
594
595    /// Constructs a retry transaction from this retryable's stored fields
596    /// combined with the provided runtime parameters.
597    pub fn make_tx<B: SystemStateBackend>(
598        &self,
599        backend: &mut B,
600        chain_id: U256,
601        nonce: u64,
602        gas_fee_cap: U256,
603        gas: u64,
604        ticket_id: B256,
605        refund_to: Address,
606        max_refund: U256,
607        submission_fee_refund: U256,
608    ) -> Result<arb_alloy_consensus::tx::ArbRetryTx, RetryableError> {
609        Ok(arb_alloy_consensus::tx::ArbRetryTx {
610            chain_id,
611            nonce,
612            from: self.from(backend)?,
613            gas_fee_cap,
614            gas,
615            to: self.to(backend)?,
616            value: self.callvalue(backend)?,
617            data: self.calldata(backend)?.into(),
618            ticket_id,
619            refund_to,
620            max_refund,
621            submission_fee_refund,
622        })
623    }
624}
625
626/// Computes the escrow address for a retryable ticket.
627pub fn retryable_escrow_address(ticket_id: B256) -> Address {
628    let mut data = Vec::with_capacity(16 + 32);
629    data.extend_from_slice(b"retryable escrow");
630    data.extend_from_slice(ticket_id.as_slice());
631    let hash = keccak256(&data);
632    Address::from_slice(&hash[12..])
633}
634
635/// Submission fee for a retryable ticket: `(1400 + 6 * len) * l1_base_fee`,
636/// computed with big-integer arithmetic to prevent overflow.
637pub fn retryable_submission_fee(calldata_length: usize, l1_base_fee: U256) -> U256 {
638    let factor = U256::from(1400u64)
639        .saturating_add(U256::from(6u64).saturating_mul(U256::from(calldata_length as u128)));
640    l1_base_fee.saturating_mul(factor)
641}
642
643/// Rounds up byte count to number of 32-byte words.
644fn words_for_bytes(bytes: u64) -> u64 {
645    bytes.div_ceil(32)
646}