From 7cf31f3c03e8b5be6b1137609aae290b8463b2bb Mon Sep 17 00:00:00 2001 From: SantiagoPittella Date: Fri, 13 Mar 2026 10:43:39 -0300 Subject: [PATCH 1/2] feat: add GetNoteError endpoint in ntx builder --- CHANGELOG.md | 2 + Cargo.lock | 3 + bin/node/src/commands/bundled.rs | 53 +++++++--- bin/node/src/commands/mod.rs | 1 + bin/node/src/commands/rpc.rs | 15 ++- crates/ntx-builder/Cargo.toml | 5 +- crates/ntx-builder/src/actor/mod.rs | 66 +++++++++++-- crates/ntx-builder/src/builder.rs | 41 +++++++- .../db/migrations/2026020900000_setup/up.sql | 5 + crates/ntx-builder/src/db/mod.rs | 17 +++- crates/ntx-builder/src/db/models/conv.rs | 6 +- .../ntx-builder/src/db/models/queries/mod.rs | 2 + .../src/db/models/queries/notes.rs | 53 ++++++++-- .../src/db/models/queries/tests.rs | 81 ++++++++++++++- crates/ntx-builder/src/db/schema.rs | 2 + crates/ntx-builder/src/lib.rs | 1 + crates/ntx-builder/src/server.rs | 99 +++++++++++++++++++ crates/proto/build.rs | 2 + crates/proto/src/clients/mod.rs | 23 +++++ crates/rpc/src/server/api.rs | 53 +++++++++- crates/rpc/src/server/mod.rs | 2 + crates/rpc/src/tests.rs | 1 + docs/external/src/operator/architecture.md | 4 + docs/external/src/rpc.md | 27 +++++ docs/internal/src/ntx-builder.md | 11 +++ proto/build.rs | 8 ++ proto/proto/internal/ntx_builder.proto | 30 ++++++ proto/proto/rpc.proto | 22 +++++ proto/src/lib.rs | 8 ++ 29 files changed, 592 insertions(+), 51 deletions(-) create mode 100644 crates/ntx-builder/src/server.rs create mode 100644 proto/proto/internal/ntx_builder.proto diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cedd8c0e8..d9adfe8d2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Enhancements - Expose per-tree RocksDB tuning options ([#1782](https://github.com/0xMiden/node/pull/1782)). +- Added a gRPC server to the NTX builder, configurable via `--ntx-builder.url` / `MIDEN_NODE_NTX_BUILDER_URL` (https://github.com/0xMiden/node/issues/1758). +- Added `GetNoteError` gRPC endpoint to query the latest execution error for network notes (https://github.com/0xMiden/node/issues/1758). - Added verbose `info!`-level logging to the network transaction builder for transaction execution, note filtering failures, and transaction outcomes ([#1770](https://github.com/0xMiden/node/pull/1770)). - [BREAKING] Move block proving from Blocker Producer to the Store ([#1579](https://github.com/0xMiden/node/pull/1579)). - [BREAKING] Updated miden-base dependencies to use `next` branch; renamed `NoteInputs` to `NoteStorage`, `.inputs()` to `.storage()`, and database `inputs` column to `storage` ([#1595](https://github.com/0xMiden/node/pull/1595)). diff --git a/Cargo.lock b/Cargo.lock index 16ea17404b..8e1d31e0ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3054,6 +3054,7 @@ dependencies = [ "libsqlite3-sys", "miden-node-db", "miden-node-proto", + "miden-node-proto-build", "miden-node-test-macro", "miden-node-utils", "miden-protocol", @@ -3068,6 +3069,8 @@ dependencies = [ "tokio-stream", "tokio-util", "tonic", + "tonic-reflection", + "tower-http", "tracing", "url", ] diff --git a/bin/node/src/commands/bundled.rs b/bin/node/src/commands/bundled.rs index 2db11c0652..dd2c9bc103 100644 --- a/bin/node/src/commands/bundled.rs +++ b/bin/node/src/commands/bundled.rs @@ -246,10 +246,44 @@ impl BundledCommand { .id() }; + // Prepare network transaction builder (bind listener + config before starting RPC, + // so that the ntx-builder URL is available for the RPC proxy). + let mut ntx_builder_url_for_rpc = None; + let ntx_builder_prepared = if should_start_ntx_builder { + let store_ntx_builder_url = Url::parse(&format!("http://{store_ntx_builder_address}")) + .context("Failed to parse URL")?; + let block_producer_url = block_producer_url.clone(); + let validator_url = validator_url.clone(); + + let builder_config = ntx_builder.into_builder_config( + store_ntx_builder_url, + block_producer_url, + validator_url, + &data_directory, + ); + + // Bind a listener for the ntx-builder gRPC server. + let ntx_builder_listener = TcpListener::bind("127.0.0.1:0") + .await + .context("Failed to bind to ntx-builder gRPC endpoint")?; + let ntx_builder_address = ntx_builder_listener + .local_addr() + .context("Failed to retrieve the ntx-builder's gRPC address")?; + ntx_builder_url_for_rpc = Some( + Url::parse(&format!("http://{ntx_builder_address}")) + .context("Failed to parse ntx-builder URL")?, + ); + + Some((builder_config, ntx_builder_listener)) + } else { + None + }; + // Start RPC component. let rpc_id = { let block_producer_url = block_producer_url.clone(); let validator_url = validator_url.clone(); + let ntx_builder_url = ntx_builder_url_for_rpc; join_set .spawn(async move { let store_url = Url::parse(&format!("http://{store_rpc_address}")) @@ -259,6 +293,7 @@ impl BundledCommand { store_url, block_producer_url: Some(block_producer_url), validator_url, + ntx_builder_url, grpc_options, } .serve() @@ -275,27 +310,15 @@ impl BundledCommand { (rpc_id, "rpc"), ]); - // Start network transaction builder. The endpoint is available after loading completes. - if should_start_ntx_builder { - let store_ntx_builder_url = Url::parse(&format!("http://{store_ntx_builder_address}")) - .context("Failed to parse URL")?; - let block_producer_url = block_producer_url.clone(); - let validator_url = validator_url.clone(); - - let builder_config = ntx_builder.into_builder_config( - store_ntx_builder_url, - block_producer_url, - validator_url, - &data_directory, - ); - + // Start network transaction builder. + if let Some((builder_config, ntx_builder_listener)) = ntx_builder_prepared { let id = join_set .spawn(async move { builder_config .build() .await .context("failed to initialize ntx builder")? - .run() + .run(Some(ntx_builder_listener)) .await .context("failed while serving ntx builder component") }) diff --git a/bin/node/src/commands/mod.rs b/bin/node/src/commands/mod.rs index 25c0ddf235..ce628e5ee7 100644 --- a/bin/node/src/commands/mod.rs +++ b/bin/node/src/commands/mod.rs @@ -47,6 +47,7 @@ const ENV_NTX_SCRIPT_CACHE_SIZE: &str = "MIDEN_NTX_DATA_STORE_SCRIPT_CACHE_SIZE" const ENV_VALIDATOR_KEY: &str = "MIDEN_NODE_VALIDATOR_KEY"; const ENV_VALIDATOR_KMS_KEY_ID: &str = "MIDEN_NODE_VALIDATOR_KMS_KEY_ID"; const ENV_NTX_DATA_DIRECTORY: &str = "MIDEN_NODE_NTX_DATA_DIRECTORY"; +const ENV_NTX_BUILDER_URL: &str = "MIDEN_NODE_NTX_BUILDER_URL"; const DEFAULT_NTX_TICKER_INTERVAL: Duration = Duration::from_millis(200); const DEFAULT_NTX_IDLE_TIMEOUT: Duration = Duration::from_secs(5 * 60); diff --git a/bin/node/src/commands/rpc.rs b/bin/node/src/commands/rpc.rs index 04e82f917b..a0ecd455d9 100644 --- a/bin/node/src/commands/rpc.rs +++ b/bin/node/src/commands/rpc.rs @@ -4,7 +4,13 @@ use miden_node_utils::clap::GrpcOptionsExternal; use miden_node_utils::grpc::UrlExt; use url::Url; -use super::{ENV_BLOCK_PRODUCER_URL, ENV_RPC_URL, ENV_STORE_RPC_URL, ENV_VALIDATOR_URL}; +use super::{ + ENV_BLOCK_PRODUCER_URL, + ENV_NTX_BUILDER_URL, + ENV_RPC_URL, + ENV_STORE_RPC_URL, + ENV_VALIDATOR_URL, +}; use crate::commands::ENV_ENABLE_OTEL; #[derive(clap::Subcommand)] @@ -28,6 +34,11 @@ pub enum RpcCommand { #[arg(long = "validator.url", env = ENV_VALIDATOR_URL, value_name = "URL")] validator_url: Url, + /// The network transaction builder's gRPC url. If unset, the `GetNoteError` endpoint + /// will be unavailable. + #[arg(long = "ntx-builder.url", env = ENV_NTX_BUILDER_URL, value_name = "URL")] + ntx_builder_url: Option, + /// Enables the exporting of traces for OpenTelemetry. /// /// This can be further configured using environment variables as defined in the official @@ -47,6 +58,7 @@ impl RpcCommand { store_url, block_producer_url, validator_url, + ntx_builder_url, enable_otel: _, grpc_options, } = self; @@ -61,6 +73,7 @@ impl RpcCommand { store_url, block_producer_url, validator_url, + ntx_builder_url, grpc_options, } .serve() diff --git a/crates/ntx-builder/Cargo.toml b/crates/ntx-builder/Cargo.toml index 6110a7a6d4..a614ddb1a3 100644 --- a/crates/ntx-builder/Cargo.toml +++ b/crates/ntx-builder/Cargo.toml @@ -21,6 +21,7 @@ futures = { workspace = true } libsqlite3-sys = { workspace = true } miden-node-db = { workspace = true } miden-node-proto = { workspace = true } +miden-node-proto-build = { features = ["internal"], workspace = true } miden-node-utils = { workspace = true } miden-protocol = { default-features = true, workspace = true } miden-remote-prover-client = { features = ["tx-prover"], workspace = true } @@ -28,9 +29,11 @@ miden-standards = { workspace = true } miden-tx = { default-features = true, workspace = true } thiserror = { workspace = true } tokio = { features = ["rt-multi-thread"], workspace = true } -tokio-stream = { workspace = true } +tokio-stream = { features = ["net"], workspace = true } tokio-util = { workspace = true } tonic = { workspace = true } +tonic-reflection = { workspace = true } +tower-http = { workspace = true } tracing = { workspace = true } url = { workspace = true } diff --git a/crates/ntx-builder/src/actor/mod.rs b/crates/ntx-builder/src/actor/mod.rs index 46f090f3c8..c253af4340 100644 --- a/crates/ntx-builder/src/actor/mod.rs +++ b/crates/ntx-builder/src/actor/mod.rs @@ -24,7 +24,6 @@ use url::Url; use crate::chain_state::ChainState; use crate::clients::{BlockProducerClient, StoreClient}; use crate::db::Db; -use crate::inflight_note::InflightNetworkNote; // ACTOR REQUESTS // ================================================================================================ @@ -36,7 +35,7 @@ pub enum ActorRequest { /// the oneshot channel, preventing race conditions where the actor could re-select the same /// notes before the failure is persisted. NotesFailed { - nullifiers: Vec, + failed_notes: Vec<(Nullifier, String)>, block_num: BlockNumber, ack_tx: tokio::sync::oneshot::Sender<()>, }, @@ -411,23 +410,66 @@ impl AccountActor { ); self.cache_note_scripts(scripts_to_cache).await; if !failed.is_empty() { - let nullifiers: Vec<_> = - failed.into_iter().map(|note| note.note.nullifier()).collect(); - self.mark_notes_failed(&nullifiers, block_num).await; + let failed_notes: Vec<_> = failed + .into_iter() + .map(|f| { + let error_msg = f.error.as_report(); + tracing::info!( + note.id = %f.note.id(), + nullifier = %f.note.nullifier(), + err = %error_msg, + "note failed: consumability check", + ); + (f.note.nullifier(), error_msg) + }) + .collect(); + self.mark_notes_failed(&failed_notes, block_num).await; } self.mode = ActorMode::TransactionInflight(tx_id); }, // Transaction execution failed. Err(err) => { + let error_msg = err.as_report(); tracing::error!( %account_id, ?note_ids, - err = err.as_report(), + err = %error_msg, "network transaction failed", ); self.mode = ActorMode::NoViableNotes; - let nullifiers: Vec<_> = notes.iter().map(InflightNetworkNote::nullifier).collect(); - self.mark_notes_failed(&nullifiers, block_num).await; + + // For `AllNotesFailed`, use the per-note errors which contain the + // specific reason each note failed (e.g. consumability check details). + let failed_notes: Vec<_> = + if let execute::NtxError::AllNotesFailed(per_note) = err { + per_note + .into_iter() + .map(|f| { + let note_error = f.error.as_report(); + tracing::info!( + note.id = %f.note.id(), + nullifier = %f.note.nullifier(), + err = %note_error, + "note failed: consumability check", + ); + (f.note.nullifier(), note_error) + }) + .collect() + } else { + notes + .iter() + .map(|note| { + tracing::info!( + note.id = %note.to_inner().as_note().id(), + nullifier = %note.nullifier(), + err = %error_msg, + "note failed: transaction execution error", + ); + (note.nullifier(), error_msg.clone()) + }) + .collect() + }; + self.mark_notes_failed(&failed_notes, block_num).await; }, } } @@ -449,12 +491,16 @@ impl AccountActor { /// Sends a request to the coordinator to mark notes as failed and waits for the DB write to /// complete. This prevents a race condition where the actor could re-select the same notes /// before the failure counts are updated in the database. - async fn mark_notes_failed(&self, nullifiers: &[Nullifier], block_num: BlockNumber) { + async fn mark_notes_failed( + &self, + failed_notes: &[(Nullifier, String)], + block_num: BlockNumber, + ) { let (ack_tx, ack_rx) = tokio::sync::oneshot::channel(); if self .request_tx .send(ActorRequest::NotesFailed { - nullifiers: nullifiers.to_vec(), + failed_notes: failed_notes.to_vec(), block_num, ack_tx, }) diff --git a/crates/ntx-builder/src/builder.rs b/crates/ntx-builder/src/builder.rs index e47570d29c..3cb2d60a4b 100644 --- a/crates/ntx-builder/src/builder.rs +++ b/crates/ntx-builder/src/builder.rs @@ -7,7 +7,9 @@ use miden_node_proto::domain::account::NetworkAccountId; use miden_node_proto::domain::mempool::MempoolEvent; use miden_protocol::account::delta::AccountUpdateDetails; use miden_protocol::block::BlockHeader; +use tokio::net::TcpListener; use tokio::sync::{RwLock, mpsc}; +use tokio::task::JoinSet; use tokio_stream::StreamExt; use tonic::Status; @@ -17,6 +19,7 @@ use crate::chain_state::ChainState; use crate::clients::StoreClient; use crate::coordinator::Coordinator; use crate::db::Db; +use crate::server::NtxBuilderRpcServer; // NETWORK TRANSACTION BUILDER // ================================================================================================ @@ -86,9 +89,13 @@ impl NetworkTransactionBuilder { /// Runs the network transaction builder event loop until a fatal error occurs. /// + /// If a `TcpListener` is provided, a gRPC server is also spawned to expose the + /// `GetNoteError` endpoint. + /// /// This method: - /// 1. Spawns a background task to load existing network accounts from the store - /// 2. Runs the main event loop, processing mempool events and managing actors + /// 1. Optionally starts a gRPC server for note error queries + /// 2. Spawns a background task to load existing network accounts from the store + /// 3. Runs the main event loop, processing mempool events and managing actors /// /// # Errors /// @@ -96,7 +103,31 @@ impl NetworkTransactionBuilder { /// - The mempool event stream ends unexpectedly /// - An actor encounters a fatal error /// - The account loader task fails - pub async fn run(mut self) -> anyhow::Result<()> { + /// - The gRPC server fails + pub async fn run(self, listener: Option) -> anyhow::Result<()> { + let mut join_set = JoinSet::new(); + + // Start the gRPC server if a listener is provided. + if let Some(listener) = listener { + let server = NtxBuilderRpcServer::new(self.db.clone()); + join_set.spawn(async move { + server.serve(listener).await.context("ntx-builder gRPC server failed") + }); + } + + join_set.spawn(self.run_event_loop()); + + // Wait for either the event loop or the gRPC server to complete. + // Any completion is treated as fatal. + if let Some(result) = join_set.join_next().await { + result.context("ntx-builder task panicked")??; + } + + Ok(()) + } + + /// Runs the main event loop. + async fn run_event_loop(mut self) -> anyhow::Result<()> { // Spawn a background task to load network accounts from the store. // Accounts are sent through a channel and processed in the main event loop. let (account_tx, mut account_rx) = @@ -245,9 +276,9 @@ impl NetworkTransactionBuilder { /// Processes a request from an account actor. async fn handle_actor_request(&mut self, request: ActorRequest) -> Result<(), anyhow::Error> { match request { - ActorRequest::NotesFailed { nullifiers, block_num, ack_tx } => { + ActorRequest::NotesFailed { failed_notes, block_num, ack_tx } => { self.db - .notes_failed(nullifiers, block_num) + .notes_failed(failed_notes, block_num) .await .context("failed to mark notes as failed")?; let _ = ack_tx.send(()); diff --git a/crates/ntx-builder/src/db/migrations/2026020900000_setup/up.sql b/crates/ntx-builder/src/db/migrations/2026020900000_setup/up.sql index 68f3793d83..4a1480b08d 100644 --- a/crates/ntx-builder/src/db/migrations/2026020900000_setup/up.sql +++ b/crates/ntx-builder/src/db/migrations/2026020900000_setup/up.sql @@ -44,10 +44,14 @@ CREATE TABLE notes ( account_id BLOB NOT NULL, -- Serialized SingleTargetNetworkNote. note_data BLOB NOT NULL, + -- Note ID bytes. + note_id BLOB, -- Backoff tracking: number of failed execution attempts. attempt_count INTEGER NOT NULL DEFAULT 0, -- Backoff tracking: block number of the last failed attempt. NULL if never attempted. last_attempt INTEGER, + -- Latest execution error message. NULL if no error recorded. + last_error TEXT, -- NULL if the note came from a committed block; transaction ID if created by inflight tx. created_by BLOB, -- NULL if unconsumed; transaction ID of the consuming inflight tx. @@ -60,6 +64,7 @@ CREATE TABLE notes ( CREATE INDEX idx_notes_account ON notes(account_id); CREATE INDEX idx_notes_created_by ON notes(created_by) WHERE created_by IS NOT NULL; CREATE INDEX idx_notes_consumed_by ON notes(consumed_by) WHERE consumed_by IS NOT NULL; +CREATE INDEX idx_notes_note_id ON notes(note_id) WHERE note_id IS NOT NULL; -- Persistent cache of note scripts, keyed by script root hash. -- Survives restarts so scripts don't need to be re-fetched from the store. diff --git a/crates/ntx-builder/src/db/mod.rs b/crates/ntx-builder/src/db/mod.rs index 8cbb7e39dd..f5418faa0b 100644 --- a/crates/ntx-builder/src/db/mod.rs +++ b/crates/ntx-builder/src/db/mod.rs @@ -7,7 +7,7 @@ use miden_protocol::Word; use miden_protocol::account::Account; use miden_protocol::account::delta::AccountUpdateDetails; use miden_protocol::block::{BlockHeader, BlockNumber}; -use miden_protocol::note::{NoteScript, Nullifier}; +use miden_protocol::note::{NoteId, NoteScript, Nullifier}; use miden_protocol::transaction::TransactionId; use miden_standards::note::AccountTargetNetworkNote; use tracing::{info, instrument}; @@ -101,19 +101,28 @@ impl Db { .await } - /// Marks notes as failed by incrementing `attempt_count` and setting `last_attempt`. + /// Marks notes as failed by incrementing `attempt_count`, setting `last_attempt`, and storing + /// the latest error message. pub async fn notes_failed( &self, - nullifiers: Vec, + failed_notes: Vec<(Nullifier, String)>, block_num: BlockNumber, ) -> Result<()> { self.inner .transact("notes_failed", move |conn| { - queries::notes_failed(conn, &nullifiers, block_num) + queries::notes_failed(conn, &failed_notes, block_num) }) .await } + /// Returns the latest execution error for a note identified by its note ID. + pub async fn get_note_error(&self, note_id: NoteId) -> Result> { + let note_id_bytes = models::conv::note_id_to_bytes(¬e_id); + self.inner + .query("get_note_error", move |conn| queries::get_note_error(conn, ¬e_id_bytes)) + .await + } + /// Handles a `TransactionAdded` mempool event by writing effects to the DB. pub async fn handle_transaction_added( &self, diff --git a/crates/ntx-builder/src/db/models/conv.rs b/crates/ntx-builder/src/db/models/conv.rs index b32a292538..dd129df728 100644 --- a/crates/ntx-builder/src/db/models/conv.rs +++ b/crates/ntx-builder/src/db/models/conv.rs @@ -5,7 +5,7 @@ use miden_node_proto::domain::account::NetworkAccountId; use miden_protocol::Word; use miden_protocol::account::{Account, AccountId}; use miden_protocol::block::{BlockHeader, BlockNumber}; -use miden_protocol::note::{NoteScript, Nullifier}; +use miden_protocol::note::{NoteId, NoteScript, Nullifier}; use miden_protocol::transaction::TransactionId; use miden_tx::utils::{Deserializable, Serializable}; @@ -32,6 +32,10 @@ pub fn nullifier_to_bytes(nullifier: &Nullifier) -> Vec { nullifier.to_bytes() } +pub fn note_id_to_bytes(note_id: &NoteId) -> Vec { + note_id.to_bytes() +} + pub fn block_num_to_i64(block_num: BlockNumber) -> i64 { i64::from(block_num.as_u32()) } diff --git a/crates/ntx-builder/src/db/models/queries/mod.rs b/crates/ntx-builder/src/db/models/queries/mod.rs index 7b6329396d..e831c3ba8a 100644 --- a/crates/ntx-builder/src/db/models/queries/mod.rs +++ b/crates/ntx-builder/src/db/models/queries/mod.rs @@ -148,8 +148,10 @@ pub fn add_transaction( .expect("network note's target account must be a network account"), ), note_data: note.as_note().to_bytes(), + note_id: Some(conversions::note_id_to_bytes(¬e.as_note().id())), attempt_count: 0, last_attempt: None, + last_error: None, created_by: Some(tx_id_bytes.clone()), consumed_by: None, }; diff --git a/crates/ntx-builder/src/db/models/queries/notes.rs b/crates/ntx-builder/src/db/models/queries/notes.rs index b986953a1f..97214178a8 100644 --- a/crates/ntx-builder/src/db/models/queries/notes.rs +++ b/crates/ntx-builder/src/db/models/queries/notes.rs @@ -33,12 +33,25 @@ pub struct NoteInsert { pub nullifier: Vec, pub account_id: Vec, pub note_data: Vec, + pub note_id: Option>, pub attempt_count: i32, pub last_attempt: Option, + pub last_error: Option, pub created_by: Option>, pub consumed_by: Option>, } +/// Row returned by `get_note_error()`. +#[derive(Debug, Clone, Queryable, Selectable)] +#[diesel(table_name = schema::notes)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub struct NoteErrorRow { + pub note_id: Option>, + pub last_error: Option, + pub attempt_count: i32, + pub last_attempt: Option, +} + // QUERIES // ================================================================================================ @@ -50,8 +63,9 @@ pub struct NoteInsert { /// /// ```sql /// INSERT OR REPLACE INTO notes -/// (nullifier, account_id, note_data, attempt_count, last_attempt, created_by, consumed_by) -/// VALUES (?1, ?2, ?3, 0, NULL, NULL, NULL) +/// (nullifier, account_id, note_data, note_id, attempt_count, last_attempt, last_error, +/// created_by, consumed_by) +/// VALUES (?1, ?2, ?3, ?4, 0, NULL, NULL, NULL, NULL) /// ``` pub fn insert_committed_notes( conn: &mut SqliteConnection, @@ -65,8 +79,10 @@ pub fn insert_committed_notes( .expect("account ID of a network note should be a network account"), ), note_data: note.as_note().to_bytes(), + note_id: Some(conversions::note_id_to_bytes(¬e.as_note().id())), attempt_count: 0, last_attempt: None, + last_error: None, created_by: None, consumed_by: None, }; @@ -125,7 +141,8 @@ pub fn available_notes( Ok(result) } -/// Marks notes as failed by incrementing `attempt_count` and setting `last_attempt`. +/// Marks notes as failed by incrementing `attempt_count`, setting `last_attempt`, and storing +/// the latest error message. /// /// # Raw SQL /// @@ -133,29 +150,51 @@ pub fn available_notes( /// /// ```sql /// UPDATE notes -/// SET attempt_count = attempt_count + 1, last_attempt = ?1 -/// WHERE nullifier = ?2 +/// SET attempt_count = attempt_count + 1, last_attempt = ?1, last_error = ?2 +/// WHERE nullifier = ?3 /// ``` pub fn notes_failed( conn: &mut SqliteConnection, - nullifiers: &[Nullifier], + failed_notes: &[(Nullifier, String)], block_num: BlockNumber, ) -> Result<(), DatabaseError> { let block_num_val = conversions::block_num_to_i64(block_num); - for nullifier in nullifiers { + for (nullifier, error) in failed_notes { let nullifier_bytes = conversions::nullifier_to_bytes(nullifier); diesel::update(schema::notes::table.find(&nullifier_bytes)) .set(( schema::notes::attempt_count.eq(schema::notes::attempt_count + 1), schema::notes::last_attempt.eq(Some(block_num_val)), + schema::notes::last_error.eq(Some(error)), )) .execute(conn)?; } Ok(()) } +/// Returns the latest execution error for a note identified by its note ID. +/// +/// # Raw SQL +/// +/// ```sql +/// SELECT note_id, last_error, attempt_count, last_attempt +/// FROM notes +/// WHERE note_id = ?1 +/// ``` +pub fn get_note_error( + conn: &mut SqliteConnection, + note_id_bytes: &[u8], +) -> Result, DatabaseError> { + schema::notes::table + .filter(schema::notes::note_id.eq(note_id_bytes)) + .select(NoteErrorRow::as_select()) + .first(conn) + .optional() + .map_err(Into::into) +} + // HELPERS // ================================================================================================ diff --git a/crates/ntx-builder/src/db/models/queries/tests.rs b/crates/ntx-builder/src/db/models/queries/tests.rs index f8bf1015e3..fda6d87ec3 100644 --- a/crates/ntx-builder/src/db/models/queries/tests.rs +++ b/crates/ntx-builder/src/db/models/queries/tests.rs @@ -345,9 +345,24 @@ fn available_notes_filters_consumed_and_exceeded_attempts() { // Mark one note as failed many times (exceed max_attempts=3). let block_num = BlockNumber::from(100u32); - notes_failed(conn, &[note_failed.as_note().nullifier()], block_num).unwrap(); - notes_failed(conn, &[note_failed.as_note().nullifier()], block_num).unwrap(); - notes_failed(conn, &[note_failed.as_note().nullifier()], block_num).unwrap(); + notes_failed( + conn, + &[(note_failed.as_note().nullifier(), "test error".to_string())], + block_num, + ) + .unwrap(); + notes_failed( + conn, + &[(note_failed.as_note().nullifier(), "test error".to_string())], + block_num, + ) + .unwrap(); + notes_failed( + conn, + &[(note_failed.as_note().nullifier(), "test error".to_string())], + block_num, + ) + .unwrap(); // Query available notes with max_attempts=3. let result = available_notes(conn, account_id, block_num, 3).unwrap(); @@ -390,8 +405,14 @@ fn notes_failed_increments_attempt_count() { insert_committed_notes(conn, std::slice::from_ref(¬e)).unwrap(); let block_num = BlockNumber::from(5u32); - notes_failed(conn, &[note.as_note().nullifier()], block_num).unwrap(); - notes_failed(conn, &[note.as_note().nullifier()], block_num).unwrap(); + notes_failed(conn, &[(note.as_note().nullifier(), "execution failed".to_string())], block_num) + .unwrap(); + notes_failed( + conn, + &[(note.as_note().nullifier(), "execution failed 2".to_string())], + block_num, + ) + .unwrap(); let (attempt_count, last_attempt): (i32, Option) = schema::notes::table .find(conversions::nullifier_to_bytes(¬e.as_note().nullifier())) @@ -403,6 +424,56 @@ fn notes_failed_increments_attempt_count() { assert_eq!(last_attempt, Some(conversions::block_num_to_i64(block_num))); } +// GET NOTE ERROR TESTS +// ================================================================================================ + +#[test] +fn get_note_error_returns_latest_error() { + let (conn, _dir) = &mut test_conn(); + + let account_id = mock_network_account_id(); + let note = mock_single_target_note(account_id, 10); + let note_id = note.as_note().id(); + + // Insert as committed note. + insert_committed_notes(conn, std::slice::from_ref(¬e)).unwrap(); + + // Initially no error. + let result = get_note_error(conn, &conversions::note_id_to_bytes(¬e_id)).unwrap(); + assert!(result.is_some()); + let row = result.unwrap(); + assert!(row.last_error.is_none()); + assert_eq!(row.attempt_count, 0); + + // Mark as failed. + let block_num = BlockNumber::from(5u32); + notes_failed(conn, &[(note.as_note().nullifier(), "first error".to_string())], block_num) + .unwrap(); + + let result = get_note_error(conn, &conversions::note_id_to_bytes(¬e_id)).unwrap(); + let row = result.unwrap(); + assert_eq!(row.last_error.as_deref(), Some("first error")); + assert_eq!(row.attempt_count, 1); + + // Mark as failed again with different error, should overwrite. + notes_failed(conn, &[(note.as_note().nullifier(), "second error".to_string())], block_num) + .unwrap(); + + let result = get_note_error(conn, &conversions::note_id_to_bytes(¬e_id)).unwrap(); + let row = result.unwrap(); + assert_eq!(row.last_error.as_deref(), Some("second error")); + assert_eq!(row.attempt_count, 2); +} + +#[test] +fn get_note_error_returns_none_for_unknown_note() { + let (conn, _dir) = &mut test_conn(); + + let unknown_id = vec![0u8; 32]; + let result = get_note_error(conn, &unknown_id).unwrap(); + assert!(result.is_none()); +} + // CHAIN STATE TESTS // ================================================================================================ diff --git a/crates/ntx-builder/src/db/schema.rs b/crates/ntx-builder/src/db/schema.rs index 93dca8ce5e..c52ca5f538 100644 --- a/crates/ntx-builder/src/db/schema.rs +++ b/crates/ntx-builder/src/db/schema.rs @@ -29,8 +29,10 @@ diesel::table! { nullifier -> Binary, account_id -> Binary, note_data -> Binary, + note_id -> Nullable, attempt_count -> Integer, last_attempt -> Nullable, + last_error -> Nullable, created_by -> Nullable, consumed_by -> Nullable, } diff --git a/crates/ntx-builder/src/lib.rs b/crates/ntx-builder/src/lib.rs index fb63bc5be5..16302ed251 100644 --- a/crates/ntx-builder/src/lib.rs +++ b/crates/ntx-builder/src/lib.rs @@ -22,6 +22,7 @@ mod clients; mod coordinator; pub(crate) mod db; pub(crate) mod inflight_note; +pub mod server; #[cfg(test)] pub(crate) mod test_utils; diff --git a/crates/ntx-builder/src/server.rs b/crates/ntx-builder/src/server.rs new file mode 100644 index 0000000000..0e61bd63cd --- /dev/null +++ b/crates/ntx-builder/src/server.rs @@ -0,0 +1,99 @@ +use anyhow::Context; +use miden_node_proto::generated::note::NoteId; +use miden_node_proto::generated::ntx_builder::{self, api_server}; +use miden_node_proto_build::ntx_builder_api_descriptor; +use miden_node_utils::panic::{CatchPanicLayer, catch_panic_layer_fn}; +use miden_node_utils::tracing::grpc::grpc_trace_fn; +use miden_protocol::Word; +use tokio::net::TcpListener; +use tokio_stream::wrappers::TcpListenerStream; +use tonic::{Request, Response, Status}; +use tonic_reflection::server; +use tower_http::trace::TraceLayer; + +use crate::COMPONENT; +use crate::db::Db; + +// NTX BUILDER RPC SERVER +// ================================================================================================ + +/// gRPC server for the network transaction builder. +/// +/// Exposes endpoints for querying note execution errors, useful for debugging +/// network notes that fail to be consumed. +pub struct NtxBuilderRpcServer { + db: Db, +} + +impl NtxBuilderRpcServer { + pub fn new(db: Db) -> Self { + Self { db } + } + + /// Starts the gRPC server on the given listener. + pub async fn serve(self, listener: TcpListener) -> anyhow::Result<()> { + let api_service = api_server::ApiServer::new(self); + let reflection_service = server::Builder::configure() + .register_file_descriptor_set(ntx_builder_api_descriptor()) + .build_v1() + .context("failed to build reflection service")?; + + let reflection_service_alpha = server::Builder::configure() + .register_file_descriptor_set(ntx_builder_api_descriptor()) + .build_v1alpha() + .context("failed to build reflection service")?; + + tracing::info!( + target: COMPONENT, + endpoint = ?listener.local_addr(), + "NTX builder gRPC server initialized", + ); + + tonic::transport::Server::builder() + .layer(CatchPanicLayer::custom(catch_panic_layer_fn)) + .layer(TraceLayer::new_for_grpc().make_span_with(grpc_trace_fn)) + .add_service(api_service) + .add_service(reflection_service) + .add_service(reflection_service_alpha) + .serve_with_incoming(TcpListenerStream::new(listener)) + .await + .context("failed to serve NTX builder gRPC API") + } +} + +#[tonic::async_trait] +impl api_server::Api for NtxBuilderRpcServer { + #[expect(clippy::cast_sign_loss)] + async fn get_note_error( + &self, + request: Request, + ) -> Result, Status> { + let note_id_proto = request.into_inner(); + + let note_id_digest: Word = note_id_proto + .id + .as_ref() + .ok_or_else(|| Status::invalid_argument("missing note ID digest"))? + .try_into() + .map_err(|_| Status::invalid_argument("invalid note ID digest"))?; + + let note_id = miden_protocol::note::NoteId::from_raw(note_id_digest); + + let row = self.db.get_note_error(note_id).await.map_err(|err| { + tracing::error!(err = %err, "failed to query note error from DB"); + Status::internal("database error") + })?; + + let Some(row) = row else { + return Err(Status::not_found("note not found in ntx-builder database")); + }; + + let response = ntx_builder::GetNoteErrorResponse { + error: row.last_error, + attempt_count: row.attempt_count as u32, + last_attempt_block_num: row.last_attempt.map(|v| v as u32), + }; + + Ok(Response::new(response)) + } +} diff --git a/crates/proto/build.rs b/crates/proto/build.rs index 2e21007a46..4c3d38ab47 100644 --- a/crates/proto/build.rs +++ b/crates/proto/build.rs @@ -3,6 +3,7 @@ use std::path::Path; use fs_err as fs; use miden_node_proto_build::{ block_producer_api_descriptor, + ntx_builder_api_descriptor, remote_prover_api_descriptor, rpc_api_descriptor, store_block_producer_api_descriptor, @@ -30,6 +31,7 @@ fn main() -> miette::Result<()> { generate_bindings(block_producer_api_descriptor(), &dst_dir)?; generate_bindings(remote_prover_api_descriptor(), &dst_dir)?; generate_bindings(validator_api_descriptor(), &dst_dir)?; + generate_bindings(ntx_builder_api_descriptor(), &dst_dir)?; generate_mod_rs(&dst_dir).into_diagnostic().wrap_err("generating mod.rs")?; diff --git a/crates/proto/src/clients/mod.rs b/crates/proto/src/clients/mod.rs index 3599b472c4..66f11a5268 100644 --- a/crates/proto/src/clients/mod.rs +++ b/crates/proto/src/clients/mod.rs @@ -120,6 +120,7 @@ type GeneratedProxyStatusClient = generated::remote_prover::proxy_status_api_client::ProxyStatusApiClient; type GeneratedProverClient = generated::remote_prover::api_client::ApiClient; type GeneratedValidatorClient = generated::validator::api_client::ApiClient; +type GeneratedNtxBuilderClient = generated::ntx_builder::api_client::ApiClient; // gRPC CLIENTS // ================================================================================================ @@ -140,6 +141,8 @@ pub struct RemoteProverProxyStatusClient(GeneratedProxyStatusClient); pub struct RemoteProverClient(GeneratedProverClient); #[derive(Debug, Clone)] pub struct ValidatorClient(GeneratedValidatorClient); +#[derive(Debug, Clone)] +pub struct NtxBuilderClient(GeneratedNtxBuilderClient); impl DerefMut for RpcClient { fn deref_mut(&mut self) -> &mut Self::Target { @@ -253,6 +256,20 @@ impl Deref for ValidatorClient { } } +impl DerefMut for NtxBuilderClient { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Deref for NtxBuilderClient { + type Target = GeneratedNtxBuilderClient; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + // GRPC CLIENT BUILDER TRAIT // ================================================================================================ @@ -315,6 +332,12 @@ impl GrpcClient for ValidatorClient { } } +impl GrpcClient for NtxBuilderClient { + fn with_interceptor(channel: Channel, interceptor: Interceptor) -> Self { + Self(GeneratedNtxBuilderClient::new(InterceptedService::new(channel, interceptor))) + } +} + // STRICT TYPE-SAFE BUILDER (NO DEFAULTS) // ================================================================================================ diff --git a/crates/rpc/src/server/api.rs b/crates/rpc/src/server/api.rs index a0ec88859a..01fc0566e4 100644 --- a/crates/rpc/src/server/api.rs +++ b/crates/rpc/src/server/api.rs @@ -2,7 +2,13 @@ use std::sync::{Arc, LazyLock}; use std::time::Duration; use anyhow::Context; -use miden_node_proto::clients::{BlockProducerClient, Builder, StoreRpcClient, ValidatorClient}; +use miden_node_proto::clients::{ + BlockProducerClient, + Builder, + NtxBuilderClient, + StoreRpcClient, + ValidatorClient, +}; use miden_node_proto::errors::ConversionError; use miden_node_proto::generated::rpc::MempoolStats; use miden_node_proto::generated::rpc::api_server::{self, Api}; @@ -37,11 +43,17 @@ pub struct RpcService { store: StoreRpcClient, block_producer: Option, validator: ValidatorClient, + ntx_builder: Option, genesis_commitment: Option, } impl RpcService { - pub(super) fn new(store_url: Url, block_producer_url: Option, validator_url: Url) -> Self { + pub(super) fn new( + store_url: Url, + block_producer_url: Option, + validator_url: Url, + ntx_builder_url: Option, + ) -> Self { let store = { info!(target: COMPONENT, store_endpoint = %store_url, "Initializing store client"); Builder::new(store_url) @@ -83,10 +95,26 @@ impl RpcService { .connect_lazy::() }; + let ntx_builder = ntx_builder_url.map(|ntx_builder_url| { + info!( + target: COMPONENT, + ntx_builder_endpoint = %ntx_builder_url, + "Initializing ntx-builder client", + ); + Builder::new(ntx_builder_url) + .without_tls() + .without_timeout() + .without_metadata_version() + .without_metadata_genesis() + .with_otel_context_injection() + .connect_lazy::() + }); + Self { store, block_producer, validator, + ntx_builder, genesis_commitment: None, } } @@ -487,6 +515,27 @@ impl api_server::Api for RpcService { Ok(Response::new(RPC_LIMITS.clone())) } + + // -- Note debugging endpoints ---------------------------------------------------------------- + + async fn get_note_error( + &self, + request: Request, + ) -> Result, Status> { + debug!(target: COMPONENT, request = ?request.get_ref()); + + let Some(ntx_builder) = &self.ntx_builder else { + return Err(Status::unavailable("Network transaction builder is not enabled")); + }; + + let response = ntx_builder.clone().get_note_error(request).await?.into_inner(); + + Ok(Response::new(proto::rpc::GetNoteErrorResponse { + error: response.error, + attempt_count: response.attempt_count, + last_attempt_block_num: response.last_attempt_block_num, + })) + } } // HELPERS diff --git a/crates/rpc/src/server/mod.rs b/crates/rpc/src/server/mod.rs index 63de3f04ad..002fc48a0b 100644 --- a/crates/rpc/src/server/mod.rs +++ b/crates/rpc/src/server/mod.rs @@ -32,6 +32,7 @@ pub struct Rpc { pub store_url: Url, pub block_producer_url: Option, pub validator_url: Url, + pub ntx_builder_url: Option, pub grpc_options: GrpcOptionsExternal, } @@ -45,6 +46,7 @@ impl Rpc { self.store_url.clone(), self.block_producer_url.clone(), self.validator_url, + self.ntx_builder_url.clone(), ); let genesis = api diff --git a/crates/rpc/src/tests.rs b/crates/rpc/src/tests.rs index 89b7a23c4a..cab49f3273 100644 --- a/crates/rpc/src/tests.rs +++ b/crates/rpc/src/tests.rs @@ -431,6 +431,7 @@ async fn start_rpc_with_options( store_url, block_producer_url: Some(block_producer_url), validator_url, + ntx_builder_url: None, grpc_options, } .serve() diff --git a/docs/external/src/operator/architecture.md b/docs/external/src/operator/architecture.md index 6745077526..5a540ccfd0 100644 --- a/docs/external/src/operator/architecture.md +++ b/docs/external/src/operator/architecture.md @@ -58,3 +58,7 @@ Internally, the builder spawns a dedicated actor for each network account that h idle (no notes to consume) for a configurable duration are automatically deactivated to conserve resources, and are re-activated when new notes arrive. The idle timeout can be tuned with the `--ntx-builder.idle-timeout` CLI argument (default: 5 minutes). + +The builder also exposes an internal gRPC server that the RPC component uses to proxy debugging endpoints such as +`GetNoteError`. In bundled mode this is wired automatically; in distributed mode operators must set +`--ntx-builder.url` (or `MIDEN_NODE_NTX_BUILDER_URL`) on the RPC component. diff --git a/docs/external/src/rpc.md b/docs/external/src/rpc.md index 7e4598d8a4..9cc736a55d 100644 --- a/docs/external/src/rpc.md +++ b/docs/external/src/rpc.md @@ -26,6 +26,7 @@ The gRPC service definition can be found in the Miden node's `proto` [directory] - [SyncChainMmr](#syncchainmmr) - [SyncTransactions](#synctransactions) - [Status](#status) +- [GetNoteError](#getnoteerror) @@ -231,6 +232,32 @@ Returns transaction records for specific accounts within a block range. Request the status of the node components. The response contains the current version of the RPC component and the connection status of the other components, including their versions and the number of the most recent block in the chain (chain tip). +### GetNoteError + +Returns the latest execution error for a network note, if any. This is useful for debugging notes that are failing to be consumed by the network transaction builder. + +This endpoint is only available when the network transaction builder is enabled and connected. If it is not configured, the endpoint returns `UNAVAILABLE`. + +#### Request + +```protobuf +message NoteId { + Digest id = 1; // The note ID +} +``` + +#### Response + +```protobuf +message GetNoteErrorResponse { + optional string error = 1; // The latest error message, if any + uint32 attempt_count = 2; // Number of failed execution attempts + optional fixed32 last_attempt_block_num = 3; // Block number of the last failed attempt, if any +} +``` + +If the note is not found in the network transaction builder's database, the endpoint returns `NOT_FOUND`. + ## Error Handling The Miden node uses standard gRPC error reporting mechanisms. When an RPC call fails, a `Status` object is returned containing: diff --git a/docs/internal/src/ntx-builder.md b/docs/internal/src/ntx-builder.md index a662f76584..b6f2926198 100644 --- a/docs/internal/src/ntx-builder.md +++ b/docs/internal/src/ntx-builder.md @@ -53,3 +53,14 @@ coordinator (via the `send_targeted` path). The block-producer remains blissfully unaware of network transactions. From its perspective a network transaction is simply the same as any other. + +## gRPC Server + +The NTB exposes an internal gRPC server for querying its state. The RPC component proxies public +requests to this server. In bundled mode the server is started automatically on a random port and +wired to the RPC; in distributed mode operators must pass the NTB's address to the RPC via +`--ntx-builder.url` (or `MIDEN_NODE_NTX_BUILDER_URL`). + +Currently the only endpoint is `GetNoteError(note_id)` which returns the latest execution error +for a given network note, along with the attempt count and the block number of the last attempt. +This is useful for debugging notes that fail to be consumed. diff --git a/proto/build.rs b/proto/build.rs index 7246ab4958..c4c2f9b924 100644 --- a/proto/build.rs +++ b/proto/build.rs @@ -11,6 +11,7 @@ const STORE_BLOCK_PRODUCER_PROTO: &str = "internal/store.proto"; const BLOCK_PRODUCER_PROTO: &str = "internal/block_producer.proto"; const REMOTE_PROVER_PROTO: &str = "remote_prover.proto"; const VALIDATOR_PROTO: &str = "internal/validator.proto"; +const NTX_BUILDER_PROTO: &str = "internal/ntx_builder.proto"; const RPC_DESCRIPTOR: &str = "rpc_file_descriptor.bin"; const STORE_RPC_DESCRIPTOR: &str = "store_rpc_file_descriptor.bin"; @@ -19,6 +20,7 @@ const STORE_BLOCK_PRODUCER_DESCRIPTOR: &str = "store_block_producer_file_descrip const BLOCK_PRODUCER_DESCRIPTOR: &str = "block_producer_file_descriptor.bin"; const REMOTE_PROVER_DESCRIPTOR: &str = "remote_prover_file_descriptor.bin"; const VALIDATOR_DESCRIPTOR: &str = "validator_file_descriptor.bin"; +const NTX_BUILDER_DESCRIPTOR: &str = "ntx_builder_file_descriptor.bin"; /// Generates Rust protobuf bindings from .proto files. /// @@ -75,5 +77,11 @@ fn main() -> miette::Result<()> { .into_diagnostic() .wrap_err("writing validator file descriptor")?; + let ntx_builder_file_descriptor = protox::compile([NTX_BUILDER_PROTO], includes)?; + let ntx_builder_path = out_dir.join(NTX_BUILDER_DESCRIPTOR); + fs::write(&ntx_builder_path, ntx_builder_file_descriptor.encode_to_vec()) + .into_diagnostic() + .wrap_err("writing ntx builder file descriptor")?; + Ok(()) } diff --git a/proto/proto/internal/ntx_builder.proto b/proto/proto/internal/ntx_builder.proto new file mode 100644 index 0000000000..ecc8514a85 --- /dev/null +++ b/proto/proto/internal/ntx_builder.proto @@ -0,0 +1,30 @@ +// Internal gRPC API for the network transaction builder component. +syntax = "proto3"; +package ntx_builder; + +import "types/note.proto"; + +// NTX BUILDER API +// ================================================================================================ + +// API for querying network transaction builder state. +service Api { + // Returns the latest execution error for a network note, if any. + // + // This is useful for debugging notes that are failing to be consumed by + // the network transaction builder. + rpc GetNoteError(note.NoteId) returns (GetNoteErrorResponse) {} +} + +// GET NOTE ERROR +// ================================================================================================ + +// Response containing the latest execution error for a network note. +message GetNoteErrorResponse { + // The latest error message, if any. + optional string error = 1; + // Number of failed execution attempts. + uint32 attempt_count = 2; + // Block number of the last failed attempt, if any. + optional fixed32 last_attempt_block_num = 3; +} diff --git a/proto/proto/rpc.proto b/proto/proto/rpc.proto index 1a218539ee..2bb6178e77 100644 --- a/proto/proto/rpc.proto +++ b/proto/proto/rpc.proto @@ -105,6 +105,15 @@ service Api { // Returns MMR delta needed to synchronize the chain MMR within the requested block range. rpc SyncChainMmr(SyncChainMmrRequest) returns (SyncChainMmrResponse) {} + + // NOTE DEBUGGING ENDPOINTS + // -------------------------------------------------------------------------------------------- + + // Returns the latest execution error for a network note, if any. + // + // This is useful for debugging notes that are failing to be consumed by + // the network transaction builder. + rpc GetNoteError(note.NoteId) returns (GetNoteErrorResponse) {} } // RPC STATUS @@ -613,6 +622,19 @@ message TransactionRecord { transaction.TransactionHeader header = 2; } +// GET NOTE ERROR +// ================================================================================================ + +// Response containing the latest execution error for a network note. +message GetNoteErrorResponse { + // The latest error message, if any. + optional string error = 1; + // Number of failed execution attempts. + uint32 attempt_count = 2; + // Block number of the last failed attempt, if any. + optional fixed32 last_attempt_block_num = 3; +} + // RPC LIMITS // ================================================================================================ diff --git a/proto/src/lib.rs b/proto/src/lib.rs index 8e8440d19d..6cbc6eb015 100644 --- a/proto/src/lib.rs +++ b/proto/src/lib.rs @@ -55,3 +55,11 @@ pub fn validator_api_descriptor() -> FileDescriptorSet { FileDescriptorSet::decode(&bytes[..]) .expect("bytes should be a valid file descriptor created by build.rs") } + +/// Returns the Protobuf file descriptor for the NTX builder API. +#[cfg(feature = "internal")] +pub fn ntx_builder_api_descriptor() -> FileDescriptorSet { + let bytes = include_bytes!(concat!(env!("OUT_DIR"), "/", "ntx_builder_file_descriptor.bin")); + FileDescriptorSet::decode(&bytes[..]) + .expect("bytes should be a valid file descriptor created by build.rs") +} From 25aee9df5cee768636efd878cedb67c093376a5a Mon Sep 17 00:00:00 2001 From: Juan Munoz Date: Mon, 16 Mar 2026 14:53:42 -0300 Subject: [PATCH 2/2] chore: address comments --- crates/ntx-builder/src/actor/mod.rs | 62 +++++++++---------- crates/ntx-builder/src/db/mod.rs | 4 +- .../src/db/models/queries/notes.rs | 6 +- .../src/db/models/queries/tests.rs | 34 +++++++--- crates/ntx-builder/src/lib.rs | 3 + docs/internal/src/ntx-builder.md | 2 +- 6 files changed, 64 insertions(+), 47 deletions(-) diff --git a/crates/ntx-builder/src/actor/mod.rs b/crates/ntx-builder/src/actor/mod.rs index c253af4340..7e6de51097 100644 --- a/crates/ntx-builder/src/actor/mod.rs +++ b/crates/ntx-builder/src/actor/mod.rs @@ -17,10 +17,12 @@ use miden_protocol::block::BlockNumber; use miden_protocol::note::{NoteScript, Nullifier}; use miden_protocol::transaction::TransactionId; use miden_remote_prover_client::RemoteTransactionProver; +use miden_tx::FailedNote; use tokio::sync::{AcquireError, Notify, RwLock, Semaphore, mpsc}; use tokio_util::sync::CancellationToken; use url::Url; +use crate::NoteError; use crate::chain_state::ChainState; use crate::clients::{BlockProducerClient, StoreClient}; use crate::db::Db; @@ -35,7 +37,7 @@ pub enum ActorRequest { /// the oneshot channel, preventing race conditions where the actor could re-select the same /// notes before the failure is persisted. NotesFailed { - failed_notes: Vec<(Nullifier, String)>, + failed_notes: Vec<(Nullifier, NoteError)>, block_num: BlockNumber, ack_tx: tokio::sync::oneshot::Sender<()>, }, @@ -410,19 +412,7 @@ impl AccountActor { ); self.cache_note_scripts(scripts_to_cache).await; if !failed.is_empty() { - let failed_notes: Vec<_> = failed - .into_iter() - .map(|f| { - let error_msg = f.error.as_report(); - tracing::info!( - note.id = %f.note.id(), - nullifier = %f.note.nullifier(), - err = %error_msg, - "note failed: consumability check", - ); - (f.note.nullifier(), error_msg) - }) - .collect(); + let failed_notes = log_failed_notes(failed); self.mark_notes_failed(&failed_notes, block_num).await; } self.mode = ActorMode::TransactionInflight(tx_id); @@ -440,22 +430,10 @@ impl AccountActor { // For `AllNotesFailed`, use the per-note errors which contain the // specific reason each note failed (e.g. consumability check details). - let failed_notes: Vec<_> = - if let execute::NtxError::AllNotesFailed(per_note) = err { - per_note - .into_iter() - .map(|f| { - let note_error = f.error.as_report(); - tracing::info!( - note.id = %f.note.id(), - nullifier = %f.note.nullifier(), - err = %note_error, - "note failed: consumability check", - ); - (f.note.nullifier(), note_error) - }) - .collect() - } else { + let failed_notes: Vec<_> = match err { + execute::NtxError::AllNotesFailed(per_note) => log_failed_notes(per_note), + other => { + let error: NoteError = Arc::new(other); notes .iter() .map(|note| { @@ -465,10 +443,11 @@ impl AccountActor { err = %error_msg, "note failed: transaction execution error", ); - (note.nullifier(), error_msg.clone()) + (note.nullifier(), error.clone()) }) .collect() - }; + }, + }; self.mark_notes_failed(&failed_notes, block_num).await; }, } @@ -493,7 +472,7 @@ impl AccountActor { /// before the failure counts are updated in the database. async fn mark_notes_failed( &self, - failed_notes: &[(Nullifier, String)], + failed_notes: &[(Nullifier, NoteError)], block_num: BlockNumber, ) { let (ack_tx, ack_rx) = tokio::sync::oneshot::channel(); @@ -513,3 +492,20 @@ impl AccountActor { let _ = ack_rx.await; } } + +/// Logs each failed note and returns a vec of `(nullifier, error)` pairs. +fn log_failed_notes(failed: Vec) -> Vec<(Nullifier, NoteError)> { + failed + .into_iter() + .map(|f| { + let error_msg = f.error.as_report(); + tracing::info!( + note.id = %f.note.id(), + nullifier = %f.note.nullifier(), + err = %error_msg, + "note failed: consumability check", + ); + (f.note.nullifier(), Arc::new(f.error) as NoteError) + }) + .collect() +} diff --git a/crates/ntx-builder/src/db/mod.rs b/crates/ntx-builder/src/db/mod.rs index f5418faa0b..28f1fef933 100644 --- a/crates/ntx-builder/src/db/mod.rs +++ b/crates/ntx-builder/src/db/mod.rs @@ -12,10 +12,10 @@ use miden_protocol::transaction::TransactionId; use miden_standards::note::AccountTargetNetworkNote; use tracing::{info, instrument}; -use crate::COMPONENT; use crate::db::migrations::apply_migrations; use crate::db::models::queries; use crate::inflight_note::InflightNetworkNote; +use crate::{COMPONENT, NoteError}; pub(crate) mod models; @@ -105,7 +105,7 @@ impl Db { /// the latest error message. pub async fn notes_failed( &self, - failed_notes: Vec<(Nullifier, String)>, + failed_notes: Vec<(Nullifier, NoteError)>, block_num: BlockNumber, ) -> Result<()> { self.inner diff --git a/crates/ntx-builder/src/db/models/queries/notes.rs b/crates/ntx-builder/src/db/models/queries/notes.rs index 97214178a8..533ec5d6e7 100644 --- a/crates/ntx-builder/src/db/models/queries/notes.rs +++ b/crates/ntx-builder/src/db/models/queries/notes.rs @@ -8,6 +8,7 @@ use miden_protocol::note::{Note, Nullifier}; use miden_standards::note::AccountTargetNetworkNote; use miden_tx::utils::{Deserializable, Serializable}; +use crate::NoteError; use crate::db::models::conv as conversions; use crate::db::schema; use crate::inflight_note::InflightNetworkNote; @@ -155,19 +156,20 @@ pub fn available_notes( /// ``` pub fn notes_failed( conn: &mut SqliteConnection, - failed_notes: &[(Nullifier, String)], + failed_notes: &[(Nullifier, NoteError)], block_num: BlockNumber, ) -> Result<(), DatabaseError> { let block_num_val = conversions::block_num_to_i64(block_num); for (nullifier, error) in failed_notes { let nullifier_bytes = conversions::nullifier_to_bytes(nullifier); + let error_report = error.as_report(); diesel::update(schema::notes::table.find(&nullifier_bytes)) .set(( schema::notes::attempt_count.eq(schema::notes::attempt_count + 1), schema::notes::last_attempt.eq(Some(block_num_val)), - schema::notes::last_error.eq(Some(error)), + schema::notes::last_error.eq(Some(error_report)), )) .execute(conn)?; } diff --git a/crates/ntx-builder/src/db/models/queries/tests.rs b/crates/ntx-builder/src/db/models/queries/tests.rs index fda6d87ec3..48ec573414 100644 --- a/crates/ntx-builder/src/db/models/queries/tests.rs +++ b/crates/ntx-builder/src/db/models/queries/tests.rs @@ -1,14 +1,22 @@ //! DB-level tests for NTX builder query functions. +use std::sync::Arc; + use diesel::prelude::*; use miden_protocol::Word; use miden_protocol::block::BlockNumber; use super::*; +use crate::NoteError; use crate::db::models::conv as conversions; use crate::db::{Db, schema}; use crate::test_utils::*; +/// Creates a [`NoteError`] from a string message, for use in tests. +fn test_note_error(msg: &str) -> NoteError { + Arc::new(std::io::Error::other(msg.to_string())) +} + // TEST HELPERS // ================================================================================================ @@ -347,19 +355,19 @@ fn available_notes_filters_consumed_and_exceeded_attempts() { let block_num = BlockNumber::from(100u32); notes_failed( conn, - &[(note_failed.as_note().nullifier(), "test error".to_string())], + &[(note_failed.as_note().nullifier(), test_note_error("test error"))], block_num, ) .unwrap(); notes_failed( conn, - &[(note_failed.as_note().nullifier(), "test error".to_string())], + &[(note_failed.as_note().nullifier(), test_note_error("test error"))], block_num, ) .unwrap(); notes_failed( conn, - &[(note_failed.as_note().nullifier(), "test error".to_string())], + &[(note_failed.as_note().nullifier(), test_note_error("test error"))], block_num, ) .unwrap(); @@ -405,11 +413,15 @@ fn notes_failed_increments_attempt_count() { insert_committed_notes(conn, std::slice::from_ref(¬e)).unwrap(); let block_num = BlockNumber::from(5u32); - notes_failed(conn, &[(note.as_note().nullifier(), "execution failed".to_string())], block_num) - .unwrap(); notes_failed( conn, - &[(note.as_note().nullifier(), "execution failed 2".to_string())], + &[(note.as_note().nullifier(), test_note_error("execution failed"))], + block_num, + ) + .unwrap(); + notes_failed( + conn, + &[(note.as_note().nullifier(), test_note_error("execution failed 2"))], block_num, ) .unwrap(); @@ -447,7 +459,7 @@ fn get_note_error_returns_latest_error() { // Mark as failed. let block_num = BlockNumber::from(5u32); - notes_failed(conn, &[(note.as_note().nullifier(), "first error".to_string())], block_num) + notes_failed(conn, &[(note.as_note().nullifier(), test_note_error("first error"))], block_num) .unwrap(); let result = get_note_error(conn, &conversions::note_id_to_bytes(¬e_id)).unwrap(); @@ -456,8 +468,12 @@ fn get_note_error_returns_latest_error() { assert_eq!(row.attempt_count, 1); // Mark as failed again with different error, should overwrite. - notes_failed(conn, &[(note.as_note().nullifier(), "second error".to_string())], block_num) - .unwrap(); + notes_failed( + conn, + &[(note.as_note().nullifier(), test_note_error("second error"))], + block_num, + ) + .unwrap(); let result = get_note_error(conn, &conversions::note_id_to_bytes(¬e_id)).unwrap(); let row = result.unwrap(); diff --git a/crates/ntx-builder/src/lib.rs b/crates/ntx-builder/src/lib.rs index 16302ed251..bbda883c68 100644 --- a/crates/ntx-builder/src/lib.rs +++ b/crates/ntx-builder/src/lib.rs @@ -11,10 +11,13 @@ use clients::{BlockProducerClient, StoreClient}; use coordinator::Coordinator; use db::Db; use futures::TryStreamExt; +use miden_node_utils::ErrorReport; use miden_node_utils::lru_cache::LruCache; use tokio::sync::{RwLock, mpsc}; use url::Url; +pub(crate) type NoteError = Arc; + mod actor; mod builder; mod chain_state; diff --git a/docs/internal/src/ntx-builder.md b/docs/internal/src/ntx-builder.md index b6f2926198..2fa9546cbd 100644 --- a/docs/internal/src/ntx-builder.md +++ b/docs/internal/src/ntx-builder.md @@ -56,7 +56,7 @@ network transaction is simply the same as any other. ## gRPC Server -The NTB exposes an internal gRPC server for querying its state. The RPC component proxies public +The NTX exposes an internal gRPC server for querying its state. The RPC component proxies public requests to this server. In bundled mode the server is started automatically on a random port and wired to the RPC; in distributed mode operators must pass the NTB's address to the RPC via `--ntx-builder.url` (or `MIDEN_NODE_NTX_BUILDER_URL`).