Skip to main content

forge_script/
progress.rs

1use crate::receipts::{
2    PendingReceiptError, TxStatus, check_tx_status, check_tx_status_once, format_receipt,
3};
4use alloy_chains::Chain;
5use alloy_network::{Network, ReceiptResponse};
6use alloy_primitives::{
7    B256,
8    map::{B256HashMap, HashMap},
9};
10use alloy_provider::RootProvider;
11use eyre::Result;
12use forge_script_sequence::ScriptSequence;
13use foundry_cli::utils::init_progress;
14use foundry_common::shell;
15use futures::StreamExt;
16use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
17use parking_lot::RwLock;
18use std::{fmt::Write, sync::Arc, time::Duration};
19use yansi::Paint;
20
21/// State of [ProgressBar]s displayed for the given [ScriptSequence].
22#[derive(Debug)]
23pub struct SequenceProgressState {
24    /// The top spinner with content of the format "Sequence #{id} on {network} | {status}""
25    top_spinner: ProgressBar,
26    /// Progress bar with the count of transactions.
27    txs: ProgressBar,
28    /// Progress var with the count of confirmed transactions.
29    receipts: ProgressBar,
30    /// Standalone spinners for pending transactions.
31    tx_spinners: B256HashMap<ProgressBar>,
32    /// Copy of the main [MultiProgress] instance.
33    multi: MultiProgress,
34}
35
36impl SequenceProgressState {
37    pub fn new<N: Network>(
38        sequence_idx: usize,
39        sequence: &ScriptSequence<N>,
40        multi: MultiProgress,
41    ) -> Self {
42        let mut state = if shell::is_quiet() || shell::is_json() {
43            let top_spinner = ProgressBar::hidden();
44            let txs = ProgressBar::hidden();
45            let receipts = ProgressBar::hidden();
46
47            Self { top_spinner, txs, receipts, tx_spinners: Default::default(), multi }
48        } else {
49            let mut template = "{spinner:.green}".to_string();
50            write!(template, " Sequence #{} on {}", sequence_idx + 1, Chain::from(sequence.chain))
51                .unwrap();
52            template.push_str("{msg}");
53
54            let top_spinner = ProgressBar::new_spinner().with_style(
55                ProgressStyle::with_template(&template).unwrap().tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈✅"),
56            );
57            let top_spinner = multi.add(top_spinner);
58
59            let txs = multi.insert_after(
60                &top_spinner,
61                init_progress(sequence.transactions.len() as u64, "txes").with_prefix("    "),
62            );
63
64            let receipts = multi.insert_after(
65                &txs,
66                init_progress(sequence.transactions.len() as u64, "receipts").with_prefix("    "),
67            );
68
69            top_spinner.enable_steady_tick(Duration::from_millis(100));
70            txs.enable_steady_tick(Duration::from_millis(1000));
71            receipts.enable_steady_tick(Duration::from_millis(1000));
72
73            txs.set_position(sequence.receipts.len() as u64);
74            receipts.set_position(sequence.receipts.len() as u64);
75
76            Self { top_spinner, txs, receipts, tx_spinners: Default::default(), multi }
77        };
78
79        for tx_hash in &sequence.pending {
80            state.tx_sent(*tx_hash);
81        }
82
83        state
84    }
85
86    /// Called when a new transaction is sent. Displays a spinner with a hash of the transaction and
87    /// advances the sent transactions progress bar.
88    pub fn tx_sent(&mut self, tx_hash: B256) {
89        // Avoid showing more than 10 spinners.
90        if self.tx_spinners.len() < 10 {
91            let spinner = if shell::is_quiet() || shell::is_json() {
92                ProgressBar::hidden()
93            } else {
94                let spinner = ProgressBar::new_spinner()
95                    .with_style(
96                        ProgressStyle::with_template("    {spinner:.green} {msg}")
97                            .unwrap()
98                            .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈"),
99                    )
100                    .with_message(format!("{} {}", "[Pending]".yellow(), tx_hash));
101
102                let spinner = self.multi.insert_before(&self.txs, spinner);
103                spinner.enable_steady_tick(Duration::from_millis(100));
104                spinner
105            };
106
107            self.tx_spinners.insert(tx_hash, spinner);
108        }
109        self.txs.inc(1);
110    }
111
112    /// Removes the pending transaction spinner and advances confirmed transactions progress bar.
113    pub fn finish_tx_spinner(&mut self, tx_hash: B256) {
114        if let Some(spinner) = self.tx_spinners.remove(&tx_hash) {
115            spinner.finish_and_clear();
116        }
117        self.receipts.inc(1);
118    }
119
120    /// Same as finish_tx_spinner but also prints a message to stdout above all other progress bars.
121    pub fn finish_tx_spinner_with_msg(&mut self, tx_hash: B256, msg: &str) -> std::io::Result<()> {
122        self.finish_tx_spinner(tx_hash);
123
124        if !(shell::is_quiet() || shell::is_json()) {
125            self.multi.println(msg)?;
126        }
127
128        Ok(())
129    }
130
131    /// Sets status for the current sequence progress.
132    pub fn set_status(&mut self, status: &str) {
133        self.top_spinner.set_message(format!(" | {status}"));
134    }
135
136    /// Hides transactions and receipts progress bar, leaving only top line with the latest set
137    /// status.
138    pub fn finish(&self) {
139        // Polling can stop before every pending transaction is checked.
140        for spinner in self.tx_spinners.values() {
141            spinner.finish_and_clear();
142        }
143        self.top_spinner.finish();
144        self.txs.finish_and_clear();
145        self.receipts.finish_and_clear();
146    }
147}
148
149/// Cloneable wrapper around [SequenceProgressState].
150#[derive(Debug, Clone)]
151pub struct SequenceProgress {
152    pub inner: Arc<RwLock<SequenceProgressState>>,
153}
154
155impl SequenceProgress {
156    pub fn new<N: Network>(
157        sequence_idx: usize,
158        sequence: &ScriptSequence<N>,
159        multi: MultiProgress,
160    ) -> Self {
161        Self {
162            inner: Arc::new(RwLock::new(SequenceProgressState::new(sequence_idx, sequence, multi))),
163        }
164    }
165}
166
167/// Container for multiple [SequenceProgress] instances keyed by sequence index.
168#[derive(Debug, Clone, Default)]
169pub struct ScriptProgress {
170    state: Arc<RwLock<HashMap<usize, SequenceProgress>>>,
171    multi: MultiProgress,
172}
173
174impl ScriptProgress {
175    /// Returns a [SequenceProgress] instance for the given sequence index. If it doesn't exist,
176    /// creates one.
177    pub fn get_sequence_progress<N: Network>(
178        &self,
179        sequence_idx: usize,
180        sequence: &ScriptSequence<N>,
181    ) -> SequenceProgress {
182        if let Some(progress) = self.state.read().get(&sequence_idx) {
183            return progress.clone();
184        }
185        let progress = SequenceProgress::new(sequence_idx, sequence, self.multi.clone());
186        self.state.write().insert(sequence_idx, progress.clone());
187        progress
188    }
189
190    /// Traverses a set of pending transactions and either finds receipts, or clears
191    /// them from the deployment sequence.
192    ///
193    /// For each `tx_hash`, we check if it has confirmed. If it has
194    /// confirmed, we push the receipt (if successful) or push an error (if
195    /// revert). If the transaction has not confirmed, but can be found in the
196    /// node's mempool, we wait for its receipt to be available. If the transaction
197    /// has not confirmed, and cannot be found in the mempool, we remove it from
198    /// the `deploy_sequence.pending` vector so that it will be rebroadcast in
199    /// later steps, stop polling, and check every remaining hash once. `blocked_hashes`
200    /// cannot be mined until an earlier transaction is replayed, so they are only checked
201    /// once.
202    #[allow(clippy::too_many_arguments)]
203    pub async fn wait_for_pending<N: Network>(
204        &self,
205        sequence_idx: usize,
206        deployment_sequence: &mut ScriptSequence<N>,
207        provider: &RootProvider<N>,
208        timeout: u64,
209        confirmations: u64,
210        submission_hashes: (&[B256], &[B256]),
211        blocked_hashes: &[B256],
212    ) -> Result<()> {
213        let (durable_hashes, replayable_hashes) = submission_hashes;
214        if deployment_sequence.pending.is_empty() {
215            return Ok(());
216        }
217
218        let count = deployment_sequence.pending.len();
219        let seq_progress = self.get_sequence_progress(sequence_idx, deployment_sequence);
220
221        seq_progress.inner.write().set_status("Waiting for pending transactions");
222
223        trace!("Checking status of {count} pending transactions");
224
225        // Poll in operation order, so that no transaction waits on an earlier one left unpolled.
226        let mut pending = deployment_sequence.pending.clone();
227        pending.sort_by_cached_key(|hash| {
228            deployment_sequence.transactions.iter().position(|tx| tx.hash == Some(*hash))
229        });
230
231        let futs = pending
232            .iter()
233            .filter(|hash| !blocked_hashes.contains(hash))
234            .map(|&tx| check_tx_status(provider, tx, timeout, confirmations));
235        let mut tasks = Some(futures::stream::iter(futs).buffer_unordered(10));
236        let mut polled = Vec::with_capacity(count);
237        let mut unpolled = None;
238
239        let mut errors: Vec<String> = vec![];
240        let mut discarded_transactions = false;
241
242        loop {
243            let (tx_hash, result) = if let Some(tasks) = &mut tasks
244                && let Some((tx_hash, result)) = tasks.next().await
245            {
246                polled.push(tx_hash);
247                (tx_hash, result)
248            } else {
249                // Polling stopped, so check every hash left unpolled once.
250                tasks = None;
251                let unpolled = unpolled.get_or_insert_with(|| {
252                    pending
253                        .iter()
254                        .rev()
255                        .filter(|hash| !polled.contains(hash))
256                        .copied()
257                        .collect::<Vec<_>>()
258                });
259                let Some(tx_hash) = unpolled.pop() else { break };
260                let Some(status) = check_tx_status_once(provider, tx_hash, confirmations).await
261                else {
262                    continue;
263                };
264                (tx_hash, Ok(status))
265            };
266            match result {
267                Err(err) => {
268                    // Check if this is a retry error for pending receipts
269                    if err.downcast_ref::<PendingReceiptError>().is_some() {
270                        // We've already retried several times with sleep, but the receipt is still
271                        // pending
272                        if durable_hashes.contains(&tx_hash) {
273                            errors.push(format!(
274                                "Durable submission {tx_hash:?} is still pending; refusing to discard its recovery identity"
275                            ));
276                        } else {
277                            discarded_transactions = true;
278                            deployment_sequence.remove_pending(tx_hash);
279                        }
280                        seq_progress
281                            .inner
282                            .write()
283                            .finish_tx_spinner_with_msg(tx_hash, &err.to_string())?;
284                    } else {
285                        errors.push(format!(
286                            "Failure on receiving a receipt for {tx_hash:?}:\n{err}"
287                        ));
288                        seq_progress.inner.write().finish_tx_spinner(tx_hash);
289                    }
290                }
291                Ok(TxStatus::Dropped) => {
292                    if replayable_hashes.contains(&tx_hash) {
293                        deployment_sequence.remove_pending(tx_hash);
294                        discarded_transactions = true;
295                    } else if durable_hashes.contains(&tx_hash) {
296                        errors.push(format!(
297                            "Durable submission {tx_hash:?} is not currently visible; refusing to discard its recovery identity"
298                        ));
299                    } else {
300                        // We want to remove it from pending so it will be re-broadcast.
301                        deployment_sequence.remove_pending(tx_hash);
302                        discarded_transactions = true;
303                    }
304
305                    let msg = format!(
306                        "Transaction {tx_hash:?} is not currently visible to the RPC endpoint."
307                    );
308                    seq_progress.inner.write().finish_tx_spinner_with_msg(tx_hash, &msg)?;
309
310                    // A later nonce can stay visible indefinitely behind this transaction, so
311                    // stop polling and check every remaining hash once instead.
312                    tasks = None;
313                }
314                Ok(TxStatus::Success(receipt)) => {
315                    trace!(tx_hash=?tx_hash, "received tx receipt");
316
317                    let msg = format_receipt(
318                        deployment_sequence.chain.into(),
319                        &receipt,
320                        Some(deployment_sequence),
321                    );
322                    seq_progress.inner.write().finish_tx_spinner_with_msg(tx_hash, &msg)?;
323
324                    deployment_sequence.remove_pending(receipt.transaction_hash());
325                    deployment_sequence.add_receipt(receipt);
326                }
327                Ok(TxStatus::Revert(receipt)) => {
328                    warn!(tx_hash=?tx_hash, "Transaction Failure");
329                    deployment_sequence.remove_pending(receipt.transaction_hash());
330                    if !deployment_sequence.reverted.contains(&tx_hash) {
331                        deployment_sequence.reverted.push(tx_hash);
332                    }
333
334                    let msg = format_receipt(
335                        deployment_sequence.chain.into(),
336                        &receipt,
337                        Some(deployment_sequence),
338                    );
339                    seq_progress.inner.write().finish_tx_spinner_with_msg(tx_hash, &msg)?;
340
341                    errors.push(format!("Transaction Failure: {:?}", receipt.transaction_hash()));
342                }
343            }
344        }
345
346        // print any errors
347        if !errors.is_empty() {
348            let mut error_msg = errors.join("\n");
349
350            // Add information about using --resume if necessary
351            if !deployment_sequence.pending.is_empty() || discarded_transactions {
352                error_msg += r#"
353
354Add `--resume` to your command to try and continue broadcasting the transactions. This will attempt to resend transactions that were discarded by the RPC."#;
355            }
356
357            eyre::bail!(error_msg);
358        } else if discarded_transactions {
359            // If we have discarded transactions but no errors, still inform the user
360            sh_warn!(
361                "Some transactions were discarded by the RPC node. Use `--resume` to retry these transactions."
362            )?;
363        }
364
365        Ok(())
366    }
367}