Skip to main content

miden_node_ntx_builder/
lib.rs

1use std::num::NonZeroUsize;
2use std::path::PathBuf;
3use std::sync::Arc;
4use std::time::Duration;
5
6use actor::AccountActorContext;
7use anyhow::Context;
8use builder::MempoolEventStream;
9use chain_state::SharedChainState;
10use clients::{BlockProducerClient, StoreClient, ValidatorClient};
11use coordinator::Coordinator;
12use db::Db;
13use futures::TryStreamExt;
14use miden_node_utils::ErrorReport;
15use miden_node_utils::lru_cache::LruCache;
16use miden_remote_prover_client::RemoteTransactionProver;
17use tokio::sync::mpsc;
18use url::Url;
19
20pub(crate) type NoteError = Arc<dyn ErrorReport + Send + Sync>;
21
22mod actor;
23mod builder;
24mod chain_state;
25mod clients;
26mod coordinator;
27pub(crate) mod db;
28pub(crate) mod inflight_note;
29pub mod server;
30
31#[cfg(test)]
32pub(crate) mod test_utils;
33
34pub use builder::NetworkTransactionBuilder;
35
36// CONSTANTS
37// =================================================================================================
38
39const COMPONENT: &str = "miden-ntx-builder";
40
41/// Default maximum number of network notes a network transaction is allowed to consume.
42const DEFAULT_MAX_NOTES_PER_TX: NonZeroUsize = NonZeroUsize::new(20).expect("literal is non-zero");
43const _: () = assert!(DEFAULT_MAX_NOTES_PER_TX.get() <= miden_tx::MAX_NUM_CHECKER_NOTES);
44
45/// Default maximum number of network transactions which should be in progress concurrently.
46///
47/// This only counts transactions which are being computed locally and does not include
48/// uncommitted transactions in the mempool.
49const DEFAULT_MAX_CONCURRENT_TXS: usize = 4;
50
51/// Default maximum number of blocks to keep in the chain MMR.
52const DEFAULT_MAX_BLOCK_COUNT: usize = 4;
53
54/// Default channel capacity for account loading from the store.
55const DEFAULT_ACCOUNT_CHANNEL_CAPACITY: usize = 1_000;
56
57/// Default maximum number of attempts to execute a failing note before dropping it.
58const DEFAULT_MAX_NOTE_ATTEMPTS: usize = 30;
59
60/// Default script cache size.
61const DEFAULT_SCRIPT_CACHE_SIZE: NonZeroUsize =
62    NonZeroUsize::new(1_000).expect("literal is non-zero");
63
64/// Default duration after which an idle network account actor will deactivate.
65const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(5 * 60);
66
67/// Default maximum number of crashes an account actor is allowed before being deactivated.
68const DEFAULT_MAX_ACCOUNT_CRASHES: usize = 10;
69
70/// Default initial sleep applied between per-request retries on transient infrastructure failures
71/// (downed prover, transport error, validator/block-producer crash, store gRPC hiccup). Doubles
72/// on each retry up to [`DEFAULT_REQUEST_BACKOFF_MAX`].
73const DEFAULT_REQUEST_BACKOFF_INITIAL: Duration = Duration::from_millis(100);
74
75/// Default upper bound on the per-request retry backoff sleep.
76const DEFAULT_REQUEST_BACKOFF_MAX: Duration = Duration::from_secs(30);
77
78/// Default maximum number of VM execution cycles allowed for a network transaction.
79///
80/// This limits the computational cost of network transactions. The protocol maximum is
81/// `1 << 29` but network transactions should be much cheaper.
82const DEFAULT_MAX_TX_CYCLES: u32 = 1 << 19;
83
84// CONFIGURATION
85// =================================================================================================
86
87/// Configuration for the Network Transaction Builder.
88///
89/// This struct contains all the settings needed to create and run a `NetworkTransactionBuilder`.
90#[derive(Debug, Clone)]
91pub struct NtxBuilderConfig {
92    /// Address of the store gRPC server (ntx-builder API).
93    pub store_url: Url,
94
95    /// Address of the block producer gRPC server.
96    pub block_producer_url: Url,
97
98    /// Address of the validator gRPC server.
99    pub validator_url: Url,
100
101    /// Address of the remote transaction prover. If `None`, transactions will be proven locally.
102    pub tx_prover_url: Option<Url>,
103
104    /// Size of the LRU cache for note scripts. Scripts are fetched from the store and cached
105    /// to avoid repeated gRPC calls.
106    pub script_cache_size: NonZeroUsize,
107
108    /// Maximum number of network transactions which should be in progress concurrently across
109    /// all account actors.
110    pub max_concurrent_txs: usize,
111
112    /// Maximum number of network notes a single transaction is allowed to consume.
113    pub max_notes_per_tx: NonZeroUsize,
114
115    /// Maximum number of attempts to execute a failing note before dropping it.
116    /// Notes use exponential backoff between attempts.
117    pub max_note_attempts: usize,
118
119    /// Maximum number of blocks to keep in the chain MMR. Older blocks are pruned.
120    pub max_block_count: usize,
121
122    /// Channel capacity for loading accounts from the store during startup.
123    pub account_channel_capacity: usize,
124
125    /// Duration after which an idle network account will deactivate.
126    ///
127    /// An account is considered idle once it has no viable notes to consume.
128    /// A deactivated account will reactivate if targeted with new notes.
129    pub idle_timeout: Duration,
130
131    /// Maximum number of crashes before an account deactivated.
132    ///
133    /// Once this limit is reached, no new transactions will be created for this account.
134    pub max_account_crashes: usize,
135
136    /// Maximum number of VM execution cycles allowed for a single network transaction.
137    ///
138    /// Network transactions that exceed this limit will fail with an execution error.
139    /// Defaults to 2^18 cycles.
140    pub max_cycles: u32,
141
142    /// Initial sleep applied between per-request retries on transient infrastructure failures
143    /// (e.g. prover unreachable, validator/block-producer crash, transport error, store gRPC
144    /// hiccup). Doubles on each retry up to [`Self::request_backoff_max`]. Per-note
145    /// `attempt_count` is *not* advanced while retries are in progress.
146    pub request_backoff_initial: Duration,
147
148    /// Upper bound on the per-request retry backoff sleep.
149    pub request_backoff_max: Duration,
150
151    /// Path to the SQLite database file used for persistent state.
152    pub database_filepath: PathBuf,
153}
154
155impl NtxBuilderConfig {
156    pub fn new(
157        store_url: Url,
158        block_producer_url: Url,
159        validator_url: Url,
160        database_filepath: PathBuf,
161    ) -> Self {
162        Self {
163            store_url,
164            block_producer_url,
165            validator_url,
166            tx_prover_url: None,
167            script_cache_size: DEFAULT_SCRIPT_CACHE_SIZE,
168            max_concurrent_txs: DEFAULT_MAX_CONCURRENT_TXS,
169            max_notes_per_tx: DEFAULT_MAX_NOTES_PER_TX,
170            max_note_attempts: DEFAULT_MAX_NOTE_ATTEMPTS,
171            max_block_count: DEFAULT_MAX_BLOCK_COUNT,
172            account_channel_capacity: DEFAULT_ACCOUNT_CHANNEL_CAPACITY,
173            idle_timeout: DEFAULT_IDLE_TIMEOUT,
174            max_account_crashes: DEFAULT_MAX_ACCOUNT_CRASHES,
175            max_cycles: DEFAULT_MAX_TX_CYCLES,
176            request_backoff_initial: DEFAULT_REQUEST_BACKOFF_INITIAL,
177            request_backoff_max: DEFAULT_REQUEST_BACKOFF_MAX,
178            database_filepath,
179        }
180    }
181
182    /// Sets the remote transaction prover URL.
183    ///
184    /// If not set, transactions will be proven locally.
185    #[must_use]
186    pub fn with_tx_prover_url(mut self, url: Option<Url>) -> Self {
187        self.tx_prover_url = url;
188        self
189    }
190
191    /// Sets the script cache size.
192    #[must_use]
193    pub fn with_script_cache_size(mut self, size: NonZeroUsize) -> Self {
194        self.script_cache_size = size;
195        self
196    }
197
198    /// Sets the maximum number of concurrent transactions.
199    #[must_use]
200    pub fn with_max_concurrent_txs(mut self, max: usize) -> Self {
201        self.max_concurrent_txs = max;
202        self
203    }
204
205    /// Sets the maximum number of notes per transaction.
206    ///
207    /// # Panics
208    ///
209    /// Panics if `max` exceeds `miden_tx::MAX_NUM_CHECKER_NOTES`.
210    #[must_use]
211    pub fn with_max_notes_per_tx(mut self, max: NonZeroUsize) -> Self {
212        assert!(
213            max.get() <= miden_tx::MAX_NUM_CHECKER_NOTES,
214            "max_notes_per_tx ({}) exceeds MAX_NUM_CHECKER_NOTES ({})",
215            max,
216            miden_tx::MAX_NUM_CHECKER_NOTES
217        );
218        self.max_notes_per_tx = max;
219        self
220    }
221
222    /// Sets the maximum number of note execution attempts.
223    #[must_use]
224    pub fn with_max_note_attempts(mut self, max: usize) -> Self {
225        self.max_note_attempts = max;
226        self
227    }
228
229    /// Sets the maximum number of blocks to keep in the chain MMR.
230    #[must_use]
231    pub fn with_max_block_count(mut self, max: usize) -> Self {
232        self.max_block_count = max;
233        self
234    }
235
236    /// Sets the account channel capacity for startup loading.
237    #[must_use]
238    pub fn with_account_channel_capacity(mut self, capacity: usize) -> Self {
239        self.account_channel_capacity = capacity;
240        self
241    }
242
243    /// Sets the idle timeout for actors.
244    ///
245    /// Actors that remain idle (no viable notes) for this duration will be deactivated.
246    #[must_use]
247    pub fn with_idle_timeout(mut self, timeout: Duration) -> Self {
248        self.idle_timeout = timeout;
249        self
250    }
251
252    /// Sets the maximum number of crashes before an account actor is deactivated.
253    #[must_use]
254    pub fn with_max_account_crashes(mut self, max: usize) -> Self {
255        self.max_account_crashes = max;
256        self
257    }
258
259    /// Sets the maximum number of VM execution cycles for network transactions.
260    #[must_use]
261    pub fn with_max_cycles(mut self, max: u32) -> Self {
262        self.max_cycles = max;
263        self
264    }
265
266    /// Sets the per-request retry backoff bounds (initial sleep and cap) used when retrying
267    /// transient infrastructure failures inside a single transaction attempt.
268    #[must_use]
269    pub fn with_request_backoff(mut self, initial: Duration, max: Duration) -> Self {
270        self.request_backoff_initial = initial;
271        self.request_backoff_max = max;
272        self
273    }
274
275    /// Builds and initializes the network transaction builder.
276    ///
277    /// This method connects to the store and block producer services, fetches the current
278    /// chain tip, and subscribes to mempool events.
279    ///
280    /// # Errors
281    ///
282    /// Returns an error if:
283    /// - The store connection fails
284    /// - The mempool subscription fails (after retries)
285    /// - The store contains no blocks (not bootstrapped)
286    pub async fn build(self) -> anyhow::Result<NetworkTransactionBuilder> {
287        // Set up the database (bootstrap + connection pool).
288        let db = Db::setup(self.database_filepath.clone()).await?;
289
290        // Purge inflight state from previous run.
291        db.purge_inflight().await.context("failed to purge inflight state")?;
292
293        let script_cache = LruCache::new(self.script_cache_size);
294        let coordinator =
295            Coordinator::new(self.max_concurrent_txs, self.max_account_crashes, db.clone());
296
297        let store = StoreClient::new(self.store_url.clone());
298        let block_producer = BlockProducerClient::new(self.block_producer_url.clone());
299        let validator = ValidatorClient::new(self.validator_url.clone());
300        let prover = self.tx_prover_url.clone().map(RemoteTransactionProver::new);
301
302        // Subscribe to mempool first to ensure we don't miss any events. The subscription
303        // replays all inflight transactions, so the subscriber's state is fully reconstructed.
304        let subscription = block_producer
305            .subscribe_to_mempool_with_retry()
306            .await
307            .map_err(|err| anyhow::anyhow!(err))
308            .context("failed to subscribe to mempool events")?;
309        let mempool_events: MempoolEventStream = Box::pin(subscription.into_stream());
310
311        let (chain_tip_header, chain_mmr) = store
312            .get_latest_blockchain_data_with_retry()
313            .await?
314            .context("store should contain a latest block")?;
315
316        // Store the chain tip in the DB.
317        db.upsert_chain_state(chain_tip_header.block_num(), chain_tip_header.clone())
318            .await
319            .context("failed to upsert chain state")?;
320
321        let chain_state = Arc::new(SharedChainState::new(chain_tip_header, chain_mmr));
322
323        let (request_tx, actor_request_rx) = mpsc::channel(1);
324
325        let actor_context = AccountActorContext {
326            block_producer: block_producer.clone(),
327            validator,
328            prover,
329            chain_state: Arc::clone(&chain_state),
330            store: store.clone(),
331            script_cache,
332            max_notes_per_tx: self.max_notes_per_tx,
333            max_note_attempts: self.max_note_attempts,
334            idle_timeout: self.idle_timeout,
335            db: db.clone(),
336            request_tx,
337            max_cycles: self.max_cycles,
338            request_backoff_initial: self.request_backoff_initial,
339            request_backoff_max: self.request_backoff_max,
340        };
341
342        Ok(NetworkTransactionBuilder::new(
343            self,
344            coordinator,
345            store,
346            db,
347            chain_state,
348            actor_context,
349            mempool_events,
350            actor_request_rx,
351        ))
352    }
353}