forge_script_sequence/
reader.rs1use 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#[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 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 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 pub fn read<N: Network>(&self) -> eyre::Result<Vec<ScriptSequence<N>>>
61 where
62 N::TxEnvelope: for<'d> Deserialize<'d>,
63 {
64 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 if path.components().any(|c| c.as_os_str().to_string_lossy().contains("-latest")) {
73 continue;
74 }
75
76 if path.components().any(|c| c == Component::Normal("multi".as_ref())) {
78 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 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 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 pub fn filter_and_sort<N: Network>(
129 &self,
130 broadcasts: Vec<ScriptSequence<N>>,
131 ) -> Vec<ScriptSequence<N>> {
132 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 seqs.sort_by_key(|s| std::cmp::Reverse(s.timestamp));
146
147 seqs
148 }
149
150 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 targets.sort_by_key(|t| std::cmp::Reverse(t.1.block_number()));
178
179 targets
180 }
181}
182
183fn 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}