arb_precompiles/
arbretryabletx.rs

1use alloy_evm::precompiles::{DynPrecompile, PrecompileInput};
2use alloy_primitives::{keccak256, Address, Log, B256, U256};
3use alloy_sol_types::{SolError, SolEvent, SolInterface};
4use revm::precompile::{PrecompileError, PrecompileId, PrecompileOutput, PrecompileResult};
5
6use crate::{
7    interfaces::IArbRetryableTx,
8    storage_slot::{
9        derive_subspace_key, map_slot, vector_length_slot, ARBOS_STATE_ADDRESS,
10        L2_PRICING_SUBSPACE, RETRYABLES_SUBSPACE, ROOT_STORAGE_KEY,
11    },
12};
13
14/// ArbRetryableTx precompile address (0x6e).
15pub const ARBRETRYABLETX_ADDRESS: Address = Address::new([
16    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
17    0x00, 0x00, 0x00, 0x6e,
18]);
19
20/// Default retryable lifetime: 7 days in seconds.
21const RETRYABLE_LIFETIME_SECONDS: u64 = 7 * 24 * 60 * 60;
22const RETRYABLE_REAP_PRICE: u64 = 58_000;
23
24// Retryable ticket storage field offsets (within the ticket's sub-storage).
25const NUM_TRIES_OFFSET: u64 = 0;
26const FROM_OFFSET: u64 = 1;
27const TO_OFFSET: u64 = 2;
28const CALLVALUE_OFFSET: u64 = 3;
29const BENEFICIARY_OFFSET: u64 = 4;
30const TIMEOUT_OFFSET: u64 = 5;
31const TIMEOUT_WINDOWS_LEFT_OFFSET: u64 = 6;
32
33/// Timeout queue subspace key within the retryables storage.
34const TIMEOUT_QUEUE_KEY: &[u8] = &[0];
35
36const SLOAD_GAS: u64 = 800;
37const SSTORE_GAS: u64 = 20_000;
38const SSTORE_ZERO_GAS: u64 = 5_000;
39const SSTORE_RESET_GAS: u64 = 5_000;
40const COPY_GAS: u64 = 3;
41const TX_GAS: u64 = 21_000;
42const LOG_GAS: u64 = 375;
43const LOG_TOPIC_GAS: u64 = 375;
44const LOG_DATA_GAS: u64 = 8;
45
46/// ABI-encoded data size for RedeemScheduled: 4 non-indexed params × 32 bytes.
47const REDEEM_SCHEDULED_DATA_BYTES: u64 = 128;
48
49/// Gas cost for emitting the RedeemScheduled event (LOG4 with 128 data bytes).
50const REDEEM_SCHEDULED_EVENT_COST: u64 =
51    LOG_GAS + 4 * LOG_TOPIC_GAS + LOG_DATA_GAS * REDEEM_SCHEDULED_DATA_BYTES;
52
53pub fn ticket_created_topic() -> B256 {
54    IArbRetryableTx::TicketCreated::SIGNATURE_HASH
55}
56
57pub fn redeem_scheduled_topic() -> B256 {
58    IArbRetryableTx::RedeemScheduled::SIGNATURE_HASH
59}
60
61pub fn lifetime_extended_topic() -> B256 {
62    IArbRetryableTx::LifetimeExtended::SIGNATURE_HASH
63}
64
65pub fn canceled_topic() -> B256 {
66    IArbRetryableTx::Canceled::SIGNATURE_HASH
67}
68
69pub fn create_arbretryabletx_precompile() -> DynPrecompile {
70    DynPrecompile::new_stateful(PrecompileId::custom("arbretryabletx"), handler)
71}
72
73fn handler(mut input: PrecompileInput<'_>) -> PrecompileResult {
74    let gas_limit = input.gas;
75    crate::init_precompile_gas(input.data.len());
76
77    let call = match IArbRetryableTx::ArbRetryableTxCalls::abi_decode(input.data) {
78        Ok(c) => c,
79        Err(_) => return crate::burn_all_revert(gas_limit),
80    };
81
82    use IArbRetryableTx::ArbRetryableTxCalls as Calls;
83    let result = match call {
84        Calls::getLifetime(_) => {
85            let lifetime = U256::from(RETRYABLE_LIFETIME_SECONDS);
86            Ok(PrecompileOutput::new(
87                (SLOAD_GAS + COPY_GAS).min(gas_limit),
88                lifetime.to_be_bytes::<32>().to_vec().into(),
89            ))
90        }
91        Calls::getCurrentRedeemer(_) => {
92            let redeemer = crate::get_current_redeemer();
93            Ok(PrecompileOutput::new(
94                (SLOAD_GAS + COPY_GAS).min(gas_limit),
95                redeemer.to_be_bytes::<32>().to_vec().into(),
96            ))
97        }
98        Calls::submitRetryable(_) => {
99            let data = IArbRetryableTx::NotCallable {}.abi_encode();
100            return crate::sol_error_revert(data, gas_limit);
101        }
102        Calls::getTimeout(c) => handle_get_timeout(&mut input, c.ticketId),
103        Calls::getBeneficiary(c) => handle_get_beneficiary(&mut input, c.ticketId),
104        Calls::redeem(c) => handle_redeem(&mut input, c.ticketId),
105        Calls::keepalive(c) => handle_keepalive(&mut input, c.ticketId),
106        Calls::cancel(c) => handle_cancel(&mut input, c.ticketId),
107    };
108    crate::gas_check(gas_limit, result)
109}
110
111// ── helpers ──────────────────────────────────────────────────────────
112
113fn load_arbos(input: &mut PrecompileInput<'_>) -> Result<(), PrecompileError> {
114    input
115        .internals_mut()
116        .load_account(ARBOS_STATE_ADDRESS)
117        .map_err(|e| PrecompileError::other(format!("load_account: {e:?}")))?;
118    Ok(())
119}
120
121fn sload_field(input: &mut PrecompileInput<'_>, slot: U256) -> Result<U256, PrecompileError> {
122    let val = input
123        .internals_mut()
124        .sload(ARBOS_STATE_ADDRESS, slot)
125        .map_err(|_| PrecompileError::other("sload failed"))?;
126    crate::charge_precompile_gas(SLOAD_GAS);
127    Ok(val.data)
128}
129
130fn sstore_field(
131    input: &mut PrecompileInput<'_>,
132    slot: U256,
133    value: U256,
134) -> Result<(), PrecompileError> {
135    input
136        .internals_mut()
137        .sstore(ARBOS_STATE_ADDRESS, slot, value)
138        .map_err(|_| PrecompileError::other("sstore failed"))?;
139    Ok(())
140}
141
142/// Derive the storage key for a specific retryable ticket.
143fn ticket_storage_key(ticket_id: B256) -> B256 {
144    let retryables_key = derive_subspace_key(ROOT_STORAGE_KEY, RETRYABLES_SUBSPACE);
145    derive_subspace_key(retryables_key.as_slice(), ticket_id.as_slice())
146}
147
148/// Open a retryable ticket by verifying it exists (timeout > 0) and hasn't expired.
149/// Returns the ticket's storage key.
150fn open_retryable(
151    input: &mut PrecompileInput<'_>,
152    ticket_id: B256,
153    current_timestamp: u64,
154) -> Result<Option<B256>, PrecompileError> {
155    let ticket_key = ticket_storage_key(ticket_id);
156    let timeout_slot = map_slot(ticket_key.as_slice(), TIMEOUT_OFFSET);
157    let timeout = sload_field(input, timeout_slot)?;
158    let timeout_u64: u64 = timeout.try_into().unwrap_or(0);
159
160    if timeout_u64 == 0 || timeout_u64 < current_timestamp {
161        return Ok(None);
162    }
163
164    Ok(Some(ticket_key))
165}
166
167/// Effective timeout = stored_timeout + timeout_windows_left * RETRYABLE_LIFETIME.
168fn handle_get_timeout(input: &mut PrecompileInput<'_>, ticket_id: B256) -> PrecompileResult {
169    let gas_limit = input.gas;
170    let current_timestamp: u64 = input
171        .internals()
172        .block_timestamp()
173        .try_into()
174        .unwrap_or(u64::MAX);
175
176    load_arbos(input)?;
177
178    let ticket_key = ticket_storage_key(ticket_id);
179
180    let timeout_slot = map_slot(ticket_key.as_slice(), TIMEOUT_OFFSET);
181    let timeout = sload_field(input, timeout_slot)?;
182    let timeout_u64: u64 = timeout.try_into().unwrap_or(0);
183
184    if timeout_u64 == 0 || timeout_u64 < current_timestamp {
185        let data = IArbRetryableTx::NoTicketWithID {}.abi_encode();
186        return crate::sol_error_revert(data, gas_limit);
187    }
188
189    // Read timeout_windows_left for effective timeout calculation.
190    let windows_slot = map_slot(ticket_key.as_slice(), TIMEOUT_WINDOWS_LEFT_OFFSET);
191    let windows = sload_field(input, windows_slot)?;
192    let windows_u64: u64 = windows.try_into().unwrap_or(0);
193
194    let effective_timeout = timeout_u64 + windows_u64 * RETRYABLE_LIFETIME_SECONDS;
195
196    // OAS(1) + OpenRetryable timeout(1) + CalculateTimeout timeout+windows(2) + argsCost(3) +
197    // resultCost(3).
198    Ok(PrecompileOutput::new(
199        (4 * SLOAD_GAS + 2 * COPY_GAS).min(gas_limit),
200        U256::from(effective_timeout)
201            .to_be_bytes::<32>()
202            .to_vec()
203            .into(),
204    ))
205}
206
207/// Derive the timeout queue storage key.
208fn timeout_queue_key() -> B256 {
209    let retryables_key = derive_subspace_key(ROOT_STORAGE_KEY, RETRYABLES_SUBSPACE);
210    derive_subspace_key(retryables_key.as_slice(), TIMEOUT_QUEUE_KEY)
211}
212
213/// Queue Put: reads nextPutOffset (slot 0), writes the value at that offset, increments
214/// nextPutOffset.
215fn queue_put(input: &mut PrecompileInput<'_>, value: B256) -> Result<(), PrecompileError> {
216    let queue_key = timeout_queue_key();
217
218    // nextPutOffset is at offset 0 within the queue sub-storage.
219    let put_offset_slot = map_slot(queue_key.as_slice(), 0);
220    let put_offset = sload_field(input, put_offset_slot)?;
221    let put_offset_u64: u64 = put_offset
222        .try_into()
223        .map_err(|_| PrecompileError::other("invalid queue put offset"))?;
224
225    // Store the value at map_slot_b256(queue_key, value_as_key) using the offset as key.
226    let item_slot = map_slot(queue_key.as_slice(), put_offset_u64);
227    sstore_field(input, item_slot, U256::from_be_bytes(value.0))?;
228
229    // Increment nextPutOffset.
230    sstore_field(input, put_offset_slot, U256::from(put_offset_u64 + 1))?;
231
232    Ok(())
233}
234
235fn handle_get_beneficiary(input: &mut PrecompileInput<'_>, ticket_id: B256) -> PrecompileResult {
236    let gas_limit = input.gas;
237    let current_timestamp: u64 = input
238        .internals()
239        .block_timestamp()
240        .try_into()
241        .unwrap_or(u64::MAX);
242
243    load_arbos(input)?;
244
245    let ticket_key = match open_retryable(input, ticket_id, current_timestamp)? {
246        Some(k) => k,
247        None => {
248            let data = IArbRetryableTx::NoTicketWithID {}.abi_encode();
249            return crate::sol_error_revert(data, gas_limit);
250        }
251    };
252
253    let beneficiary_slot = map_slot(ticket_key.as_slice(), BENEFICIARY_OFFSET);
254    let beneficiary = sload_field(input, beneficiary_slot)?;
255
256    // OAS(1) + OpenRetryable timeout(1) + beneficiary(1) + argsCost(3) + resultCost(3).
257    Ok(PrecompileOutput::new(
258        (3 * SLOAD_GAS + 2 * COPY_GAS).min(gas_limit),
259        beneficiary.to_be_bytes::<32>().to_vec().into(),
260    ))
261}
262
263/// Redeem validates the retryable, increments numTries, donates remaining gas
264/// to the retry tx, and emits a RedeemScheduled event. The block executor
265/// discovers the event in the execution logs and schedules the retry tx.
266fn handle_redeem(input: &mut PrecompileInput<'_>, ticket_id: B256) -> PrecompileResult {
267    let gas_limit = input.gas;
268    let caller = input.caller;
269    let current_timestamp: u64 = input
270        .internals()
271        .block_timestamp()
272        .try_into()
273        .unwrap_or(u64::MAX);
274
275    // Guard: cannot redeem itself during its own retry execution.
276    {
277        let current_retryable = crate::get_current_retryable_id();
278        if !current_retryable.is_zero()
279            && B256::from(current_retryable.to_be_bytes::<32>()) == ticket_id
280        {
281            return Err(PrecompileError::other("retryable cannot redeem itself"));
282        }
283    }
284
285    // RetryableSizeBytes → OpenRetryable reads timeout (1 sload).
286    let ticket_key_pre = ticket_storage_key(ticket_id);
287    let timeout_slot = map_slot(ticket_key_pre.as_slice(), TIMEOUT_OFFSET);
288    let timeout_val = sload_field(input, timeout_slot)?;
289    let timeout_u64: u64 = timeout_val.try_into().unwrap_or(0);
290
291    let (_calldata_words, write_bytes, calldata_raw_size) =
292        if timeout_u64 == 0 || timeout_u64 < current_timestamp {
293            (0u64, 0u64, 0u64)
294        } else {
295            let calldata_sub = derive_subspace_key(ticket_key_pre.as_slice(), &[1]);
296            let calldata_size_slot = map_slot(calldata_sub.as_slice(), 0);
297            let calldata_size = sload_field(input, calldata_size_slot)?;
298            let calldata_size_u64: u64 = calldata_size.try_into().unwrap_or(0);
299            let cw = calldata_size_u64.div_ceil(32);
300            let nbytes = 6 * 32 + 32 + 32 * cw;
301            let wb = nbytes.div_ceil(32);
302            (cw, wb, calldata_size_u64)
303        };
304
305    const PARAMS_SLOAD_GAS: u64 = 50;
306    let retryable_size_gas = PARAMS_SLOAD_GAS.saturating_mul(write_bytes);
307    crate::charge_precompile_gas(retryable_size_gas);
308
309    // OpenRetryable reads timeout again (second sload).
310    let timeout_val2 = sload_field(input, timeout_slot)?;
311    let timeout_u64_2: u64 = timeout_val2.try_into().unwrap_or(0);
312    if timeout_u64_2 == 0 || timeout_u64_2 < current_timestamp {
313        let data = IArbRetryableTx::NoTicketWithID {}.abi_encode();
314        return crate::sol_error_revert(data, gas_limit);
315    }
316
317    let num_tries_slot = map_slot(ticket_key_pre.as_slice(), NUM_TRIES_OFFSET);
318    let num_tries = sload_field(input, num_tries_slot)?;
319    crate::charge_precompile_gas(SSTORE_GAS);
320    let nonce: u64 = num_tries.try_into().unwrap_or(0);
321    let internals = input.internals_mut();
322    internals
323        .sstore(ARBOS_STATE_ADDRESS, num_tries_slot, U256::from(nonce + 1))
324        .map_err(|_| PrecompileError::other("sstore failed"))?;
325
326    // MakeTx reads: from + to + callvalue + GetBytes(size + floor(len/32) loop + trailing)
327    let make_tx_reads = 5 + calldata_raw_size / 32;
328    crate::charge_precompile_gas(make_tx_reads * SLOAD_GAS);
329
330    // Compute deterministic retry tx hash: keccak256(ticket_id || nonce).
331    let mut hash_input = [0u8; 64];
332    hash_input[..32].copy_from_slice(ticket_id.as_slice());
333    hash_input[32..].copy_from_slice(&U256::from(nonce).to_be_bytes::<32>());
334    let retry_tx_hash = keccak256(hash_input);
335
336    let backlog_reservation = compute_backlog_update_cost(input)?;
337
338    let gas_used_so_far = crate::get_precompile_gas();
339
340    let future_gas_costs = REDEEM_SCHEDULED_EVENT_COST + COPY_GAS + backlog_reservation;
341    let gas_remaining = gas_limit.saturating_sub(gas_used_so_far);
342    if gas_remaining < future_gas_costs + TX_GAS {
343        return Err(PrecompileError::other(
344            "not enough gas to run redeem attempt",
345        ));
346    }
347    let gas_to_donate = gas_remaining - future_gas_costs;
348
349    let actual_backlog_cost = compute_actual_backlog_cost(input)?;
350
351    let max_refund = U256::MAX;
352    let submission_fee_refund = U256::ZERO;
353
354    // Emit RedeemScheduled event.
355    let topic0 = redeem_scheduled_topic();
356    let topic1 = ticket_id;
357    let topic2 = B256::from(retry_tx_hash);
358    let mut seq_bytes = [0u8; 32];
359    seq_bytes[24..32].copy_from_slice(&nonce.to_be_bytes());
360    let topic3 = B256::from(seq_bytes);
361
362    let mut event_data = Vec::with_capacity(128);
363    event_data.extend_from_slice(&U256::from(gas_to_donate).to_be_bytes::<32>());
364    event_data.extend_from_slice(&B256::left_padding_from(caller.as_slice()).0);
365    event_data.extend_from_slice(&max_refund.to_be_bytes::<32>());
366    event_data.extend_from_slice(&submission_fee_refund.to_be_bytes::<32>());
367
368    let internals = input.internals_mut();
369    internals.log(Log::new_unchecked(
370        ARBRETRYABLETX_ADDRESS,
371        vec![topic0, topic1, topic2, topic3],
372        event_data.into(),
373    ));
374
375    // Total gas = pre-donate charges + event + donated gas + reserved backlog + resultCost
376    let total_gas = gas_used_so_far
377        + REDEEM_SCHEDULED_EVENT_COST
378        + gas_to_donate
379        + actual_backlog_cost
380        + COPY_GAS;
381
382    Ok(PrecompileOutput::new(
383        total_gas.min(gas_limit),
384        retry_tx_hash.to_vec().into(),
385    ))
386}
387
388fn compute_actual_backlog_cost(input: &mut PrecompileInput<'_>) -> Result<u64, PrecompileError> {
389    use arb_chainspec::arbos_version as arb_ver;
390    let arbos_version = crate::get_arbos_version();
391    if arbos_version >= arb_ver::ARBOS_VERSION_MULTI_GAS_CONSTRAINTS {
392        return Ok(arbos::l2_pricing::MULTI_CONSTRAINT_STATIC_BACKLOG_UPDATE_COST);
393    }
394    if arbos_version >= arb_ver::ARBOS_VERSION_MULTI_CONSTRAINT_FIX {
395        let len = read_gas_constraints_length_free(input)?;
396        if len > 0 {
397            return Ok(2 * SLOAD_GAS + len.saturating_mul(SLOAD_GAS + SSTORE_RESET_GAS));
398        }
399    }
400    Ok(SLOAD_GAS + SSTORE_GAS)
401}
402
403fn compute_backlog_update_cost(input: &mut PrecompileInput<'_>) -> Result<u64, PrecompileError> {
404    use arb_chainspec::arbos_version as arb_ver;
405    let arbos_version = crate::get_arbos_version();
406    if arbos_version >= arb_ver::ARBOS_VERSION_MULTI_GAS_CONSTRAINTS {
407        return Ok(arbos::l2_pricing::MULTI_CONSTRAINT_STATIC_BACKLOG_UPDATE_COST);
408    }
409
410    let mut result = 0u64;
411    if arbos_version >= arb_ver::ARBOS_VERSION_50 {
412        result += SLOAD_GAS;
413    }
414    if arbos_version >= arb_ver::ARBOS_VERSION_MULTI_CONSTRAINT_FIX {
415        let len = read_gas_constraints_length(input)?;
416        if len > 0 {
417            result += SLOAD_GAS;
418            result += len.saturating_mul(SLOAD_GAS + SSTORE_GAS);
419            return Ok(result);
420        }
421    }
422    result += SLOAD_GAS + SSTORE_GAS;
423    Ok(result)
424}
425
426fn read_gas_constraints_length_free(
427    input: &mut PrecompileInput<'_>,
428) -> Result<u64, PrecompileError> {
429    let l2_subspace_key = derive_subspace_key(ROOT_STORAGE_KEY, L2_PRICING_SUBSPACE);
430    let gas_constraints_subspace_key = derive_subspace_key(l2_subspace_key.as_slice(), &[0]);
431    let len_slot = vector_length_slot(&gas_constraints_subspace_key);
432    let val = input
433        .internals_mut()
434        .sload(ARBOS_STATE_ADDRESS, len_slot)
435        .map_err(|_| PrecompileError::other("sload failed"))?;
436    Ok(val.data.try_into().unwrap_or(0))
437}
438
439fn read_gas_constraints_length(input: &mut PrecompileInput<'_>) -> Result<u64, PrecompileError> {
440    let l2_subspace_key = derive_subspace_key(ROOT_STORAGE_KEY, L2_PRICING_SUBSPACE);
441    let gas_constraints_subspace_key = derive_subspace_key(l2_subspace_key.as_slice(), &[0]);
442    let len_slot = vector_length_slot(&gas_constraints_subspace_key);
443    let val = sload_field(input, len_slot)?;
444    Ok(val.try_into().unwrap_or(0))
445}
446
447/// Keepalive adds one lifetime period to the ticket's expiry.
448///
449/// Opens the retryable, verifies effective timeout isn't too far in the future,
450/// adds a duplicate entry to the timeout queue, and increments timeout_windows_left.
451fn handle_keepalive(input: &mut PrecompileInput<'_>, ticket_id: B256) -> PrecompileResult {
452    let gas_limit = input.gas;
453    let current_timestamp: u64 = input
454        .internals()
455        .block_timestamp()
456        .try_into()
457        .unwrap_or(u64::MAX);
458
459    load_arbos(input)?;
460
461    let ticket_key = match open_retryable(input, ticket_id, current_timestamp)? {
462        Some(k) => k,
463        None => {
464            let data = IArbRetryableTx::NoTicketWithID {}.abi_encode();
465            return crate::sol_error_revert(data, gas_limit);
466        }
467    };
468
469    // Read calldata size for updateCost computation (RetryableSizeBytes).
470    let calldata_sub = derive_subspace_key(ticket_key.as_slice(), &[1]);
471    let calldata_size_slot = map_slot(calldata_sub.as_slice(), 0);
472    let calldata_size = sload_field(input, calldata_size_slot)?;
473    let calldata_size_u64: u64 = calldata_size.try_into().unwrap_or(0);
474
475    // Read timeout and timeout_windows_left to compute effective timeout.
476    let timeout_slot = map_slot(ticket_key.as_slice(), TIMEOUT_OFFSET);
477    let timeout = sload_field(input, timeout_slot)?;
478    let timeout_u64: u64 = timeout
479        .try_into()
480        .map_err(|_| PrecompileError::other("invalid timeout"))?;
481
482    let windows_slot = map_slot(ticket_key.as_slice(), TIMEOUT_WINDOWS_LEFT_OFFSET);
483    let windows = sload_field(input, windows_slot)?;
484    let windows_u64: u64 = windows
485        .try_into()
486        .map_err(|_| PrecompileError::other("invalid windows"))?;
487
488    let effective_timeout = timeout_u64 + windows_u64 * RETRYABLE_LIFETIME_SECONDS;
489
490    // The window limit is current_time + one lifetime.
491    let window_limit = current_timestamp + RETRYABLE_LIFETIME_SECONDS;
492    if effective_timeout > window_limit {
493        return Err(PrecompileError::other("timeout too far into the future"));
494    }
495
496    // Put the ticket into the timeout queue (duplicate entry for the new window).
497    queue_put(input, ticket_id)?;
498
499    // Increment timeout_windows_left.
500    let new_windows = windows_u64 + 1;
501    sstore_field(input, windows_slot, U256::from(new_windows))?;
502
503    let new_timeout = effective_timeout + RETRYABLE_LIFETIME_SECONDS;
504
505    // Emit LifetimeExtended(bytes32 indexed ticketId, uint256 newTimeout).
506    let topic0 = lifetime_extended_topic();
507    let mut event_data = Vec::with_capacity(32);
508    event_data.extend_from_slice(&U256::from(new_timeout).to_be_bytes::<32>());
509    input.internals_mut().log(Log::new_unchecked(
510        ARBRETRYABLETX_ADDRESS,
511        vec![topic0, ticket_id],
512        event_data.into(),
513    ));
514
515    // 8 SLOADs + 3 SSTOREs + argsCost(3) + updateCost + event(1381)
516    // + RetryableReapPrice(58000) + resultCost(3).
517    // updateCost = WordsForBytes(nbytes) * SstoreSetGas/100, where
518    // nbytes = 6*32 + 32 + 32*WordsForBytes(calldataSize).
519    let calldata_words = calldata_size_u64.div_ceil(32);
520    let nbytes = 6 * 32 + 32 + 32 * calldata_words;
521    let update_cost = nbytes.div_ceil(32) * (SSTORE_GAS / 100);
522    let event_cost = LOG_GAS + 2 * LOG_TOPIC_GAS + LOG_DATA_GAS * 32;
523    let gas_used = 8 * SLOAD_GAS
524        + 3 * SSTORE_GAS
525        + 2 * COPY_GAS
526        + update_cost
527        + event_cost
528        + RETRYABLE_REAP_PRICE;
529
530    Ok(PrecompileOutput::new(
531        gas_used.min(gas_limit),
532        U256::from(new_timeout).to_be_bytes::<32>().to_vec().into(),
533    ))
534}
535
536/// Cancel the ticket and refund its callvalue to its beneficiary.
537///
538/// Verifies the caller is the beneficiary, then clears all storage fields.
539/// Balance transfer (escrow → beneficiary) is handled by the executor.
540fn handle_cancel(input: &mut PrecompileInput<'_>, ticket_id: B256) -> PrecompileResult {
541    let gas_limit = input.gas;
542    let caller = input.caller;
543    let current_timestamp: u64 = input
544        .internals()
545        .block_timestamp()
546        .try_into()
547        .unwrap_or(u64::MAX);
548
549    load_arbos(input)?;
550
551    let ticket_key = match open_retryable(input, ticket_id, current_timestamp)? {
552        Some(k) => k,
553        None => {
554            let data = IArbRetryableTx::NoTicketWithID {}.abi_encode();
555            return crate::sol_error_revert(data, gas_limit);
556        }
557    };
558
559    // Read beneficiary and verify caller is the beneficiary.
560    let beneficiary_slot = map_slot(ticket_key.as_slice(), BENEFICIARY_OFFSET);
561    let beneficiary = sload_field(input, beneficiary_slot)?;
562
563    // The caller address is left-padded with zeros in 20 bytes.
564    let caller_u256 = U256::from_be_slice(caller.as_slice());
565    if caller_u256 != beneficiary {
566        return Err(PrecompileError::other(
567            "only the beneficiary may cancel a retryable",
568        ));
569    }
570
571    // Clear all storage fields for this retryable ticket (DeleteRetryable).
572    let offsets = [
573        NUM_TRIES_OFFSET,
574        FROM_OFFSET,
575        TO_OFFSET,
576        CALLVALUE_OFFSET,
577        BENEFICIARY_OFFSET,
578        TIMEOUT_OFFSET,
579        TIMEOUT_WINDOWS_LEFT_OFFSET,
580    ];
581    for offset in offsets {
582        let slot = map_slot(ticket_key.as_slice(), offset);
583        sstore_field(input, slot, U256::ZERO)?;
584    }
585
586    // Clear calldata bytes (ClearBytes on calldata sub-storage).
587    let calldata_sub = derive_subspace_key(ticket_key.as_slice(), &[1]);
588    let calldata_size_slot = map_slot(calldata_sub.as_slice(), 0);
589    let calldata_size = sload_field(input, calldata_size_slot)?;
590    let calldata_size_u64: u64 = calldata_size.try_into().unwrap_or(0);
591    let calldata_words = calldata_size_u64.div_ceil(32);
592    if calldata_size_u64 > 0 {
593        for i in 0..calldata_words {
594            let word_slot = map_slot(calldata_sub.as_slice(), 1 + i);
595            sstore_field(input, word_slot, U256::ZERO)?;
596        }
597        sstore_field(input, calldata_size_slot, U256::ZERO)?;
598    }
599
600    // Emit Canceled(bytes32 indexed ticketId).
601    input.internals_mut().log(Log::new_unchecked(
602        ARBRETRYABLETX_ADDRESS,
603        vec![canceled_topic(), ticket_id],
604        Default::default(),
605    ));
606
607    // 6 SLOADs + 7 × ClearByUint64(5000) + ClearBytes(variable)
608    // + Canceled event (LOG2: 375+2*375=1125) + argsCost(3).
609    let clear_bytes_cost = if calldata_size_u64 > 0 {
610        (calldata_words + 1) * SSTORE_ZERO_GAS
611    } else {
612        0
613    };
614    let event_cost = LOG_GAS + 2 * LOG_TOPIC_GAS;
615    let gas_used = 6 * SLOAD_GAS + 7 * SSTORE_ZERO_GAS + clear_bytes_cost + event_cost + COPY_GAS;
616
617    Ok(PrecompileOutput::new(
618        gas_used.min(gas_limit),
619        Vec::new().into(),
620    ))
621}