Skip to content

Commit 4e7163c

Browse files
feat: add GetNoteError endpoint in ntx builder (#1792)
1 parent 0ea9d7c commit 4e7163c

File tree

29 files changed

+604
-52
lines changed

29 files changed

+604
-52
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
### Enhancements
66

77
- Expose per-tree RocksDB tuning options ([#1782](https://github.com/0xMiden/node/pull/1782)).
8+
- 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).
9+
- Added `GetNoteError` gRPC endpoint to query the latest execution error for network notes (https://github.com/0xMiden/node/issues/1758).
810
- 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)).
911
- [BREAKING] Move block proving from Blocker Producer to the Store ([#1579](https://github.com/0xMiden/node/pull/1579)).
1012
- [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)).

Cargo.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

bin/node/src/commands/bundled.rs

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -246,10 +246,44 @@ impl BundledCommand {
246246
.id()
247247
};
248248

249+
// Prepare network transaction builder (bind listener + config before starting RPC,
250+
// so that the ntx-builder URL is available for the RPC proxy).
251+
let mut ntx_builder_url_for_rpc = None;
252+
let ntx_builder_prepared = if should_start_ntx_builder {
253+
let store_ntx_builder_url = Url::parse(&format!("http://{store_ntx_builder_address}"))
254+
.context("Failed to parse URL")?;
255+
let block_producer_url = block_producer_url.clone();
256+
let validator_url = validator_url.clone();
257+
258+
let builder_config = ntx_builder.into_builder_config(
259+
store_ntx_builder_url,
260+
block_producer_url,
261+
validator_url,
262+
&data_directory,
263+
);
264+
265+
// Bind a listener for the ntx-builder gRPC server.
266+
let ntx_builder_listener = TcpListener::bind("127.0.0.1:0")
267+
.await
268+
.context("Failed to bind to ntx-builder gRPC endpoint")?;
269+
let ntx_builder_address = ntx_builder_listener
270+
.local_addr()
271+
.context("Failed to retrieve the ntx-builder's gRPC address")?;
272+
ntx_builder_url_for_rpc = Some(
273+
Url::parse(&format!("http://{ntx_builder_address}"))
274+
.context("Failed to parse ntx-builder URL")?,
275+
);
276+
277+
Some((builder_config, ntx_builder_listener))
278+
} else {
279+
None
280+
};
281+
249282
// Start RPC component.
250283
let rpc_id = {
251284
let block_producer_url = block_producer_url.clone();
252285
let validator_url = validator_url.clone();
286+
let ntx_builder_url = ntx_builder_url_for_rpc;
253287
join_set
254288
.spawn(async move {
255289
let store_url = Url::parse(&format!("http://{store_rpc_address}"))
@@ -259,6 +293,7 @@ impl BundledCommand {
259293
store_url,
260294
block_producer_url: Some(block_producer_url),
261295
validator_url,
296+
ntx_builder_url,
262297
grpc_options,
263298
}
264299
.serve()
@@ -275,27 +310,15 @@ impl BundledCommand {
275310
(rpc_id, "rpc"),
276311
]);
277312

278-
// Start network transaction builder. The endpoint is available after loading completes.
279-
if should_start_ntx_builder {
280-
let store_ntx_builder_url = Url::parse(&format!("http://{store_ntx_builder_address}"))
281-
.context("Failed to parse URL")?;
282-
let block_producer_url = block_producer_url.clone();
283-
let validator_url = validator_url.clone();
284-
285-
let builder_config = ntx_builder.into_builder_config(
286-
store_ntx_builder_url,
287-
block_producer_url,
288-
validator_url,
289-
&data_directory,
290-
);
291-
313+
// Start network transaction builder.
314+
if let Some((builder_config, ntx_builder_listener)) = ntx_builder_prepared {
292315
let id = join_set
293316
.spawn(async move {
294317
builder_config
295318
.build()
296319
.await
297320
.context("failed to initialize ntx builder")?
298-
.run()
321+
.run(Some(ntx_builder_listener))
299322
.await
300323
.context("failed while serving ntx builder component")
301324
})

bin/node/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ const ENV_NTX_SCRIPT_CACHE_SIZE: &str = "MIDEN_NTX_DATA_STORE_SCRIPT_CACHE_SIZE"
4747
const ENV_VALIDATOR_KEY: &str = "MIDEN_NODE_VALIDATOR_KEY";
4848
const ENV_VALIDATOR_KMS_KEY_ID: &str = "MIDEN_NODE_VALIDATOR_KMS_KEY_ID";
4949
const ENV_NTX_DATA_DIRECTORY: &str = "MIDEN_NODE_NTX_DATA_DIRECTORY";
50+
const ENV_NTX_BUILDER_URL: &str = "MIDEN_NODE_NTX_BUILDER_URL";
5051

5152
const DEFAULT_NTX_TICKER_INTERVAL: Duration = Duration::from_millis(200);
5253
const DEFAULT_NTX_IDLE_TIMEOUT: Duration = Duration::from_secs(5 * 60);

bin/node/src/commands/rpc.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@ use miden_node_utils::clap::GrpcOptionsExternal;
44
use miden_node_utils::grpc::UrlExt;
55
use url::Url;
66

7-
use super::{ENV_BLOCK_PRODUCER_URL, ENV_RPC_URL, ENV_STORE_RPC_URL, ENV_VALIDATOR_URL};
7+
use super::{
8+
ENV_BLOCK_PRODUCER_URL,
9+
ENV_NTX_BUILDER_URL,
10+
ENV_RPC_URL,
11+
ENV_STORE_RPC_URL,
12+
ENV_VALIDATOR_URL,
13+
};
814
use crate::commands::ENV_ENABLE_OTEL;
915

1016
#[derive(clap::Subcommand)]
@@ -28,6 +34,11 @@ pub enum RpcCommand {
2834
#[arg(long = "validator.url", env = ENV_VALIDATOR_URL, value_name = "URL")]
2935
validator_url: Url,
3036

37+
/// The network transaction builder's gRPC url. If unset, the `GetNoteError` endpoint
38+
/// will be unavailable.
39+
#[arg(long = "ntx-builder.url", env = ENV_NTX_BUILDER_URL, value_name = "URL")]
40+
ntx_builder_url: Option<Url>,
41+
3142
/// Enables the exporting of traces for OpenTelemetry.
3243
///
3344
/// This can be further configured using environment variables as defined in the official
@@ -47,6 +58,7 @@ impl RpcCommand {
4758
store_url,
4859
block_producer_url,
4960
validator_url,
61+
ntx_builder_url,
5062
enable_otel: _,
5163
grpc_options,
5264
} = self;
@@ -61,6 +73,7 @@ impl RpcCommand {
6173
store_url,
6274
block_producer_url,
6375
validator_url,
76+
ntx_builder_url,
6477
grpc_options,
6578
}
6679
.serve()

crates/ntx-builder/Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,19 @@ futures = { workspace = true }
2121
libsqlite3-sys = { workspace = true }
2222
miden-node-db = { workspace = true }
2323
miden-node-proto = { workspace = true }
24+
miden-node-proto-build = { features = ["internal"], workspace = true }
2425
miden-node-utils = { workspace = true }
2526
miden-protocol = { default-features = true, workspace = true }
2627
miden-remote-prover-client = { features = ["tx-prover"], workspace = true }
2728
miden-standards = { workspace = true }
2829
miden-tx = { default-features = true, workspace = true }
2930
thiserror = { workspace = true }
3031
tokio = { features = ["rt-multi-thread"], workspace = true }
31-
tokio-stream = { workspace = true }
32+
tokio-stream = { features = ["net"], workspace = true }
3233
tokio-util = { workspace = true }
3334
tonic = { workspace = true }
35+
tonic-reflection = { workspace = true }
36+
tower-http = { workspace = true }
3437
tracing = { workspace = true }
3538
url = { workspace = true }
3639

crates/ntx-builder/src/actor/mod.rs

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,15 @@ use miden_protocol::block::BlockNumber;
1818
use miden_protocol::note::{NoteScript, Nullifier};
1919
use miden_protocol::transaction::TransactionId;
2020
use miden_remote_prover_client::RemoteTransactionProver;
21+
use miden_tx::FailedNote;
2122
use tokio::sync::{Notify, RwLock, Semaphore, mpsc};
2223
use tokio_util::sync::CancellationToken;
2324
use url::Url;
2425

26+
use crate::NoteError;
2527
use crate::chain_state::ChainState;
2628
use crate::clients::{BlockProducerClient, StoreClient};
2729
use crate::db::Db;
28-
use crate::inflight_note::InflightNetworkNote;
2930

3031
// ACTOR REQUESTS
3132
// ================================================================================================
@@ -37,7 +38,7 @@ pub enum ActorRequest {
3738
/// the oneshot channel, preventing race conditions where the actor could re-select the same
3839
/// notes before the failure is persisted.
3940
NotesFailed {
40-
nullifiers: Vec<Nullifier>,
41+
failed_notes: Vec<(Nullifier, NoteError)>,
4142
block_num: BlockNumber,
4243
ack_tx: tokio::sync::oneshot::Sender<()>,
4344
},
@@ -423,23 +424,43 @@ impl AccountActor {
423424
);
424425
self.cache_note_scripts(scripts_to_cache).await;
425426
if !failed.is_empty() {
426-
let nullifiers: Vec<_> =
427-
failed.into_iter().map(|note| note.note.nullifier()).collect();
428-
self.mark_notes_failed(&nullifiers, block_num).await;
427+
let failed_notes = log_failed_notes(failed);
428+
self.mark_notes_failed(&failed_notes, block_num).await;
429429
}
430430
self.mode = ActorMode::TransactionInflight(tx_id);
431431
},
432432
// Transaction execution failed.
433433
Err(err) => {
434+
let error_msg = err.as_report();
434435
tracing::error!(
435436
%account_id,
436437
?note_ids,
437-
err = err.as_report(),
438+
err = %error_msg,
438439
"network transaction failed",
439440
);
440441
self.mode = ActorMode::NoViableNotes;
441-
let nullifiers: Vec<_> = notes.iter().map(InflightNetworkNote::nullifier).collect();
442-
self.mark_notes_failed(&nullifiers, block_num).await;
442+
443+
// For `AllNotesFailed`, use the per-note errors which contain the
444+
// specific reason each note failed (e.g. consumability check details).
445+
let failed_notes: Vec<_> = match err {
446+
execute::NtxError::AllNotesFailed(per_note) => log_failed_notes(per_note),
447+
other => {
448+
let error: NoteError = Arc::new(other);
449+
notes
450+
.iter()
451+
.map(|note| {
452+
tracing::info!(
453+
note.id = %note.to_inner().as_note().id(),
454+
nullifier = %note.nullifier(),
455+
err = %error_msg,
456+
"note failed: transaction execution error",
457+
);
458+
(note.nullifier(), error.clone())
459+
})
460+
.collect()
461+
},
462+
};
463+
self.mark_notes_failed(&failed_notes, block_num).await;
443464
},
444465
}
445466
}
@@ -461,12 +482,16 @@ impl AccountActor {
461482
/// Sends a request to the coordinator to mark notes as failed and waits for the DB write to
462483
/// complete. This prevents a race condition where the actor could re-select the same notes
463484
/// before the failure counts are updated in the database.
464-
async fn mark_notes_failed(&self, nullifiers: &[Nullifier], block_num: BlockNumber) {
485+
async fn mark_notes_failed(
486+
&self,
487+
failed_notes: &[(Nullifier, NoteError)],
488+
block_num: BlockNumber,
489+
) {
465490
let (ack_tx, ack_rx) = tokio::sync::oneshot::channel();
466491
if self
467492
.request_tx
468493
.send(ActorRequest::NotesFailed {
469-
nullifiers: nullifiers.to_vec(),
494+
failed_notes: failed_notes.to_vec(),
470495
block_num,
471496
ack_tx,
472497
})
@@ -479,3 +504,20 @@ impl AccountActor {
479504
let _ = ack_rx.await;
480505
}
481506
}
507+
508+
/// Logs each failed note and returns a vec of `(nullifier, error)` pairs.
509+
fn log_failed_notes(failed: Vec<FailedNote>) -> Vec<(Nullifier, NoteError)> {
510+
failed
511+
.into_iter()
512+
.map(|f| {
513+
let error_msg = f.error.as_report();
514+
tracing::info!(
515+
note.id = %f.note.id(),
516+
nullifier = %f.note.nullifier(),
517+
err = %error_msg,
518+
"note failed: consumability check",
519+
);
520+
(f.note.nullifier(), Arc::new(f.error) as NoteError)
521+
})
522+
.collect()
523+
}

crates/ntx-builder/src/builder.rs

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ use miden_node_proto::domain::account::NetworkAccountId;
77
use miden_node_proto::domain::mempool::MempoolEvent;
88
use miden_protocol::account::delta::AccountUpdateDetails;
99
use miden_protocol::block::BlockHeader;
10+
use tokio::net::TcpListener;
1011
use tokio::sync::{RwLock, mpsc};
12+
use tokio::task::JoinSet;
1113
use tokio_stream::StreamExt;
1214
use tonic::Status;
1315

@@ -17,6 +19,7 @@ use crate::chain_state::ChainState;
1719
use crate::clients::StoreClient;
1820
use crate::coordinator::Coordinator;
1921
use crate::db::Db;
22+
use crate::server::NtxBuilderRpcServer;
2023

2124
// NETWORK TRANSACTION BUILDER
2225
// ================================================================================================
@@ -86,17 +89,45 @@ impl NetworkTransactionBuilder {
8689

8790
/// Runs the network transaction builder event loop until a fatal error occurs.
8891
///
92+
/// If a `TcpListener` is provided, a gRPC server is also spawned to expose the
93+
/// `GetNoteError` endpoint.
94+
///
8995
/// This method:
90-
/// 1. Spawns a background task to load existing network accounts from the store
91-
/// 2. Runs the main event loop, processing mempool events and managing actors
96+
/// 1. Optionally starts a gRPC server for note error queries
97+
/// 2. Spawns a background task to load existing network accounts from the store
98+
/// 3. Runs the main event loop, processing mempool events and managing actors
9299
///
93100
/// # Errors
94101
///
95102
/// Returns an error if:
96103
/// - The mempool event stream ends unexpectedly
97104
/// - An actor encounters a fatal error
98105
/// - The account loader task fails
99-
pub async fn run(mut self) -> anyhow::Result<()> {
106+
/// - The gRPC server fails
107+
pub async fn run(self, listener: Option<TcpListener>) -> anyhow::Result<()> {
108+
let mut join_set = JoinSet::new();
109+
110+
// Start the gRPC server if a listener is provided.
111+
if let Some(listener) = listener {
112+
let server = NtxBuilderRpcServer::new(self.db.clone());
113+
join_set.spawn(async move {
114+
server.serve(listener).await.context("ntx-builder gRPC server failed")
115+
});
116+
}
117+
118+
join_set.spawn(self.run_event_loop());
119+
120+
// Wait for either the event loop or the gRPC server to complete.
121+
// Any completion is treated as fatal.
122+
if let Some(result) = join_set.join_next().await {
123+
result.context("ntx-builder task panicked")??;
124+
}
125+
126+
Ok(())
127+
}
128+
129+
/// Runs the main event loop.
130+
async fn run_event_loop(mut self) -> anyhow::Result<()> {
100131
// Spawn a background task to load network accounts from the store.
101132
// Accounts are sent through a channel and processed in the main event loop.
102133
let (account_tx, mut account_rx) =
@@ -245,9 +276,9 @@ impl NetworkTransactionBuilder {
245276
/// Processes a request from an account actor.
246277
async fn handle_actor_request(&mut self, request: ActorRequest) -> Result<(), anyhow::Error> {
247278
match request {
248-
ActorRequest::NotesFailed { nullifiers, block_num, ack_tx } => {
279+
ActorRequest::NotesFailed { failed_notes, block_num, ack_tx } => {
249280
self.db
250-
.notes_failed(nullifiers, block_num)
281+
.notes_failed(failed_notes, block_num)
251282
.await
252283
.context("failed to mark notes as failed")?;
253284
let _ = ack_tx.send(());

crates/ntx-builder/src/db/migrations/2026020900000_setup/up.sql

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,14 @@ CREATE TABLE notes (
4444
account_id BLOB NOT NULL,
4545
-- Serialized SingleTargetNetworkNote.
4646
note_data BLOB NOT NULL,
47+
-- Note ID bytes.
48+
note_id BLOB,
4749
-- Backoff tracking: number of failed execution attempts.
4850
attempt_count INTEGER NOT NULL DEFAULT 0,
4951
-- Backoff tracking: block number of the last failed attempt. NULL if never attempted.
5052
last_attempt INTEGER,
53+
-- Latest execution error message. NULL if no error recorded.
54+
last_error TEXT,
5155
-- NULL if the note came from a committed block; transaction ID if created by inflight tx.
5256
created_by BLOB,
5357
-- NULL if unconsumed; transaction ID of the consuming inflight tx.
@@ -60,6 +64,7 @@ CREATE TABLE notes (
6064
CREATE INDEX idx_notes_account ON notes(account_id);
6165
CREATE INDEX idx_notes_created_by ON notes(created_by) WHERE created_by IS NOT NULL;
6266
CREATE INDEX idx_notes_consumed_by ON notes(consumed_by) WHERE consumed_by IS NOT NULL;
67+
CREATE INDEX idx_notes_note_id ON notes(note_id) WHERE note_id IS NOT NULL;
6368

6469
-- Persistent cache of note scripts, keyed by script root hash.
6570
-- Survives restarts so scripts don't need to be re-fetched from the store.

0 commit comments

Comments
 (0)