miden_node_ntx_builder/builder/
mod.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3use std::time::Duration;
4
5use anyhow::Context;
6use futures::TryStreamExt;
7use miden_node_proto::domain::account::NetworkAccountPrefix;
8use miden_node_utils::ErrorReport;
9use miden_remote_prover_client::remote_prover::tx_prover::RemoteTransactionProver;
10use tokio::sync::Barrier;
11use tokio::time;
12use url::Url;
13
14use crate::MAX_IN_PROGRESS_TXS;
15use crate::block_producer::BlockProducerClient;
16use crate::store::StoreClient;
17use crate::transaction::NtxError;
18
19// NETWORK TRANSACTION BUILDER
20// ================================================================================================
21
22/// Network transaction builder component.
23///
24/// The network transaction builder is in in charge of building transactions that consume notes
25/// against network accounts. These notes are identified and communicated by the block producer.
26/// The service maintains a list of unconsumed notes and periodically executes and proves
27/// transactions that consume them (reaching out to the store to retrieve state as necessary).
28pub struct NetworkTransactionBuilder {
29    /// Address of the store gRPC server.
30    pub store_url: Url,
31    /// Address of the block producer gRPC server.
32    pub block_producer_url: Url,
33    /// Address of the remote prover. If `None`, transactions will be proven locally, which is
34    /// undesirable due to the perofmrance impact.
35    pub tx_prover_url: Option<Url>,
36    /// Interval for checking pending notes and executing network transactions.
37    pub ticker_interval: Duration,
38    /// A checkpoint used to sync start-up process with the block-producer.
39    ///
40    /// This informs the block-producer when we have subscribed to mempool events and that it is
41    /// safe to begin block-production.
42    pub bp_checkpoint: Arc<Barrier>,
43}
44
45impl NetworkTransactionBuilder {
46    pub async fn serve_new(self) -> anyhow::Result<()> {
47        let store = StoreClient::new(self.store_url);
48        let block_producer = BlockProducerClient::new(self.block_producer_url);
49
50        let mut state = crate::state::State::load(store.clone())
51            .await
52            .context("failed to load ntx state")?;
53
54        let mut mempool_events = block_producer
55            .subscribe_to_mempool_with_retry(state.chain_tip())
56            .await
57            .context("failed to subscribe to mempool events")?;
58
59        // Unlock the block-producer's block production. The block-producer is prevented from
60        // producing blocks until we have subscribed to mempool events.
61        //
62        // This is a temporary work-around until the ntb can resync on the fly.
63        self.bp_checkpoint.wait().await;
64
65        let prover = self.tx_prover_url.map(RemoteTransactionProver::new);
66
67        let mut interval = tokio::time::interval(self.ticker_interval);
68        interval.set_missed_tick_behavior(time::MissedTickBehavior::Skip);
69
70        // Tracks network transaction tasks until they are submitted to the mempool.
71        //
72        // We also map the task ID to the network account so we can mark it as failed if it doesn't
73        // get submitted.
74        let mut inflight = JoinSet::new();
75        let mut inflight_idx = HashMap::new();
76
77        let context = crate::transaction::NtxContext {
78            block_producer: block_producer.clone(),
79            prover,
80        };
81
82        loop {
83            tokio::select! {
84                _next = interval.tick() => {
85                    if inflight.len() > MAX_IN_PROGRESS_TXS {
86                        tracing::info!("At maximum network tx capacity, skipping");
87                        continue;
88                    }
89
90                    let Some(candidate) = state.select_candidate(crate::MAX_NOTES_PER_TX) else {
91                        tracing::debug!("No candidate network transaction available");
92                        continue;
93                    };
94
95                    let network_account_prefix = NetworkAccountPrefix::try_from(candidate.account.id())
96                                                 .expect("all accounts managed by NTB are network accounts");
97                    let indexed_candidate = (network_account_prefix, candidate.chain_tip_header.block_num());
98                    let task_id = inflight.spawn({
99                        let context = context.clone();
100                        context.execute_transaction(candidate)
101                    }).id();
102
103                    // SAFETY: This is definitely a network account.
104                    inflight_idx.insert(task_id, indexed_candidate);
105                },
106                event = mempool_events.try_next() => {
107                    let event = event
108                                .context("mempool event stream ended")?
109                                .context("mempool event stream failed")?;
110                    state.mempool_update(event).await.context("failed to update state")?;
111                },
112                completed = inflight.join_next_with_id() => {
113                    // Grab the task ID and associated network account reference.
114                    let task_id = match &completed {
115                        Ok((task_id, _)) => *task_id,
116                        Err(join_handle) => join_handle.id(),
117                    };
118                    // SAFETY: both inflights should have the same set.
119                    let (candidate, block_num) = inflight_idx.remove(&task_id).unwrap();
120
121                    match completed {
122                        // Some notes failed.
123                        Ok((_, Ok(failed))) => {
124                            let notes = failed.into_iter().map(|note| note.note).collect::<Vec<_>>();
125                            state.notes_failed(candidate, notes.as_slice(), block_num);
126                        },
127                        // Transaction execution failed.
128                        Ok((_, Err(err))) => {
129                            tracing::warn!(err=err.as_report(), "network transaction failed");
130                            match err {
131                                NtxError::AllNotesFailed(failed) => {
132                                    let notes = failed.into_iter().map(|note| note.note).collect::<Vec<_>>();
133                                    state.notes_failed(candidate, notes.as_slice(), block_num);
134                                },
135                                NtxError::InputNotes(_)
136                                | NtxError::NoteFilter(_)
137                                | NtxError::Execution(_)
138                                | NtxError::Proving(_)
139                                | NtxError::Submission(_)
140                                | NtxError::Panic(_) => {},
141                            }
142                            state.candidate_failed(candidate);
143                        },
144                        // Unexpected error occurred.
145                        Err(err) => {
146                            tracing::warn!(err=err.as_report(), "network transaction panicked");
147                            state.candidate_failed(candidate);
148                        }
149                    }
150                }
151            }
152        }
153    }
154}
155
156/// A wrapper arounnd tokio's [`JoinSet`](tokio::task::JoinSet) which returns pending instead of
157/// [`None`] if its empty.
158///
159/// This makes it much more convenient to use in a `select!`.
160struct JoinSet<T>(tokio::task::JoinSet<T>);
161
162impl<T> JoinSet<T>
163where
164    T: 'static,
165{
166    fn new() -> Self {
167        Self(tokio::task::JoinSet::new())
168    }
169
170    fn spawn<F>(&mut self, task: F) -> tokio::task::AbortHandle
171    where
172        F: Future<Output = T>,
173        F: Send + 'static,
174        T: Send,
175    {
176        self.0.spawn(task)
177    }
178
179    async fn join_next_with_id(&mut self) -> Result<(tokio::task::Id, T), tokio::task::JoinError> {
180        if self.0.is_empty() {
181            std::future::pending().await
182        } else {
183            // Cannot be None as its not empty.
184            self.0.join_next_with_id().await.unwrap()
185        }
186    }
187
188    fn len(&self) -> usize {
189        self.0.len()
190    }
191}