Skip to main content

forge_script_sequence/
sequence.rs

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/// Sensitive values from the transactions in a script sequence
24#[derive(Clone, Default, Serialize, Deserialize)]
25pub struct SensitiveTransactionMetadata {
26    pub rpc: String,
27}
28
29/// Sensitive info from the script sequence which is saved into the cache folder
30#[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/// Helper that saves the transactions sequence and its state on which transactions have been
38/// broadcasted
39#[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    /// Contains paths to the sequence files
51    /// None if sequence should not be saved to disk (e.g. part of a multi-chain sequence)
52    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    /// Hashes of mined transactions that reverted. They are kept out of `receipts`, which
60    /// consumers treat as successful, and resume never resubmits them.
61    #[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    /// Loads The sequence for the corresponding json file
98    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    /// Saves the transactions as file if it's a standalone deployment.
128    /// `save_ts` should be set to true for checkpoint updates, which might happen many times and
129    /// could result in us saving many identical files.
130    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        // broadcast folder writes
148        //../run-latest.json
149        fs::write_pretty_json_file(path, &self)?;
150        if save_ts {
151            //../run-[timestamp].json
152            fs::copy(path, path.with_file_name(&ts_name))?;
153        }
154
155        // cache folder writes
156        //../run-latest.json
157        fs::write_sensitive_json_file(sensitive_path, &sensitive_script_sequence)?;
158        if save_ts {
159            //../run-[timestamp].json
160            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    /// Sorts all receipts with ascending transaction index
187    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    /// Gets paths in the formats
203    /// `./broadcast/[contract_filename]/[chain_id]/[sig]-latest.json` and
204    /// `./cache/[contract_filename]/[chain_id]/[sig]-latest.json`.
205    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        // TODO: ideally we want the name of the function here if sig is calldata
230        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    /// Returns the first RPC URL of this sequence.
240    pub fn rpc_url(&self) -> &str {
241        self.transactions.front().expect("empty sequence").rpc.as_str()
242    }
243
244    /// Returns the list of the transactions without the metadata.
245    pub fn transactions(&self) -> impl Iterator<Item = &TransactionMaybeSigned<N>> {
246        self.transactions.iter().map(|tx| tx.tx())
247    }
248
249    /// Copies RPC URLs from a matching sensitive-cache sequence.
250    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
272/// Converts the `sig` argument into the corresponding file path.
273///
274/// This accepts either the signature of the function or the raw calldata.
275pub fn sig_to_file_name(sig: &str) -> String {
276    if let Some((name, _)) = sig.split_once('(') {
277        // strip until call argument parenthesis
278        return name.to_string();
279    }
280    // assume calldata if `sig` is hex
281    if let Ok(calldata) = hex::decode(sig) {
282        // in which case we return the function selector if available
283        if let Some(selector) = calldata.get(..SELECTOR_LEN) {
284            return hex::encode(selector);
285        }
286        // fallback to original string if calldata is too short to contain selector
287        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        // valid calldata with 0x prefix
381        assert_eq!(
382            sig_to_file_name(
383                "0x522bb704000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfFFb92266"
384            )
385            .as_str(),
386            "522bb704"
387        );
388        // short calldata: should not panic and should return input as-is
389        assert_eq!(sig_to_file_name("0x1234").as_str(), "0x1234");
390        assert_eq!(sig_to_file_name("123").as_str(), "123");
391        // invalid hex: should return input as-is
392        assert_eq!(sig_to_file_name("0xnotahex").as_str(), "0xnotahex");
393        // non-hex non-signature: should return input as-is
394        assert_eq!(sig_to_file_name("not_a_sig_or_hex").as_str(), "not_a_sig_or_hex");
395    }
396}