Skip to main content

forge_script/
sequence.rs

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    /// Endpoint that replaces the saved one for this run, never persisted.
163    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    /// Uses `rpc` instead of the saved endpoint for the rest of this run.
286    pub(crate) fn set_rpc_override(&mut self, rpc: String) {
287        self.rpc_override = Some(rpc);
288    }
289
290    /// Returns the endpoint used to reach the sequence at `index`.
291    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    /// Returns the Tempo sponsor recorded when the deployment was created.
491    pub(crate) const fn sponsor(&self) -> Option<Address> {
492        self.recovery.sponsor()
493    }
494
495    /// Atomically promotes a dry-run sequence to the broadcast paths.
496    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    /// Prefixes a multichain error with the chain of the sequence at `index`, adding the sequence
538    /// number when another sequence is on the same chain.
539    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
563/// Format transaction details for display
564fn 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    // Show contract name and address if available
577    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    // Show decoded function if available
584    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
600/// Returns the commit hash of the project if it exists
601pub fn get_commit_hash(root: &Path) -> Option<String> {
602    Git::new(root).commit_hash(true, "HEAD").ok()
603}
604
605/// Returns a stable transaction ordinal across the complete script plan, including completed
606/// transactions so that resuming does not change expiring nonce discriminators.
607pub(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
615/// Returns the pending hashes of `sequence` that cannot be mined yet, because an earlier
616/// transaction on the same chain from the same sender has no receipt, recorded revert, or pending
617/// hash.
618pub(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        // The first transaction is neither pending nor confirmed.
703        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}