arb_precompiles/
arbretryabletx.rs

1use alloy_evm::precompiles::{DynPrecompile, PrecompileInput};
2use alloy_primitives::{keccak256, Address, Log, B256, U256};
3use revm::precompile::{PrecompileError, PrecompileId, PrecompileOutput, PrecompileResult};
4
5use crate::storage_slot::{
6    current_redeemer_slot, current_retryable_slot, derive_subspace_key, map_slot,
7    ARBOS_STATE_ADDRESS, RETRYABLES_SUBSPACE, ROOT_STORAGE_KEY,
8};
9
10/// ArbRetryableTx precompile address (0x6e).
11pub const ARBRETRYABLETX_ADDRESS: Address = Address::new([
12    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
13    0x00, 0x00, 0x00, 0x6e,
14]);
15
16// Function selectors.
17const REDEEM: [u8; 4] = [0xed, 0xa1, 0x12, 0x2c];
18const GET_LIFETIME: [u8; 4] = [0x81, 0xe6, 0xe0, 0x83];
19const GET_TIMEOUT: [u8; 4] = [0x9f, 0x10, 0x25, 0xc6];
20const KEEPALIVE: [u8; 4] = [0xf0, 0xb2, 0x1a, 0x41];
21const GET_BENEFICIARY: [u8; 4] = [0xba, 0x20, 0xdd, 0xa4];
22const CANCEL: [u8; 4] = [0xc4, 0xd2, 0x52, 0xf5];
23const GET_CURRENT_REDEEMER: [u8; 4] = [0xde, 0x4b, 0xa2, 0xb3];
24const SUBMIT_RETRYABLE: [u8; 4] = [0xc9, 0xf9, 0x5d, 0x32];
25
26/// Default retryable lifetime: 7 days in seconds.
27const RETRYABLE_LIFETIME_SECONDS: u64 = 7 * 24 * 60 * 60;
28const RETRYABLE_REAP_PRICE: u64 = 58_000;
29
30// Retryable ticket storage field offsets (within the ticket's sub-storage).
31const NUM_TRIES_OFFSET: u64 = 0;
32const FROM_OFFSET: u64 = 1;
33const TO_OFFSET: u64 = 2;
34const CALLVALUE_OFFSET: u64 = 3;
35const BENEFICIARY_OFFSET: u64 = 4;
36const TIMEOUT_OFFSET: u64 = 5;
37const TIMEOUT_WINDOWS_LEFT_OFFSET: u64 = 6;
38
39/// Timeout queue subspace key within the retryables storage.
40const TIMEOUT_QUEUE_KEY: &[u8] = &[0];
41
42const SLOAD_GAS: u64 = 800;
43const SSTORE_GAS: u64 = 20_000;
44const SSTORE_ZERO_GAS: u64 = 5_000;
45const COPY_GAS: u64 = 3;
46const TX_GAS: u64 = 21_000;
47const LOG_GAS: u64 = 375;
48const LOG_TOPIC_GAS: u64 = 375;
49const LOG_DATA_GAS: u64 = 8;
50
51/// ABI-encoded data size for RedeemScheduled: 4 non-indexed params × 32 bytes.
52const REDEEM_SCHEDULED_DATA_BYTES: u64 = 128;
53
54/// Gas cost for emitting the RedeemScheduled event (LOG4 with 128 data bytes).
55const REDEEM_SCHEDULED_EVENT_COST: u64 =
56    LOG_GAS + 4 * LOG_TOPIC_GAS + LOG_DATA_GAS * REDEEM_SCHEDULED_DATA_BYTES;
57
58/// Backlog update cost: read + write. Write cost depends on whether
59/// the new value is zero (StorageClearCost=5000) or non-zero (StorageWriteCost=20000).
60/// This is computed dynamically in handle_redeem based on current backlog.
61///
62/// TicketCreated event topic0.
63/// keccak256("TicketCreated(bytes32)")
64pub fn ticket_created_topic() -> B256 {
65    keccak256("TicketCreated(bytes32)")
66}
67
68/// RedeemScheduled event topic0.
69/// keccak256("RedeemScheduled(bytes32,bytes32,uint64,uint64,address,uint256,uint256)")
70pub fn redeem_scheduled_topic() -> B256 {
71    keccak256("RedeemScheduled(bytes32,bytes32,uint64,uint64,address,uint256,uint256)")
72}
73
74pub fn create_arbretryabletx_precompile() -> DynPrecompile {
75    DynPrecompile::new_stateful(PrecompileId::custom("arbretryabletx"), handler)
76}
77
78fn handler(mut input: PrecompileInput<'_>) -> PrecompileResult {
79    let data = input.data;
80    if data.len() < 4 {
81        return Err(PrecompileError::other("input too short"));
82    }
83
84    let selector: [u8; 4] = [data[0], data[1], data[2], data[3]];
85    let gas_limit = input.gas;
86
87    let result = match selector {
88        GET_LIFETIME => {
89            let lifetime = U256::from(RETRYABLE_LIFETIME_SECONDS);
90            Ok(PrecompileOutput::new(
91                (SLOAD_GAS + COPY_GAS).min(gas_limit),
92                lifetime.to_be_bytes::<32>().to_vec().into(),
93            ))
94        }
95        GET_CURRENT_REDEEMER => {
96            // Read the current redeemer from scratch storage slot.
97            // The executor writes refund_to here before retry tx execution.
98            let internals = input.internals_mut();
99            internals
100                .load_account(ARBOS_STATE_ADDRESS)
101                .map_err(|e| PrecompileError::other(format!("load_account: {e:?}")))?;
102            let redeemer = internals
103                .sload(ARBOS_STATE_ADDRESS, current_redeemer_slot())
104                .map_err(|_| PrecompileError::other("sload failed"))?
105                .data;
106            Ok(PrecompileOutput::new(
107                (SLOAD_GAS + COPY_GAS).min(gas_limit),
108                redeemer.to_be_bytes::<32>().to_vec().into(),
109            ))
110        }
111        SUBMIT_RETRYABLE => {
112            // Not callable — exists only for ABI/explorer purposes.
113            Err(PrecompileError::other("not callable"))
114        }
115        GET_TIMEOUT => handle_get_timeout(&mut input),
116        GET_BENEFICIARY => handle_get_beneficiary(&mut input),
117        REDEEM => handle_redeem(&mut input),
118        KEEPALIVE => handle_keepalive(&mut input),
119        CANCEL => handle_cancel(&mut input),
120        _ => Err(PrecompileError::other("unknown ArbRetryableTx selector")),
121    };
122    crate::gas_check(gas_limit, result)
123}
124
125// ── helpers ──────────────────────────────────────────────────────────
126
127fn load_arbos(input: &mut PrecompileInput<'_>) -> Result<(), PrecompileError> {
128    input
129        .internals_mut()
130        .load_account(ARBOS_STATE_ADDRESS)
131        .map_err(|e| PrecompileError::other(format!("load_account: {e:?}")))?;
132    Ok(())
133}
134
135fn sload_field(input: &mut PrecompileInput<'_>, slot: U256) -> Result<U256, PrecompileError> {
136    let val = input
137        .internals_mut()
138        .sload(ARBOS_STATE_ADDRESS, slot)
139        .map_err(|_| PrecompileError::other("sload failed"))?;
140    Ok(val.data)
141}
142
143fn sstore_field(
144    input: &mut PrecompileInput<'_>,
145    slot: U256,
146    value: U256,
147) -> Result<(), PrecompileError> {
148    input
149        .internals_mut()
150        .sstore(ARBOS_STATE_ADDRESS, slot, value)
151        .map_err(|_| PrecompileError::other("sstore failed"))?;
152    Ok(())
153}
154
155/// Derive the storage key for a specific retryable ticket.
156fn ticket_storage_key(ticket_id: B256) -> B256 {
157    let retryables_key = derive_subspace_key(ROOT_STORAGE_KEY, RETRYABLES_SUBSPACE);
158    derive_subspace_key(retryables_key.as_slice(), ticket_id.as_slice())
159}
160
161/// Open a retryable ticket by verifying it exists (timeout > 0) and hasn't expired.
162/// Returns the ticket's storage key.
163fn open_retryable(
164    input: &mut PrecompileInput<'_>,
165    ticket_id: B256,
166    current_timestamp: u64,
167) -> Result<B256, PrecompileError> {
168    let ticket_key = ticket_storage_key(ticket_id);
169    let timeout_slot = map_slot(ticket_key.as_slice(), TIMEOUT_OFFSET);
170    let timeout = sload_field(input, timeout_slot)?;
171    let timeout_u64: u64 = timeout
172        .try_into()
173        .map_err(|_| PrecompileError::other("invalid timeout value"))?;
174
175    if timeout_u64 == 0 {
176        return Err(PrecompileError::other("retryable ticket not found"));
177    }
178    if timeout_u64 < current_timestamp {
179        return Err(PrecompileError::other("retryable ticket expired"));
180    }
181
182    Ok(ticket_key)
183}
184
185/// GetTimeout returns the effective timeout for a retryable ticket.
186/// Effective timeout = stored_timeout + timeout_windows_left * RETRYABLE_LIFETIME.
187fn handle_get_timeout(input: &mut PrecompileInput<'_>) -> PrecompileResult {
188    let data = input.data;
189    if data.len() < 36 {
190        return Err(PrecompileError::other("input too short"));
191    }
192
193    let gas_limit = input.gas;
194    let ticket_id = B256::from_slice(&data[4..36]);
195
196    load_arbos(input)?;
197
198    let ticket_key = ticket_storage_key(ticket_id);
199
200    // Read raw timeout.
201    let timeout_slot = map_slot(ticket_key.as_slice(), TIMEOUT_OFFSET);
202    let timeout = sload_field(input, timeout_slot)?;
203    let timeout_u64: u64 = timeout.try_into().unwrap_or(0);
204
205    if timeout_u64 == 0 {
206        return Err(PrecompileError::other("retryable ticket not found"));
207    }
208
209    // Read timeout_windows_left for effective timeout calculation.
210    let windows_slot = map_slot(ticket_key.as_slice(), TIMEOUT_WINDOWS_LEFT_OFFSET);
211    let windows = sload_field(input, windows_slot)?;
212    let windows_u64: u64 = windows.try_into().unwrap_or(0);
213
214    let effective_timeout = timeout_u64 + windows_u64 * RETRYABLE_LIFETIME_SECONDS;
215
216    // OAS(1) + OpenRetryable timeout(1) + CalculateTimeout timeout+windows(2) + argsCost(3) +
217    // resultCost(3).
218    Ok(PrecompileOutput::new(
219        (4 * SLOAD_GAS + 2 * COPY_GAS).min(gas_limit),
220        U256::from(effective_timeout)
221            .to_be_bytes::<32>()
222            .to_vec()
223            .into(),
224    ))
225}
226
227/// Derive the timeout queue storage key.
228fn timeout_queue_key() -> B256 {
229    let retryables_key = derive_subspace_key(ROOT_STORAGE_KEY, RETRYABLES_SUBSPACE);
230    derive_subspace_key(retryables_key.as_slice(), TIMEOUT_QUEUE_KEY)
231}
232
233/// Queue Put: reads nextPutOffset (slot 0), writes the value at that offset, increments
234/// nextPutOffset.
235fn queue_put(input: &mut PrecompileInput<'_>, value: B256) -> Result<(), PrecompileError> {
236    let queue_key = timeout_queue_key();
237
238    // nextPutOffset is at offset 0 within the queue sub-storage.
239    let put_offset_slot = map_slot(queue_key.as_slice(), 0);
240    let put_offset = sload_field(input, put_offset_slot)?;
241    let put_offset_u64: u64 = put_offset
242        .try_into()
243        .map_err(|_| PrecompileError::other("invalid queue put offset"))?;
244
245    // Store the value at map_slot_b256(queue_key, value_as_key) using the offset as key.
246    let item_slot = map_slot(queue_key.as_slice(), put_offset_u64);
247    sstore_field(input, item_slot, U256::from_be_bytes(value.0))?;
248
249    // Increment nextPutOffset.
250    sstore_field(input, put_offset_slot, U256::from(put_offset_u64 + 1))?;
251
252    Ok(())
253}
254
255/// GetBeneficiary returns the beneficiary address for a retryable ticket.
256fn handle_get_beneficiary(input: &mut PrecompileInput<'_>) -> PrecompileResult {
257    let data = input.data;
258    if data.len() < 36 {
259        return Err(PrecompileError::other("input too short"));
260    }
261
262    let gas_limit = input.gas;
263    let ticket_id = B256::from_slice(&data[4..36]);
264    let current_timestamp: u64 = input
265        .internals()
266        .block_timestamp()
267        .try_into()
268        .unwrap_or(u64::MAX);
269
270    load_arbos(input)?;
271
272    let ticket_key = open_retryable(input, ticket_id, current_timestamp)?;
273
274    // Read beneficiary (stored as address in 32 bytes, right-aligned).
275    let beneficiary_slot = map_slot(ticket_key.as_slice(), BENEFICIARY_OFFSET);
276    let beneficiary = sload_field(input, beneficiary_slot)?;
277
278    // OAS(1) + OpenRetryable timeout(1) + beneficiary(1) + argsCost(3) + resultCost(3).
279    Ok(PrecompileOutput::new(
280        (3 * SLOAD_GAS + 2 * COPY_GAS).min(gas_limit),
281        beneficiary.to_be_bytes::<32>().to_vec().into(),
282    ))
283}
284
285/// Redeem validates the retryable, increments numTries, donates remaining gas
286/// to the retry tx, and emits a RedeemScheduled event. The block executor
287/// discovers the event in the execution logs and schedules the retry tx.
288fn handle_redeem(input: &mut PrecompileInput<'_>) -> PrecompileResult {
289    let data = input.data;
290    if data.len() < 36 {
291        return Err(PrecompileError::other("input too short"));
292    }
293
294    let gas_limit = input.gas;
295    let ticket_id = B256::from_slice(&data[4..36]);
296    let caller = input.caller;
297    let current_timestamp: u64 = input
298        .internals()
299        .block_timestamp()
300        .try_into()
301        .unwrap_or(u64::MAX);
302
303    let internals = input.internals_mut();
304
305    // Load the ArbOS state account.
306    internals
307        .load_account(ARBOS_STATE_ADDRESS)
308        .map_err(|e| PrecompileError::other(format!("load_account: {e:?}")))?;
309
310    // Guard: a retryable cannot redeem itself during its own retry execution.
311    let current_retryable = internals
312        .sload(ARBOS_STATE_ADDRESS, current_retryable_slot())
313        .map_err(|_| PrecompileError::other("sload failed"))?
314        .data;
315    if !current_retryable.is_zero()
316        && B256::from(current_retryable.to_be_bytes::<32>()) == ticket_id
317    {
318        return Err(PrecompileError::other("retryable cannot redeem itself"));
319    }
320
321    // Read retryable data through internals.sload.
322    let ticket_key_pre = ticket_storage_key(ticket_id);
323    let (calldata_words, write_bytes, nonce) = {
324        // Read timeout
325        let timeout_slot = map_slot(ticket_key_pre.as_slice(), TIMEOUT_OFFSET);
326        let timeout_check = internals
327            .sload(ARBOS_STATE_ADDRESS, timeout_slot)
328            .map_err(|_| PrecompileError::other("sload failed"))?
329            .data;
330        let timeout_u64: u64 = timeout_check.try_into().unwrap_or(0);
331        if timeout_u64 == 0 || timeout_u64 < current_timestamp {
332            return Err(PrecompileError::other(
333                "retryable ticket not found or expired",
334            ));
335        }
336
337        // Read calldata size
338        let calldata_sub = derive_subspace_key(ticket_key_pre.as_slice(), &[1]);
339        let calldata_size_slot = map_slot(calldata_sub.as_slice(), 0);
340        let calldata_size = internals
341            .sload(ARBOS_STATE_ADDRESS, calldata_size_slot)
342            .map_err(|_| PrecompileError::other("sload failed"))?
343            .data;
344        let calldata_size_u64: u64 = calldata_size.try_into().unwrap_or(0);
345        let cw = calldata_size_u64.div_ceil(32);
346        let nbytes = 6 * 32 + 32 + 32 * cw;
347        let wb = nbytes.div_ceil(32);
348
349        // Read numTries
350        let num_tries_slot = map_slot(ticket_key_pre.as_slice(), NUM_TRIES_OFFSET);
351        let num_tries = internals
352            .sload(ARBOS_STATE_ADDRESS, num_tries_slot)
353            .map_err(|_| PrecompileError::other("sload failed"))?
354            .data;
355        let n: u64 = num_tries.try_into().unwrap_or(0);
356
357        (cw, wb, n)
358    };
359    let _ticket_key = ticket_key_pre;
360
361    // Compute deterministic retry tx hash: keccak256(ticket_id || nonce).
362    let mut hash_input = [0u8; 64];
363    hash_input[..32].copy_from_slice(ticket_id.as_slice());
364    hash_input[32..].copy_from_slice(&U256::from(nonce).to_be_bytes::<32>());
365    let retry_tx_hash = keccak256(hash_input);
366
367    // Gas accounting matching Nitro's precompile framework.
368    //
369    // In Nitro, `c.State` uses the precompile Context as burner, so ALL
370    // storage reads/writes charge gas. Additionally, the explicit
371    // `c.Burn(StorageAccess, params.SloadGas * writeBytes)` uses
372    // params.SloadGas = 50 (NOT StorageReadCost = 800).
373    //
374    // Charges before gas_to_donate calculation (all through burner):
375    //   Framework argsCost:        3  (CopyGas * 1 word)
376    //   OpenArbosState version:  800  (1 storage read)
377    //   RetryableSizeBytes:     1600  (2 storage reads: timeout + calldataSize)
378    //   Explicit burn:    50*writeBytes  (params.SloadGas * writeBytes)
379    //   OpenRetryable:          800  (1 storage read: timeout)
380    //   IncrementNumTries:    20800  (1 read + 1 write: 800 + 20000)
381    //   MakeTx reads:          N*800  (from + to + value + calldataSize + calldataWords)
382    //
383    // After gas_to_donate: event + c.Burn(donate) + ShrinkBacklog + copyGas
384    //
385    // The precompile returns gasLeft = BacklogUpdateCost_reserved - actual_shrinkBacklog_cost.
386    // When backlog is zero, ShrinkBacklog costs 5800 instead of 20800, leaving 15000 as gasLeft.
387
388    // Compute retryable size gas: params.SloadGas (50) * writeBytes
389    const PARAMS_SLOAD_GAS: u64 = 50; // params.SloadGas (NOT StorageReadCost)
390    let retryable_size_gas = PARAMS_SLOAD_GAS.saturating_mul(write_bytes);
391
392    // Count MakeTx burner reads: from(1) + to(1) + value(1) + calldataSize(1) + calldataWords
393    let make_tx_reads = 4 + calldata_words;
394
395    // Total gas charged before gas_to_donate
396    let gas_used_so_far = COPY_GAS                                // framework argsCost (3)
397        + SLOAD_GAS                             // OpenArbosState version read (800)
398        + 2 * SLOAD_GAS                         // RetryableSizeBytes: timeout + calldataSize (1600)
399        + retryable_size_gas                    // explicit c.Burn: 50 * writeBytes
400        + SLOAD_GAS                             // OpenRetryable timeout (800)
401        + SLOAD_GAS + SSTORE_GAS                // IncrementNumTries: read + write (20800)
402        + make_tx_reads * SLOAD_GAS; // MakeTx reads
403
404    // BacklogUpdateCost: RESERVATION used for computing gas_to_donate.
405    // Actual ShrinkBacklog cost may be less (5800 if writing zero).
406    let backlog_reservation = SLOAD_GAS + SSTORE_GAS; // 800 + 20000 = 20800
407
408    // Future gas costs: use RESERVATION for computing gas_to_donate
409    // (matching Nitro's BacklogUpdateCost()). The savings from
410    // over-reservation naturally become gasLeft.
411    let future_gas_costs = REDEEM_SCHEDULED_EVENT_COST + COPY_GAS + backlog_reservation;
412    let gas_remaining = gas_limit.saturating_sub(gas_used_so_far);
413    if gas_remaining < future_gas_costs + TX_GAS {
414        return Err(PrecompileError::other(
415            "not enough gas to run redeem attempt",
416        ));
417    }
418    let gas_to_donate = gas_remaining - future_gas_costs;
419
420    // Actual ShrinkBacklog cost: Nitro's writeCost() checks the VALUE
421    // BEING WRITTEN, not the current value. After ShrinkBacklog shrinks
422    // by gas_to_donate, the new backlog determines the write cost.
423    let actual_backlog_cost = {
424        let current_backlog = crate::get_current_gas_backlog();
425        let new_backlog = current_backlog.saturating_sub(gas_to_donate);
426        let write_cost = if new_backlog == 0 {
427            SSTORE_ZERO_GAS // 5000 (StorageWriteZeroCost)
428        } else {
429            SSTORE_GAS // 20000 (StorageWriteCost)
430        };
431        SLOAD_GAS + write_cost
432    };
433
434    // Manual redeem: maxRefund = 2^256 - 1, submissionFeeRefund = 0.
435    let max_refund = U256::MAX;
436    let submission_fee_refund = U256::ZERO;
437
438    // Emit RedeemScheduled event.
439    let topic0 = redeem_scheduled_topic();
440    let topic1 = ticket_id;
441    let topic2 = B256::from(retry_tx_hash);
442    let mut seq_bytes = [0u8; 32];
443    seq_bytes[24..32].copy_from_slice(&nonce.to_be_bytes());
444    let topic3 = B256::from(seq_bytes);
445
446    let mut event_data = Vec::with_capacity(128);
447    event_data.extend_from_slice(&U256::from(gas_to_donate).to_be_bytes::<32>());
448    event_data.extend_from_slice(&B256::left_padding_from(caller.as_slice()).0);
449    event_data.extend_from_slice(&max_refund.to_be_bytes::<32>());
450    event_data.extend_from_slice(&submission_fee_refund.to_be_bytes::<32>());
451
452    internals.log(Log::new_unchecked(
453        ARBRETRYABLETX_ADDRESS,
454        vec![topic0, topic1, topic2, topic3],
455        event_data.into(),
456    ));
457
458    // Return total gas consumed, matching Nitro's model:
459    // gas_used = pre-donate charges + event + donated gas + actual backlog cost + copy
460    // gasLeft = gas_limit - gas_used = BacklogUpdateCost_reserved - actual_backlog_cost
461    //
462    // This matches Nitro where the precompile burns gas_to_donate, then
463    // ShrinkBacklog charges the actual (possibly cheaper) cost, leaving
464    // the over-reservation savings as gasLeft.
465    let total_gas = gas_used_so_far
466        + REDEEM_SCHEDULED_EVENT_COST
467        + gas_to_donate
468        + actual_backlog_cost
469        + COPY_GAS;
470
471    Ok(PrecompileOutput::new(
472        total_gas.min(gas_limit),
473        retry_tx_hash.to_vec().into(),
474    ))
475}
476
477/// Keepalive adds one lifetime period to the ticket's expiry.
478///
479/// Opens the retryable, verifies effective timeout isn't too far in the future,
480/// adds a duplicate entry to the timeout queue, and increments timeout_windows_left.
481fn handle_keepalive(input: &mut PrecompileInput<'_>) -> PrecompileResult {
482    let data = input.data;
483    if data.len() < 36 {
484        return Err(PrecompileError::other("input too short"));
485    }
486
487    let gas_limit = input.gas;
488    let ticket_id = B256::from_slice(&data[4..36]);
489    let current_timestamp: u64 = input
490        .internals()
491        .block_timestamp()
492        .try_into()
493        .unwrap_or(u64::MAX);
494
495    load_arbos(input)?;
496
497    // Open the retryable (verifies exists and not expired).
498    let ticket_key = open_retryable(input, ticket_id, current_timestamp)?;
499
500    // Read calldata size for updateCost computation (RetryableSizeBytes).
501    let calldata_sub = derive_subspace_key(ticket_key.as_slice(), &[1]);
502    let calldata_size_slot = map_slot(calldata_sub.as_slice(), 0);
503    let calldata_size = sload_field(input, calldata_size_slot)?;
504    let calldata_size_u64: u64 = calldata_size.try_into().unwrap_or(0);
505
506    // Read timeout and timeout_windows_left to compute effective timeout.
507    let timeout_slot = map_slot(ticket_key.as_slice(), TIMEOUT_OFFSET);
508    let timeout = sload_field(input, timeout_slot)?;
509    let timeout_u64: u64 = timeout
510        .try_into()
511        .map_err(|_| PrecompileError::other("invalid timeout"))?;
512
513    let windows_slot = map_slot(ticket_key.as_slice(), TIMEOUT_WINDOWS_LEFT_OFFSET);
514    let windows = sload_field(input, windows_slot)?;
515    let windows_u64: u64 = windows
516        .try_into()
517        .map_err(|_| PrecompileError::other("invalid windows"))?;
518
519    let effective_timeout = timeout_u64 + windows_u64 * RETRYABLE_LIFETIME_SECONDS;
520
521    // The window limit is current_time + one lifetime.
522    let window_limit = current_timestamp + RETRYABLE_LIFETIME_SECONDS;
523    if effective_timeout > window_limit {
524        return Err(PrecompileError::other("timeout too far into the future"));
525    }
526
527    // Put the ticket into the timeout queue (duplicate entry for the new window).
528    queue_put(input, ticket_id)?;
529
530    // Increment timeout_windows_left.
531    let new_windows = windows_u64 + 1;
532    sstore_field(input, windows_slot, U256::from(new_windows))?;
533
534    let new_timeout = effective_timeout + RETRYABLE_LIFETIME_SECONDS;
535
536    // 8 SLOADs + 3 SSTOREs + argsCost(3) + updateCost + event(1381)
537    // + RetryableReapPrice(58000) + resultCost(3).
538    // updateCost = WordsForBytes(nbytes) * SstoreSetGas/100, where
539    // nbytes = 6*32 + 32 + 32*WordsForBytes(calldataSize).
540    let calldata_words = calldata_size_u64.div_ceil(32);
541    let nbytes = 6 * 32 + 32 + 32 * calldata_words;
542    let update_cost = nbytes.div_ceil(32) * (SSTORE_GAS / 100);
543    let event_cost = LOG_GAS + 2 * LOG_TOPIC_GAS + LOG_DATA_GAS * 32;
544    let gas_used = 8 * SLOAD_GAS
545        + 3 * SSTORE_GAS
546        + 2 * COPY_GAS
547        + update_cost
548        + event_cost
549        + RETRYABLE_REAP_PRICE;
550
551    Ok(PrecompileOutput::new(
552        gas_used.min(gas_limit),
553        U256::from(new_timeout).to_be_bytes::<32>().to_vec().into(),
554    ))
555}
556
557/// Cancel the ticket and refund its callvalue to its beneficiary.
558///
559/// Verifies the caller is the beneficiary, then clears all storage fields.
560/// Balance transfer (escrow → beneficiary) is handled by the executor.
561fn handle_cancel(input: &mut PrecompileInput<'_>) -> PrecompileResult {
562    let data = input.data;
563    if data.len() < 36 {
564        return Err(PrecompileError::other("input too short"));
565    }
566
567    let gas_limit = input.gas;
568    let ticket_id = B256::from_slice(&data[4..36]);
569    let caller = input.caller;
570    let current_timestamp: u64 = input
571        .internals()
572        .block_timestamp()
573        .try_into()
574        .unwrap_or(u64::MAX);
575
576    load_arbos(input)?;
577
578    // Open the retryable (verifies exists and not expired).
579    let ticket_key = open_retryable(input, ticket_id, current_timestamp)?;
580
581    // Read beneficiary and verify caller is the beneficiary.
582    let beneficiary_slot = map_slot(ticket_key.as_slice(), BENEFICIARY_OFFSET);
583    let beneficiary = sload_field(input, beneficiary_slot)?;
584
585    // The caller address is left-padded with zeros in 20 bytes.
586    let caller_u256 = U256::from_be_slice(caller.as_slice());
587    if caller_u256 != beneficiary {
588        return Err(PrecompileError::other(
589            "only the beneficiary may cancel a retryable",
590        ));
591    }
592
593    // Clear all storage fields for this retryable ticket (DeleteRetryable).
594    let offsets = [
595        NUM_TRIES_OFFSET,
596        FROM_OFFSET,
597        TO_OFFSET,
598        CALLVALUE_OFFSET,
599        BENEFICIARY_OFFSET,
600        TIMEOUT_OFFSET,
601        TIMEOUT_WINDOWS_LEFT_OFFSET,
602    ];
603    for offset in offsets {
604        let slot = map_slot(ticket_key.as_slice(), offset);
605        sstore_field(input, slot, U256::ZERO)?;
606    }
607
608    // Clear calldata bytes (ClearBytes on calldata sub-storage).
609    let calldata_sub = derive_subspace_key(ticket_key.as_slice(), &[1]);
610    let calldata_size_slot = map_slot(calldata_sub.as_slice(), 0);
611    let calldata_size = sload_field(input, calldata_size_slot)?;
612    let calldata_size_u64: u64 = calldata_size.try_into().unwrap_or(0);
613    let calldata_words = calldata_size_u64.div_ceil(32);
614    if calldata_size_u64 > 0 {
615        for i in 0..calldata_words {
616            let word_slot = map_slot(calldata_sub.as_slice(), 1 + i);
617            sstore_field(input, word_slot, U256::ZERO)?;
618        }
619        sstore_field(input, calldata_size_slot, U256::ZERO)?;
620    }
621
622    // 6 SLOADs + 7 × ClearByUint64(5000) + ClearBytes(variable)
623    // + Canceled event (LOG2: 375+2*375=1125) + argsCost(3).
624    // DeleteRetryable SLOADs: timeout(1) + beneficiary(1) + ClearBytes size(1) = 3
625    // Total SLOADs: OAS(1) + OpenRetryable(1) + beneficiary(1) + DeleteRetryable(3) = 6
626    let clear_bytes_cost = if calldata_size_u64 > 0 {
627        (calldata_words + 1) * SSTORE_ZERO_GAS
628    } else {
629        0
630    };
631    let event_cost = LOG_GAS + 2 * LOG_TOPIC_GAS;
632    let gas_used = 6 * SLOAD_GAS + 7 * SSTORE_ZERO_GAS + clear_bytes_cost + event_cost + COPY_GAS;
633
634    Ok(PrecompileOutput::new(
635        gas_used.min(gas_limit),
636        Vec::new().into(),
637    ))
638}