Skip to main content

forge_script/
recovery.rs

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