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
88pub(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 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 || (!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 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 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}