1use crate::{
2 multi_sequence::MultiChainSequence,
3 recovery::{AttemptKind, DelegatedStatus, RecoveryLock, RecoveryStore, SignedPayload},
4};
5use alloy_consensus::transaction::SignerRecoverable;
6use alloy_eips::eip2718::{Decodable2718, Encodable2718};
7use alloy_network::{Network, ReceiptResponse};
8use alloy_primitives::{Address, B256, Bytes};
9use eyre::{ContextCompat, Report, Result, bail, eyre};
10use forge_script_sequence::{ScriptSequence, TransactionWithMetadata};
11use foundry_cli::utils::Git;
12use foundry_common::{FoundryTransactionBuilder, fmt::UIfmt};
13use foundry_compilers::ArtifactId;
14use foundry_config::Config;
15use serde::{Deserialize, Serialize};
16use std::{
17 collections::HashMap,
18 fmt::{Error, Write},
19 path::{Path, PathBuf},
20};
21
22pub(crate) fn completed_transaction_prefix<N: Network>(
23 sequence: &ScriptSequence<N>,
24) -> Result<usize> {
25 let mut receipts = HashMap::<_, usize>::new();
26 for receipt in &sequence.receipts {
27 *receipts.entry(receipt.transaction_hash()).or_default() += 1;
28 }
29
30 let mut prefix = 0;
31 let mut incomplete = false;
32 for transaction in &sequence.transactions {
33 let complete = transaction.hash.is_some_and(|hash| {
34 receipts.get_mut(&hash).is_some_and(|count| {
35 if *count == 0 {
36 false
37 } else {
38 *count -= 1;
39 true
40 }
41 })
42 });
43 if complete {
44 if incomplete {
45 bail!("script progress contains a receipt after an incomplete operation");
46 }
47 prefix += 1;
48 } else {
49 incomplete = true;
50 }
51 }
52 if receipts.values().any(|count| *count != 0) {
53 bail!("script progress contains a receipt without a matching operation");
54 }
55 Ok(prefix)
56}
57
58#[derive(Clone, Serialize, Deserialize)]
59#[serde(
60 tag = "kind",
61 content = "sequence",
62 rename_all = "camelCase",
63 bound(
64 serialize = "N::TxEnvelope: Serialize",
65 deserialize = "N::TxEnvelope: for<'de2> Deserialize<'de2>"
66 )
67)]
68pub(crate) enum SequenceData<N: Network> {
69 Single(Box<ScriptSequence<N>>),
70 Multi(MultiChainSequence<N>),
71}
72
73impl<N: Network> SequenceData<N>
74where
75 N::TxEnvelope: Serialize,
76{
77 fn save(&mut self, silent: bool, save_ts: bool) -> Result<()> {
78 match self {
79 Self::Single(sequence) => sequence.save(silent, save_ts),
80 Self::Multi(sequence) => sequence.save(silent, save_ts),
81 }
82 }
83
84 pub fn sequences(&self) -> &[ScriptSequence<N>] {
85 match self {
86 Self::Single(sequence) => std::slice::from_ref(&**sequence),
87 Self::Multi(sequence) => &sequence.deployments,
88 }
89 }
90
91 pub fn sequences_mut(&mut self) -> &mut [ScriptSequence<N>] {
92 match self {
93 Self::Single(sequence) => std::slice::from_mut(&mut **sequence),
94 Self::Multi(sequence) => &mut sequence.deployments,
95 }
96 }
97
98 pub const fn is_multi(&self) -> bool {
99 matches!(self, Self::Multi(_))
100 }
101
102 pub fn paths(&self) -> (std::path::PathBuf, std::path::PathBuf) {
103 match self {
104 Self::Single(sequence) => sequence.paths.clone().expect("sequence paths not set"),
105 Self::Multi(sequence) => (sequence.path.clone(), sequence.sensitive_path.clone()),
106 }
107 }
108
109 pub(crate) fn set_paths(&mut self, paths: (PathBuf, PathBuf)) {
110 match self {
111 Self::Single(sequence) => sequence.paths = Some(paths),
112 Self::Multi(sequence) => {
113 sequence.path = paths.0;
114 sequence.sensitive_path = paths.1;
115 }
116 }
117 }
118
119 pub(crate) fn has_recovery_generation(&self) -> bool {
120 self.sequences().iter().any(|sequence| sequence.recovery_generation.is_some())
121 }
122
123 pub(crate) fn set_recovery_generation(&mut self, generation: B256) {
124 for sequence in self.sequences_mut() {
125 sequence.recovery_generation = Some(generation);
126 }
127 }
128
129 pub(crate) fn publish(&self, paths: &(PathBuf, PathBuf)) -> Result<()> {
130 let mut export = self.clone();
131 export.set_paths(paths.clone());
132 export.save(true, false)
133 }
134
135 fn save_timestamped(&self, paths: &(PathBuf, PathBuf)) -> Result<()> {
136 match self {
137 Self::Single(sequence) => {
138 let filename = format!("run-{}.json", sequence.timestamp);
139 std::fs::copy(&paths.0, paths.0.with_file_name(&filename))?;
140 std::fs::copy(&paths.1, paths.1.with_file_name(filename))?;
141 }
142 Self::Multi(sequence) => {
143 let timestamp = sequence.timestamp;
144 for path in [&paths.0, &paths.1] {
145 let timestamped = PathBuf::from(
146 path.to_string_lossy().replace("-latest", &format!("-{timestamp}")),
147 );
148 std::fs::create_dir_all(timestamped.parent().unwrap())?;
149 std::fs::copy(path, timestamped)?;
150 }
151 }
152 }
153 Ok(())
154 }
155}
156
157pub struct ScriptSequenceKind<N: Network>
158where
159 N::TxEnvelope: for<'d> Deserialize<'d> + Serialize,
160{
161 recovery: RecoveryStore<N>,
162 rpc_override: Option<String>,
164}
165
166impl<N: Network> ScriptSequenceKind<N>
167where
168 N::TxEnvelope: for<'d> Deserialize<'d> + Serialize,
169{
170 #[cfg(test)]
171 pub fn new_single(
172 sequence: ScriptSequence<N>,
173 batch: bool,
174 sponsor: Option<Address>,
175 ) -> Result<Self> {
176 Self::create(SequenceData::Single(Box::new(sequence)), batch, sponsor)
177 }
178
179 pub fn load_single(
180 config: &Config,
181 sig: &str,
182 target: &ArtifactId,
183 chain: u64,
184 dry_run: bool,
185 batch: bool,
186 ) -> Result<Self>
187 where
188 N::TxEnvelope: SignerRecoverable,
189 N::TransactionRequest: FoundryTransactionBuilder<N>,
190 {
191 let paths = ScriptSequence::<N>::get_paths(config, sig, target, chain, dry_run)?;
192 Self::load(paths, batch, || {
193 ScriptSequence::load(config, sig, target, chain, dry_run)
194 .map(|sequence| SequenceData::Single(Box::new(sequence)))
195 })
196 }
197
198 pub fn load_multi(
199 config: &Config,
200 sig: &str,
201 target: &ArtifactId,
202 dry_run: bool,
203 batch: bool,
204 ) -> Result<Self>
205 where
206 N::TxEnvelope: SignerRecoverable,
207 N::TransactionRequest: FoundryTransactionBuilder<N>,
208 {
209 let paths = MultiChainSequence::<N>::get_paths(config, sig, target, dry_run)?;
210 Self::load(paths, batch, || {
211 MultiChainSequence::load(config, sig, target, dry_run).map(SequenceData::Multi)
212 })
213 }
214
215 fn load(
216 paths: (PathBuf, PathBuf),
217 batch: bool,
218 load_legacy: impl FnOnce() -> Result<SequenceData<N>>,
219 ) -> Result<Self>
220 where
221 N::TxEnvelope: SignerRecoverable,
222 N::TransactionRequest: FoundryTransactionBuilder<N>,
223 {
224 let lock = RecoveryLock::acquire(&paths)?;
225 let recovery = if let Some(recovery) = RecoveryStore::load(&paths, batch, lock)? {
226 recovery
227 } else {
228 let lock = RecoveryLock::acquire(&paths)?;
229 let data = load_legacy()?;
230 if data.has_recovery_generation() {
231 bail!("recovery exports reference a missing authoritative snapshot");
232 }
233 RecoveryStore::import(data, batch, lock)?
234 };
235 let sequence = Self { recovery, rpc_override: None };
236 sequence.recovery.data().publish(&paths)?;
237 Ok(sequence)
238 }
239
240 pub(crate) fn create(
241 data: SequenceData<N>,
242 batch: bool,
243 sponsor: Option<Address>,
244 ) -> Result<Self> {
245 let paths = data.paths();
246 let recovery = RecoveryStore::create(data, batch, sponsor)?;
247 let sequence = Self { recovery, rpc_override: None };
248 sequence.recovery.data().publish(&paths)?;
249 Ok(sequence)
250 }
251
252 pub fn save(&mut self, silent: bool, save_ts: bool) -> Result<()> {
253 self.recovery.save()?;
254 let paths = self.recovery.data().paths();
255 self.recovery.data().publish(&paths)?;
256 if save_ts {
257 self.recovery.data().save_timestamped(&paths)?;
258 }
259 if !silent {
260 if foundry_common::shell::is_json() {
261 sh_println!(
262 "{}",
263 serde_json::json!({
264 "status": "success",
265 "transactions": paths.0.display().to_string(),
266 "sensitive": paths.1.display().to_string(),
267 })
268 )?;
269 } else {
270 sh_println!("\nTransactions saved to: {}\n", paths.0.display())?;
271 sh_println!("Sensitive values saved to: {}\n", paths.1.display())?;
272 }
273 }
274 Ok(())
275 }
276
277 pub fn sequences(&self) -> &[ScriptSequence<N>] {
278 self.recovery.data().sequences()
279 }
280
281 pub fn sequences_mut(&mut self) -> &mut [ScriptSequence<N>] {
282 self.recovery.data_mut().sequences_mut()
283 }
284
285 pub(crate) fn set_rpc_override(&mut self, rpc: String) {
287 self.rpc_override = Some(rpc);
288 }
289
290 pub(crate) fn rpc_url(&self, index: usize) -> &str {
292 self.rpc_override.as_deref().unwrap_or_else(|| self.sequences()[index].rpc_url())
293 }
294
295 pub(crate) fn signed_payload(&self, sequence: usize, index: usize) -> Option<&SignedPayload> {
296 self.recovery.signed_payload(sequence, index)
297 }
298
299 pub(crate) fn submission_hashes(&self, sequence: usize) -> (Vec<B256>, Vec<B256>) {
300 self.recovery.submission_hashes(sequence)
301 }
302
303 pub(crate) fn persist_signed_payload(
304 &mut self,
305 sequence: usize,
306 index: usize,
307 payload: Bytes,
308 sender_scoped_replay: bool,
309 ) -> Result<B256>
310 where
311 N::TxEnvelope: SignerRecoverable,
312 N::TransactionRequest: FoundryTransactionBuilder<N>,
313 {
314 self.recovery.persist_signed_payload(sequence, index, payload, sender_scoped_replay)
315 }
316
317 pub(crate) fn delegated_status(
318 &self,
319 sequence: usize,
320 index: usize,
321 ) -> Option<DelegatedStatus> {
322 self.recovery.delegated_status(sequence, index)
323 }
324
325 pub(crate) fn persist_delegated_request(
326 &mut self,
327 sequence: usize,
328 index: usize,
329 request: N::TransactionRequest,
330 ) -> Result<()> {
331 self.recovery.persist_delegated_request(sequence, index, request)
332 }
333
334 pub(crate) fn persist_delegated_status(
335 &mut self,
336 sequence: usize,
337 index: usize,
338 status: DelegatedStatus,
339 ) -> Result<()> {
340 self.recovery.persist_delegated_status(sequence, index, status)
341 }
342
343 pub(crate) fn clear_delegated_request(&mut self, sequence: usize, index: usize) -> Result<()> {
344 self.recovery.clear_delegated_request(sequence, index)
345 }
346
347 pub(crate) fn resolve_delegated_hash(
348 &mut self,
349 attempt_id: B256,
350 hash: B256,
351 transaction: &N::TransactionResponse,
352 ) -> Result<()>
353 where
354 N::TransactionRequest: FoundryTransactionBuilder<N>,
355 {
356 let (sequence, index) =
357 self.recovery.resolve_delegated_hash(attempt_id, hash, transaction)?;
358 if !self.recovery.is_batch() {
359 self.sequences_mut()[sequence].add_pending(index, hash);
360 }
361 Ok(())
362 }
363
364 pub(crate) fn batch_attempt(
365 &self,
366 sequence: usize,
367 ) -> Option<(usize, B256, &AttemptKind<N::TransactionRequest>)> {
368 self.recovery.batch_attempt(sequence)
369 }
370
371 pub(crate) fn persist_batch_signed_payload(
372 &mut self,
373 sequence: usize,
374 first_operation: usize,
375 request: N::TransactionRequest,
376 payload: Bytes,
377 ) -> Result<B256>
378 where
379 N::TxEnvelope: Decodable2718 + Encodable2718,
380 {
381 self.recovery.persist_batch_signed_payload(sequence, first_operation, request, payload)
382 }
383
384 pub(crate) fn persist_batch_delegated_request(
385 &mut self,
386 sequence: usize,
387 first_operation: usize,
388 request: N::TransactionRequest,
389 ) -> Result<()> {
390 self.recovery.persist_batch_delegated_request(sequence, first_operation, request)
391 }
392
393 pub(crate) fn restore_delegated_pending(
394 &mut self,
395 attempt_id: Option<B256>,
396 resolved_hash: Option<B256>,
397 retry_unknown: bool,
398 ) -> Result<Option<(usize, usize, B256, B256)>> {
399 let resolution_requested = resolved_hash.is_some() || retry_unknown;
400 if resolution_requested && attempt_id.is_none() {
401 bail!("--resume-attempt is required to resolve an interrupted submission");
402 }
403
404 for (sequence, index, _, status) in self.recovery.delegated_attempts() {
405 if status == DelegatedStatus::Prepared {
406 self.recovery.persist_delegated_status(
407 sequence,
408 index,
409 DelegatedStatus::OutcomeUnknown,
410 )?;
411 }
412 }
413
414 let mut pending_resolution = None;
415 if let Some(attempt_id) = attempt_id {
416 let (sequence, index) = self
417 .recovery
418 .delegated_attempt_location(attempt_id)
419 .context("no interrupted delegated submission matches --resume-attempt")?;
420 if !matches!(
421 self.recovery.delegated_status(sequence, index),
422 Some(DelegatedStatus::OutcomeUnknown)
423 ) {
424 bail!("delegated submission attempt {attempt_id} does not require resolution");
425 }
426 if retry_unknown {
427 self.recovery.clear_delegated_request(sequence, index)?;
428 } else if let Some(hash) = resolved_hash {
429 pending_resolution = Some((sequence, index, attempt_id, hash));
430 } else {
431 bail!("--resume-attempt requires --resume-tx-hash or --resume-retry");
432 }
433 }
434
435 let batch = self.recovery.is_batch();
436 for (sequence, index, attempt, status) in self.recovery.delegated_attempts() {
437 match status {
438 DelegatedStatus::OutcomeUnknown => {
439 if pending_resolution.is_some_and(|(_, _, target, _)| target == attempt) {
440 continue;
441 }
442 bail!(
443 "submission outcome for delegated attempt {attempt} on chain {} is unknown; target it with --resume-attempt and provide --resume-tx-hash, or use --resume-retry only after proving it was not submitted",
444 self.sequences()[sequence].chain
445 );
446 }
447 DelegatedStatus::Pending { hash } if !batch => {
448 let deployment = &mut self.sequences_mut()[sequence];
449 if !deployment.reverted.contains(&hash)
450 && !deployment
451 .receipts
452 .iter()
453 .any(|receipt| receipt.transaction_hash() == hash)
454 {
455 deployment.add_pending(index, hash);
456 }
457 }
458 DelegatedStatus::Prepared | DelegatedStatus::Pending { .. } => {}
459 }
460 }
461 Ok(pending_resolution)
462 }
463
464 pub(crate) fn ensure_delegated_outcomes_known(&mut self, sequence: usize) -> Result<()> {
465 for index in 0..self.sequences()[sequence].transactions.len() {
466 let Some(DelegatedStatus::Pending { hash }) =
467 self.recovery.delegated_status(sequence, index)
468 else {
469 continue;
470 };
471 let deployment = &self.sequences()[sequence];
472 if deployment.pending.contains(&hash)
473 || deployment.reverted.contains(&hash)
474 || deployment.receipts.iter().any(|receipt| receipt.transaction_hash() == hash)
475 {
476 continue;
477 }
478 let chain = deployment.chain;
479 bail!(
480 "delegated submission {hash} for operation {index} on chain {chain} is not currently visible; its recovery identity remains pending"
481 );
482 }
483 Ok(())
484 }
485
486 pub const fn is_multi(&self) -> bool {
487 self.recovery.data().is_multi()
488 }
489
490 pub(crate) const fn sponsor(&self) -> Option<Address> {
492 self.recovery.sponsor()
493 }
494
495 pub fn promote_to_broadcasted(
497 &mut self,
498 config: &Config,
499 sig: &str,
500 target: &ArtifactId,
501 ) -> Result<()> {
502 let paths = match self.recovery.data() {
503 SequenceData::Single(sequence) => {
504 ScriptSequence::<N>::get_paths(config, sig, target, sequence.chain, false)?
505 }
506 SequenceData::Multi(_) => {
507 MultiChainSequence::<N>::get_paths(config, sig, target, false)?
508 }
509 };
510 let relocation = self.recovery.prepare_relocation(&paths)?;
511 self.recovery.commit_relocation(relocation)?;
512 self.recovery.data_mut().set_paths(paths.clone());
513 self.recovery.save()?;
514 self.recovery.data().publish(&paths)?;
515 self.recovery.data().save_timestamped(&paths)?;
516 Ok(())
517 }
518
519 pub fn show_transactions(&self) -> Result<()>
520 where
521 N::TxEnvelope: UIfmt,
522 N::TransactionRequest: FoundryTransactionBuilder<N>,
523 {
524 for sequence in self.sequences() {
525 if !sequence.transactions.is_empty() {
526 sh_println!("\nChain {}\n", sequence.chain)?;
527
528 for (i, tx) in sequence.transactions.iter().enumerate() {
529 sh_print!("{}", format_transaction(i + 1, tx)?)?;
530 }
531 }
532 }
533
534 Ok(())
535 }
536
537 pub(crate) fn label_error(&self, index: usize, err: Report) -> Report {
540 if !self.is_multi() {
541 return err;
542 }
543 let chain = self.sequences()[index].chain;
544 if self.sequences().iter().filter(|sequence| sequence.chain == chain).count() > 1 {
545 eyre!("chain {chain} (sequence #{}): {err}", index + 1)
546 } else {
547 eyre!("chain {chain}: {err}")
548 }
549 }
550}
551
552impl<N: Network> Drop for ScriptSequenceKind<N>
553where
554 N::TxEnvelope: for<'d> Deserialize<'d> + Serialize,
555{
556 fn drop(&mut self) {
557 if let Err(err) = self.save(false, true) {
558 error!(?err, "could not save deployment sequence");
559 }
560 }
561}
562
563fn format_transaction<N: Network>(
565 index: usize,
566 tx: &TransactionWithMetadata<N>,
567) -> Result<String, Error>
568where
569 N::TxEnvelope: UIfmt,
570 N::TransactionRequest: FoundryTransactionBuilder<N>,
571{
572 let mut output = String::new();
573 writeln!(output, "### Transaction {index} ###")?;
574 writeln!(output, "{}", tx.tx().pretty())?;
575
576 if !tx.call_kind.is_any_create()
578 && let (Some(name), Some(addr)) = (&tx.contract_name, &tx.contract_address)
579 {
580 writeln!(output, "contract: {name}({addr})")?;
581 }
582
583 if let (Some(func), Some(args)) = (&tx.display_function, &tx.arguments) {
585 if args.is_empty() {
586 writeln!(output, "data (decoded): {func}()")?;
587 } else {
588 writeln!(output, "data (decoded): {func}(")?;
589 for (i, arg) in args.iter().enumerate() {
590 writeln!(&mut output, " {}{}", arg, if i + 1 < args.len() { "," } else { "" })?;
591 }
592 writeln!(output, ")")?;
593 }
594 }
595
596 writeln!(output)?;
597 Ok(output)
598}
599
600pub fn get_commit_hash(root: &Path) -> Option<String> {
602 Git::new(root).commit_hash(true, "HEAD").ok()
603}
604
605pub(crate) fn operation_ordinal<N: Network>(
608 sequences: &[ScriptSequence<N>],
609 sequence: usize,
610 index: usize,
611) -> usize {
612 sequences[..sequence].iter().map(|sequence| sequence.transactions.len()).sum::<usize>() + index
613}
614
615pub(crate) fn blocked_pending<N: Network>(
619 sequences: &[ScriptSequence<N>],
620 sequence: usize,
621) -> Vec<B256> {
622 let chain = sequences[sequence].chain;
623 let mut gap_senders = Vec::new();
624 let mut blocked = Vec::new();
625 for (index, deployment) in sequences[..=sequence].iter().enumerate() {
626 if deployment.chain != chain {
627 continue;
628 }
629 for tx in &deployment.transactions {
630 let sender = tx.tx().from();
631 match tx.hash {
632 Some(hash) if deployment.pending.contains(&hash) => {
633 if index == sequence && gap_senders.contains(&sender) {
634 blocked.push(hash);
635 }
636 }
637 Some(hash)
638 if deployment.reverted.contains(&hash)
639 || deployment.receipts.iter().any(|r| r.transaction_hash() == hash) => {}
640 _ => gap_senders.push(sender),
641 }
642 }
643 }
644 blocked
645}
646
647#[cfg(test)]
648mod tests {
649 use super::*;
650 use alloy_network::Ethereum;
651 use alloy_rpc_types::TransactionRequest;
652 use foundry_common::TransactionMaybeSigned;
653
654 #[test]
655 fn operation_ordinals_include_earlier_sequences_and_completed_transactions() {
656 let transaction =
657 TransactionWithMetadata::from_tx_request(TransactionMaybeSigned::<Ethereum>::Unsigned(
658 Default::default(),
659 ));
660 let mut sequences = [
661 ScriptSequence {
662 chain: 4217,
663 transactions: [transaction.clone()].into(),
664 ..Default::default()
665 },
666 ScriptSequence {
667 chain: 4218,
668 transactions: [transaction.clone(), transaction.clone()].into(),
669 ..Default::default()
670 },
671 ScriptSequence {
672 chain: 4217,
673 transactions: [transaction].into(),
674 ..Default::default()
675 },
676 ];
677 assert_eq!(operation_ordinal(&sequences, 0, 0), 0);
678 assert_eq!(operation_ordinal(&sequences, 1, 1), 2);
679 assert_eq!(operation_ordinal(&sequences, 2, 0), 3);
680 sequences[0].transactions[0].hash = Some(B256::repeat_byte(1));
681 assert_eq!(operation_ordinal(&sequences, 2, 0), 3);
682 }
683
684 #[test]
685 fn pending_behind_a_missing_transaction_is_blocked_only_on_its_chain_and_sender() {
686 let (sender, other) = (Address::repeat_byte(1), Address::repeat_byte(2));
687 let tx = |from, hash| {
688 let mut tx = TransactionWithMetadata::from_tx_request(
689 TransactionMaybeSigned::<Ethereum>::Unsigned(
690 TransactionRequest::default().from(from),
691 ),
692 );
693 tx.hash = Some(B256::repeat_byte(hash));
694 tx
695 };
696 let sequence = |chain, transactions: Vec<_>, pending: &[u8]| ScriptSequence {
697 chain,
698 transactions: transactions.into(),
699 pending: pending.iter().copied().map(B256::repeat_byte).collect(),
700 ..Default::default()
701 };
702 let sequences = [
704 sequence(1, vec![tx(sender, 1), tx(sender, 2)], &[2]),
705 sequence(2, vec![tx(sender, 3)], &[3]),
706 sequence(1, vec![tx(other, 4), tx(sender, 5)], &[4, 5]),
707 ];
708 assert_eq!(blocked_pending(&sequences, 0), [B256::repeat_byte(2)]);
709 assert!(blocked_pending(&sequences, 1).is_empty());
710 assert_eq!(blocked_pending(&sequences, 2), [B256::repeat_byte(5)]);
711 }
712
713 fn unknown_delegated_sequence() -> (tempfile::TempDir, ScriptSequenceKind<Ethereum>, B256) {
714 let dir = tempfile::tempdir().unwrap();
715 let mut deployment = ScriptSequence::<Ethereum> {
716 chain: 1,
717 paths: Some((dir.path().join("broadcast.json"), dir.path().join("cache.json"))),
718 ..Default::default()
719 };
720 deployment.transactions.push_back(TransactionWithMetadata::from_tx_request(
721 TransactionMaybeSigned::Unsigned(Default::default()),
722 ));
723 let mut sequence = ScriptSequenceKind::new_single(deployment, false, None).unwrap();
724 sequence.persist_delegated_request(0, 0, Default::default()).unwrap();
725 sequence.persist_delegated_status(0, 0, DelegatedStatus::OutcomeUnknown).unwrap();
726 let attempt = sequence.recovery.delegated_attempts()[0].2;
727 (dir, sequence, attempt)
728 }
729
730 #[test]
731 fn publish_keeps_source_paths() {
732 let dir = tempfile::tempdir().unwrap();
733 let source_paths = (dir.path().join("dry-run.json"), dir.path().join("dry-run-cache.json"));
734 let mut sequence =
735 ScriptSequence::<Ethereum> { paths: Some(source_paths.clone()), ..Default::default() };
736 sequence.transactions.push_back(TransactionWithMetadata::from_tx_request(
737 TransactionMaybeSigned::Unsigned(Default::default()),
738 ));
739 let data = SequenceData::Single(Box::new(sequence));
740
741 let public = dir.path().join("broadcast.json");
742 let sensitive = dir.path().join("broadcast-cache.json");
743 let paths = (public, sensitive.clone());
744 data.publish(&paths).unwrap();
745
746 assert_eq!(data.paths(), source_paths);
747 let value: serde_json::Value = foundry_common::fs::read_json_file(&sensitive).unwrap();
748 assert_eq!(value["transactions"].as_array().unwrap().len(), 1);
749 }
750
751 #[test]
752 fn any_deployment_generation_blocks_legacy_import() {
753 let mut data = SequenceData::<Ethereum>::Multi(MultiChainSequence {
754 deployments: vec![ScriptSequence::default(), ScriptSequence::default()],
755 path: PathBuf::new(),
756 sensitive_path: PathBuf::new(),
757 timestamp: 0,
758 });
759 data.sequences_mut()[1].recovery_generation = Some(B256::repeat_byte(0x11));
760
761 assert!(data.has_recovery_generation());
762 }
763
764 #[test]
765 fn delegated_outcome_requires_explicit_operator_resolution() {
766 let (_dir, mut sequence, attempt) = unknown_delegated_sequence();
767 let hash = B256::repeat_byte(0x42);
768
769 assert_eq!(
770 sequence.restore_delegated_pending(Some(attempt), Some(hash), false).unwrap(),
771 Some((0, 0, attempt, hash))
772 );
773
774 assert!(matches!(sequence.delegated_status(0, 0), Some(DelegatedStatus::OutcomeUnknown)));
775 assert!(sequence.sequences()[0].pending.is_empty());
776
777 let (_dir, mut sequence, attempt) = unknown_delegated_sequence();
778
779 sequence.restore_delegated_pending(Some(attempt), None, true).unwrap();
780
781 assert!(sequence.delegated_status(0, 0).is_none());
782 }
783
784 #[test]
785 fn authoritative_load_does_not_read_legacy_exports() {
786 let (_dir, sequence, attempt) = unknown_delegated_sequence();
787 let paths = sequence.recovery.data().paths();
788 drop(sequence);
789 std::fs::remove_file(&paths.0).unwrap();
790 std::fs::remove_file(&paths.1).unwrap();
791
792 let loaded = ScriptSequenceKind::<Ethereum>::load(paths.clone(), false, || {
793 panic!("authoritative recovery must not load compatibility exports");
794 })
795 .unwrap();
796 assert_eq!(loaded.recovery.delegated_attempts()[0].2, attempt);
797 assert!(paths.0.exists());
798 assert!(paths.1.exists());
799 }
800}