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 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
520fn 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 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 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
557pub 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}