1use crate::transaction::TransactionWithMetadata;
2use alloy_network::{Network, ReceiptResponse};
3use alloy_primitives::{TxHash, hex, map::HashMap};
4use eyre::{ContextCompat, Result, WrapErr};
5use foundry_common::{SELECTOR_LEN, TransactionMaybeSigned, fs, shell};
6use foundry_compilers::ArtifactId;
7use foundry_config::Config;
8use serde::{Deserialize, Serialize};
9use std::{
10 collections::VecDeque,
11 path::PathBuf,
12 time::{Duration, SystemTime, UNIX_EPOCH},
13};
14
15pub const DRY_RUN_DIR: &str = "dry-run";
16
17#[derive(Clone, Serialize, Deserialize)]
18pub struct NestedValue {
19 pub internal_type: String,
20 pub value: String,
21}
22
23#[derive(Clone, Default, Serialize, Deserialize)]
25pub struct SensitiveTransactionMetadata {
26 pub rpc: String,
27}
28
29#[derive(Clone, Default, Serialize, Deserialize)]
31pub struct SensitiveScriptSequence {
32 pub transactions: VecDeque<SensitiveTransactionMetadata>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub recovery_generation: Option<TxHash>,
35}
36
37#[derive(Clone, Serialize, Deserialize)]
40#[serde(bound(
41 serialize = "N::TransactionRequest: Serialize, N::TxEnvelope: Serialize",
42 deserialize = "N::TransactionRequest: for<'de2> Deserialize<'de2>, N::TxEnvelope: for<'de2> Deserialize<'de2>"
43))]
44pub struct ScriptSequence<N: Network> {
45 pub transactions: VecDeque<TransactionWithMetadata<N>>,
46 pub receipts: Vec<N::ReceiptResponse>,
47 pub libraries: Vec<String>,
48 pub pending: Vec<TxHash>,
49 #[serde(skip)]
50 pub paths: Option<(PathBuf, PathBuf)>,
53 pub returns: HashMap<String, NestedValue>,
54 pub timestamp: u128,
55 pub chain: u64,
56 pub commit: Option<String>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub recovery_generation: Option<TxHash>,
59 #[serde(default, skip_serializing_if = "Vec::is_empty")]
62 pub reverted: Vec<TxHash>,
63}
64
65impl<N: Network> Default for ScriptSequence<N> {
66 fn default() -> Self {
67 Self {
68 transactions: Default::default(),
69 receipts: Default::default(),
70 libraries: Default::default(),
71 pending: Default::default(),
72 paths: Default::default(),
73 returns: Default::default(),
74 timestamp: Default::default(),
75 chain: Default::default(),
76 commit: Default::default(),
77 recovery_generation: Default::default(),
78 reverted: Default::default(),
79 }
80 }
81}
82
83impl<N: Network> From<&ScriptSequence<N>> for SensitiveScriptSequence {
84 fn from(sequence: &ScriptSequence<N>) -> Self {
85 Self {
86 transactions: sequence
87 .transactions
88 .iter()
89 .map(|tx| SensitiveTransactionMetadata { rpc: tx.rpc.clone() })
90 .collect(),
91 recovery_generation: sequence.recovery_generation,
92 }
93 }
94}
95
96impl<N: Network> ScriptSequence<N> {
97 pub fn load(
99 config: &Config,
100 sig: &str,
101 target: &ArtifactId,
102 chain_id: u64,
103 dry_run: bool,
104 ) -> Result<Self>
105 where
106 N::TxEnvelope: for<'d> Deserialize<'d>,
107 {
108 let (path, sensitive_path) = Self::get_paths(config, sig, target, chain_id, dry_run)?;
109
110 let mut script_sequence: Self = fs::read_json_file(&path)
111 .wrap_err(format!("Deployment not found for chain `{chain_id}`."))?;
112
113 let sensitive_script_sequence: SensitiveScriptSequence = fs::read_json_file(
114 &sensitive_path,
115 )
116 .wrap_err(format!("Deployment's sensitive details not found for chain `{chain_id}`."))?;
117
118 script_sequence.fill_sensitive(&sensitive_script_sequence).wrap_err(format!(
119 "Deployment's sensitive details are out of sync with the broadcast file for chain `{chain_id}`; restore matching broadcast and sensitive-cache files before resuming."
120 ))?;
121
122 script_sequence.paths = Some((path, sensitive_path));
123
124 Ok(script_sequence)
125 }
126
127 pub fn save(&mut self, silent: bool, save_ts: bool) -> Result<()>
131 where
132 N::TxEnvelope: Serialize,
133 {
134 self.sort_receipts();
135
136 if self.transactions.is_empty() {
137 return Ok(());
138 }
139
140 self.timestamp = now().as_millis();
141 let ts_name = format!("run-{}.json", self.timestamp);
142
143 let sensitive_script_sequence = SensitiveScriptSequence::from(&*self);
144
145 let Some((path, sensitive_path)) = self.paths.as_ref() else { return Ok(()) };
146
147 fs::write_pretty_json_file(path, &self)?;
150 if save_ts {
151 fs::copy(path, path.with_file_name(&ts_name))?;
153 }
154
155 fs::write_sensitive_json_file(sensitive_path, &sensitive_script_sequence)?;
158 if save_ts {
159 fs::copy(sensitive_path, sensitive_path.with_file_name(&ts_name))?;
161 }
162
163 if !silent {
164 if shell::is_json() {
165 sh_println!(
166 "{}",
167 serde_json::json!({
168 "status": "success",
169 "transactions": path.display().to_string(),
170 "sensitive": sensitive_path.display().to_string(),
171 })
172 )?;
173 } else {
174 sh_println!("\nTransactions saved to: {}\n", path.display())?;
175 sh_println!("Sensitive values saved to: {}\n", sensitive_path.display())?;
176 }
177 }
178
179 Ok(())
180 }
181
182 pub fn add_receipt(&mut self, receipt: N::ReceiptResponse) {
183 self.receipts.push(receipt);
184 }
185
186 pub fn sort_receipts(&mut self) {
188 self.receipts.sort_by_key(|r| (r.block_number(), r.transaction_index()));
189 }
190
191 pub fn add_pending(&mut self, index: usize, tx_hash: TxHash) {
192 if !self.pending.contains(&tx_hash) {
193 self.transactions[index].hash = Some(tx_hash);
194 self.pending.push(tx_hash);
195 }
196 }
197
198 pub fn remove_pending(&mut self, tx_hash: TxHash) {
199 self.pending.retain(|element| element != &tx_hash);
200 }
201
202 pub fn get_paths(
206 config: &Config,
207 sig: &str,
208 target: &ArtifactId,
209 chain_id: u64,
210 dry_run: bool,
211 ) -> Result<(PathBuf, PathBuf)> {
212 let mut broadcast = config.broadcast.clone();
213 let mut cache = config.cache_path.clone();
214 let mut common = PathBuf::new();
215
216 let target_fname = target.source.file_name().wrap_err("No filename.")?;
217 common.push(target_fname);
218 common.push(chain_id.to_string());
219 if dry_run {
220 common.push(DRY_RUN_DIR);
221 }
222
223 broadcast.push(common.clone());
224 cache.push(common);
225
226 fs::create_dir_all(&broadcast)?;
227 fs::create_dir_all(&cache)?;
228
229 let filename = sig_to_file_name(sig);
231 let filename_with_ext = format!("{filename}-latest.json");
232
233 broadcast.push(&filename_with_ext);
234 cache.push(&filename_with_ext);
235
236 Ok((broadcast, cache))
237 }
238
239 pub fn rpc_url(&self) -> &str {
241 self.transactions.front().expect("empty sequence").rpc.as_str()
242 }
243
244 pub fn transactions(&self) -> impl Iterator<Item = &TransactionMaybeSigned<N>> {
246 self.transactions.iter().map(|tx| tx.tx())
247 }
248
249 pub fn fill_sensitive(&mut self, sensitive: &SensitiveScriptSequence) -> Result<()> {
251 if self.recovery_generation != sensitive.recovery_generation {
252 eyre::bail!(
253 "the broadcast file and its sensitive-cache counterpart belong to different recovery generations"
254 );
255 }
256 let transactions_len = self.transactions.len();
257 let sensitive_len = sensitive.transactions.len();
258 if transactions_len != sensitive_len {
259 eyre::bail!(
260 "sensitive-cache entry count ({sensitive_len}) does not match transaction count \
261 ({transactions_len}); the broadcast file and its sensitive-cache counterpart are \
262 out of sync"
263 );
264 }
265 for (i, tx) in self.transactions.iter_mut().enumerate() {
266 tx.rpc.clone_from(&sensitive.transactions[i].rpc);
267 }
268 Ok(())
269 }
270}
271
272pub fn sig_to_file_name(sig: &str) -> String {
276 if let Some((name, _)) = sig.split_once('(') {
277 return name.to_string();
279 }
280 if let Ok(calldata) = hex::decode(sig) {
282 if let Some(selector) = calldata.get(..SELECTOR_LEN) {
284 return hex::encode(selector);
285 }
286 return sig.to_string();
288 }
289
290 sig.to_string()
291}
292
293pub fn now() -> Duration {
294 SystemTime::now().duration_since(UNIX_EPOCH).expect("time went backwards")
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300 use alloy_network::Ethereum;
301
302 fn sequence_with_two_transactions() -> ScriptSequence<Ethereum> {
303 let mut sequence = ScriptSequence::default();
304 for rpc in ["first", "second"] {
305 let mut tx = TransactionWithMetadata::from_tx_request(
306 TransactionMaybeSigned::Unsigned(Default::default()),
307 );
308 tx.rpc = rpc.to_string();
309 sequence.transactions.push_back(tx);
310 }
311 sequence
312 }
313
314 #[test]
315 fn fill_sensitive_rejects_mismatched_counts_without_mutation() {
316 for count in [1, 3] {
317 let mut sequence = sequence_with_two_transactions();
318 let sensitive = SensitiveScriptSequence {
319 transactions: (0..count)
320 .map(|_| SensitiveTransactionMetadata { rpc: "replacement".to_string() })
321 .collect(),
322 recovery_generation: None,
323 };
324 assert_eq!(
325 sequence.fill_sensitive(&sensitive).unwrap_err().to_string(),
326 format!(
327 "sensitive-cache entry count ({count}) does not match transaction count (2); \
328 the broadcast file and its sensitive-cache counterpart are out of sync"
329 )
330 );
331 assert_eq!(
332 sequence.transactions.iter().map(|tx| tx.rpc.as_str()).collect::<Vec<_>>(),
333 ["first", "second"]
334 );
335 }
336 }
337
338 #[test]
339 fn fill_sensitive_restores_matching_cache() {
340 let mut sequence = sequence_with_two_transactions();
341 let sensitive = SensitiveScriptSequence {
342 transactions: ["restored-first", "restored-second"]
343 .into_iter()
344 .map(|rpc| SensitiveTransactionMetadata { rpc: rpc.to_string() })
345 .collect(),
346 recovery_generation: None,
347 };
348 sequence.fill_sensitive(&sensitive).unwrap();
349 assert_eq!(
350 sequence.transactions.iter().map(|tx| tx.rpc.as_str()).collect::<Vec<_>>(),
351 ["restored-first", "restored-second"]
352 );
353 }
354
355 #[test]
356 fn fill_sensitive_rejects_a_different_recovery_generation() {
357 let mut sequence = sequence_with_two_transactions();
358 sequence.recovery_generation = Some(TxHash::ZERO);
359 let sensitive = SensitiveScriptSequence {
360 transactions: Default::default(),
361 recovery_generation: Some(TxHash::with_last_byte(1)),
362 };
363
364 assert_eq!(
365 sequence.fill_sensitive(&sensitive).unwrap_err().to_string(),
366 "the broadcast file and its sensitive-cache counterpart belong to different recovery generations"
367 );
368 }
369
370 #[test]
371 fn can_convert_sig() {
372 assert_eq!(sig_to_file_name("run()").as_str(), "run");
373 assert_eq!(
374 sig_to_file_name(
375 "522bb704000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfFFb92266"
376 )
377 .as_str(),
378 "522bb704"
379 );
380 assert_eq!(
382 sig_to_file_name(
383 "0x522bb704000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfFFb92266"
384 )
385 .as_str(),
386 "522bb704"
387 );
388 assert_eq!(sig_to_file_name("0x1234").as_str(), "0x1234");
390 assert_eq!(sig_to_file_name("123").as_str(), "123");
391 assert_eq!(sig_to_file_name("0xnotahex").as_str(), "0xnotahex");
393 assert_eq!(sig_to_file_name("not_a_sig_or_hex").as_str(), "not_a_sig_or_hex");
395 }
396}