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}
34
35/// Helper that saves the transactions sequence and its state on which transactions have been
36/// broadcasted
37#[derive(Clone, Serialize, Deserialize)]
38#[serde(bound(
39    serialize = "N::TransactionRequest: Serialize, N::TxEnvelope: Serialize",
40    deserialize = "N::TransactionRequest: for<'de2> Deserialize<'de2>, N::TxEnvelope: for<'de2> Deserialize<'de2>"
41))]
42pub struct ScriptSequence<N: Network> {
43    pub transactions: VecDeque<TransactionWithMetadata<N>>,
44    pub receipts: Vec<N::ReceiptResponse>,
45    pub libraries: Vec<String>,
46    pub pending: Vec<TxHash>,
47    #[serde(skip)]
48    /// Contains paths to the sequence files
49    /// None if sequence should not be saved to disk (e.g. part of a multi-chain sequence)
50    pub paths: Option<(PathBuf, PathBuf)>,
51    pub returns: HashMap<String, NestedValue>,
52    pub timestamp: u128,
53    pub chain: u64,
54    pub commit: Option<String>,
55}
56
57impl<N: Network> Default for ScriptSequence<N> {
58    fn default() -> Self {
59        Self {
60            transactions: Default::default(),
61            receipts: Default::default(),
62            libraries: Default::default(),
63            pending: Default::default(),
64            paths: Default::default(),
65            returns: Default::default(),
66            timestamp: Default::default(),
67            chain: Default::default(),
68            commit: Default::default(),
69        }
70    }
71}
72
73impl<N: Network> From<&ScriptSequence<N>> for SensitiveScriptSequence {
74    fn from(sequence: &ScriptSequence<N>) -> Self {
75        Self {
76            transactions: sequence
77                .transactions
78                .iter()
79                .map(|tx| SensitiveTransactionMetadata { rpc: tx.rpc.clone() })
80                .collect(),
81        }
82    }
83}
84
85impl<N: Network> ScriptSequence<N> {
86    /// Loads The sequence for the corresponding json file
87    pub fn load(
88        config: &Config,
89        sig: &str,
90        target: &ArtifactId,
91        chain_id: u64,
92        dry_run: bool,
93    ) -> Result<Self>
94    where
95        N::TxEnvelope: for<'d> Deserialize<'d>,
96    {
97        let (path, sensitive_path) = Self::get_paths(config, sig, target, chain_id, dry_run)?;
98
99        let mut script_sequence: Self = fs::read_json_file(&path)
100            .wrap_err(format!("Deployment not found for chain `{chain_id}`."))?;
101
102        let sensitive_script_sequence: SensitiveScriptSequence = fs::read_json_file(
103            &sensitive_path,
104        )
105        .wrap_err(format!("Deployment's sensitive details not found for chain `{chain_id}`."))?;
106
107        script_sequence.fill_sensitive(&sensitive_script_sequence).wrap_err(format!(
108            "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."
109        ))?;
110
111        script_sequence.paths = Some((path, sensitive_path));
112
113        Ok(script_sequence)
114    }
115
116    /// Saves the transactions as file if it's a standalone deployment.
117    /// `save_ts` should be set to true for checkpoint updates, which might happen many times and
118    /// could result in us saving many identical files.
119    pub fn save(&mut self, silent: bool, save_ts: bool) -> Result<()>
120    where
121        N::TxEnvelope: Serialize,
122    {
123        self.sort_receipts();
124
125        if self.transactions.is_empty() {
126            return Ok(());
127        }
128
129        self.timestamp = now().as_millis();
130        let ts_name = format!("run-{}.json", self.timestamp);
131
132        let sensitive_script_sequence = SensitiveScriptSequence::from(&*self);
133
134        let Some((path, sensitive_path)) = self.paths.as_ref() else { return Ok(()) };
135
136        // broadcast folder writes
137        //../run-latest.json
138        fs::write_pretty_json_file(path, &self)?;
139        if save_ts {
140            //../run-[timestamp].json
141            fs::copy(path, path.with_file_name(&ts_name))?;
142        }
143
144        // cache folder writes
145        //../run-latest.json
146        fs::write_sensitive_json_file(sensitive_path, &sensitive_script_sequence)?;
147        if save_ts {
148            //../run-[timestamp].json
149            fs::copy(sensitive_path, sensitive_path.with_file_name(&ts_name))?;
150        }
151
152        if !silent {
153            if shell::is_json() {
154                sh_println!(
155                    "{}",
156                    serde_json::json!({
157                        "status": "success",
158                        "transactions": path.display().to_string(),
159                        "sensitive": sensitive_path.display().to_string(),
160                    })
161                )?;
162            } else {
163                sh_println!("\nTransactions saved to: {}\n", path.display())?;
164                sh_println!("Sensitive values saved to: {}\n", sensitive_path.display())?;
165            }
166        }
167
168        Ok(())
169    }
170
171    pub fn add_receipt(&mut self, receipt: N::ReceiptResponse) {
172        self.receipts.push(receipt);
173    }
174
175    /// Sorts all receipts with ascending transaction index
176    pub fn sort_receipts(&mut self) {
177        self.receipts.sort_by_key(|r| (r.block_number(), r.transaction_index()));
178    }
179
180    pub fn add_pending(&mut self, index: usize, tx_hash: TxHash) {
181        if !self.pending.contains(&tx_hash) {
182            self.transactions[index].hash = Some(tx_hash);
183            self.pending.push(tx_hash);
184        }
185    }
186
187    pub fn remove_pending(&mut self, tx_hash: TxHash) {
188        self.pending.retain(|element| element != &tx_hash);
189    }
190
191    /// Gets paths in the formats
192    /// `./broadcast/[contract_filename]/[chain_id]/[sig]-latest.json` and
193    /// `./cache/[contract_filename]/[chain_id]/[sig]-latest.json`.
194    pub fn get_paths(
195        config: &Config,
196        sig: &str,
197        target: &ArtifactId,
198        chain_id: u64,
199        dry_run: bool,
200    ) -> Result<(PathBuf, PathBuf)> {
201        let mut broadcast = config.broadcast.clone();
202        let mut cache = config.cache_path.clone();
203        let mut common = PathBuf::new();
204
205        let target_fname = target.source.file_name().wrap_err("No filename.")?;
206        common.push(target_fname);
207        common.push(chain_id.to_string());
208        if dry_run {
209            common.push(DRY_RUN_DIR);
210        }
211
212        broadcast.push(common.clone());
213        cache.push(common);
214
215        fs::create_dir_all(&broadcast)?;
216        fs::create_dir_all(&cache)?;
217
218        // TODO: ideally we want the name of the function here if sig is calldata
219        let filename = sig_to_file_name(sig);
220        let filename_with_ext = format!("{filename}-latest.json");
221
222        broadcast.push(&filename_with_ext);
223        cache.push(&filename_with_ext);
224
225        Ok((broadcast, cache))
226    }
227
228    /// Returns the first RPC URL of this sequence.
229    pub fn rpc_url(&self) -> &str {
230        self.transactions.front().expect("empty sequence").rpc.as_str()
231    }
232
233    /// Returns the list of the transactions without the metadata.
234    pub fn transactions(&self) -> impl Iterator<Item = &TransactionMaybeSigned<N>> {
235        self.transactions.iter().map(|tx| tx.tx())
236    }
237
238    /// Copies RPC URLs from a matching sensitive-cache sequence.
239    pub fn fill_sensitive(&mut self, sensitive: &SensitiveScriptSequence) -> Result<()> {
240        let transactions_len = self.transactions.len();
241        let sensitive_len = sensitive.transactions.len();
242        if transactions_len != sensitive_len {
243            eyre::bail!(
244                "sensitive-cache entry count ({sensitive_len}) does not match transaction count \
245                 ({transactions_len}); the broadcast file and its sensitive-cache counterpart are \
246                 out of sync"
247            );
248        }
249        for (i, tx) in self.transactions.iter_mut().enumerate() {
250            tx.rpc.clone_from(&sensitive.transactions[i].rpc);
251        }
252        Ok(())
253    }
254}
255
256/// Converts the `sig` argument into the corresponding file path.
257///
258/// This accepts either the signature of the function or the raw calldata.
259pub fn sig_to_file_name(sig: &str) -> String {
260    if let Some((name, _)) = sig.split_once('(') {
261        // strip until call argument parenthesis
262        return name.to_string();
263    }
264    // assume calldata if `sig` is hex
265    if let Ok(calldata) = hex::decode(sig.strip_prefix("0x").unwrap_or(sig)) {
266        // in which case we return the function selector if available
267        if let Some(selector) = calldata.get(..SELECTOR_LEN) {
268            return hex::encode(selector);
269        }
270        // fallback to original string if calldata is too short to contain selector
271        return sig.to_string();
272    }
273
274    sig.to_string()
275}
276
277pub fn now() -> Duration {
278    SystemTime::now().duration_since(UNIX_EPOCH).expect("time went backwards")
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use alloy_network::Ethereum;
285
286    fn sequence_with_two_transactions() -> ScriptSequence<Ethereum> {
287        let mut sequence = ScriptSequence::default();
288        for rpc in ["first", "second"] {
289            let mut tx = TransactionWithMetadata::from_tx_request(
290                TransactionMaybeSigned::Unsigned(Default::default()),
291            );
292            tx.rpc = rpc.to_string();
293            sequence.transactions.push_back(tx);
294        }
295        sequence
296    }
297
298    #[test]
299    fn fill_sensitive_rejects_mismatched_counts_without_mutation() {
300        for count in [1, 3] {
301            let mut sequence = sequence_with_two_transactions();
302            let sensitive = SensitiveScriptSequence {
303                transactions: (0..count)
304                    .map(|_| SensitiveTransactionMetadata { rpc: "replacement".to_string() })
305                    .collect(),
306            };
307            assert_eq!(
308                sequence.fill_sensitive(&sensitive).unwrap_err().to_string(),
309                format!(
310                    "sensitive-cache entry count ({count}) does not match transaction count (2); \
311                     the broadcast file and its sensitive-cache counterpart are out of sync"
312                )
313            );
314            assert_eq!(
315                sequence.transactions.iter().map(|tx| tx.rpc.as_str()).collect::<Vec<_>>(),
316                ["first", "second"]
317            );
318        }
319    }
320
321    #[test]
322    fn fill_sensitive_restores_matching_cache() {
323        let mut sequence = sequence_with_two_transactions();
324        let sensitive = SensitiveScriptSequence {
325            transactions: ["restored-first", "restored-second"]
326                .into_iter()
327                .map(|rpc| SensitiveTransactionMetadata { rpc: rpc.to_string() })
328                .collect(),
329        };
330        sequence.fill_sensitive(&sensitive).unwrap();
331        assert_eq!(
332            sequence.transactions.iter().map(|tx| tx.rpc.as_str()).collect::<Vec<_>>(),
333            ["restored-first", "restored-second"]
334        );
335    }
336
337    #[test]
338    fn can_convert_sig() {
339        assert_eq!(sig_to_file_name("run()").as_str(), "run");
340        assert_eq!(
341            sig_to_file_name(
342                "522bb704000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfFFb92266"
343            )
344            .as_str(),
345            "522bb704"
346        );
347        // valid calldata with 0x prefix
348        assert_eq!(
349            sig_to_file_name(
350                "0x522bb704000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfFFb92266"
351            )
352            .as_str(),
353            "522bb704"
354        );
355        // short calldata: should not panic and should return input as-is
356        assert_eq!(sig_to_file_name("0x1234").as_str(), "0x1234");
357        assert_eq!(sig_to_file_name("123").as_str(), "123");
358        // invalid hex: should return input as-is
359        assert_eq!(sig_to_file_name("0xnotahex").as_str(), "0xnotahex");
360        // non-hex non-signature: should return input as-is
361        assert_eq!(sig_to_file_name("not_a_sig_or_hex").as_str(), "not_a_sig_or_hex");
362    }
363}