Skip to main content

forge_script_sequence/
reader.rs

1use crate::{ScriptSequence, TransactionWithMetadata};
2use alloy_network::{Network, ReceiptResponse};
3use eyre::{Result, bail};
4use foundry_common::{errors::FsPathError, fs};
5use revm_inspectors::tracing::types::CallKind;
6use serde::Deserialize;
7use serde_json::Value;
8use std::path::{Component, Path, PathBuf};
9
10/// This type reads broadcast files in the
11/// `project_root/broadcast/{contract_name}.s.sol/{chain_id}/` directory.
12///
13/// It consists methods that filter and search for transactions in the broadcast files that match a
14/// `transactionType` if provided.
15///
16/// Note:
17///
18/// It only returns transactions for which there exists a corresponding receipt in the broadcast.
19#[derive(Debug, Clone)]
20pub struct BroadcastReader {
21    contract_name: String,
22    chain_id: u64,
23    tx_type: Vec<CallKind>,
24    broadcast_path: PathBuf,
25}
26
27impl BroadcastReader {
28    /// Create a new `BroadcastReader` instance.
29    pub fn new(contract_name: String, chain_id: u64, broadcast_path: &Path) -> Result<Self> {
30        if !broadcast_path.is_dir() {
31            bail!("broadcast dir does not exist or is not a directory");
32        }
33
34        Ok(Self {
35            contract_name,
36            chain_id,
37            tx_type: Default::default(),
38            broadcast_path: broadcast_path.to_path_buf(),
39        })
40    }
41
42    /// Set the transaction type to filter by.
43    pub fn with_tx_type(mut self, tx_type: CallKind) -> Self {
44        self.tx_type.push(tx_type);
45        self
46    }
47
48    fn matches_filters<N: Network>(&self, tx: &TransactionWithMetadata<N>) -> bool {
49        let name_filter = tx.contract_name.as_ref().is_some_and(|cn| *cn == self.contract_name);
50        let type_filter = self.tx_type.is_empty() || self.tx_type.contains(&tx.call_kind);
51        name_filter && type_filter
52    }
53
54    /// Read all broadcast files in the broadcast directory.
55    ///
56    /// Example structure:
57    ///
58    /// project-root/broadcast/{script_name}.s.sol/{chain_id}/*.json
59    /// project-root/broadcast/multi/{multichain_script_name}.s.sol-{timestamp}/deploy.json
60    pub fn read<N: Network>(&self) -> eyre::Result<Vec<ScriptSequence<N>>>
61    where
62        N::TxEnvelope: for<'d> Deserialize<'d>,
63    {
64        // 1. Recursively read all .json files in the broadcast directory
65        let mut broadcasts = vec![];
66        for entry in walkdir::WalkDir::new(&self.broadcast_path) {
67            let entry = entry?;
68            let path = entry.path();
69
70            if path.is_file() && path.extension().is_some_and(|ext| ext == "json") {
71                // Ignore -latest to avoid duplicating broadcast entries
72                if path.components().any(|c| c.as_os_str().to_string_lossy().contains("-latest")) {
73                    continue;
74                }
75
76                // Detect Multichain broadcasts using "multi" in the path
77                if path.components().any(|c| c == Component::Normal("multi".as_ref())) {
78                    // Parse as MultiScriptSequence
79
80                    let broadcast = fs::read_json_file::<Value>(path)?;
81                    let multichain_deployments = broadcast
82                        .get("deployments")
83                        .and_then(Value::as_array)
84                        .and_then(|deployments| {
85                            deployments
86                                .iter()
87                                .cloned()
88                                .map(parse_sequence)
89                                .collect::<serde_json::Result<Vec<_>>>()
90                                .ok()
91                        })
92                        .unwrap_or_default();
93
94                    broadcasts.extend(multichain_deployments);
95                    continue;
96                }
97
98                let broadcast = parse_sequence(fs::read_json_file(path)?)
99                    .map_err(|source| FsPathError::ReadJson { source, path: path.into() })?;
100                broadcasts.push(broadcast);
101            }
102        }
103
104        let broadcasts = self.filter_and_sort(broadcasts);
105
106        Ok(broadcasts)
107    }
108
109    /// Attempts read the latest broadcast file in the broadcast directory.
110    ///
111    /// This may be the `run-latest.json` file or the broadcast file with the latest timestamp.
112    pub fn read_latest<N: Network>(&self) -> eyre::Result<ScriptSequence<N>>
113    where
114        N::TxEnvelope: for<'d> Deserialize<'d>,
115    {
116        let broadcasts = self.read()?;
117
118        // Find the broadcast with the latest timestamp
119        let target = broadcasts
120            .into_iter()
121            .max_by_key(|broadcast| broadcast.timestamp)
122            .ok_or_else(|| eyre::eyre!("No broadcasts found"))?;
123
124        Ok(target)
125    }
126
127    /// Applies the filters and sorts the broadcasts by descending timestamp.
128    pub fn filter_and_sort<N: Network>(
129        &self,
130        broadcasts: Vec<ScriptSequence<N>>,
131    ) -> Vec<ScriptSequence<N>> {
132        // Apply the filters
133        let mut seqs = broadcasts
134            .into_iter()
135            .filter(|broadcast| {
136                if broadcast.chain != self.chain_id {
137                    return false;
138                }
139
140                broadcast.transactions.iter().any(|tx| self.matches_filters(tx))
141            })
142            .collect::<Vec<_>>();
143
144        // Sort by descending timestamp
145        seqs.sort_by_key(|s| std::cmp::Reverse(s.timestamp));
146
147        seqs
148    }
149
150    /// Search for transactions in the broadcast that match the specified `contractName` and
151    /// `txType`.
152    ///
153    /// It cross-checks the transactions with their corresponding receipts in the broadcast and
154    /// returns the result.
155    ///
156    /// Transactions that don't have a corresponding receipt are ignored.
157    ///
158    /// Sorts the transactions by descending block number.
159    pub fn into_tx_receipts<N: Network>(
160        &self,
161        broadcast: ScriptSequence<N>,
162    ) -> Vec<(TransactionWithMetadata<N>, N::ReceiptResponse)> {
163        let ScriptSequence { transactions, receipts, .. } = broadcast;
164
165        let mut targets: Vec<_> = transactions
166            .into_iter()
167            .filter(|tx| self.matches_filters(tx))
168            .filter_map(|tx| {
169                let receipt = receipts
170                    .iter()
171                    .find(|r| tx.hash.is_some_and(|hash| hash == r.transaction_hash()))?;
172                Some((tx, receipt.clone()))
173            })
174            .collect();
175
176        // Sort by descending block number
177        targets.sort_by_key(|t| std::cmp::Reverse(t.1.block_number()));
178
179        targets
180    }
181}
182
183/// Deserializes a broadcast sequence, treating receipts without a `type` as legacy like
184/// Ethereum's receipt envelope does, so every network accepts the same files.
185fn parse_sequence<N: Network>(mut sequence: Value) -> serde_json::Result<ScriptSequence<N>>
186where
187    N::TxEnvelope: for<'d> Deserialize<'d>,
188{
189    if let Some(receipts) = sequence.get_mut("receipts").and_then(Value::as_array_mut) {
190        for receipt in receipts.iter_mut().filter_map(Value::as_object_mut) {
191            if receipt.get("type").is_none_or(Value::is_null) {
192                receipt.insert("type".to_string(), "0x0".into());
193            }
194        }
195    }
196    serde_json::from_value(sequence)
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use alloy_network::{AnyNetwork, Ethereum};
203    use alloy_primitives::{Address, B256, Bloom};
204    use foundry_common::TransactionMaybeSigned;
205
206    fn deployment(address: Address) -> ScriptSequence<Ethereum> {
207        let mut tx = TransactionWithMetadata::from_tx_request(TransactionMaybeSigned::Unsigned(
208            Default::default(),
209        ));
210        tx.call_kind = CallKind::Create;
211        tx.contract_name = Some("Counter".to_string());
212        tx.contract_address = Some(address);
213        ScriptSequence { transactions: [tx].into(), timestamp: 1, chain: 1, ..Default::default() }
214    }
215
216    #[test]
217    fn read_latest_prefers_later_multichain_sequence_on_equal_timestamp() {
218        let root = std::env::temp_dir().join(format!("broadcast-reader-{}", std::process::id()));
219        let dir = root.join("multi").join("Counter.s.sol-1");
220        std::fs::create_dir_all(&dir).unwrap();
221        let (older, newer) = (Address::with_last_byte(1), Address::with_last_byte(2));
222        fs::write_json_file(
223            &dir.join("deploy.json"),
224            &serde_json::json!({ "deployments": [deployment(older), deployment(newer)] }),
225        )
226        .unwrap();
227
228        let latest = BroadcastReader::new("Counter".to_string(), 1, &root)
229            .unwrap()
230            .read_latest::<Ethereum>()
231            .unwrap();
232        assert_eq!(latest.transactions[0].contract_address, Some(newer));
233
234        std::fs::remove_dir_all(root).unwrap();
235    }
236
237    #[test]
238    fn reads_receipts_without_type_as_legacy() {
239        let root = std::env::temp_dir().join(format!("broadcast-typeless-{}", std::process::id()));
240        let hash = B256::with_last_byte(1);
241        let sequence = |address: Address, ty: Option<serde_json::Value>| {
242            let mut sequence = deployment(address);
243            sequence.transactions[0].hash = Some(hash);
244            let mut sequence = serde_json::to_value(sequence).unwrap();
245            let mut receipt = serde_json::json!({
246                "status": "0x1",
247                "cumulativeGasUsed": "0x5208",
248                "logs": [],
249                "logsBloom": Bloom::ZERO,
250                "transactionHash": hash,
251                "transactionIndex": "0x0",
252                "blockHash": B256::ZERO,
253                "blockNumber": "0x7",
254                "gasUsed": "0x5208",
255                "effectiveGasPrice": "0x1",
256                "from": Address::ZERO,
257                "to": null,
258                "contractAddress": address,
259            });
260            if let Some(ty) = ty {
261                receipt["type"] = ty;
262            }
263            sequence["receipts"] = serde_json::json!([receipt]);
264            sequence
265        };
266        let dir = root.join("Counter.s.sol").join("1");
267        std::fs::create_dir_all(&dir).unwrap();
268        let reader = BroadcastReader::new("Counter".to_string(), 1, &root).unwrap();
269        fs::write_json_file(&dir.join("run-1.json"), &sequence(Address::with_last_byte(1), None))
270            .unwrap();
271        let latest = reader.read_latest::<AnyNetwork>().unwrap();
272        let receipts = reader.into_tx_receipts(latest);
273        assert_eq!(receipts.len(), 1);
274        assert_eq!(receipts[0].1.block_number(), Some(7));
275        assert!(receipts[0].1.status());
276        std::fs::remove_dir_all(&root).unwrap();
277
278        let dir = root.join("multi").join("Counter.s.sol-1");
279        std::fs::create_dir_all(&dir).unwrap();
280        let deployments = [
281            sequence(Address::with_last_byte(2), Some(serde_json::Value::Null)),
282            sequence(Address::with_last_byte(3), Some("0x2".into())),
283        ];
284        fs::write_json_file(
285            &dir.join("deploy.json"),
286            &serde_json::json!({ "deployments": deployments }),
287        )
288        .unwrap();
289        assert_eq!(reader.read::<AnyNetwork>().unwrap().len(), 2);
290
291        std::fs::remove_dir_all(root).unwrap();
292    }
293}