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::{B256, Bytes};
9use eyre::{ContextCompat, Result, bail};
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(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(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}
163
164impl<N: Network> ScriptSequenceKind<N>
165where
166    N::TxEnvelope: for<'d> Deserialize<'d> + Serialize,
167{
168    pub fn new_single(sequence: ScriptSequence<N>, batch: bool) -> Result<Self> {
169        Self::create(SequenceData::Single(sequence), batch)
170    }
171
172    pub fn new_multi(sequence: MultiChainSequence<N>, batch: bool) -> Result<Self> {
173        Self::create(SequenceData::Multi(sequence), batch)
174    }
175
176    pub fn load_single(
177        config: &Config,
178        sig: &str,
179        target: &ArtifactId,
180        chain: u64,
181        dry_run: bool,
182        batch: bool,
183    ) -> Result<Self>
184    where
185        N::TxEnvelope: SignerRecoverable,
186        N::TransactionRequest: FoundryTransactionBuilder<N>,
187    {
188        let paths = ScriptSequence::<N>::get_paths(config, sig, target, chain, dry_run)?;
189        Self::load(paths, batch, || {
190            ScriptSequence::load(config, sig, target, chain, dry_run).map(SequenceData::Single)
191        })
192    }
193
194    pub fn load_multi(
195        config: &Config,
196        sig: &str,
197        target: &ArtifactId,
198        dry_run: bool,
199        batch: bool,
200    ) -> Result<Self>
201    where
202        N::TxEnvelope: SignerRecoverable,
203        N::TransactionRequest: FoundryTransactionBuilder<N>,
204    {
205        let paths = MultiChainSequence::<N>::get_paths(config, sig, target, dry_run)?;
206        Self::load(paths, batch, || {
207            MultiChainSequence::load(config, sig, target, dry_run).map(SequenceData::Multi)
208        })
209    }
210
211    fn load(
212        paths: (PathBuf, PathBuf),
213        batch: bool,
214        load_legacy: impl FnOnce() -> Result<SequenceData<N>>,
215    ) -> Result<Self>
216    where
217        N::TxEnvelope: SignerRecoverable,
218        N::TransactionRequest: FoundryTransactionBuilder<N>,
219    {
220        let lock = RecoveryLock::acquire(&paths)?;
221        let recovery = if let Some(recovery) = RecoveryStore::load(&paths, batch, lock)? {
222            recovery
223        } else {
224            let lock = RecoveryLock::acquire(&paths)?;
225            let data = load_legacy()?;
226            if data.has_recovery_generation() {
227                bail!("recovery exports reference a missing authoritative snapshot");
228            }
229            RecoveryStore::import(data, batch, lock)?
230        };
231        let sequence = Self { recovery };
232        sequence.recovery.data().publish(&paths)?;
233        Ok(sequence)
234    }
235
236    fn create(data: SequenceData<N>, batch: bool) -> Result<Self> {
237        let paths = data.paths();
238        let recovery = RecoveryStore::create(data, batch)?;
239        let sequence = Self { recovery };
240        sequence.recovery.data().publish(&paths)?;
241        Ok(sequence)
242    }
243
244    pub fn save(&mut self, silent: bool, save_ts: bool) -> Result<()> {
245        self.recovery.save()?;
246        let paths = self.recovery.data().paths();
247        self.recovery.data().publish(&paths)?;
248        if save_ts {
249            self.recovery.data().save_timestamped(&paths)?;
250        }
251        if !silent {
252            if foundry_common::shell::is_json() {
253                sh_println!(
254                    "{}",
255                    serde_json::json!({
256                        "status": "success",
257                        "transactions": paths.0.display().to_string(),
258                        "sensitive": paths.1.display().to_string(),
259                    })
260                )?;
261            } else {
262                sh_println!("\nTransactions saved to: {}\n", paths.0.display())?;
263                sh_println!("Sensitive values saved to: {}\n", paths.1.display())?;
264            }
265        }
266        Ok(())
267    }
268
269    pub fn sequences(&self) -> &[ScriptSequence<N>] {
270        self.recovery.data().sequences()
271    }
272
273    pub fn sequences_mut(&mut self) -> &mut [ScriptSequence<N>] {
274        self.recovery.data_mut().sequences_mut()
275    }
276
277    pub(crate) fn signed_payload(&self, sequence: usize, index: usize) -> Option<&SignedPayload> {
278        self.recovery.signed_payload(sequence, index)
279    }
280
281    pub(crate) fn submission_hashes(&self, sequence: usize) -> (Vec<B256>, Vec<B256>) {
282        self.recovery.submission_hashes(sequence)
283    }
284
285    pub(crate) fn persist_signed_payload(
286        &mut self,
287        sequence: usize,
288        index: usize,
289        payload: Bytes,
290    ) -> Result<B256>
291    where
292        N::TxEnvelope: SignerRecoverable,
293        N::TransactionRequest: FoundryTransactionBuilder<N>,
294    {
295        self.recovery.persist_signed_payload(sequence, index, payload)
296    }
297
298    pub(crate) fn delegated_status(
299        &self,
300        sequence: usize,
301        index: usize,
302    ) -> Option<DelegatedStatus> {
303        self.recovery.delegated_status(sequence, index)
304    }
305
306    pub(crate) fn persist_delegated_request(
307        &mut self,
308        sequence: usize,
309        index: usize,
310        request: N::TransactionRequest,
311    ) -> Result<()> {
312        self.recovery.persist_delegated_request(sequence, index, request)
313    }
314
315    pub(crate) fn persist_delegated_status(
316        &mut self,
317        sequence: usize,
318        index: usize,
319        status: DelegatedStatus,
320    ) -> Result<()> {
321        self.recovery.persist_delegated_status(sequence, index, status)
322    }
323
324    pub(crate) fn clear_delegated_request(&mut self, sequence: usize, index: usize) -> Result<()> {
325        self.recovery.clear_delegated_request(sequence, index)
326    }
327
328    pub(crate) fn resolve_delegated_hash(
329        &mut self,
330        attempt_id: B256,
331        hash: B256,
332        transaction: &N::TransactionResponse,
333    ) -> Result<()>
334    where
335        N::TransactionRequest: FoundryTransactionBuilder<N>,
336    {
337        let (sequence, index) =
338            self.recovery.resolve_delegated_hash(attempt_id, hash, transaction)?;
339        if !self.recovery.is_batch() {
340            self.sequences_mut()[sequence].add_pending(index, hash);
341        }
342        Ok(())
343    }
344
345    pub(crate) fn batch_attempt(
346        &self,
347        sequence: usize,
348    ) -> Option<(usize, B256, &AttemptKind<N::TransactionRequest>)> {
349        self.recovery.batch_attempt(sequence)
350    }
351
352    pub(crate) fn persist_batch_signed_payload(
353        &mut self,
354        sequence: usize,
355        first_operation: usize,
356        request: N::TransactionRequest,
357        payload: Bytes,
358    ) -> Result<B256>
359    where
360        N::TxEnvelope: Decodable2718 + Encodable2718,
361    {
362        self.recovery.persist_batch_signed_payload(sequence, first_operation, request, payload)
363    }
364
365    pub(crate) fn persist_batch_delegated_request(
366        &mut self,
367        sequence: usize,
368        first_operation: usize,
369        request: N::TransactionRequest,
370    ) -> Result<()> {
371        self.recovery.persist_batch_delegated_request(sequence, first_operation, request)
372    }
373
374    pub(crate) fn restore_delegated_pending(
375        &mut self,
376        attempt_id: Option<B256>,
377        resolved_hash: Option<B256>,
378        retry_unknown: bool,
379    ) -> Result<Option<(usize, usize, B256, B256)>> {
380        let resolution_requested = resolved_hash.is_some() || retry_unknown;
381        if resolution_requested && attempt_id.is_none() {
382            bail!("--resume-attempt is required to resolve an interrupted submission");
383        }
384
385        for (sequence, index, _, status) in self.recovery.delegated_attempts() {
386            if status == DelegatedStatus::Prepared {
387                self.recovery.persist_delegated_status(
388                    sequence,
389                    index,
390                    DelegatedStatus::OutcomeUnknown,
391                )?;
392            }
393        }
394
395        let mut pending_resolution = None;
396        if let Some(attempt_id) = attempt_id {
397            let (sequence, index) = self
398                .recovery
399                .delegated_attempt_location(attempt_id)
400                .context("no interrupted delegated submission matches --resume-attempt")?;
401            if !matches!(
402                self.recovery.delegated_status(sequence, index),
403                Some(DelegatedStatus::OutcomeUnknown)
404            ) {
405                bail!("delegated submission attempt {attempt_id} does not require resolution");
406            }
407            if retry_unknown {
408                self.recovery.clear_delegated_request(sequence, index)?;
409            } else if let Some(hash) = resolved_hash {
410                pending_resolution = Some((sequence, index, attempt_id, hash));
411            } else {
412                bail!("--resume-attempt requires --resume-tx-hash or --resume-retry");
413            }
414        }
415
416        let batch = self.recovery.is_batch();
417        for (sequence, index, attempt, status) in self.recovery.delegated_attempts() {
418            match status {
419                DelegatedStatus::OutcomeUnknown => {
420                    if pending_resolution.is_some_and(|(_, _, target, _)| target == attempt) {
421                        continue;
422                    }
423                    bail!(
424                        "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",
425                        self.sequences()[sequence].chain
426                    );
427                }
428                DelegatedStatus::Pending { hash } if !batch => {
429                    let deployment = &mut self.sequences_mut()[sequence];
430                    if !deployment.receipts.iter().any(|receipt| receipt.transaction_hash() == hash)
431                    {
432                        deployment.add_pending(index, hash);
433                    }
434                }
435                DelegatedStatus::Prepared | DelegatedStatus::Pending { .. } => {}
436            }
437        }
438        Ok(pending_resolution)
439    }
440
441    pub(crate) fn ensure_delegated_outcomes_known(&mut self, sequence: usize) -> Result<()> {
442        for index in 0..self.sequences()[sequence].transactions.len() {
443            let Some(DelegatedStatus::Pending { hash }) =
444                self.recovery.delegated_status(sequence, index)
445            else {
446                continue;
447            };
448            let deployment = &self.sequences()[sequence];
449            if deployment.pending.contains(&hash)
450                || deployment.receipts.iter().any(|receipt| receipt.transaction_hash() == hash)
451            {
452                continue;
453            }
454            let chain = deployment.chain;
455            bail!(
456                "delegated submission {hash} for operation {index} on chain {chain} is not currently visible; its recovery identity remains pending"
457            );
458        }
459        Ok(())
460    }
461
462    pub const fn is_multi(&self) -> bool {
463        self.recovery.data().is_multi()
464    }
465
466    /// Atomically promotes a dry-run sequence to the broadcast paths.
467    pub fn promote_to_broadcasted(
468        &mut self,
469        config: &Config,
470        sig: &str,
471        target: &ArtifactId,
472    ) -> Result<()> {
473        let paths = match self.recovery.data() {
474            SequenceData::Single(sequence) => {
475                ScriptSequence::<N>::get_paths(config, sig, target, sequence.chain, false)?
476            }
477            SequenceData::Multi(_) => {
478                MultiChainSequence::<N>::get_paths(config, sig, target, false)?
479            }
480        };
481        let relocation = self.recovery.prepare_relocation(&paths)?;
482        self.recovery.commit_relocation(relocation)?;
483        self.recovery.data_mut().set_paths(paths.clone());
484        self.recovery.save()?;
485        self.recovery.data().publish(&paths)?;
486        self.recovery.data().save_timestamped(&paths)?;
487        Ok(())
488    }
489
490    pub fn show_transactions(&self) -> Result<()>
491    where
492        N::TxEnvelope: UIfmt,
493        N::TransactionRequest: FoundryTransactionBuilder<N>,
494    {
495        for sequence in self.sequences() {
496            if !sequence.transactions.is_empty() {
497                sh_println!("\nChain {}\n", sequence.chain)?;
498
499                for (i, tx) in sequence.transactions.iter().enumerate() {
500                    sh_print!("{}", format_transaction(i + 1, tx)?)?;
501                }
502            }
503        }
504
505        Ok(())
506    }
507}
508
509impl<N: Network> Drop for ScriptSequenceKind<N>
510where
511    N::TxEnvelope: for<'d> Deserialize<'d> + Serialize,
512{
513    fn drop(&mut self) {
514        if let Err(err) = self.save(false, true) {
515            error!(?err, "could not save deployment sequence");
516        }
517    }
518}
519
520/// Format transaction details for display
521fn format_transaction<N: Network>(
522    index: usize,
523    tx: &TransactionWithMetadata<N>,
524) -> Result<String, Error>
525where
526    N::TxEnvelope: UIfmt,
527    N::TransactionRequest: FoundryTransactionBuilder<N>,
528{
529    let mut output = String::new();
530    writeln!(output, "### Transaction {index} ###")?;
531    writeln!(output, "{}", tx.tx().pretty())?;
532
533    // Show contract name and address if available
534    if !tx.call_kind.is_any_create()
535        && let (Some(name), Some(addr)) = (&tx.contract_name, &tx.contract_address)
536    {
537        writeln!(output, "contract: {name}({addr})")?;
538    }
539
540    // Show decoded function if available
541    if let (Some(func), Some(args)) = (&tx.display_function, &tx.arguments) {
542        if args.is_empty() {
543            writeln!(output, "data (decoded): {func}()")?;
544        } else {
545            writeln!(output, "data (decoded): {func}(")?;
546            for (i, arg) in args.iter().enumerate() {
547                writeln!(&mut output, "  {}{}", arg, if i + 1 < args.len() { "," } else { "" })?;
548            }
549            writeln!(output, ")")?;
550        }
551    }
552
553    writeln!(output)?;
554    Ok(output)
555}
556
557/// Returns the commit hash of the project if it exists
558pub fn get_commit_hash(root: &Path) -> Option<String> {
559    Git::new(root).commit_hash(true, "HEAD").ok()
560}
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565    use alloy_network::Ethereum;
566    use foundry_common::TransactionMaybeSigned;
567
568    fn unknown_delegated_sequence() -> (tempfile::TempDir, ScriptSequenceKind<Ethereum>, B256) {
569        let dir = tempfile::tempdir().unwrap();
570        let mut deployment = ScriptSequence::<Ethereum> {
571            chain: 1,
572            paths: Some((dir.path().join("broadcast.json"), dir.path().join("cache.json"))),
573            ..Default::default()
574        };
575        deployment.transactions.push_back(TransactionWithMetadata::from_tx_request(
576            TransactionMaybeSigned::Unsigned(Default::default()),
577        ));
578        let mut sequence = ScriptSequenceKind::new_single(deployment, false).unwrap();
579        sequence.persist_delegated_request(0, 0, Default::default()).unwrap();
580        sequence.persist_delegated_status(0, 0, DelegatedStatus::OutcomeUnknown).unwrap();
581        let attempt = sequence.recovery.delegated_attempts()[0].2;
582        (dir, sequence, attempt)
583    }
584
585    #[test]
586    fn publish_keeps_source_paths() {
587        let dir = tempfile::tempdir().unwrap();
588        let source_paths = (dir.path().join("dry-run.json"), dir.path().join("dry-run-cache.json"));
589        let mut sequence =
590            ScriptSequence::<Ethereum> { paths: Some(source_paths.clone()), ..Default::default() };
591        sequence.transactions.push_back(TransactionWithMetadata::from_tx_request(
592            TransactionMaybeSigned::Unsigned(Default::default()),
593        ));
594        let data = SequenceData::Single(sequence);
595
596        let public = dir.path().join("broadcast.json");
597        let sensitive = dir.path().join("broadcast-cache.json");
598        let paths = (public, sensitive.clone());
599        data.publish(&paths).unwrap();
600
601        assert_eq!(data.paths(), source_paths);
602        let value: serde_json::Value = foundry_common::fs::read_json_file(&sensitive).unwrap();
603        assert_eq!(value["transactions"].as_array().unwrap().len(), 1);
604    }
605
606    #[test]
607    fn any_deployment_generation_blocks_legacy_import() {
608        let mut data = SequenceData::<Ethereum>::Multi(MultiChainSequence {
609            deployments: vec![ScriptSequence::default(), ScriptSequence::default()],
610            path: PathBuf::new(),
611            sensitive_path: PathBuf::new(),
612            timestamp: 0,
613        });
614        data.sequences_mut()[1].recovery_generation = Some(B256::repeat_byte(0x11));
615
616        assert!(data.has_recovery_generation());
617    }
618
619    #[test]
620    fn delegated_outcome_requires_explicit_operator_resolution() {
621        let (_dir, mut sequence, attempt) = unknown_delegated_sequence();
622        let hash = B256::repeat_byte(0x42);
623
624        assert_eq!(
625            sequence.restore_delegated_pending(Some(attempt), Some(hash), false).unwrap(),
626            Some((0, 0, attempt, hash))
627        );
628
629        assert!(matches!(sequence.delegated_status(0, 0), Some(DelegatedStatus::OutcomeUnknown)));
630        assert!(sequence.sequences()[0].pending.is_empty());
631
632        let (_dir, mut sequence, attempt) = unknown_delegated_sequence();
633
634        sequence.restore_delegated_pending(Some(attempt), None, true).unwrap();
635
636        assert!(sequence.delegated_status(0, 0).is_none());
637    }
638
639    #[test]
640    fn authoritative_load_does_not_read_legacy_exports() {
641        let (_dir, sequence, attempt) = unknown_delegated_sequence();
642        let paths = sequence.recovery.data().paths();
643        drop(sequence);
644        std::fs::remove_file(&paths.0).unwrap();
645        std::fs::remove_file(&paths.1).unwrap();
646
647        let loaded = ScriptSequenceKind::<Ethereum>::load(paths.clone(), false, || {
648            panic!("authoritative recovery must not load compatibility exports");
649        })
650        .unwrap();
651        assert_eq!(loaded.recovery.delegated_attempts()[0].2, attempt);
652        assert!(paths.0.exists());
653        assert!(paths.1.exists());
654    }
655}