Skip to main content

forge_script/
recovery.rs

1use crate::{
2    multi_sequence::MultiChainSequence,
3    sequence::{SequenceData, completed_transaction_prefix, operation_ordinal},
4};
5use alloy_consensus::{Transaction, transaction::SignerRecoverable};
6use alloy_eips::eip2718::{Decodable2718, Encodable2718};
7use alloy_network::{Network, ReceiptResponse, TransactionBuilder, TransactionResponse};
8use alloy_primitives::{Address, B256, Bytes, TxKind, keccak256};
9use eyre::{ContextCompat, Result, WrapErr, bail};
10use forge_script_sequence::{ScriptSequence, TransactionWithMetadata, now};
11use foundry_common::{FoundryTransactionBuilder, TransactionMaybeSigned};
12use serde::{Deserialize, Serialize};
13use std::{
14    fs::{File, OpenOptions},
15    io::{BufWriter, Write},
16    path::{Path, PathBuf},
17};
18use tempfile::NamedTempFile;
19use tempo_primitives::transaction::{TEMPO_EXPIRING_NONCE_KEY, TempoTxEnvelope};
20
21#[cfg(unix)]
22use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
23
24const RECOVERY_VERSION: u32 = 1;
25
26#[derive(Clone, Serialize, Deserialize)]
27#[serde(bound(
28    serialize = "N::TxEnvelope: Serialize",
29    deserialize = "N::TxEnvelope: for<'de2> Deserialize<'de2>"
30))]
31struct RecoveryPlan<N: Network> {
32    version: u32,
33    generation: B256,
34    multi: bool,
35    batch: bool,
36    /// Tempo sponsor of the run that created the snapshot. Resume must not prepare new
37    /// transactions without it.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    sponsor: Option<Address>,
40    deployments: Vec<RecoveryDeployment<N>>,
41    data: SequenceData<N>,
42}
43
44#[derive(Clone, Serialize, Deserialize)]
45#[serde(bound = "")]
46struct RecoveryDeployment<N: Network> {
47    chain: u64,
48    batch_id: Option<u32>,
49    operations: Vec<RecoveryOperation>,
50    #[serde(default, skip_serializing_if = "Vec::is_empty")]
51    attempts: Vec<SubmissionAttempt<N::TransactionRequest>>,
52}
53
54#[derive(Clone, Serialize, Deserialize)]
55struct RecoveryOperation {
56    id: OperationId,
57    fingerprint: B256,
58    rpc: String,
59}
60
61#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62struct OperationId {
63    sequence: u32,
64    index: u32,
65}
66
67#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
68pub(crate) struct SignedPayload {
69    pub(crate) payload: Bytes,
70    pub(crate) hash: B256,
71}
72
73#[derive(Clone, Serialize, Deserialize)]
74struct SubmissionAttempt<T> {
75    id: B256,
76    members: Vec<OperationId>,
77    kind: AttemptKind<T>,
78}
79
80#[derive(Clone, Serialize, Deserialize)]
81#[serde(tag = "kind", rename_all = "camelCase")]
82pub(crate) enum AttemptKind<T> {
83    Signed { request: Option<T>, payload: SignedPayload },
84    Delegated { request: T, status: DelegatedStatus },
85    Legacy { hash: B256 },
86}
87
88#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(tag = "status", rename_all = "camelCase")]
90pub(crate) enum DelegatedStatus {
91    Prepared,
92    Pending { hash: B256 },
93    OutcomeUnknown,
94}
95
96/// Owns the authoritative recovery snapshot and its process-lifetime writer lock.
97pub(crate) struct RecoveryStore<N: Network> {
98    path: PathBuf,
99    _lock: RecoveryLock,
100    plan: RecoveryPlan<N>,
101}
102
103pub(crate) struct RecoveryLock {
104    path: PathBuf,
105    _files: Vec<File>,
106}
107
108pub(crate) struct RecoveryRelocation {
109    lock: RecoveryLock,
110}
111
112impl<N: Network> RecoveryStore<N>
113where
114    N::TxEnvelope: for<'de> Deserialize<'de> + Serialize,
115{
116    pub(crate) const fn data(&self) -> &SequenceData<N> {
117        &self.plan.data
118    }
119
120    pub(crate) const fn data_mut(&mut self) -> &mut SequenceData<N> {
121        &mut self.plan.data
122    }
123
124    pub(crate) const fn is_batch(&self) -> bool {
125        self.plan.batch
126    }
127
128    pub(crate) const fn sponsor(&self) -> Option<Address> {
129        self.plan.sponsor
130    }
131
132    pub(crate) fn create(
133        mut data: SequenceData<N>,
134        batch: bool,
135        sponsor: Option<Address>,
136    ) -> Result<Self> {
137        let paths = data.paths();
138        let lock = RecoveryLock::acquire(&paths)?;
139        let pending = pending_path(&lock.path);
140        if pending.exists() {
141            bail!(
142                "uncommitted recovery snapshot `{}` already exists; resume it before starting a new execution",
143                pending.display()
144            );
145        }
146        if previous_unfinished::<N>(&lock.path, &paths, data.is_multi()) {
147            bail!(
148                "the previous broadcast `{}` has unfinished operations; finish it with `{}`, or pass `--start-fresh` to archive it and broadcast anew",
149                paths.0.display(),
150                if data.is_multi() { "--multi --resume" } else { "--resume" }
151            );
152        }
153        let generation = B256::random();
154        data.set_recovery_generation(generation);
155        let plan = RecoveryPlan { sponsor, ..RecoveryPlan::new(data, batch, generation)? };
156        write_snapshot(&lock.path, &plan)?;
157        Self::finish_open(lock, plan)
158    }
159
160    /// Moves the unfinished progress of a previous execution of `data` to timestamped names, so
161    /// that a new execution can replace it.
162    pub(crate) fn archive_unfinished(data: &SequenceData<N>) -> Result<()> {
163        let paths = &data.paths();
164        let lock = RecoveryLock::acquire(paths)?;
165        // A crash mid-commit leaves the newest complete snapshot pending, as resume would find it.
166        let pending = pending_path(&lock.path);
167        if pending.exists() {
168            commit_pending_plan(&pending, &lock.path)?;
169        }
170        if !previous_unfinished::<N>(&lock.path, paths, data.is_multi()) {
171            return Ok(());
172        }
173        let timestamp = now().as_millis();
174        for path in [&lock.path, &paths.0, &paths.1] {
175            if path.exists() {
176                let archive = path.with_extension(format!("{timestamp}.json"));
177                std::fs::rename(path, &archive)?;
178                sh_status!("Archived `{}` to `{}`", path.display(), archive.display())?;
179            }
180        }
181        Ok(())
182    }
183
184    pub(crate) fn load(
185        paths: &(PathBuf, PathBuf),
186        batch: bool,
187        lock: RecoveryLock,
188    ) -> Result<Option<Self>>
189    where
190        N::TxEnvelope: SignerRecoverable,
191        N::TransactionRequest: FoundryTransactionBuilder<N>,
192    {
193        let pending = pending_path(&lock.path);
194        let (mut plan, recover_pending) = if pending.exists() {
195            (load_plan(&pending)?, true)
196        } else if lock.path.exists() {
197            (load_plan(&lock.path)?, false)
198        } else {
199            return Ok(None);
200        };
201        plan.restore_sensitive();
202        plan.data.set_paths(paths.clone());
203        plan.validate(batch)?;
204        plan.validate_signed_payloads()?;
205        if recover_pending {
206            commit_pending_plan(&pending, &lock.path)?;
207        }
208        Self::finish_open(lock, plan).map(Some)
209    }
210
211    pub(crate) fn import(mut data: SequenceData<N>, batch: bool, lock: RecoveryLock) -> Result<Self>
212    where
213        N::TxEnvelope: SignerRecoverable,
214        N::TransactionRequest: FoundryTransactionBuilder<N>,
215    {
216        let generation = B256::random();
217        data.set_recovery_generation(generation);
218        let mut plan = RecoveryPlan::new(data, batch, generation)?;
219        if batch {
220            plan.import_legacy_batch_attempts()?;
221        }
222        plan.validate_signed_payloads()?;
223        write_snapshot(&lock.path, &plan)?;
224        Self::finish_open(lock, plan)
225    }
226
227    fn finish_open(lock: RecoveryLock, plan: RecoveryPlan<N>) -> Result<Self> {
228        #[cfg(unix)]
229        for path in [&lock.path, &pending_path(&lock.path)] {
230            if path.exists() {
231                std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
232            }
233        }
234        Ok(Self { path: lock.path.clone(), _lock: lock, plan })
235    }
236
237    pub(crate) fn save(&self) -> Result<()> {
238        self.plan.validate(self.plan.batch)?;
239        write_snapshot(&self.path, &self.plan)
240    }
241
242    fn submission_attempts(
243        &self,
244        sequence: usize,
245        index: usize,
246    ) -> impl Iterator<Item = &SubmissionAttempt<N::TransactionRequest>> {
247        let operation = self
248            .plan
249            .deployments
250            .get(sequence)
251            .and_then(|deployment| deployment.operations.get(index))
252            .map(|operation| operation.id);
253        self.plan
254            .deployments
255            .get(sequence)
256            .into_iter()
257            .flat_map(|deployment| &deployment.attempts)
258            .filter(move |attempt| operation.is_some_and(|id| attempt.members.contains(&id)))
259    }
260
261    pub(crate) fn signed_payload(&self, sequence: usize, index: usize) -> Option<&SignedPayload> {
262        self.submission_attempts(sequence, index).find_map(|attempt| match &attempt.kind {
263            AttemptKind::Signed { payload, .. } => Some(payload),
264            AttemptKind::Delegated { .. } | AttemptKind::Legacy { .. } => None,
265        })
266    }
267
268    pub(crate) fn delegated_status(
269        &self,
270        sequence: usize,
271        index: usize,
272    ) -> Option<DelegatedStatus> {
273        self.submission_attempts(sequence, index).find_map(|attempt| match &attempt.kind {
274            AttemptKind::Delegated { status, .. } => Some(*status),
275            AttemptKind::Signed { .. } | AttemptKind::Legacy { .. } => None,
276        })
277    }
278
279    pub(crate) fn submission_hashes(&self, sequence: usize) -> (Vec<B256>, Vec<B256>) {
280        let mut durable = Vec::new();
281        let mut replayable = Vec::new();
282        for attempt in self
283            .plan
284            .deployments
285            .get(sequence)
286            .into_iter()
287            .flat_map(|deployment| &deployment.attempts)
288        {
289            match &attempt.kind {
290                AttemptKind::Signed { payload, .. } => {
291                    durable.push(payload.hash);
292                    replayable.push(payload.hash);
293                }
294                AttemptKind::Delegated { status: DelegatedStatus::Pending { hash }, .. } => {
295                    durable.push(*hash);
296                }
297                AttemptKind::Legacy { hash } => durable.push(*hash),
298                AttemptKind::Delegated { .. } => {}
299            }
300        }
301        (durable, replayable)
302    }
303
304    pub(crate) fn batch_attempt(
305        &self,
306        sequence: usize,
307    ) -> Option<(usize, B256, &AttemptKind<N::TransactionRequest>)> {
308        if !self.plan.batch {
309            return None;
310        }
311        let attempt = self.plan.deployments.get(sequence)?.attempts.first()?;
312        Some((attempt.members.first()?.index as usize, attempt.id, &attempt.kind))
313    }
314
315    pub(crate) fn persist_signed_payload(
316        &mut self,
317        sequence: usize,
318        index: usize,
319        payload: Bytes,
320        sender_scoped_replay: bool,
321    ) -> Result<B256>
322    where
323        N::TxEnvelope: SignerRecoverable,
324        N::TransactionRequest: FoundryTransactionBuilder<N>,
325    {
326        let deployment = self
327            .plan
328            .deployments
329            .get(sequence)
330            .context("signed payload deployment is not in the recovery snapshot")?;
331        let operation = deployment
332            .operations
333            .get(index)
334            .context("signed payload operation is not in the recovery snapshot")?;
335        let transaction = &self.plan.data.sequences()[sequence].transactions[index];
336        let signed = validate_signed_payload::<N>(
337            payload,
338            transaction,
339            deployment.chain,
340            index,
341            operation_ordinal(self.plan.data.sequences(), sequence, index),
342        )?;
343        let id = operation.id;
344        // From T1B, expiring replay identities exclude sponsor signatures, so different envelopes
345        // can still be replays. Compare identities across all sequences of the same chain before
346        // recording an attempt the node would reject. Keep envelope hashes for receipt tracking.
347        let replay_hash = expiring_replay_hash(&signed.payload, sender_scoped_replay)?;
348        for attempt in self
349            .plan
350            .deployments
351            .iter()
352            .enumerate()
353            .filter(|(i, other)| {
354                *i == sequence || (replay_hash.is_some() && other.chain == deployment.chain)
355            })
356            .flat_map(|(_, other)| &other.attempts)
357        {
358            if !attempt.members.contains(&id)
359                && let AttemptKind::Signed { payload, .. } = &attempt.kind
360                && match replay_hash {
361                    Some(hash) => {
362                        expiring_replay_hash(&payload.payload, sender_scoped_replay)? == Some(hash)
363                    }
364                    None => payload.hash == signed.hash,
365                }
366            {
367                bail!(
368                    "transaction {index} is identical to an earlier transaction of this script ({}) and would be rejected as a replay; identical Tempo expiring nonce transactions are only distinct from the T12 hardfork (TIP-1106)",
369                    signed.hash
370                );
371            }
372        }
373        if let Some(existing) = self.signed_payload(sequence, index) {
374            if existing != &signed {
375                bail!("refusing to replace an existing signed payload");
376            }
377            return Ok(existing.hash);
378        }
379        if deployment.attempts.iter().any(|attempt| attempt.members.contains(&id)) {
380            bail!("refusing to replace an existing submission attempt");
381        }
382        let hash = signed.hash;
383        self.persist_attempt(
384            sequence,
385            SubmissionAttempt {
386                id: B256::random(),
387                members: vec![id],
388                kind: AttemptKind::Signed { request: None, payload: signed },
389            },
390        )?;
391        Ok(hash)
392    }
393
394    pub(crate) fn persist_delegated_request(
395        &mut self,
396        sequence: usize,
397        index: usize,
398        request: N::TransactionRequest,
399    ) -> Result<()> {
400        let deployment = self
401            .plan
402            .deployments
403            .get(sequence)
404            .context("delegated operation is not in the recovery snapshot")?;
405        let id = deployment
406            .operations
407            .get(index)
408            .context("delegated operation is not in the recovery snapshot")?
409            .id;
410        if deployment.attempts.iter().any(|attempt| attempt.members.contains(&id)) {
411            bail!("refusing to replace an existing submission attempt");
412        }
413        self.persist_attempt(
414            sequence,
415            SubmissionAttempt {
416                id: B256::random(),
417                members: vec![id],
418                kind: AttemptKind::Delegated { request, status: DelegatedStatus::Prepared },
419            },
420        )
421    }
422
423    pub(crate) fn persist_delegated_status(
424        &mut self,
425        sequence: usize,
426        index: usize,
427        status: DelegatedStatus,
428    ) -> Result<()> {
429        let deployment = self
430            .plan
431            .deployments
432            .get_mut(sequence)
433            .context("delegated operation is not in the recovery snapshot")?;
434        let id = deployment
435            .operations
436            .get(index)
437            .context("delegated operation is not in the recovery snapshot")?
438            .id;
439        let attempt = deployment
440            .attempts
441            .iter_mut()
442            .find(|attempt| attempt.members.contains(&id))
443            .context("delegated operation has no submission attempt")?;
444        let AttemptKind::Delegated { status: current, .. } = &mut attempt.kind else {
445            bail!("operation has no delegated submission attempt");
446        };
447        let previous = *current;
448        if previous == status {
449            return Ok(());
450        }
451        *current = status;
452        if let Err(error) = write_snapshot(&self.path, &self.plan) {
453            let AttemptKind::Delegated { status, .. } = &mut self.plan.deployments[sequence]
454                .attempts
455                .iter_mut()
456                .find(|attempt| attempt.members.contains(&id))
457                .unwrap()
458                .kind
459            else {
460                unreachable!()
461            };
462            *status = previous;
463            return Err(error);
464        }
465        Ok(())
466    }
467
468    pub(crate) fn clear_delegated_request(&mut self, sequence: usize, index: usize) -> Result<()> {
469        let deployment = self
470            .plan
471            .deployments
472            .get_mut(sequence)
473            .context("delegated operation is not in the recovery snapshot")?;
474        let id = deployment
475            .operations
476            .get(index)
477            .context("delegated operation is not in the recovery snapshot")?
478            .id;
479        let position = deployment
480            .attempts
481            .iter()
482            .position(|attempt| {
483                attempt.members.contains(&id)
484                    && matches!(&attempt.kind, AttemptKind::Delegated { .. })
485            })
486            .context("delegated operation has no submission attempt")?;
487        let attempt = deployment.attempts.remove(position);
488        if let Err(error) = write_snapshot(&self.path, &self.plan) {
489            self.plan.deployments[sequence].attempts.insert(position, attempt);
490            return Err(error);
491        }
492        Ok(())
493    }
494
495    pub(crate) fn delegated_attempt_location(&self, id: B256) -> Option<(usize, usize)> {
496        self.plan.deployments.iter().enumerate().find_map(|(sequence, deployment)| {
497            let attempt = deployment.attempts.iter().find(|attempt| {
498                attempt.id == id && matches!(attempt.kind, AttemptKind::Delegated { .. })
499            })?;
500            let operation = attempt.members.first()?;
501            Some((sequence, operation.index as usize))
502        })
503    }
504
505    pub(crate) fn delegated_attempts(&self) -> Vec<(usize, usize, B256, DelegatedStatus)> {
506        self.plan
507            .deployments
508            .iter()
509            .enumerate()
510            .flat_map(|(sequence, deployment)| {
511                deployment.attempts.iter().filter_map(move |attempt| {
512                    let AttemptKind::Delegated { status, .. } = &attempt.kind else { return None };
513                    Some((sequence, attempt.members.first()?.index as usize, attempt.id, *status))
514                })
515            })
516            .collect()
517    }
518
519    pub(crate) fn resolve_delegated_hash(
520        &mut self,
521        attempt_id: B256,
522        hash: B256,
523        transaction: &N::TransactionResponse,
524    ) -> Result<(usize, usize)>
525    where
526        N::TransactionRequest: FoundryTransactionBuilder<N>,
527    {
528        let (sequence, index) = self
529            .delegated_attempt_location(attempt_id)
530            .context("no interrupted delegated submission matches --resume-attempt")?;
531        let deployment = &self.plan.deployments[sequence];
532        let attempt = deployment.attempts.iter().find(|attempt| attempt.id == attempt_id).unwrap();
533        let AttemptKind::Delegated { request, status } = &attempt.kind else { unreachable!() };
534        if !matches!(status, DelegatedStatus::Prepared | DelegatedStatus::OutcomeUnknown) {
535            bail!("delegated submission attempt {attempt_id} does not require resolution");
536        }
537        validate_delegated_transaction::<N>(transaction, request, deployment.chain, hash)?;
538        self.persist_delegated_status(sequence, index, DelegatedStatus::Pending { hash })?;
539        Ok((sequence, index))
540    }
541
542    pub(crate) fn persist_batch_signed_payload(
543        &mut self,
544        sequence: usize,
545        first_operation: usize,
546        request: N::TransactionRequest,
547        payload: Bytes,
548    ) -> Result<B256>
549    where
550        N::TxEnvelope: Decodable2718 + Encodable2718,
551    {
552        let signed = SignedPayload { hash: signed_payload_hash::<N>(&payload)?, payload };
553        let hash = signed.hash;
554        self.persist_batch_attempt(
555            sequence,
556            first_operation,
557            AttemptKind::Signed { request: Some(request), payload: signed },
558        )?;
559        Ok(hash)
560    }
561
562    pub(crate) fn persist_batch_delegated_request(
563        &mut self,
564        sequence: usize,
565        first_operation: usize,
566        request: N::TransactionRequest,
567    ) -> Result<()> {
568        self.persist_batch_attempt(
569            sequence,
570            first_operation,
571            AttemptKind::Delegated { request, status: DelegatedStatus::Prepared },
572        )
573    }
574
575    fn persist_batch_attempt(
576        &mut self,
577        sequence: usize,
578        first_operation: usize,
579        kind: AttemptKind<N::TransactionRequest>,
580    ) -> Result<()> {
581        if self.batch_attempt(sequence).is_some() {
582            bail!("refusing to replace an existing batch submission attempt");
583        }
584        let members = self
585            .plan
586            .deployments
587            .get(sequence)
588            .context("batch deployment is not in the recovery snapshot")?
589            .operations
590            .get(first_operation..)
591            .context("batch operation is not in the recovery snapshot")?
592            .iter()
593            .map(|operation| operation.id)
594            .collect::<Vec<_>>();
595        if members.is_empty() {
596            bail!("batch submission has no operations");
597        }
598        self.persist_attempt(sequence, SubmissionAttempt { id: B256::random(), members, kind })
599    }
600
601    fn persist_attempt(
602        &mut self,
603        sequence: usize,
604        attempt: SubmissionAttempt<N::TransactionRequest>,
605    ) -> Result<()> {
606        self.plan
607            .deployments
608            .get_mut(sequence)
609            .context("submission deployment is not in the recovery snapshot")?
610            .attempts
611            .push(attempt);
612        if let Err(error) = write_snapshot(&self.path, &self.plan) {
613            self.plan.deployments[sequence].attempts.pop();
614            return Err(error);
615        }
616        Ok(())
617    }
618
619    pub(crate) fn prepare_relocation(
620        &self,
621        paths: &(PathBuf, PathBuf),
622    ) -> Result<RecoveryRelocation> {
623        let lock = RecoveryLock::acquire(paths)?;
624        let path = &lock.path;
625        if paths.0.exists() {
626            bail!(
627                "broadcast progress `{}` already exists; refusing to replace it",
628                paths.0.display()
629            );
630        }
631        if path.exists() {
632            let existing: RecoveryPlan<N> = foundry_common::fs::read_json_file(path)
633                .wrap_err_with(|| format!("recovery snapshot `{}` is corrupt", path.display()))?;
634            existing.validate(self.plan.batch)?;
635            self.plan.validate(self.plan.batch)?;
636            if serde_json::to_value(existing.deployments)?
637                != serde_json::to_value(&self.plan.deployments)?
638            {
639                bail!("destination recovery snapshot does not match the script operations");
640            }
641        }
642        write_snapshot(path, &self.plan)?;
643        Ok(RecoveryRelocation { lock })
644    }
645
646    pub(crate) fn commit_relocation(&mut self, relocation: RecoveryRelocation) -> Result<()> {
647        let RecoveryRelocation { lock } = relocation;
648        let path = &lock.path;
649        #[cfg(unix)]
650        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
651        self.path.clone_from(path);
652        self._lock = lock;
653        Ok(())
654    }
655}
656
657impl RecoveryLock {
658    pub(crate) fn acquire(paths: &(PathBuf, PathBuf)) -> Result<Self> {
659        let path = recovery_path_from_sensitive(&paths.1)?;
660        let mut lock_paths =
661            vec![path.with_extension("lock"), paths.0.with_extension("recovery.lock")];
662        lock_paths.sort();
663        lock_paths.dedup();
664        let mut files = Vec::with_capacity(lock_paths.len());
665        for lock_path in lock_paths {
666            if let Some(parent) = lock_path.parent() {
667                std::fs::create_dir_all(parent)?;
668            }
669            let mut options = OpenOptions::new();
670            options.read(true).write(true).create(true);
671            #[cfg(unix)]
672            options.mode(0o600);
673            let file = options.open(&lock_path)?;
674            #[cfg(unix)]
675            file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
676            file.try_lock().wrap_err_with(|| {
677                format!("another process is already modifying recovery state `{}`", path.display())
678            })?;
679            files.push(file);
680        }
681        Ok(Self { path, _files: files })
682    }
683}
684
685impl<N: Network> RecoveryPlan<N>
686where
687    N::TxEnvelope: for<'de> Deserialize<'de> + Serialize,
688{
689    fn new(data: SequenceData<N>, batch: bool, generation: B256) -> Result<Self> {
690        let deployments = data
691            .sequences()
692            .iter()
693            .enumerate()
694            .map(|(sequence, deployment)| {
695                Ok(RecoveryDeployment {
696                    chain: deployment.chain,
697                    batch_id: batch.then(|| u32::try_from(sequence).expect("too many sequences")),
698                    attempts: Vec::new(),
699                    operations: deployment
700                        .transactions
701                        .iter()
702                        .enumerate()
703                        .map(|(index, transaction)| {
704                            Ok(RecoveryOperation {
705                                id: OperationId {
706                                    sequence: u32::try_from(sequence).expect("too many sequences"),
707                                    index: u32::try_from(index).expect("too many operations"),
708                                },
709                                fingerprint: operation_fingerprint(transaction)?,
710                                rpc: transaction.rpc.clone(),
711                            })
712                        })
713                        .collect::<Result<Vec<_>>>()?,
714                })
715            })
716            .collect::<Result<Vec<_>>>()?;
717        Ok(Self {
718            version: RECOVERY_VERSION,
719            generation,
720            multi: data.is_multi(),
721            batch,
722            sponsor: None,
723            deployments,
724            data,
725        })
726    }
727
728    fn import_legacy_batch_attempts(&mut self) -> Result<()> {
729        for (deployment, data) in self.deployments.iter_mut().zip(self.data.sequences().iter()) {
730            let completed = completed_transaction_prefix(data)?;
731            if data.pending.is_empty() {
732                if data
733                    .transactions
734                    .iter()
735                    .skip(completed)
736                    .any(|transaction| transaction.hash.is_some())
737                {
738                    bail!("legacy batch progress has operation hashes without a pending hash");
739                }
740                continue;
741            }
742            let [hash] = data.pending.as_slice() else {
743                bail!("cannot import legacy batch progress with multiple pending hashes");
744            };
745            let first = data
746                .transactions
747                .iter()
748                .position(|transaction| transaction.hash == Some(*hash))
749                .context("legacy batch hash is not bound to a recovery operation")?;
750            if completed != first {
751                bail!("legacy batch progress omits an incomplete operation");
752            }
753            if data
754                .transactions
755                .iter()
756                .skip(first)
757                .any(|transaction| transaction.hash != Some(*hash))
758            {
759                bail!("cannot import inconsistent legacy batch operation hashes");
760            }
761            deployment.attempts.push(SubmissionAttempt {
762                id: B256::random(),
763                members: deployment.operations[first..]
764                    .iter()
765                    .map(|operation| operation.id)
766                    .collect(),
767                kind: AttemptKind::Legacy { hash: *hash },
768            });
769        }
770        Ok(())
771    }
772
773    fn validate(&self, batch: bool) -> Result<()> {
774        if self.version != RECOVERY_VERSION {
775            bail!(
776                "unsupported recovery snapshot version {}; expected version {}",
777                self.version,
778                RECOVERY_VERSION
779            );
780        }
781        if self.batch != batch || self.multi != self.data.is_multi() {
782            bail!("recovery snapshot does not match the requested script mode");
783        }
784        if self
785            .data
786            .sequences()
787            .iter()
788            .any(|sequence| sequence.recovery_generation != Some(self.generation))
789        {
790            bail!("recovery snapshot contains inconsistent generations");
791        }
792        let mut saved = self.deployments.clone();
793        for deployment in &mut saved {
794            deployment.attempts.clear();
795        }
796        let expected = Self::new(self.data.clone(), self.batch, self.generation)?;
797        if serde_json::to_value(saved)? != serde_json::to_value(&expected.deployments)? {
798            bail!("recovery snapshot does not match the script operations; refusing to resume");
799        }
800        Ok(())
801    }
802
803    fn validate_signed_payloads(&self) -> Result<()>
804    where
805        N::TxEnvelope: SignerRecoverable,
806        N::TransactionRequest: FoundryTransactionBuilder<N>,
807    {
808        for (sequence, deployment) in self.deployments.iter().enumerate() {
809            let data = &self.data.sequences()[sequence];
810            if self.batch && deployment.attempts.is_empty() {
811                let completed = completed_transaction_prefix(data)?;
812                if !data.pending.is_empty()
813                    || data
814                        .transactions
815                        .iter()
816                        .skip(completed)
817                        .any(|transaction| transaction.hash.is_some())
818                {
819                    bail!("batch progress exists without a durable submission attempt");
820                }
821            }
822            if self.batch && deployment.attempts.len() > 1 {
823                bail!("recovery snapshot contains multiple batch submission attempts");
824            }
825            let mut claimed = vec![false; deployment.operations.len()];
826            for attempt in &deployment.attempts {
827                let Some(first) = attempt.members.first() else {
828                    bail!("recovery snapshot attempt has invalid operation membership");
829                };
830                if first.sequence as usize != sequence {
831                    bail!("recovery snapshot attempt has invalid operation membership");
832                }
833                let start = first.index as usize;
834                let end = start
835                    .checked_add(attempt.members.len())
836                    .context("recovery snapshot attempt has invalid operation membership")?;
837                let operations = deployment
838                    .operations
839                    .get(start..end)
840                    .context("recovery snapshot attempt has invalid operation membership")?;
841                if operations
842                    .iter()
843                    .map(|operation| operation.id)
844                    .ne(attempt.members.iter().copied())
845                    || claimed[start..end].iter().any(|claimed| *claimed)
846                    || if self.batch {
847                        end != deployment.operations.len()
848                    } else {
849                        attempt.members.len() != 1
850                    }
851                {
852                    bail!("recovery snapshot attempt has invalid operation membership");
853                }
854                if self.batch {
855                    let completed = completed_transaction_prefix(data)?;
856                    if completed != start && completed != deployment.operations.len() {
857                        bail!("batch submission attempt omits an incomplete operation");
858                    }
859                    let expected_hash = match &attempt.kind {
860                        AttemptKind::Signed { payload, .. } => Some(payload.hash),
861                        AttemptKind::Delegated {
862                            status: DelegatedStatus::Pending { hash },
863                            ..
864                        }
865                        | AttemptKind::Legacy { hash } => Some(*hash),
866                        AttemptKind::Delegated { .. } => None,
867                    };
868                    if data.pending.len() > 1
869                        || data.pending.iter().any(|hash| Some(*hash) != expected_hash)
870                        || data
871                            .transactions
872                            .iter()
873                            .skip(start)
874                            .filter_map(|transaction| transaction.hash)
875                            .any(|hash| Some(hash) != expected_hash)
876                    {
877                        bail!("batch submission attempt conflicts with script progress");
878                    }
879                }
880                claimed[start..end].fill(true);
881                match &attempt.kind {
882                    AttemptKind::Signed { request, payload } => {
883                        if request.is_some() != self.batch {
884                            bail!("recovery snapshot signed attempt has an invalid request");
885                        }
886                        let validated = if let [operation] = operations
887                            && !self.batch
888                        {
889                            validate_signed_payload::<N>(
890                                payload.payload.clone(),
891                                &data.transactions[operation.id.index as usize],
892                                deployment.chain,
893                                operation.id.index as usize,
894                                operation_ordinal(
895                                    self.data.sequences(),
896                                    sequence,
897                                    operation.id.index as usize,
898                                ),
899                            )?
900                        } else {
901                            SignedPayload {
902                                hash: signed_payload_hash::<N>(&payload.payload)?,
903                                payload: payload.payload.clone(),
904                            }
905                        };
906                        if validated != *payload {
907                            bail!("recovery snapshot signed payload does not match its hash");
908                        }
909                    }
910                    AttemptKind::Legacy { .. } if !self.batch => {
911                        bail!("ordinary recovery snapshot contains a legacy batch attempt");
912                    }
913                    AttemptKind::Delegated { .. } | AttemptKind::Legacy { .. } => {}
914                }
915            }
916        }
917        Ok(())
918    }
919
920    fn restore_sensitive(&mut self) {
921        for (deployment, data) in self.deployments.iter().zip(self.data.sequences_mut().iter_mut())
922        {
923            for (operation, transaction) in
924                deployment.operations.iter().zip(data.transactions.iter_mut())
925            {
926                transaction.rpc.clone_from(&operation.rpc);
927            }
928        }
929    }
930}
931
932fn operation_fingerprint<N: Network>(transaction: &TransactionWithMetadata<N>) -> Result<B256>
933where
934    N::TxEnvelope: for<'de> Deserialize<'de> + Serialize,
935{
936    let mut transaction = transaction.clone();
937    transaction.hash = None;
938    transaction.rpc.clear();
939    let transaction =
940        serde_json::from_value::<TransactionWithMetadata<N>>(serde_json::to_value(transaction)?)?;
941    let mut value = serde_json::to_value(transaction)?;
942    value.sort_all_objects();
943    Ok(keccak256(serde_json::to_vec(&value)?))
944}
945
946fn load_plan<N: Network>(path: &Path) -> Result<RecoveryPlan<N>>
947where
948    N::TxEnvelope: for<'de> Deserialize<'de>,
949{
950    foundry_common::fs::read_json_file(path)
951        .wrap_err_with(|| format!("recovery snapshot `{}` is corrupt", path.display()))
952}
953
954/// Returns whether the previous execution at `paths` submitted anything and left an operation, on
955/// any chain, with neither a receipt nor a recorded revert. Without a recovery snapshot, progress
956/// saved before snapshots existed is read from the broadcast export. An unreadable snapshot counts
957/// as unfinished, as resume would refuse it too; an unreadable export counts as finished.
958fn previous_unfinished<N: Network>(snapshot: &Path, paths: &(PathBuf, PathBuf), multi: bool) -> bool
959where
960    N::TxEnvelope: for<'de> Deserialize<'de> + Serialize,
961{
962    let (data, attempted) = if snapshot.exists() {
963        let Ok(plan) = load_plan::<N>(snapshot) else { return true };
964        let attempted = plan.deployments.iter().any(|deployment| !deployment.attempts.is_empty());
965        (plan.data, attempted)
966    } else {
967        let data = if multi {
968            foundry_common::fs::read_json_file::<MultiChainSequence<N>>(&paths.0)
969                .map(SequenceData::Multi)
970        } else {
971            foundry_common::fs::read_json_file::<ScriptSequence<N>>(&paths.0)
972                .map(|sequence| SequenceData::Single(Box::new(sequence)))
973        };
974        let Ok(data) = data else { return false };
975        (data, false)
976    };
977    let sequences = data.sequences();
978    let submitted = attempted
979        || sequences.iter().flat_map(|sequence| &sequence.transactions).any(|tx| tx.hash.is_some());
980    submitted
981        && sequences.iter().any(|sequence| {
982            sequence.transactions.iter().any(|tx| {
983                !tx.hash.is_some_and(|hash| {
984                    sequence.reverted.contains(&hash)
985                        || sequence
986                            .receipts
987                            .iter()
988                            .any(|receipt| receipt.transaction_hash() == hash)
989                })
990            })
991        })
992}
993
994fn pending_path(path: &Path) -> PathBuf {
995    path.with_extension("pending")
996}
997
998pub(crate) fn recovery_exists(paths: &(PathBuf, PathBuf)) -> Result<bool> {
999    let path = recovery_path_from_sensitive(&paths.1)?;
1000    Ok(path.exists() || pending_path(&path).exists())
1001}
1002
1003fn commit_pending_plan(pending: &Path, path: &Path) -> Result<()> {
1004    #[cfg(windows)]
1005    if path.exists() {
1006        std::fs::remove_file(path)?;
1007    }
1008    std::fs::rename(pending, path)?;
1009    #[cfg(unix)]
1010    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
1011    #[cfg(unix)]
1012    File::open(path.parent().context("recovery plan has no parent directory")?)?.sync_all()?;
1013    Ok(())
1014}
1015
1016fn recovery_path_from_sensitive(sensitive_path: &Path) -> Result<PathBuf> {
1017    let filename = sensitive_path.file_name().context("sensitive cache path has no filename")?;
1018    Ok(sensitive_path.with_file_name(format!("{}.recovery.json", filename.to_string_lossy())))
1019}
1020
1021fn signed_payload_hash<N: Network>(payload: &Bytes) -> Result<B256>
1022where
1023    N::TxEnvelope: Decodable2718 + Encodable2718,
1024{
1025    Ok(N::TxEnvelope::decode_2718_exact(payload)
1026        .wrap_err("recovery snapshot contains an invalid signed payload")?
1027        .trie_hash())
1028}
1029
1030fn validate_signed_payload<N: Network>(
1031    payload: Bytes,
1032    planned: &TransactionWithMetadata<N>,
1033    chain: u64,
1034    index: usize,
1035    ordinal: usize,
1036) -> Result<SignedPayload>
1037where
1038    N::TxEnvelope: SignerRecoverable,
1039    N::TransactionRequest: FoundryTransactionBuilder<N>,
1040{
1041    let envelope = N::TxEnvelope::decode_2718_exact(&payload)
1042        .wrap_err("recovery snapshot contains an invalid signed payload")?;
1043    let signer = envelope
1044        .recover_signer()
1045        .wrap_err("recovery snapshot signed payload has an invalid signature")?;
1046    let transaction = planned.tx();
1047    let chain_matches = match transaction {
1048        TransactionMaybeSigned::Signed { tx, .. } => {
1049            envelope.trie_hash() == tx.trie_hash() && tx.chain_id().is_none_or(|id| id == chain)
1050        }
1051        TransactionMaybeSigned::Unsigned(_) => envelope.chain_id() == Some(chain),
1052    };
1053    // The planned nonce is the sender's sequential nonce. A Tempo expiring nonce transaction
1054    // never reads nonce state and is signed with 0 or, from T12, with its complete-plan ordinal as
1055    // the TIP-1106 discriminator. Accept sequence-local indices from older recovery snapshots
1056    // too.
1057    let nonce = envelope.nonce();
1058    let nonce_matches = transaction.nonce() == Some(nonce)
1059        || ((nonce == 0 || nonce == ordinal as u64 || nonce == index as u64)
1060            && <N::TransactionRequest as From<N::TxEnvelope>>::from(envelope.clone()).nonce_key()
1061                == Some(TEMPO_EXPIRING_NONCE_KEY));
1062    if transaction.from() != Some(signer)
1063        || !chain_matches
1064        || !nonce_matches
1065        || transaction.to() != envelope.to()
1066        || transaction.value().unwrap_or_default() != envelope.value()
1067        || transaction.input().cloned().unwrap_or_default() != *envelope.input()
1068        || transaction.authorization_list().as_deref().unwrap_or_default()
1069            != envelope.authorization_list().unwrap_or_default()
1070    {
1071        bail!("signed payload does not match its planned transaction");
1072    }
1073    Ok(SignedPayload { hash: envelope.trie_hash(), payload })
1074}
1075
1076fn validate_delegated_transaction<N: Network>(
1077    transaction: &N::TransactionResponse,
1078    planned: &N::TransactionRequest,
1079    chain: u64,
1080    hash: B256,
1081) -> Result<()>
1082where
1083    N::TransactionRequest: FoundryTransactionBuilder<N>,
1084{
1085    // Preserve network-specific execution fields while allowing the signer to choose gas and fees.
1086    let resolved =
1087        <N::TransactionRequest as From<N::TransactionResponse>>::from(transaction.clone());
1088    let sender = planned.from().context("delegated request has no sender")?;
1089    let planned_tempo_aa = planned.is_tempo_aa();
1090    let resolved_tempo_aa = resolved.is_tempo_aa();
1091    let planned_fields = serde_json::to_value(planned)?;
1092    let resolved_fields = serde_json::to_value(&resolved)?;
1093    let same = |field: &str| planned_fields.get(field) == resolved_fields.get(field);
1094    let same_list = |field: &str| {
1095        let empty = serde_json::Value::Array(Vec::new());
1096        planned_fields.get(field).filter(|value| !value.is_null()).unwrap_or(&empty)
1097            == resolved_fields.get(field).filter(|value| !value.is_null()).unwrap_or(&empty)
1098    };
1099    if transaction.tx_hash() != hash
1100        || transaction.from() != sender
1101        || transaction.chain_id() != Some(chain)
1102        || planned.chain_id() != Some(chain)
1103        || transaction.nonce() != planned.nonce().context("delegated request has no nonce")?
1104        || planned_tempo_aa != resolved_tempo_aa
1105        // A missing `to` and `TxKind::Create` both describe a contract creation.
1106        || (!planned_tempo_aa
1107            && (resolved.kind().unwrap_or(TxKind::Create)
1108                != planned.kind().unwrap_or(TxKind::Create)
1109                || resolved.value().unwrap_or_default() != planned.value().unwrap_or_default()
1110                || resolved.input().unwrap_or_default() != planned.input().unwrap_or_default()))
1111        || transaction.authorization_list().unwrap_or_default()
1112            != planned.authorization_list().map(Vec::as_slice).unwrap_or_default()
1113        || planned.access_list().filter(|list| !list.is_empty())
1114            != resolved.access_list().filter(|list| !list.is_empty())
1115        || planned.blob_versioned_hashes().filter(|hashes| !hashes.is_empty())
1116            != resolved.blob_versioned_hashes().filter(|hashes| !hashes.is_empty())
1117        || (planned_tempo_aa
1118            && canonical_tempo_calls::<N>(planned, &planned_fields)?
1119                != canonical_tempo_calls::<N>(&resolved, &resolved_fields)?)
1120        || !same_list("aaAuthorizationList")
1121        || planned.nonce_key().unwrap_or_default() != resolved.nonce_key().unwrap_or_default()
1122        || planned.fee_token() != resolved.fee_token()
1123        || planned.valid_before() != resolved.valid_before()
1124        || planned.valid_after() != resolved.valid_after()
1125        || !same("keyAuthorization")
1126        || delegated_fee_payer::<N>(planned, sender)?
1127            != delegated_fee_payer::<N>(&resolved, sender)?
1128    {
1129        bail!("resolved transaction does not match its delegated submission attempt");
1130    }
1131    Ok(())
1132}
1133
1134fn canonical_tempo_calls<N: Network>(
1135    request: &N::TransactionRequest,
1136    fields: &serde_json::Value,
1137) -> Result<Vec<serde_json::Value>> {
1138    let mut calls =
1139        fields.get("calls").and_then(serde_json::Value::as_array).cloned().unwrap_or_default();
1140    if let Some(to) = request.kind() {
1141        calls.push(serde_json::to_value(tempo_primitives::transaction::Call {
1142            to,
1143            value: request.value().unwrap_or_default(),
1144            input: request.input().cloned().unwrap_or_default(),
1145        })?);
1146    }
1147    Ok(calls)
1148}
1149
1150fn delegated_fee_payer<N: Network>(
1151    request: &N::TransactionRequest,
1152    sender: Address,
1153) -> Result<Option<Address>>
1154where
1155    N::TransactionRequest: FoundryTransactionBuilder<N>,
1156{
1157    let Some(signature) = request.fee_payer_signature() else { return Ok(None) };
1158    let hash = request
1159        .compute_sponsor_hash(sender)
1160        .context("failed to compute delegated Tempo sponsor hash")?;
1161    Ok(Some(
1162        signature
1163            .recover_address_from_prehash(&hash)
1164            .wrap_err("failed to recover delegated Tempo sponsor")?,
1165    ))
1166}
1167
1168fn write_snapshot<N: Network>(path: &Path, plan: &RecoveryPlan<N>) -> Result<()>
1169where
1170    N::TxEnvelope: Serialize,
1171{
1172    let pending = pending_path(path);
1173    write_plan(&pending, plan)?;
1174    commit_pending_plan(&pending, path)
1175}
1176
1177fn write_plan<N: Network>(path: &Path, plan: &RecoveryPlan<N>) -> Result<()>
1178where
1179    N::TxEnvelope: Serialize,
1180{
1181    let parent = path.parent().context("recovery plan has no parent directory")?;
1182    std::fs::create_dir_all(parent)?;
1183    let mut tmp = NamedTempFile::new_in(parent)?;
1184    #[cfg(unix)]
1185    tmp.as_file().set_permissions(std::fs::Permissions::from_mode(0o600))?;
1186    {
1187        let mut writer = BufWriter::new(tmp.as_file_mut());
1188        serde_json::to_writer_pretty(&mut writer, plan)?;
1189        writer.flush()?;
1190    }
1191    tmp.as_file().sync_all()?;
1192    tmp.persist(path).map_err(|error| error.error)?;
1193    #[cfg(unix)]
1194    File::open(parent)?.sync_all()?;
1195    Ok(())
1196}
1197
1198/// Tempo replay protection excludes sponsor signatures from T1B onward.
1199fn expiring_replay_hash(payload: &[u8], sender_scoped_replay: bool) -> Result<Option<B256>> {
1200    if let Ok(TempoTxEnvelope::AA(tx)) = TempoTxEnvelope::decode_2718_exact(payload)
1201        && tx.tx().is_expiring_nonce_tx()
1202    {
1203        return Ok(Some(if sender_scoped_replay {
1204            tx.expiring_nonce_hash(tx.recover_signer()?)
1205        } else {
1206            *tx.hash()
1207        }));
1208    }
1209    Ok(None)
1210}
1211
1212#[cfg(test)]
1213mod tests {
1214    use super::*;
1215    use alloy_consensus::{
1216        Eip658Value, Receipt, ReceiptEnvelope, ReceiptWithBloom, SignableTransaction, TxEip1559,
1217        TxEnvelope, transaction::Recovered,
1218    };
1219    use alloy_network::Ethereum;
1220    use alloy_primitives::{Bloom, Signature, U256, hex};
1221    use alloy_rpc_types::{Transaction as RpcTransaction, TransactionReceipt, TransactionRequest};
1222    use alloy_signer::SignerSync;
1223    use tempo_alloy::{TempoNetwork, rpc::TempoTransactionRequest};
1224    use tempo_primitives::{
1225        AASigned, TempoSignature,
1226        transaction::{Call, PrimitiveSignature},
1227    };
1228
1229    const SIGNED_TX: &[u8] = &hex!(
1230        "02f86b0180843b9aca008502540be4008252089400000000000000000000000000000000000000016480c001a070d55e79ed3ac9fc8f51e78eb91fd054720d943d66633f2eb1bc960f0126b0eca052eda05a792680de3181e49bab4093541f75b49d1ecbe443077b3660c836016a"
1231    );
1232    const OTHER_SIGNED_TX: &[u8] = &hex!(
1233        "02f86b0180843b9aca008502540be4008252089400000000000000000000000000000000000000018080c001a0cce9a61187b5d18a89ecd27ec675e3b3f10d37f165627ef89a15a7fe76395ce8a07537f5bffb358ffbef22cda84b1c92f7211723f9e09ae037e81686805d3e5505"
1234    );
1235
1236    const ROOT_PRIVATE_KEY: &str =
1237        "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
1238
1239    fn sequence(dir: &Path) -> SequenceData<Ethereum> {
1240        let mut transaction = TransactionWithMetadata::from_tx_request(
1241            TransactionMaybeSigned::Unsigned(Default::default()),
1242        );
1243        transaction.rpc = "http://localhost:8545".to_string();
1244        let mut sequence = forge_script_sequence::ScriptSequence::default();
1245        sequence.transactions.push_back(transaction);
1246        sequence.paths = Some((dir.join("broadcast.json"), dir.join("cache.json")));
1247        SequenceData::Single(Box::new(sequence))
1248    }
1249
1250    fn signed_sequence(dir: &Path) -> SequenceData<Ethereum> {
1251        let transactions = [SIGNED_TX, OTHER_SIGNED_TX].map(|payload| {
1252            let envelope = TxEnvelope::decode_2718_exact(payload).unwrap();
1253            let from = envelope.recover_signer().unwrap();
1254            let mut request: TransactionRequest = envelope.into();
1255            request.from = Some(from);
1256            TransactionWithMetadata::from_tx_request(TransactionMaybeSigned::new(request))
1257        });
1258        let mut sequence = forge_script_sequence::ScriptSequence {
1259            chain: 1,
1260            transactions: transactions.into(),
1261            ..Default::default()
1262        };
1263        sequence.paths = Some((dir.join("broadcast.json"), dir.join("cache.json")));
1264        SequenceData::Single(Box::new(sequence))
1265    }
1266
1267    fn delegated_transaction(payload: &[u8]) -> (TransactionRequest, RpcTransaction) {
1268        let envelope = TxEnvelope::decode_2718_exact(payload).unwrap();
1269        let from = envelope.recover_signer().unwrap();
1270        let mut request: TransactionRequest = envelope.clone().into();
1271        request.from = Some(from);
1272        let transaction = RpcTransaction {
1273            inner: Recovered::new_unchecked(envelope, from),
1274            block_hash: None,
1275            block_number: None,
1276            transaction_index: None,
1277            effective_gas_price: None,
1278            block_timestamp: None,
1279        };
1280        (request, transaction)
1281    }
1282
1283    fn tempo_transaction(
1284        request: TempoTransactionRequest,
1285        from: Address,
1286    ) -> RpcTransaction<TempoTxEnvelope> {
1287        let envelope =
1288            TempoTxEnvelope::AA(request.build_aa().unwrap().into_signed(TempoSignature::default()));
1289        RpcTransaction {
1290            inner: Recovered::new_unchecked(envelope, from),
1291            block_hash: None,
1292            block_number: None,
1293            transaction_index: None,
1294            effective_gas_price: None,
1295            block_timestamp: None,
1296        }
1297    }
1298
1299    #[test]
1300    fn expiring_signed_payload_accepts_complete_plan_ordinal_and_legacy_index() {
1301        let signer = foundry_wallets::utils::create_local_signer(ROOT_PRIVATE_KEY).unwrap();
1302        let request = expiring_request(signer.address(), 4217, 8);
1303        let planned =
1304            TransactionWithMetadata::from_tx_request(TransactionMaybeSigned::new(request.clone()));
1305        for nonce in [0, 1, 3, 4] {
1306            let mut request = request.clone();
1307            request.inner.nonce = Some(nonce);
1308            let payload = sign_tempo_payload(request, &signer);
1309            assert_eq!(
1310                validate_signed_payload::<TempoNetwork>(payload, &planned, 4217, 1, 3).is_ok(),
1311                nonce != 4
1312            );
1313        }
1314    }
1315
1316    #[test]
1317    fn identical_expiring_payload_is_rejected_across_same_chain_sequences() {
1318        let dir = tempfile::tempdir().unwrap();
1319        let signer = foundry_wallets::utils::create_local_signer(ROOT_PRIVATE_KEY).unwrap();
1320        // A -> B -> A: the planned nonce on A advances, the signed expiring nonce stays 0.
1321        let deployments = [(4217, 0), (4218, 0), (4217, 1)]
1322            .map(|(chain, nonce)| ScriptSequence::<TempoNetwork> {
1323                chain,
1324                transactions: [TransactionWithMetadata::from_tx_request(
1325                    TransactionMaybeSigned::new(expiring_request(signer.address(), chain, nonce)),
1326                )]
1327                .into(),
1328                ..Default::default()
1329            })
1330            .into();
1331        let data = SequenceData::Multi(MultiChainSequence {
1332            deployments,
1333            path: dir.path().join("broadcast.json"),
1334            sensitive_path: dir.path().join("cache.json"),
1335            timestamp: 0,
1336        });
1337        let payload = sign_tempo_payload(expiring_request(signer.address(), 4217, 0), &signer);
1338
1339        let mut store = RecoveryStore::create(data, false, None).unwrap();
1340        let hash = store.persist_signed_payload(0, 0, payload.clone(), true).unwrap();
1341        assert_eq!(
1342            store.persist_signed_payload(2, 0, payload, true).unwrap_err().to_string(),
1343            format!(
1344                "transaction 0 is identical to an earlier transaction of this script ({hash}) and would be rejected as a replay; identical Tempo expiring nonce transactions are only distinct from the T12 hardfork (TIP-1106)"
1345            )
1346        );
1347    }
1348
1349    fn sign_tempo_sponsor(
1350        request: &mut TempoTransactionRequest,
1351        from: Address,
1352        sponsor: &impl SignerSync,
1353    ) {
1354        request.fee_payer_signature = None;
1355        let hash = request.compute_sponsor_hash(from).unwrap();
1356        request.fee_payer_signature = Some(sponsor.sign_hash_sync(&hash).unwrap());
1357    }
1358
1359    fn load(paths: &(PathBuf, PathBuf), batch: bool) -> Result<RecoveryStore<Ethereum>> {
1360        let lock = RecoveryLock::acquire(paths)?;
1361        RecoveryStore::load(paths, batch, lock)?.context("missing recovery snapshot")
1362    }
1363
1364    #[test]
1365    fn snapshot_is_versioned_private_and_locks_writers() {
1366        let dir = tempfile::tempdir().unwrap();
1367        let data = sequence(dir.path());
1368        let paths = data.paths();
1369        let store = RecoveryStore::create(data, false, None).unwrap();
1370        let value: serde_json::Value = foundry_common::fs::read_json_file(&store.path).unwrap();
1371        assert_eq!(value["version"], RECOVERY_VERSION);
1372        assert_eq!(value["deployments"][0]["operations"][0]["id"]["sequence"], 0);
1373        assert_eq!(value["deployments"][0]["operations"][0]["id"]["index"], 0);
1374        assert!(value["deployments"][0]["operations"][0]["fingerprint"].is_string());
1375        assert!(value["deployments"][0]["operations"][0].get("transaction").is_none());
1376        assert!(RecoveryLock::acquire(&paths).is_err());
1377        #[cfg(unix)]
1378        assert_eq!(store.path.metadata().unwrap().permissions().mode() & 0o777, 0o600);
1379    }
1380
1381    #[test]
1382    fn snapshot_recovers_without_compatibility_exports() {
1383        let dir = tempfile::tempdir().unwrap();
1384        let data = sequence(dir.path());
1385        let paths = data.paths();
1386        let hash = B256::repeat_byte(0x11);
1387        {
1388            let mut store = RecoveryStore::create(data, false, None).unwrap();
1389            let deployment = &mut store.data_mut().sequences_mut()[0];
1390            deployment.transactions[0].hash = Some(hash);
1391            deployment.pending.push(hash);
1392            store.save().unwrap();
1393        }
1394        for path in [&paths.0, &paths.1] {
1395            assert!(!path.exists());
1396        }
1397
1398        let store = load(&paths, false).unwrap();
1399        let deployment = &store.data().sequences()[0];
1400        assert_eq!(deployment.transactions[0].hash, Some(hash));
1401        assert_eq!(deployment.pending, [hash]);
1402        store.data().publish(&paths).unwrap();
1403        assert!(paths.0.exists());
1404        assert!(paths.1.exists());
1405    }
1406
1407    #[test]
1408    fn snapshot_ignores_stale_compatibility_exports() {
1409        let dir = tempfile::tempdir().unwrap();
1410        let data = sequence(dir.path());
1411        let paths = data.paths();
1412        let hash = B256::repeat_byte(0x22);
1413        {
1414            let mut store = RecoveryStore::create(data.clone(), false, None).unwrap();
1415            store.data_mut().sequences_mut()[0].transactions[0].hash = Some(hash);
1416            store.save().unwrap();
1417        }
1418        data.publish(&paths).unwrap();
1419
1420        let store = load(&paths, false).unwrap();
1421        assert_eq!(store.data().sequences()[0].transactions[0].hash, Some(hash));
1422    }
1423
1424    #[test]
1425    fn corrupt_snapshot_fails_closed_even_with_valid_exports() {
1426        let dir = tempfile::tempdir().unwrap();
1427        let data = sequence(dir.path());
1428        let paths = data.paths();
1429        data.publish(&paths).unwrap();
1430        let snapshot = {
1431            let store = RecoveryStore::create(data, false, None).unwrap();
1432            store.path
1433        };
1434        std::fs::write(snapshot, b"{").unwrap();
1435
1436        assert!(load(&paths, false).err().unwrap().to_string().contains("is corrupt"));
1437    }
1438
1439    #[test]
1440    fn interrupted_snapshot_replacement_is_recovered() {
1441        let dir = tempfile::tempdir().unwrap();
1442        let data = sequence(dir.path());
1443        let paths = data.paths();
1444        let hash = B256::repeat_byte(0x33);
1445        let snapshot = {
1446            let mut store = RecoveryStore::create(data, false, None).unwrap();
1447            store.data_mut().sequences_mut()[0].pending.push(hash);
1448            write_plan(&pending_path(&store.path), &store.plan).unwrap();
1449            store.path.clone()
1450        };
1451
1452        let store = load(&paths, false).unwrap();
1453        assert_eq!(store.data().sequences()[0].pending, [hash]);
1454        assert!(snapshot.exists());
1455        assert!(!pending_path(&snapshot).exists());
1456    }
1457
1458    #[test]
1459    fn changed_operations_fail_closed() {
1460        let dir = tempfile::tempdir().unwrap();
1461        let mut store = RecoveryStore::create(sequence(dir.path()), false, None).unwrap();
1462        store.data_mut().sequences_mut()[0].transactions.clear();
1463        assert!(store.save().unwrap_err().to_string().contains("does not match"));
1464    }
1465
1466    #[test]
1467    fn relocation_preserves_the_authoritative_snapshot() {
1468        let dir = tempfile::tempdir().unwrap();
1469        let data = sequence(dir.path());
1470        let source_paths = data.paths();
1471        let mut store = RecoveryStore::create(data, false, None).unwrap();
1472        let destination = dir.path().join("broadcast-cache.json");
1473        let destination_snapshot = recovery_path_from_sensitive(&destination).unwrap();
1474        let paths = (dir.path().join("broadcasted.json"), destination);
1475        let relocation = store.prepare_relocation(&paths).unwrap();
1476        assert!(RecoveryLock::acquire(&source_paths).is_err());
1477        store.commit_relocation(relocation).unwrap();
1478        assert_eq!(store.path, destination_snapshot);
1479        assert!(destination_snapshot.exists());
1480        RecoveryLock::acquire(&source_paths).unwrap();
1481    }
1482
1483    #[test]
1484    fn signed_payload_is_immutable_and_survives_reload() {
1485        let dir = tempfile::tempdir().unwrap();
1486        let data = signed_sequence(dir.path());
1487        let paths = data.paths();
1488        let expected_hash = {
1489            let mut store = RecoveryStore::create(data, false, None).unwrap();
1490            let hash = store.persist_signed_payload(0, 0, SIGNED_TX.into(), true).unwrap();
1491            assert!(store.persist_signed_payload(0, 0, OTHER_SIGNED_TX.into(), true).is_err());
1492            hash
1493        };
1494
1495        let store = load(&paths, false).unwrap();
1496        let signed = store.signed_payload(0, 0).unwrap();
1497        assert_eq!(signed.payload.as_ref(), SIGNED_TX);
1498        assert_eq!(signed.hash, expected_hash);
1499    }
1500
1501    #[test]
1502    fn signed_payload_is_bound_to_its_planned_operation() {
1503        let dir = tempfile::tempdir().unwrap();
1504        let mut store = RecoveryStore::create(signed_sequence(dir.path()), false, None).unwrap();
1505
1506        assert!(store.persist_signed_payload(1, 0, SIGNED_TX.into(), true).is_err());
1507        assert!(store.persist_signed_payload(0, 1, SIGNED_TX.into(), true).is_err());
1508    }
1509
1510    #[test]
1511    fn delegated_submission_is_immutable_and_survives_reload() {
1512        let dir = tempfile::tempdir().unwrap();
1513        let data = sequence(dir.path());
1514        let paths = data.paths();
1515        let request = TransactionRequest::default();
1516        let hash = B256::repeat_byte(0x42);
1517        {
1518            let mut store = RecoveryStore::create(data, false, None).unwrap();
1519            store.persist_delegated_request(0, 0, request).unwrap();
1520            store.persist_delegated_status(0, 0, DelegatedStatus::Pending { hash }).unwrap();
1521            assert!(store.persist_delegated_request(0, 0, Default::default()).is_err());
1522        }
1523
1524        let store = load(&paths, false).unwrap();
1525        assert!(
1526            matches!(store.delegated_status(0, 0), Some(DelegatedStatus::Pending { hash: value }) if value == hash)
1527        );
1528    }
1529
1530    #[test]
1531    fn delegated_resolution_is_bound_to_its_planned_transaction() {
1532        let (mut request, transaction) = delegated_transaction(SIGNED_TX);
1533        let hash = transaction.tx_hash();
1534        validate_delegated_transaction::<Ethereum>(&transaction, &request, 1, hash).unwrap();
1535
1536        request.gas = Some(100_000);
1537        request.max_fee_per_gas = Some(10_000);
1538        validate_delegated_transaction::<Ethereum>(&transaction, &request, 1, hash).unwrap();
1539
1540        request.blob_versioned_hashes = Some(vec![B256::repeat_byte(0x42)]);
1541        assert!(
1542            validate_delegated_transaction::<Ethereum>(&transaction, &request, 1, hash).is_err()
1543        );
1544
1545        let (other, _) = delegated_transaction(OTHER_SIGNED_TX);
1546        assert!(validate_delegated_transaction::<Ethereum>(&transaction, &other, 1, hash).is_err());
1547    }
1548
1549    #[test]
1550    fn delegated_resolution_matches_creations_by_kind() {
1551        let from = Address::repeat_byte(0x11);
1552        let transaction = |to: TxKind| {
1553            let envelope = TxEnvelope::Eip1559(
1554                TxEip1559 {
1555                    chain_id: 1,
1556                    gas_limit: 100_000,
1557                    max_fee_per_gas: 1,
1558                    max_priority_fee_per_gas: 1,
1559                    to,
1560                    input: Bytes::from_static(&[0x60, 0x00]),
1561                    ..Default::default()
1562                }
1563                .into_signed(Signature::test_signature()),
1564            );
1565            RpcTransaction {
1566                inner: Recovered::new_unchecked(envelope, from),
1567                block_hash: None,
1568                block_number: None,
1569                transaction_index: None,
1570                effective_gas_price: None,
1571                block_timestamp: None,
1572            }
1573        };
1574        // Serialize the planned request the same way the recovery snapshot stores it.
1575        let planned = |to: TxKind| {
1576            let mut request: TransactionRequest = transaction(to).inner.into_inner().into();
1577            request.from = Some(from);
1578            serde_json::from_value::<TransactionRequest>(serde_json::to_value(request).unwrap())
1579                .unwrap()
1580        };
1581        let validate = |transaction: &RpcTransaction, planned: &TransactionRequest| {
1582            validate_delegated_transaction::<Ethereum>(
1583                transaction,
1584                planned,
1585                1,
1586                transaction.tx_hash(),
1587            )
1588        };
1589        let create = transaction(TxKind::Create);
1590        let call = transaction(TxKind::Call(Address::repeat_byte(0x22)));
1591        assert_eq!(planned(TxKind::Create).to, None);
1592
1593        validate(&create, &planned(TxKind::Create)).unwrap();
1594        validate(&call, &planned(TxKind::Call(Address::repeat_byte(0x22)))).unwrap();
1595        assert!(validate(&create, &planned(TxKind::Call(Address::repeat_byte(0x22)))).is_err());
1596        assert!(validate(&call, &planned(TxKind::Create)).is_err());
1597        assert!(validate(&call, &planned(TxKind::Call(Address::repeat_byte(0x33)))).is_err());
1598    }
1599
1600    #[test]
1601    fn delegated_tempo_resolution_matches_creations_by_kind() {
1602        let from = Address::repeat_byte(0x11);
1603        let transaction = |to: TxKind| {
1604            let envelope = TempoTxEnvelope::Eip1559(
1605                TxEip1559 {
1606                    chain_id: 4217,
1607                    gas_limit: 100_000,
1608                    max_fee_per_gas: 1,
1609                    max_priority_fee_per_gas: 1,
1610                    to,
1611                    input: Bytes::from_static(&[0x60, 0x00]),
1612                    ..Default::default()
1613                }
1614                .into_signed(Signature::test_signature()),
1615            );
1616            RpcTransaction {
1617                inner: Recovered::new_unchecked(envelope, from),
1618                block_hash: None,
1619                block_number: None,
1620                transaction_index: None,
1621                effective_gas_price: None,
1622                block_timestamp: None,
1623            }
1624        };
1625        // Serialize the planned request the same way the recovery snapshot stores it.
1626        let planned = |to: TxKind| {
1627            let mut request = <TempoTransactionRequest as From<_>>::from(transaction(to));
1628            request.inner.from = Some(from);
1629            serde_json::from_value::<TempoTransactionRequest>(
1630                serde_json::to_value(request).unwrap(),
1631            )
1632            .unwrap()
1633        };
1634        let validate = |transaction: &RpcTransaction<TempoTxEnvelope>,
1635                        planned: &TempoTransactionRequest| {
1636            validate_delegated_transaction::<TempoNetwork>(
1637                transaction,
1638                planned,
1639                4217,
1640                transaction.tx_hash(),
1641            )
1642        };
1643        let create = transaction(TxKind::Create);
1644        let call = transaction(TxKind::Call(Address::repeat_byte(0x22)));
1645        assert_eq!(planned(TxKind::Create).inner.to, None);
1646        assert!(!planned(TxKind::Create).is_tempo_aa());
1647
1648        validate(&create, &planned(TxKind::Create)).unwrap();
1649        validate(&call, &planned(TxKind::Call(Address::repeat_byte(0x22)))).unwrap();
1650        assert!(validate(&create, &planned(TxKind::Call(Address::repeat_byte(0x22)))).is_err());
1651        assert!(validate(&call, &planned(TxKind::Create)).is_err());
1652    }
1653
1654    #[test]
1655    fn delegated_tempo_resolution_checks_nonce_domain_and_calls() {
1656        let from = Address::repeat_byte(0x11);
1657        let resolved = TempoTransactionRequest {
1658            inner: TransactionRequest {
1659                from: Some(from),
1660                gas: Some(0),
1661                max_fee_per_gas: Some(0),
1662                max_priority_fee_per_gas: Some(0),
1663                nonce: Some(0),
1664                chain_id: Some(4217),
1665                ..Default::default()
1666            },
1667            nonce_key: Some(U256::from(7)),
1668            calls: vec![
1669                Call {
1670                    to: TxKind::Call(Address::repeat_byte(0x22)),
1671                    value: U256::ONE,
1672                    input: Bytes::from_static(&[0x12]),
1673                },
1674                Call {
1675                    to: TxKind::Call(Address::repeat_byte(0x33)),
1676                    value: U256::from(2),
1677                    input: Bytes::from_static(&[0x34]),
1678                },
1679            ],
1680            ..Default::default()
1681        };
1682        let transaction = tempo_transaction(resolved, from);
1683        let mut request = <TempoTransactionRequest as From<_>>::from(transaction.clone());
1684        request.from = Some(from);
1685        let hash = transaction.tx_hash();
1686        validate_delegated_transaction::<TempoNetwork>(&transaction, &request, 4217, hash).unwrap();
1687        assert!(
1688            validate_delegated_transaction::<TempoNetwork>(
1689                &transaction,
1690                &request,
1691                4217,
1692                B256::ZERO,
1693            )
1694            .is_err()
1695        );
1696
1697        request.valid_before = std::num::NonZeroU64::new(1);
1698        assert!(
1699            validate_delegated_transaction::<TempoNetwork>(&transaction, &request, 4217, hash)
1700                .is_err()
1701        );
1702        request.valid_before = None;
1703        request.fee_token = Some(Address::repeat_byte(0x44));
1704        assert!(
1705            validate_delegated_transaction::<TempoNetwork>(&transaction, &request, 4217, hash)
1706                .is_err()
1707        );
1708        request.fee_token = None;
1709
1710        let top_level = request.calls.pop().unwrap();
1711        request.inner.to = Some(top_level.to);
1712        request.inner.value = Some(top_level.value);
1713        request.inner.input = top_level.input.into();
1714        validate_delegated_transaction::<TempoNetwork>(&transaction, &request, 4217, hash).unwrap();
1715
1716        request.nonce_key = Some(U256::from(8));
1717        assert!(
1718            validate_delegated_transaction::<TempoNetwork>(&transaction, &request, 4217, hash)
1719                .is_err()
1720        );
1721        request.nonce_key = Some(U256::from(7));
1722        request.calls.push(Call {
1723            to: request.inner.to.take().unwrap(),
1724            value: request.inner.value.take().unwrap(),
1725            input: request.inner.input.input.take().unwrap(),
1726        });
1727        request.calls.swap(0, 1);
1728        assert!(
1729            validate_delegated_transaction::<TempoNetwork>(&transaction, &request, 4217, hash)
1730                .is_err()
1731        );
1732    }
1733
1734    #[test]
1735    fn delegated_tempo_resolution_checks_sponsor_identity() {
1736        let from = Address::repeat_byte(0x11);
1737        let sponsor = foundry_wallets::utils::create_local_signer(
1738            "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
1739        )
1740        .unwrap();
1741        let other_sponsor = foundry_wallets::utils::create_local_signer(
1742            "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d",
1743        )
1744        .unwrap();
1745        assert_ne!(sponsor.address(), other_sponsor.address());
1746
1747        let mut planned = TempoTransactionRequest {
1748            inner: TransactionRequest {
1749                from: Some(from),
1750                to: Some(TxKind::Call(Address::repeat_byte(0x22))),
1751                gas: Some(100_000),
1752                max_fee_per_gas: Some(10),
1753                max_priority_fee_per_gas: Some(1),
1754                nonce: Some(0),
1755                chain_id: Some(4217),
1756                ..Default::default()
1757            },
1758            nonce_key: Some(U256::from(7)),
1759            ..Default::default()
1760        };
1761        sign_tempo_sponsor(&mut planned, from, &sponsor);
1762
1763        let mut resolved = planned.clone();
1764        resolved.inner.gas = Some(120_000);
1765        resolved.inner.max_fee_per_gas = Some(12);
1766        sign_tempo_sponsor(&mut resolved, from, &sponsor);
1767        let transaction = tempo_transaction(resolved.clone(), from);
1768        validate_delegated_transaction::<TempoNetwork>(
1769            &transaction,
1770            &planned,
1771            4217,
1772            transaction.tx_hash(),
1773        )
1774        .unwrap();
1775
1776        resolved.fee_payer_signature = None;
1777        let transaction = tempo_transaction(resolved.clone(), from);
1778        assert!(
1779            validate_delegated_transaction::<TempoNetwork>(
1780                &transaction,
1781                &planned,
1782                4217,
1783                transaction.tx_hash(),
1784            )
1785            .is_err()
1786        );
1787
1788        sign_tempo_sponsor(&mut resolved, from, &other_sponsor);
1789        let transaction = tempo_transaction(resolved, from);
1790        assert!(
1791            validate_delegated_transaction::<TempoNetwork>(
1792                &transaction,
1793                &planned,
1794                4217,
1795                transaction.tx_hash(),
1796            )
1797            .is_err()
1798        );
1799    }
1800
1801    #[test]
1802    fn definite_non_submission_clears_delegated_request() {
1803        let dir = tempfile::tempdir().unwrap();
1804        let data = sequence(dir.path());
1805        let paths = data.paths();
1806        let mut store = RecoveryStore::create(data, false, None).unwrap();
1807        store.persist_delegated_request(0, 0, Default::default()).unwrap();
1808        store.clear_delegated_request(0, 0).unwrap();
1809        assert!(store.delegated_status(0, 0).is_none());
1810        drop(store);
1811
1812        assert!(load(&paths, false).unwrap().delegated_status(0, 0).is_none());
1813    }
1814
1815    #[test]
1816    fn batch_attempt_uses_shared_membership_and_survives_reload() {
1817        let dir = tempfile::tempdir().unwrap();
1818        let data = signed_sequence(dir.path());
1819        let paths = data.paths();
1820        let request = TransactionRequest::default();
1821        {
1822            let mut store = RecoveryStore::create(data, true, None).unwrap();
1823            store.persist_batch_signed_payload(0, 0, request.clone(), SIGNED_TX.into()).unwrap();
1824            assert!(store.persist_signed_payload(0, 1, OTHER_SIGNED_TX.into(), true).is_err());
1825        }
1826
1827        let store = load(&paths, true).unwrap();
1828        let Some((0, _, AttemptKind::Signed { request: Some(saved), .. })) = store.batch_attempt(0)
1829        else {
1830            panic!("expected signed batch attempt");
1831        };
1832        assert_eq!(saved, &request);
1833    }
1834
1835    #[test]
1836    fn batch_attempt_rejects_conflicting_progress() {
1837        let foreign = B256::repeat_byte(0x66);
1838        for (transaction_hash, pending) in [(Some(foreign), vec![]), (None, vec![foreign])] {
1839            let dir = tempfile::tempdir().unwrap();
1840            let data = signed_sequence(dir.path());
1841            let paths = data.paths();
1842            {
1843                let mut store = RecoveryStore::create(data, true, None).unwrap();
1844                store
1845                    .persist_batch_signed_payload(
1846                        0,
1847                        0,
1848                        TransactionRequest::default(),
1849                        SIGNED_TX.into(),
1850                    )
1851                    .unwrap();
1852                let deployment = &mut store.data_mut().sequences_mut()[0];
1853                deployment.transactions[0].hash = transaction_hash;
1854                deployment.pending = pending;
1855                write_snapshot(&store.path, &store.plan).unwrap();
1856            }
1857
1858            assert!(load(&paths, true).is_err());
1859        }
1860    }
1861
1862    #[test]
1863    fn batch_progress_without_an_attempt_fails_closed() {
1864        let dir = tempfile::tempdir().unwrap();
1865        let data = signed_sequence(dir.path());
1866        let paths = data.paths();
1867        {
1868            let mut store = RecoveryStore::create(data, true, None).unwrap();
1869            store.data_mut().sequences_mut()[0].pending.push(B256::repeat_byte(0x77));
1870            write_snapshot(&store.path, &store.plan).unwrap();
1871        }
1872
1873        assert!(load(&paths, true).is_err());
1874    }
1875
1876    #[test]
1877    fn legacy_batch_import_requires_one_consistent_pending_hash() {
1878        let dir = tempfile::tempdir().unwrap();
1879        let mut data = signed_sequence(dir.path());
1880        let paths = data.paths();
1881        let hash = B256::repeat_byte(0x55);
1882        let deployment = &mut data.sequences_mut()[0];
1883        for transaction in &mut deployment.transactions {
1884            transaction.hash = Some(hash);
1885        }
1886        deployment.pending.push(hash);
1887        let store =
1888            RecoveryStore::import(data, true, RecoveryLock::acquire(&paths).unwrap()).unwrap();
1889        assert!(
1890            matches!(store.batch_attempt(0), Some((0, _, AttemptKind::Legacy { hash: saved })) if *saved == hash)
1891        );
1892        drop(store);
1893
1894        let other = tempfile::tempdir().unwrap();
1895        let mut data = signed_sequence(other.path());
1896        let paths = data.paths();
1897        let deployment = &mut data.sequences_mut()[0];
1898        deployment.transactions[1].hash = Some(hash);
1899        deployment.pending.push(hash);
1900        assert!(RecoveryStore::import(data, true, RecoveryLock::acquire(&paths).unwrap()).is_err());
1901    }
1902
1903    #[test]
1904    fn legacy_batch_import_accepts_a_receipted_prefix() {
1905        let dir = tempfile::tempdir().unwrap();
1906        let mut data = signed_sequence(dir.path());
1907        let paths = data.paths();
1908        let completed = B256::repeat_byte(0x44);
1909        let pending = B256::repeat_byte(0x55);
1910        let deployment = &mut data.sequences_mut()[0];
1911        deployment.transactions[0].hash = Some(completed);
1912        deployment.transactions[1].hash = Some(pending);
1913        let completed_receipt = TransactionReceipt {
1914            inner: ReceiptEnvelope::Legacy(ReceiptWithBloom {
1915                receipt: Receipt {
1916                    status: Eip658Value::success(),
1917                    cumulative_gas_used: 0,
1918                    logs: Vec::new(),
1919                },
1920                logs_bloom: Bloom::ZERO,
1921            }),
1922            transaction_hash: completed,
1923            transaction_index: None,
1924            block_hash: None,
1925            block_number: None,
1926            gas_used: 0,
1927            effective_gas_price: 0,
1928            blob_gas_used: None,
1929            blob_gas_price: None,
1930            from: Address::ZERO,
1931            to: None,
1932            contract_address: None,
1933        };
1934        deployment.receipts.push(completed_receipt);
1935        deployment.pending.push(pending);
1936
1937        let store =
1938            RecoveryStore::import(data, true, RecoveryLock::acquire(&paths).unwrap()).unwrap();
1939        assert!(
1940            matches!(store.batch_attempt(0), Some((1, _, AttemptKind::Legacy { hash })) if *hash == pending)
1941        );
1942    }
1943
1944    #[test]
1945    fn rejected_legacy_batch_import_does_not_publish_a_snapshot() {
1946        let dir = tempfile::tempdir().unwrap();
1947        let mut data = signed_sequence(dir.path());
1948        let paths = data.paths();
1949        data.sequences_mut()[0].transactions[0].hash = Some(B256::repeat_byte(0x55));
1950        let snapshot = recovery_path_from_sensitive(&paths.1).unwrap();
1951
1952        assert!(RecoveryStore::import(data, true, RecoveryLock::acquire(&paths).unwrap()).is_err());
1953        assert!(!snapshot.exists());
1954    }
1955
1956    #[test]
1957    fn expiring_replay_guard_ignores_sponsor_changes_and_keeps_discriminators() {
1958        let dir = tempfile::tempdir().unwrap();
1959        let sender = foundry_wallets::utils::create_local_signer(ROOT_PRIVATE_KEY).unwrap();
1960        let sponsor = foundry_wallets::utils::create_local_signer(
1961            "0x59c6995e998f97a5a004497e5da3b5d2b2b66a87f064d39c44da0b6d6e4f8ff0",
1962        )
1963        .unwrap();
1964        let request = |nonce| expiring_request(sender.address(), 4217, nonce);
1965        let data = SequenceData::Multi(MultiChainSequence {
1966            deployments: [0, 1]
1967                .map(|nonce| ScriptSequence::<TempoNetwork> {
1968                    chain: 4217,
1969                    transactions: [TransactionWithMetadata::from_tx_request(
1970                        TransactionMaybeSigned::new(request(nonce)),
1971                    )]
1972                    .into(),
1973                    ..Default::default()
1974                })
1975                .into(),
1976            path: dir.path().join("broadcast.json"),
1977            sensitive_path: dir.path().join("cache.json"),
1978            timestamp: 0,
1979        });
1980        let payload = |nonce, sponsor: &_| {
1981            let mut request = request(nonce);
1982            sign_tempo_sponsor(&mut request, sender.address(), sponsor);
1983            sign_tempo_payload(request, &sender)
1984        };
1985        let first = payload(0, &sender);
1986        let duplicate = payload(0, &sponsor);
1987        assert_ne!(
1988            signed_payload_hash::<TempoNetwork>(&first).unwrap(),
1989            signed_payload_hash::<TempoNetwork>(&duplicate).unwrap()
1990        );
1991        assert_eq!(
1992            expiring_replay_hash(&first, true).unwrap(),
1993            expiring_replay_hash(&duplicate, true).unwrap()
1994        );
1995        let mut store = RecoveryStore::create(data, false, None).unwrap();
1996        let first_hash = store.persist_signed_payload(0, 0, first, true).unwrap();
1997        assert_eq!(store.signed_payload(0, 0).unwrap().hash, first_hash);
1998        let snapshot = std::fs::read(&store.path).unwrap();
1999        let duplicate_hash = signed_payload_hash::<TempoNetwork>(&duplicate).unwrap();
2000        assert_eq!(
2001            store.persist_signed_payload(1, 0, duplicate.clone(), true).unwrap_err().to_string(),
2002            format!(
2003                "transaction 0 is identical to an earlier transaction of this script ({duplicate_hash}) and would be rejected as a replay; identical Tempo expiring nonce transactions are only distinct from the T12 hardfork (TIP-1106)"
2004            )
2005        );
2006        assert!(store.signed_payload(1, 0).is_none());
2007        assert_eq!(std::fs::read(&store.path).unwrap(), snapshot);
2008        // Older snapshots may already contain a conflicting durable attempt.
2009        let id = store.plan.deployments[1].operations[0].id;
2010        store.plan.deployments[1].attempts.push(SubmissionAttempt {
2011            id: B256::random(),
2012            members: vec![id],
2013            kind: AttemptKind::Signed {
2014                request: None,
2015                payload: SignedPayload { payload: duplicate.clone(), hash: duplicate_hash },
2016            },
2017        });
2018        assert!(store.persist_signed_payload(1, 0, duplicate.clone(), true).is_err());
2019        store.plan.deployments[1].attempts.clear();
2020        // T1/T1A use envelope hashes, so the differently sponsored payload is executable.
2021        assert_eq!(store.persist_signed_payload(1, 0, duplicate, false).unwrap(), duplicate_hash);
2022        store.plan.deployments[1].attempts.clear();
2023        // T12 discriminators distinguish the second operation despite the sponsor change.
2024        let distinct = payload(1, &sponsor);
2025        let distinct_hash = signed_payload_hash::<TempoNetwork>(&distinct).unwrap();
2026        assert_eq!(store.persist_signed_payload(1, 0, distinct, true).unwrap(), distinct_hash);
2027        assert_eq!(store.signed_payload(1, 0).unwrap().hash, distinct_hash);
2028    }
2029
2030    fn expiring_request(from: Address, chain_id: u64, nonce: u64) -> TempoTransactionRequest {
2031        TempoTransactionRequest {
2032            inner: TransactionRequest {
2033                from: Some(from),
2034                to: Some(TxKind::Call(Address::with_last_byte(2))),
2035                chain_id: Some(chain_id),
2036                nonce: Some(nonce),
2037                gas: Some(100_000),
2038                max_fee_per_gas: Some(10),
2039                max_priority_fee_per_gas: Some(1),
2040                ..Default::default()
2041            },
2042            nonce_key: Some(TEMPO_EXPIRING_NONCE_KEY),
2043            valid_before: std::num::NonZeroU64::new(100),
2044            ..Default::default()
2045        }
2046    }
2047
2048    fn sign_tempo_payload(request: TempoTransactionRequest, signer: &impl SignerSync) -> Bytes {
2049        let tx = request.build_aa().unwrap();
2050        let signature = signer.sign_hash_sync(&tx.signature_hash()).unwrap();
2051        Bytes::from(
2052            TempoTxEnvelope::AA(AASigned::new_unhashed(
2053                tx,
2054                TempoSignature::Primitive(PrimitiveSignature::Secp256k1(signature)),
2055            ))
2056            .encoded_2718(),
2057        )
2058    }
2059
2060    #[test]
2061    fn sponsor_survives_reload_and_defaults_to_none() {
2062        let dir = tempfile::tempdir().unwrap();
2063        let data = sequence(dir.path());
2064        let paths = data.paths();
2065        let sponsor = Address::repeat_byte(0x55);
2066        let snapshot = RecoveryStore::create(data, false, Some(sponsor)).unwrap().path;
2067        let store = load(&paths, false).unwrap();
2068        assert_eq!(store.sponsor(), Some(sponsor));
2069        drop(store);
2070
2071        // Snapshots written before the sponsor was recorded load without one.
2072        let mut value: serde_json::Value = foundry_common::fs::read_json_file(&snapshot).unwrap();
2073        assert_eq!(value["sponsor"], serde_json::json!(sponsor));
2074        value.as_object_mut().unwrap().remove("sponsor");
2075        std::fs::write(&snapshot, serde_json::to_vec(&value).unwrap()).unwrap();
2076        assert_eq!(load(&paths, false).unwrap().sponsor(), None);
2077    }
2078
2079    #[test]
2080    fn new_execution_refuses_unfinished_progress() {
2081        let dir = tempfile::tempdir().unwrap();
2082        let paths = sequence(dir.path()).paths();
2083        let refused = format!(
2084            "the previous broadcast `{}` has unfinished operations; finish it with `--resume`, or pass `--start-fresh` to archive it and broadcast anew",
2085            paths.0.display()
2086        );
2087        let create = || RecoveryStore::create(sequence(dir.path()), false, None).map(|_| ());
2088
2089        // Progress without any submission is replaced.
2090        create().unwrap();
2091        create().unwrap();
2092
2093        // A submission attempt makes it unfinished, also when its commit was interrupted.
2094        let mut store = RecoveryStore::create(sequence(dir.path()), false, None).unwrap();
2095        store.persist_delegated_request(0, 0, Default::default()).unwrap();
2096        let snapshot = store.path.clone();
2097        drop(store);
2098        assert_eq!(create().unwrap_err().to_string(), refused);
2099        std::fs::rename(&snapshot, pending_path(&snapshot)).unwrap();
2100        RecoveryStore::<Ethereum>::archive_unfinished(&sequence(dir.path())).unwrap();
2101        assert!(!snapshot.exists() && !pending_path(&snapshot).exists());
2102        create().unwrap();
2103
2104        // An unreadable snapshot is never silently replaced.
2105        std::fs::write(&snapshot, "garbage").unwrap();
2106        assert_eq!(create().unwrap_err().to_string(), refused);
2107        RecoveryStore::<Ethereum>::archive_unfinished(&sequence(dir.path())).unwrap();
2108        create().unwrap();
2109
2110        // Without a snapshot, progress saved before snapshots existed is checked.
2111        std::fs::remove_file(&snapshot).unwrap();
2112        let mut legacy = sequence(dir.path());
2113        legacy.sequences_mut()[0].transactions[0].hash = Some(B256::ZERO);
2114        foundry_common::fs::write_json_file(&paths.0, &legacy.sequences()[0]).unwrap();
2115        assert_eq!(create().unwrap_err().to_string(), refused);
2116        RecoveryStore::<Ethereum>::archive_unfinished(&sequence(dir.path())).unwrap();
2117        assert!(!paths.0.exists());
2118        create().unwrap();
2119    }
2120}