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 #[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
96pub(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 pub(crate) fn archive_unfinished(data: &SequenceData<N>) -> Result<()> {
163 let paths = &data.paths();
164 let lock = RecoveryLock::acquire(paths)?;
165 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 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
954fn 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 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 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 || (!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
1198fn 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 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 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 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 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 assert_eq!(store.persist_signed_payload(1, 0, duplicate, false).unwrap(), duplicate_hash);
2022 store.plan.deployments[1].attempts.clear();
2023 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 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 create().unwrap();
2091 create().unwrap();
2092
2093 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 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 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}