From 5da39cc80a26a2210f7ee3dd50050cd2d7c8df0f Mon Sep 17 00:00:00 2001 From: SantiagoPittella Date: Thu, 16 Jul 2026 16:05:41 -0300 Subject: [PATCH 1/4] chore: mvoe ntx-builder to sqlite framework --- Cargo.lock | 2 - bin/ntx-builder/Cargo.toml | 2 - bin/ntx-builder/diesel.toml | 5 - bin/ntx-builder/src/actor/execute.rs | 25 +- bin/ntx-builder/src/actor/mod.rs | 46 +- bin/ntx-builder/src/builder.rs | 79 ++- bin/ntx-builder/src/clients/rpc.rs | 2 +- bin/ntx-builder/src/committed_block.rs | 2 +- bin/ntx-builder/src/coordinator.rs | 19 +- bin/ntx-builder/src/db/migrations.rs | 34 +- bin/ntx-builder/src/db/mod.rs | 554 +++++------------- bin/ntx-builder/src/db/models/conv.rs | 73 --- bin/ntx-builder/src/db/models/mod.rs | 4 - .../src/db/models/queries/accounts.rs | 134 ----- .../src/db/models/queries/chain_state.rs | 146 ----- .../src/db/models/queries/note_scripts.rs | 56 -- .../src/db/models/queries/tests.rs | 394 ------------- .../db/{models => queries}/account_effect.rs | 0 bin/ntx-builder/src/db/queries/accounts.rs | 52 ++ bin/ntx-builder/src/db/queries/chain_state.rs | 78 +++ .../src/db/{models => }/queries/mod.rs | 41 +- .../src/db/queries/note_scripts.rs | 29 + .../src/db/{models => }/queries/notes.rs | 190 ++---- bin/ntx-builder/src/db/queries/tests.rs | 522 +++++++++++++++++ bin/ntx-builder/src/db/schema.rs | 41 -- bin/ntx-builder/src/db/sql/account_exists.sql | 2 + .../src/db/sql/account_last_tx.sql | 2 + .../db/sql/accounts_with_pending_notes.sql | 4 + .../src/db/sql/available_notes.sql | 4 + bin/ntx-builder/src/db/sql/discard_note.sql | 5 + bin/ntx-builder/src/db/sql/get_account.sql | 2 + .../src/db/sql/get_note_status.sql | 2 + .../src/db/sql/insert_genesis_chain_state.sql | 5 + .../src/db/sql/insert_network_note.sql | 6 + .../src/db/sql/insert_note_script.sql | 2 + .../src/db/sql/lookup_note_script.sql | 2 + .../src/db/sql/mark_note_consumed.sql | 7 + bin/ntx-builder/src/db/sql/note_failed.sql | 5 + .../src/db/sql/select_chain_state.sql | 2 + .../src/db/sql/select_genesis_commitment.sql | 2 + .../src/db/sql/update_chain_state_tip.sql | 5 + bin/ntx-builder/src/db/sql/upsert_account.sql | 7 + bin/ntx-builder/src/lib.rs | 41 +- bin/ntx-builder/src/server.rs | 6 +- .../src/server/get_network_note_status.rs | 12 +- crates/db/src/sqlite/codec.rs | 5 + 46 files changed, 1131 insertions(+), 1527 deletions(-) delete mode 100644 bin/ntx-builder/diesel.toml delete mode 100644 bin/ntx-builder/src/db/models/conv.rs delete mode 100644 bin/ntx-builder/src/db/models/mod.rs delete mode 100644 bin/ntx-builder/src/db/models/queries/accounts.rs delete mode 100644 bin/ntx-builder/src/db/models/queries/chain_state.rs delete mode 100644 bin/ntx-builder/src/db/models/queries/note_scripts.rs delete mode 100644 bin/ntx-builder/src/db/models/queries/tests.rs rename bin/ntx-builder/src/db/{models => queries}/account_effect.rs (100%) create mode 100644 bin/ntx-builder/src/db/queries/accounts.rs create mode 100644 bin/ntx-builder/src/db/queries/chain_state.rs rename bin/ntx-builder/src/db/{models => }/queries/mod.rs (69%) create mode 100644 bin/ntx-builder/src/db/queries/note_scripts.rs rename bin/ntx-builder/src/db/{models => }/queries/notes.rs (53%) create mode 100644 bin/ntx-builder/src/db/queries/tests.rs delete mode 100644 bin/ntx-builder/src/db/schema.rs create mode 100644 bin/ntx-builder/src/db/sql/account_exists.sql create mode 100644 bin/ntx-builder/src/db/sql/account_last_tx.sql create mode 100644 bin/ntx-builder/src/db/sql/accounts_with_pending_notes.sql create mode 100644 bin/ntx-builder/src/db/sql/available_notes.sql create mode 100644 bin/ntx-builder/src/db/sql/discard_note.sql create mode 100644 bin/ntx-builder/src/db/sql/get_account.sql create mode 100644 bin/ntx-builder/src/db/sql/get_note_status.sql create mode 100644 bin/ntx-builder/src/db/sql/insert_genesis_chain_state.sql create mode 100644 bin/ntx-builder/src/db/sql/insert_network_note.sql create mode 100644 bin/ntx-builder/src/db/sql/insert_note_script.sql create mode 100644 bin/ntx-builder/src/db/sql/lookup_note_script.sql create mode 100644 bin/ntx-builder/src/db/sql/mark_note_consumed.sql create mode 100644 bin/ntx-builder/src/db/sql/note_failed.sql create mode 100644 bin/ntx-builder/src/db/sql/select_chain_state.sql create mode 100644 bin/ntx-builder/src/db/sql/select_genesis_commitment.sql create mode 100644 bin/ntx-builder/src/db/sql/update_chain_state_tip.sql create mode 100644 bin/ntx-builder/src/db/sql/upsert_account.sql diff --git a/Cargo.lock b/Cargo.lock index 0d23086910..bf09603a02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3472,10 +3472,8 @@ dependencies = [ "backon", "build-rs", "clap", - "diesel", "futures", "humantime", - "libsqlite3-sys", "miden-node-db", "miden-node-proto", "miden-node-proto-build", diff --git a/bin/ntx-builder/Cargo.toml b/bin/ntx-builder/Cargo.toml index a12d07c6d2..62dd2a6af3 100644 --- a/bin/ntx-builder/Cargo.toml +++ b/bin/ntx-builder/Cargo.toml @@ -21,10 +21,8 @@ doctest = false anyhow = { workspace = true } backon = { workspace = true } clap = { features = ["env", "string"], workspace = true } -diesel = { features = ["numeric", "sqlite"], workspace = true } futures = { workspace = true } humantime = { workspace = true } -libsqlite3-sys = { workspace = true } miden-node-db = { workspace = true } miden-node-proto = { workspace = true } miden-node-proto-build = { features = ["internal"], workspace = true } diff --git a/bin/ntx-builder/diesel.toml b/bin/ntx-builder/diesel.toml deleted file mode 100644 index 71215dbf76..0000000000 --- a/bin/ntx-builder/diesel.toml +++ /dev/null @@ -1,5 +0,0 @@ -# For documentation on how to configure this file, -# see diesel.rs/guides/configuring-diesel-cli - -[print_schema] -file = "src/db/schema.rs" diff --git a/bin/ntx-builder/src/actor/execute.rs b/bin/ntx-builder/src/actor/execute.rs index e9776117e2..202bf57916 100644 --- a/bin/ntx-builder/src/actor/execute.rs +++ b/bin/ntx-builder/src/actor/execute.rs @@ -3,6 +3,7 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; use backon::ExponentialBuilder; +use miden_node_db::sqlite::Database; use miden_node_utils::ErrorReport; use miden_node_utils::lru_cache::LruCache; use miden_node_utils::retry::{self, Retryable}; @@ -56,7 +57,7 @@ use tracing::Instrument; use crate::actor::candidate::TransactionCandidate; use crate::clients::{RemoteTransactionProver, RpcClient, RpcError}; -use crate::db::Db; +use crate::db::queries; use crate::{COMPONENT, LOG_TARGET}; #[derive(Debug, thiserror::Error)] @@ -167,7 +168,7 @@ pub struct NtxContext { script_cache: LruCache, /// Local database for persistent note script caching. - db: Db, + db: Database, /// Maximum number of VM execution cycles for network transactions. max_cycles: u32, @@ -190,7 +191,7 @@ impl NtxContext { prover: RemoteTransactionProver, rpc: RpcClient, script_cache: LruCache, - db: Db, + db: Database, max_cycles: u32, tx_args: TransactionArgs, request_backoff_initial: Duration, @@ -575,7 +576,7 @@ struct NtxDataStore { /// LRU cache for storing retrieved note scripts to avoid repeated RPC calls. script_cache: LruCache, /// Local database for persistent note script. - db: Db, + db: Database, /// Scripts fetched from the remote RPC service during execution, to be persisted by the /// coordinator. fetched_scripts: Arc>>, @@ -613,7 +614,7 @@ impl NtxDataStore { chain_mmr: Arc, rpc: RpcClient, script_cache: LruCache, - db: Db, + db: Database, request_backoff: ExponentialBuilder, ) -> Self { let mast_store = TransactionMastStore::new(); @@ -818,9 +819,17 @@ impl DataStore for NtxDataStore { } // 2. Local DB. - if let Some(script) = self.db.lookup_note_script(script_root).await.map_err(|err| { - DataStoreError::other_with_source("failed to look up note script in local DB", err) - })? { + if let Some(script) = self + .db + .read("lookup_note_script", move |tx| queries::lookup_note_script(tx, &script_root)) + .await + .map_err(|err| { + DataStoreError::other_with_source( + "failed to look up note script in local DB", + err, + ) + })? + { self.script_cache.put(script_root, script.clone()); return Ok(Some(script)); } diff --git a/bin/ntx-builder/src/actor/mod.rs b/bin/ntx-builder/src/actor/mod.rs index 4bf1c9d61d..b13d90561e 100644 --- a/bin/ntx-builder/src/actor/mod.rs +++ b/bin/ntx-builder/src/actor/mod.rs @@ -10,6 +10,7 @@ use allowlist::{NoteScriptNotAllowlisted, partition_by_allowlist}; use anyhow::Context; use candidate::TransactionCandidate; use futures::FutureExt; +use miden_node_db::sqlite::Database; use miden_node_utils::ErrorReport; use miden_node_utils::lru_cache::LruCache; use miden_node_utils::shutdown::CancellationToken; @@ -25,7 +26,7 @@ use tokio::sync::{Notify, Semaphore, mpsc}; use crate::chain_state::{ChainState, SharedChainState}; use crate::clients::{RemoteTransactionProver, RpcClient}; -use crate::db::Db; +use crate::db::queries; use crate::{LOG_TARGET, NoteError}; /// Builds the [`TransactionArgs`] shared by every network transaction. @@ -86,7 +87,7 @@ pub struct GrpcClients { #[derive(Clone)] pub struct State { /// Local database for account state, notes, and transaction tracking. - pub db: Db, + pub db: Database, /// The latest chain state. A single chain state is shared among all actors. pub chain: Arc, /// Shared LRU cache for storing retrieved note scripts to avoid repeated RPC calls. @@ -140,7 +141,7 @@ impl AccountActorContext { /// /// The URLs are fake and actors spawned with this context will fail on their first gRPC call, /// but this is sufficient for testing coordinator logic (registry, deactivation, etc.). - pub fn test(db: &crate::db::Db) -> Self { + pub fn test(db: &Database) -> Self { use miden_protocol::crypto::merkle::mmr::{Forest, MmrPeaks, PartialMmr}; use url::Url; @@ -305,17 +306,20 @@ impl AccountActor { let mut account = self .state .db - .get_account(account_id) + .read("get_account", move |tx| queries::get_account(tx, account_id)) .await .context("failed to load committed account")? .context("no committed state for the account; the coordinator must only spawn actors for committed accounts")?; // Determine initial mode by checking the DB for available notes. let block_num = self.state.chain.chain_tip_block_number(); + let max_note_attempts = self.config.max_note_attempts; let has_notes = self .state .db - .has_available_notes(account_id, block_num, self.config.max_note_attempts) + .read("has_available_notes", move |tx| { + queries::has_available_notes(tx, account_id, block_num, max_note_attempts) + }) .await .context("failed to check for available notes")?; let mut mode = if has_notes { @@ -408,10 +412,11 @@ impl AccountActor { return Ok(ActorMode::NotesAvailable); }; + let account_id = self.account_id; let landed = self .state .db - .account_last_tx(self.account_id) + .read("account_last_tx", move |tx| queries::account_last_tx(tx, account_id)) .await .context("failed to check submitted tx landing")? == Some(submitted_tx_id); @@ -446,7 +451,7 @@ impl AccountActor { if let Some(latest) = self .state .db - .get_account(self.account_id) + .read("get_account", move |tx| queries::get_account(tx, account_id)) .await .context("failed to reload account after submission expiry")? { @@ -472,10 +477,13 @@ impl AccountActor { let block_num = chain_state.chain_tip_header.block_num(); let max_notes = self.config.max_notes_per_tx.get(); + let max_note_attempts = self.config.max_note_attempts; let notes = self .state .db - .available_notes(account_id, block_num, self.config.max_note_attempts) + .read("available_notes", move |tx| { + queries::available_notes(tx, account_id, block_num, max_note_attempts) + }) .await .context("failed to query DB for available notes")?; @@ -783,7 +791,6 @@ mod tests { use tokio::sync::Notify; use super::*; - use crate::db::Db; use crate::test_utils::{mock_account, mock_network_account_id, mock_transaction_id}; /// Builds a valid nonce-only [`AccountPatch`] that advances `account` by a single nonce. @@ -799,7 +806,7 @@ mod tests { } /// Builds an actor wired to `db` for the given account, plus the in-memory account to drive. - fn test_actor(db: &Db, account: &Account) -> AccountActor { + fn test_actor(db: &Database, account: &Account) -> AccountActor { let ctx = AccountActorContext::test(db); AccountActor::new(account.id(), &ctx, Arc::new(Notify::new())) } @@ -808,13 +815,13 @@ mod tests { /// actor advances its in-memory account by exactly the patch the transaction produced. #[tokio::test] async fn landing_advances_in_memory_account_by_its_patch() { - let (db, _dir) = Db::test_setup().await; + let (db, _dir) = crate::db::test_setup().await; let account = mock_account(mock_network_account_id()); let account_id = account.id(); let submitted = mock_transaction_id(7); // Seed the committed row so the landing check sees our submission as the latest tx. - db.upsert_account_for_test(account_id, account.clone(), submitted) + crate::db::upsert_account_for_test(&db, account_id, account.clone(), submitted) .await .unwrap(); @@ -848,7 +855,7 @@ mod tests { /// in-memory account untouched. #[tokio::test] async fn pending_submission_keeps_waiting_without_touching_account() { - let (db, _dir) = Db::test_setup().await; + let (db, _dir) = crate::db::test_setup().await; let account = mock_account(mock_network_account_id()); // Nothing seeded: `account_last_tx` returns `None`, so the submission has not landed. The @@ -888,7 +895,7 @@ mod tests { /// resident indefinitely. #[tokio::test] async fn idle_timeout_fires_despite_repeated_notifications() { - let (db, _dir) = Db::test_setup().await; + let (db, _dir) = crate::db::test_setup().await; // A real network account with a populated allowlist, so re-evaluation on each wake reaches // a clean "no viable notes" outcome instead of erroring on a missing allowlist slot. let (account, _) = crate::test_utils::mock_network_account_update(); @@ -896,9 +903,14 @@ mod tests { // Seed the committed account but no notes, so the actor starts and stays in NoViableNotes: // every wake re-checks the DB, finds nothing, and returns to the idle state. - db.upsert_account_for_test(account_id, account.clone(), mock_transaction_id(1)) - .await - .unwrap(); + crate::db::upsert_account_for_test( + &db, + account_id, + account.clone(), + mock_transaction_id(1), + ) + .await + .unwrap(); let mut ctx = AccountActorContext::test(&db); // Short idle timeout keeps the test fast. diff --git a/bin/ntx-builder/src/builder.rs b/bin/ntx-builder/src/builder.rs index d6868bb563..ed74b4deef 100644 --- a/bin/ntx-builder/src/builder.rs +++ b/bin/ntx-builder/src/builder.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use anyhow::Context; use futures::Stream; +use miden_node_db::sqlite::Database; use miden_node_utils::shutdown::CancellationToken; use miden_node_utils::tasks::Tasks; use miden_node_utils::tracing::miden_instrument; @@ -16,7 +17,7 @@ use crate::chain_state::SharedChainState; use crate::clients::RpcError; use crate::committed_block::CommittedBlockEffects; use crate::coordinator::Coordinator; -use crate::db::{Db, LoopDb}; +use crate::db::queries; use crate::server::NtxBuilderRpcServer; use crate::{LOG_TARGET, NtxBuilderConfig}; @@ -57,7 +58,7 @@ pub struct NetworkTransactionBuilder { /// Configuration for the builder. config: NtxBuilderConfig, /// Database for persistent state. - db: Db, + db: Database, /// Stream of committed blocks from the node RPC service. block_stream: BlockStream, /// Highest block number applied to the DB so far. @@ -77,7 +78,7 @@ pub struct NetworkTransactionBuilder { impl NetworkTransactionBuilder { pub(crate) fn new( config: NtxBuilderConfig, - db: Db, + db: Database, block_stream: BlockStream, last_applied_block: BlockNumber, chain: Arc, @@ -128,14 +129,6 @@ impl NetworkTransactionBuilder { } async fn run_event_loop(mut self, shutdown: CancellationToken) -> anyhow::Result<()> { - // Pin a dedicated connection for the loop's DB writes so block application is never starved - // by the account actors competing for the shared pool. - let loop_db = self - .db - .pin_loop_connection() - .await - .context("failed to pin a database connection for the ntx-builder event loop")?; - // Phase 1: catch-up. loop { let (block, committed_tip) = tokio::select! { @@ -143,7 +136,7 @@ impl NetworkTransactionBuilder { result = self.next_block() => result?, }; let local_tip = block.header().block_num(); - self.apply_committed_block(&loop_db, block, committed_tip).await?; + self.apply_committed_block(block, committed_tip).await?; if local_tip == committed_tip { self.is_synced = true; @@ -158,8 +151,12 @@ impl NetworkTransactionBuilder { // Phase 2: spawn an actor for every account with carry-over pending notes. Accounts whose // creation has not been committed yet have their spawn deferred by the coordinator. - let pending_accounts = loop_db - .accounts_with_pending_notes(self.config.max_note_attempts) + let max_note_attempts = self.config.max_note_attempts; + let pending_accounts = self + .db + .read("accounts_with_pending_notes", move |tx| { + queries::accounts_with_pending_notes(tx, max_note_attempts) + }) .await .context("failed to load accounts with pending notes at catch-up")?; tracing::info!( @@ -193,16 +190,15 @@ impl NetworkTransactionBuilder { SteadyStateAction::Block(block) => { let (block, committed_tip) = (*block).context("block stream ended")?.context("block stream failed")?; - let effects = self - .apply_committed_block_with_effects(&loop_db, block, committed_tip) - .await?; + let effects = + self.apply_committed_block_with_effects(block, committed_tip).await?; self.coordinator.handle_committed_block(&effects).await?; }, SteadyStateAction::Request(request) => { let Some(request) = request else { anyhow::bail!("actor request channel closed unexpectedly"); }; - handle_actor_request(&loop_db, request, self.config.max_note_attempts).await?; + handle_actor_request(&self.db, request, self.config.max_note_attempts).await?; }, SteadyStateAction::Respawn(respawn) => { if let Some(account_id) = respawn { @@ -235,13 +231,10 @@ impl NetworkTransactionBuilder { /// Applies a committed block without surfacing the computed effects. async fn apply_committed_block( &mut self, - loop_db: &LoopDb, block: SignedBlock, committed_tip: BlockNumber, ) -> anyhow::Result<()> { - self.apply_committed_block_with_effects(loop_db, block, committed_tip) - .await - .map(drop) + self.apply_committed_block_with_effects(block, committed_tip).await.map(drop) } /// Applies a committed block and returns the computed `CommittedBlockEffects` so the @@ -249,7 +242,7 @@ impl NetworkTransactionBuilder { /// block. #[miden_instrument( name = "ntx.builder.apply_committed_block", - skip(self, loop_db, block), + skip(self, block), fields( block_num = %block.header().block_num(), %committed_tip, @@ -257,7 +250,6 @@ impl NetworkTransactionBuilder { )] async fn apply_committed_block_with_effects( &mut self, - loop_db: &LoopDb, block: SignedBlock, committed_tip: BlockNumber, ) -> anyhow::Result { @@ -271,8 +263,11 @@ impl NetworkTransactionBuilder { self.chain.update_chain_tip(header, self.config.max_block_count); let next_mmr = self.chain.current_mmr(); - loop_db - .apply_committed_block(effects.clone(), next_mmr) + let effects_for_db = effects.clone(); + self.db + .write("apply_committed_block", move |tx| { + queries::apply_committed_block(tx, &effects_for_db, &next_mmr) + }) .await .context("failed to apply committed block to DB")?; @@ -287,38 +282,40 @@ impl NetworkTransactionBuilder { const OVERSIZED_NOTE_DISCARD_REASON: &str = "note consumption exceeds the per-transaction cycle budget; it can never be consumed"; -/// Handles a single actor request then acknowledges the actor. Runs on the pinned loop connection -/// so the actors' shared pool cannot starve these writes. +/// Handles a single actor request then acknowledges the actor. All writes go through the +/// framework's single writer connection, so the actors' reads cannot starve them. async fn handle_actor_request( - loop_db: &LoopDb, + db: &Database, request: ActorRequest, max_note_attempts: usize, ) -> anyhow::Result<()> { match request { ActorRequest::NotesFailed { failed_notes, block_num, ack_tx } => { - loop_db - .notes_failed(failed_notes, block_num) + db.write("notes_failed", move |tx| queries::notes_failed(tx, &failed_notes, block_num)) .await .context("failed to persist note failure")?; let _ = ack_tx.send(()); }, ActorRequest::NotesDiscarded { nullifiers, block_num, ack_tx } => { - loop_db - .discard_notes( - nullifiers, + db.write("discard_notes", move |tx| { + queries::discard_notes( + tx, + &nullifiers, block_num, max_note_attempts, - OVERSIZED_NOTE_DISCARD_REASON.to_string(), + OVERSIZED_NOTE_DISCARD_REASON, ) - .await - .context("failed to persist note discard")?; + }) + .await + .context("failed to persist note discard")?; let _ = ack_tx.send(()); }, ActorRequest::CacheNoteScript { script_root, script } => { - loop_db - .insert_note_script(script_root, &script) - .await - .context("failed to cache note script")?; + db.write("insert_note_script", move |tx| { + queries::insert_note_script(tx, &script_root, &script) + }) + .await + .context("failed to cache note script")?; }, } Ok(()) diff --git a/bin/ntx-builder/src/clients/rpc.rs b/bin/ntx-builder/src/clients/rpc.rs index 928f1bd215..40d7fe46f1 100644 --- a/bin/ntx-builder/src/clients/rpc.rs +++ b/bin/ntx-builder/src/clients/rpc.rs @@ -1,5 +1,5 @@ -use std::collections::BTreeSet; use miden_node_utils::tracing::miden_instrument; +use std::collections::BTreeSet; use std::time::Duration; diff --git a/bin/ntx-builder/src/committed_block.rs b/bin/ntx-builder/src/committed_block.rs index b347849ab8..acf8f89ea7 100644 --- a/bin/ntx-builder/src/committed_block.rs +++ b/bin/ntx-builder/src/committed_block.rs @@ -4,7 +4,7 @@ use miden_protocol::note::Nullifier; use miden_protocol::transaction::{OutputNote, TransactionId}; use miden_standards::note::AccountTargetNetworkNote; -use crate::db::models::account_effect::NetworkAccountEffect; +use crate::db::queries::account_effect::NetworkAccountEffect; /// Network-relevant state extracted from a committed [`SignedBlock`]. /// diff --git a/bin/ntx-builder/src/coordinator.rs b/bin/ntx-builder/src/coordinator.rs index 53f417e518..628b04f234 100644 --- a/bin/ntx-builder/src/coordinator.rs +++ b/bin/ntx-builder/src/coordinator.rs @@ -193,7 +193,7 @@ impl Coordinator { .actor_context .state .db - .account_exists(account_id) + .read("account_exists", move |tx| crate::db::queries::account_exists(tx, account_id)) .await .context("failed to check for committed account state")?; @@ -310,9 +310,7 @@ impl Coordinator { /// drive it from the test to inspect actor requests). pub async fn test() -> (Self, tempfile::TempDir, tokio::sync::mpsc::Receiver) { - use crate::db::Db; - - let (db, dir) = Db::test_setup().await; + let (db, dir) = crate::db::test_setup().await; let (tx, rx) = tokio::sync::mpsc::channel(8); let mut actor_context = AccountActorContext::test(&db); actor_context.request_tx = tx; @@ -336,9 +334,14 @@ mod tests { /// Seeds a committed row for `account_id` so the coordinator's spawn check sees the account. async fn seed_committed_account(coordinator: &Coordinator, account_id: AccountId) { let db = coordinator.actor_context.state.db.clone(); - db.upsert_account_for_test(account_id, mock_account(account_id), mock_transaction_id(0)) - .await - .unwrap(); + crate::db::upsert_account_for_test( + &db, + account_id, + mock_account(account_id), + mock_transaction_id(0), + ) + .await + .unwrap(); } #[tokio::test] @@ -395,7 +398,7 @@ mod tests { // The creation commits in a later block; the builder persists the block's effects to the DB // before handing them to the coordinator. let db = coordinator.actor_context.state.db.clone(); - db.upsert_account_for_test(account_id, account, mock_transaction_id(0)) + crate::db::upsert_account_for_test(&db, account_id, account, mock_transaction_id(0)) .await .unwrap(); let effects = CommittedBlockEffects { diff --git a/bin/ntx-builder/src/db/migrations.rs b/bin/ntx-builder/src/db/migrations.rs index 6c45b29f18..1add8de2f2 100644 --- a/bin/ntx-builder/src/db/migrations.rs +++ b/bin/ntx-builder/src/db/migrations.rs @@ -65,9 +65,7 @@ pub fn verify_latest_schema(database_filepath: &Path) -> Result<(), DatabaseErro #[cfg(test)] mod tests { - use std::process::Command; - - use anyhow::{Context, Result, ensure}; + use anyhow::Result; use miden_node_db::migration::{SchemaHash, SchemaHashes}; use super::*; @@ -83,34 +81,4 @@ mod tests { assert_eq!(migrator.schema_hashes(), SchemaHashes(&EXPECTED_SCHEMA_HASHES)); Ok(()) } - - #[test] - #[ignore = "requires diesel CLI; CI runs this in the diesel-schema job"] - fn diesel_schema_is_in_sync_with_migrations() -> Result<()> { - let temp_dir = tempfile::tempdir()?; - let database_filepath = temp_dir.path().join("ntx-builder.sqlite3"); - bootstrap_database(&database_filepath)?; - - let output = Command::new("diesel") - .arg("print-schema") - .arg("--database-url") - .arg(&database_filepath) - .current_dir(env!("CARGO_MANIFEST_DIR")) - .output() - .context( - "failed to run diesel CLI; install it with \ - `cargo install diesel_cli --no-default-features --features sqlite`", - )?; - - ensure!( - output.status.success(), - "diesel print-schema failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - - let generated = - String::from_utf8(output.stdout).context("diesel CLI output is not UTF-8")?; - assert_eq!(generated, include_str!("schema.rs")); - Ok(()) - } } diff --git a/bin/ntx-builder/src/db/mod.rs b/bin/ntx-builder/src/db/mod.rs index 5cf012583b..add2bf79b4 100644 --- a/bin/ntx-builder/src/db/mod.rs +++ b/bin/ntx-builder/src/db/mod.rs @@ -3,421 +3,174 @@ use std::path::{Path, PathBuf}; use anyhow::Context; use miden_node_db::DatabaseError; +use miden_node_db::sqlite::Database; use miden_node_utils::tracing::miden_instrument; -use miden_protocol::Word; -use miden_protocol::account::AccountId; -use miden_protocol::block::{BlockHeader, BlockNumber, SignedBlock}; +use miden_protocol::block::SignedBlock; use miden_protocol::crypto::merkle::mmr::PartialMmr; -use miden_protocol::note::{NoteId, NoteScript, Nullifier}; -use miden_protocol::transaction::TransactionId; -use miden_standards::note::AccountTargetNetworkNote; use tracing::info; +use crate::COMPONENT; use crate::committed_block::CommittedBlockEffects; use crate::db::migrations::{bootstrap_database, migrate_database, verify_latest_schema}; -use crate::db::models::queries; -use crate::{COMPONENT, NoteError}; -pub(crate) mod models; +pub(crate) mod queries; mod migrations; -/// [diesel](https://diesel.rs) generated schema. -pub(crate) mod schema; - -pub type Result = std::result::Result; - -#[derive(Clone)] -pub struct Db { - inner: miden_node_db::Db, +/// SQL statements, kept in dedicated `.sql` files (under `sql/`). +pub(crate) mod sql { + pub(crate) const UPSERT_ACCOUNT: &str = include_str!("sql/upsert_account.sql"); + pub(crate) const ACCOUNT_LAST_TX: &str = include_str!("sql/account_last_tx.sql"); + pub(crate) const ACCOUNT_EXISTS: &str = include_str!("sql/account_exists.sql"); + pub(crate) const GET_ACCOUNT: &str = include_str!("sql/get_account.sql"); + pub(crate) const UPDATE_CHAIN_STATE_TIP: &str = include_str!("sql/update_chain_state_tip.sql"); + pub(crate) const INSERT_GENESIS_CHAIN_STATE: &str = + include_str!("sql/insert_genesis_chain_state.sql"); + pub(crate) const SELECT_GENESIS_COMMITMENT: &str = + include_str!("sql/select_genesis_commitment.sql"); + pub(crate) const SELECT_CHAIN_STATE: &str = include_str!("sql/select_chain_state.sql"); + pub(crate) const LOOKUP_NOTE_SCRIPT: &str = include_str!("sql/lookup_note_script.sql"); + pub(crate) const INSERT_NOTE_SCRIPT: &str = include_str!("sql/insert_note_script.sql"); + pub(crate) const INSERT_NETWORK_NOTE: &str = include_str!("sql/insert_network_note.sql"); + pub(crate) const MARK_NOTE_CONSUMED: &str = include_str!("sql/mark_note_consumed.sql"); + pub(crate) const AVAILABLE_NOTES: &str = include_str!("sql/available_notes.sql"); + pub(crate) const NOTE_FAILED: &str = include_str!("sql/note_failed.sql"); + pub(crate) const DISCARD_NOTE: &str = include_str!("sql/discard_note.sql"); + pub(crate) const GET_NOTE_STATUS: &str = include_str!("sql/get_note_status.sql"); + pub(crate) const ACCOUNTS_WITH_PENDING_NOTES: &str = + include_str!("sql/accounts_with_pending_notes.sql"); } -impl Db { - /// Opens an async connection pool after verifying the database is at the latest schema version. - #[miden_instrument( - target = COMPONENT, - name = "ntx_builder.database.load", - skip_all, - fields( - path=%database_filepath.display(), - ), - err, - )] - pub async fn load(database_filepath: PathBuf) -> anyhow::Result { - Self::load_with_pool_size(database_filepath, miden_node_db::default_connection_pool_size()) - .await - } - - /// Opens an async connection pool with a specific pool size after verifying the database is at - /// the latest schema version. - #[miden_instrument( - target = COMPONENT, - name = "ntx_builder.database.load", - skip_all, - fields( - path=%database_filepath.display(), - ), - err, - )] - pub async fn load_with_pool_size( - database_filepath: PathBuf, - connection_pool_size: NonZeroUsize, - ) -> anyhow::Result { - verify_latest_schema(&database_filepath).context("failed to verify database schema")?; - - Self::open_with_pool_size(&database_filepath, connection_pool_size) - } - - /// Applies all pending migrations to an existing DB. - #[miden_instrument( - target = COMPONENT, - skip_all, - )] - pub fn migrate(database_filepath: impl AsRef) -> Result<()> { - migrate_database(database_filepath.as_ref())?; - Ok(()) - } - - fn open_with_pool_size( - database_filepath: &Path, - connection_pool_size: NonZeroUsize, - ) -> anyhow::Result { - let inner = miden_node_db::Db::new_with_pool_size(database_filepath, connection_pool_size) - .context("failed to build connection pool")?; - - info!( - target: COMPONENT, - sqlite = %database_filepath.display(), - connection_pool_size = %connection_pool_size, - "Connected to the database" - ); - - Ok(Db { inner }) - } - - /// Creates and initializes the database, then seeds it with the signed genesis block. - /// - /// Mirrors the store's bootstrap (`Db::bootstrap`): after this completes the singleton - /// `chain_state` row exists at [`BlockNumber::GENESIS`], so [`crate::NtxBuilderConfig::build`] - /// can assume the genesis block is always present and never has to consume it from the - /// committed-block subscription on startup. - /// - /// Returns an error if the database has already been bootstrapped. - #[miden_instrument( - target = COMPONENT, - name = "ntx_builder.database.bootstrap", - skip_all, - fields( - path=%database_filepath.display(), - ), - err, - )] - pub async fn bootstrap( - database_filepath: PathBuf, - genesis: &SignedBlock, - ) -> anyhow::Result<()> { - bootstrap_database(&database_filepath).context("failed to bootstrap database schema")?; - let db = Self::open_with_pool_size( - &database_filepath, - miden_node_db::default_connection_pool_size(), - )?; - - let genesis_commitment = genesis.header().commitment(); - let genesis_header = genesis.header().clone(); - - db.inner - .transact("insert_genesis_chain_state", move |conn| { - queries::insert_genesis_chain_state(conn, &genesis_header, &genesis_commitment) - }) - .await - .context("failed to seed genesis chain state")?; - - let effects = CommittedBlockEffects::from_signed_block(genesis); - db.apply_committed_block(effects, PartialMmr::default()) - .await - .context("failed to insert genesis block")?; - - Ok(()) - } - - /// Reads the genesis block commitment persisted at bootstrap. - pub async fn get_genesis_commitment(&self) -> Result { - self.inner - .query("get_genesis_commitment", queries::select_genesis_commitment) - .await - } - - // BLOCK APPLICATION - // ============================================================================================ - - /// Applies the effects of a committed block (account upserts, note inserts, nullifier-driven - /// deletes, and chain-state advancement) in a single transaction. - pub async fn apply_committed_block( - &self, - effects: CommittedBlockEffects, - chain_mmr: PartialMmr, - ) -> Result<()> { - self.inner - .transact("apply_committed_block", move |conn| { - queries::apply_committed_block(conn, &effects, &chain_mmr) - }) - .await - } - - /// Reads the singleton chain state row, returning the last synced block number, its header, and - /// the persisted chain MMR if any block has been applied locally. - pub async fn get_chain_state(&self) -> Result> { - self.inner.query("get_chain_state", queries::select_chain_state).await - } - - // ACTOR-PATH QUERIES - // ============================================================================================ - - /// Returns `true` if there are notes available for consumption by the given account. - pub async fn has_available_notes( - &self, - account_id: AccountId, - block_num: BlockNumber, - max_attempts: usize, - ) -> Result { - self.inner - .query("has_available_notes", move |conn| { - let notes = queries::available_notes(conn, account_id, block_num, max_attempts)?; - Ok(!notes.is_empty()) - }) - .await - } - - /// Returns `true` if a committed state for the given account is tracked locally. - /// - /// The coordinator uses this to defer actor spawning until the account's creation transaction - /// has been committed. - pub async fn account_exists(&self, account_id: AccountId) -> Result { - self.inner - .query("account_exists", move |conn| queries::account_exists(conn, account_id)) - .await - } - - /// Returns the committed account state for the given network account, if one is tracked locally. - /// - /// Actors load their account once at startup with this and keep it in memory afterwards, - /// advancing it from the committed state the coordinator pushes after each block. - pub async fn get_account( - &self, - account_id: AccountId, - ) -> Result> { - self.inner - .query("get_account", move |conn| queries::get_account(conn, account_id)) - .await - } - - /// Returns the notes currently available for consumption by the given account. - pub async fn available_notes( - &self, - account_id: AccountId, - block_num: BlockNumber, - max_note_attempts: usize, - ) -> Result> { - self.inner - .query("available_notes", move |conn| { - queries::available_notes(conn, account_id, block_num, max_note_attempts) - }) - .await - } - - /// Returns the distinct set of network accounts that currently have at least one pending - /// (unconsumed, within attempt budget) note. - pub async fn accounts_with_pending_notes(&self, max_attempts: usize) -> Result> { - self.inner - .query("accounts_with_pending_notes", move |conn| { - queries::accounts_with_pending_notes(conn, max_attempts) - }) - .await - } - - /// Returns the latest transaction recorded against `account_id` in a committed block, if any. - /// An actor waiting on its submission compares this against its own transaction id to confirm - /// landing. - pub async fn account_last_tx(&self, account_id: AccountId) -> Result> { - self.inner - .query("account_last_tx", move |conn| queries::account_last_tx(conn, account_id)) - .await - } - - /// Marks notes as failed by incrementing `attempt_count`, setting `last_attempt`, and storing - /// the latest error message. - pub async fn notes_failed( - &self, - failed_notes: Vec<(Nullifier, NoteError)>, - block_num: BlockNumber, - ) -> Result<()> { - self.inner - .transact("notes_failed", move |conn| { - queries::notes_failed(conn, &failed_notes, block_num) - }) - .await - } - - /// Returns the status for a note identified by its note ID. - pub async fn get_note_status(&self, note_id: NoteId) -> Result> { - let note_id_bytes = models::conv::note_id_to_bytes(¬e_id); - self.inner - .query("get_note_status", move |conn| queries::get_note_status(conn, ¬e_id_bytes)) - .await - } - - // SCRIPT CACHE - // ============================================================================================ - - /// Looks up a cached note script by root hash. - pub async fn lookup_note_script(&self, script_root: Word) -> Result> { - self.inner - .query("lookup_note_script", move |conn| { - queries::lookup_note_script(conn, &script_root) - }) - .await - } - - /// Persists a note script to the local cache. - pub async fn insert_note_script(&self, script_root: Word, script: &NoteScript) -> Result<()> { - let script = script.clone(); - self.inner - .transact("insert_note_script", move |conn| { - queries::insert_note_script(conn, &script_root, &script) - }) - .await - } - - /// Pins a dedicated connection for the builder's event loop, returning a [`LoopDb`]. - /// - /// The loop performs its writes through the pinned connection so it never competes with the - /// account actors for the shared pool. - pub async fn pin_loop_connection(&self) -> Result { - Ok(LoopDb { - conn: self.inner.pinned_connection().await?, - }) - } - - /// Creates a file-backed SQLite test connection with migrations applied. - #[cfg(test)] - pub fn test_conn() -> (diesel::SqliteConnection, tempfile::TempDir) { - use diesel::{Connection, SqliteConnection}; - use miden_node_db::configure_connection_on_creation; - - let dir = tempfile::tempdir().expect("failed to create temp directory"); - let db_path = dir.path().join("test.sqlite3"); - bootstrap_database(&db_path).expect("database should bootstrap"); - let mut conn = SqliteConnection::establish(db_path.to_str().unwrap()) - .expect("temp file sqlite should always work"); - configure_connection_on_creation(&mut conn).expect("connection configuration should work"); - (conn, dir) - } - - /// Creates an async `Db` instance backed by a temp file for testing. - /// - /// Returns `(Db, TempDir)` — the `TempDir` must be kept alive for the DB's lifetime. - #[cfg(test)] - pub async fn test_setup() -> (Db, tempfile::TempDir) { - let dir = tempfile::tempdir().expect("failed to create temp directory"); - let db_path = dir.path().join("test.sqlite3"); - bootstrap_database(&db_path).expect("database should bootstrap"); - let db = Db::load(db_path).await.expect("test DB load should succeed"); - (db, dir) - } +// LIFECYCLE +// ================================================================================================ + +/// Opens an async connection pool after verifying the database is at the latest schema version. +#[miden_instrument( + target = COMPONENT, + name = "ntx_builder.database.load", + skip_all, + fields(path=%database_filepath.display()), + err, +)] +pub async fn load(database_filepath: PathBuf) -> anyhow::Result { + load_with_pool_size(database_filepath, miden_node_db::default_connection_pool_size()).await +} - /// Seeds a committed account row (and its `last_tx_id`) for tests that exercise the actor's - /// landing detection without driving a full committed block. - #[cfg(test)] - pub async fn upsert_account_for_test( - &self, - account_id: AccountId, - account: miden_protocol::account::Account, - last_tx_id: TransactionId, - ) -> Result<()> { - self.inner - .transact("test_upsert_account", move |conn| { - queries::upsert_account(conn, account_id, &account, last_tx_id) - }) - .await - } +/// Opens an async connection pool with a specific pool size after verifying the database is at the +/// latest schema version. +#[miden_instrument( + target = COMPONENT, + name = "ntx_builder.database.load", + skip_all, + fields(path=%database_filepath.display()), + err, +)] +pub async fn load_with_pool_size( + database_filepath: PathBuf, + connection_pool_size: NonZeroUsize, +) -> anyhow::Result { + verify_latest_schema(&database_filepath).context("failed to verify database schema")?; + + open_with_pool_size(&database_filepath, connection_pool_size) } -/// The subset of write operations the builder's event loop performs, bound to a connection pinned -/// out of [`Db`]'s pool. Routing the loop's writes here keeps block application off the shared pool -/// that the account actors hammer, so the loop is never starved of a connection. -pub struct LoopDb { - conn: miden_node_db::PinnedConnection, +/// Applies all pending migrations to an existing DB. +#[miden_instrument(target = COMPONENT, skip_all)] +pub fn migrate(database_filepath: impl AsRef) -> Result<(), DatabaseError> { + migrate_database(database_filepath.as_ref())?; + Ok(()) } -impl LoopDb { - /// Applies a committed block's effects (see [`Db::apply_committed_block`]) on the pinned - /// connection. - pub async fn apply_committed_block( - &self, - effects: CommittedBlockEffects, - chain_mmr: PartialMmr, - ) -> Result<()> { - self.conn - .transact("apply_committed_block", move |conn| { - queries::apply_committed_block(conn, &effects, &chain_mmr) - }) - .await - } +fn open_with_pool_size( + database_filepath: &Path, + connection_pool_size: NonZeroUsize, +) -> anyhow::Result { + let db = Database::new_with_pool_size(database_filepath, connection_pool_size) + .context("failed to build connection pool")?; + + info!( + target: COMPONENT, + sqlite = %database_filepath.display(), + connection_pool_size = %connection_pool_size, + "Connected to the database" + ); + + Ok(db) +} - /// Returns the network accounts with carry-over pending notes (see - /// [`Db::accounts_with_pending_notes`]) on the pinned connection. - pub async fn accounts_with_pending_notes(&self, max_attempts: usize) -> Result> { - self.conn - .query("accounts_with_pending_notes", move |conn| { - queries::accounts_with_pending_notes(conn, max_attempts) - }) - .await - } +/// Creates and initializes the database, then seeds it with the signed genesis block. +/// +/// Mirrors the store's bootstrap: after this completes the singleton `chain_state` row exists at +/// [`BlockNumber::GENESIS`](miden_protocol::block::BlockNumber::GENESIS), so +/// [`crate::NtxBuilderConfig::build`] can assume the genesis block is always present and never has +/// to consume it from the committed-block subscription on startup. +/// +/// Returns an error if the database has already been bootstrapped. +#[miden_instrument( + target = COMPONENT, + name = "ntx_builder.database.bootstrap", + skip_all, + fields(path=%database_filepath.display()), + err, +)] +pub async fn bootstrap(database_filepath: PathBuf, genesis: &SignedBlock) -> anyhow::Result<()> { + bootstrap_database(&database_filepath).context("failed to bootstrap database schema")?; + let db = + open_with_pool_size(&database_filepath, miden_node_db::default_connection_pool_size())?; + + let genesis_commitment = genesis.header().commitment(); + let genesis_header = genesis.header().clone(); + + db.write("insert_genesis_chain_state", move |tx| { + queries::insert_genesis_chain_state(tx, &genesis_header, &genesis_commitment) + }) + .await + .context("failed to seed genesis chain state")?; + + let effects = CommittedBlockEffects::from_signed_block(genesis); + db.write("apply_committed_block", move |tx| { + queries::apply_committed_block(tx, &effects, &PartialMmr::default()) + }) + .await + .context("failed to insert genesis block")?; + + Ok(()) +} - /// Marks notes as failed (see [`Db::notes_failed`]) on the pinned connection. - pub async fn notes_failed( - &self, - failed_notes: Vec<(Nullifier, NoteError)>, - block_num: BlockNumber, - ) -> Result<()> { - self.conn - .transact("notes_failed", move |conn| { - queries::notes_failed(conn, &failed_notes, block_num) - }) - .await - } +// TEST HELPERS +// ================================================================================================ - /// Marks notes as permanently unconsumable (see [`Db::discard_notes`]) on the pinned - /// connection. - pub async fn discard_notes( - &self, - nullifiers: Vec, - block_num: BlockNumber, - max_attempts: usize, - reason: String, - ) -> Result<()> { - self.conn - .transact("discard_notes", move |conn| { - queries::discard_notes(conn, &nullifiers, block_num, max_attempts, &reason) - }) - .await - } +/// Creates a schema-migrated (but un-seeded) database backed by a temp file for testing. +#[cfg(test)] +pub(crate) async fn test_setup() -> (Database, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("failed to create temp directory"); + let db_path = dir.path().join("test.sqlite3"); + bootstrap_database(&db_path).expect("database should bootstrap"); + let db = load(db_path).await.expect("test DB load should succeed"); + (db, dir) +} - /// Persists a note script to the local cache (see [`Db::insert_note_script`]) on the pinned - /// connection. - pub async fn insert_note_script(&self, script_root: Word, script: &NoteScript) -> Result<()> { - let script = script.clone(); - self.conn - .transact("insert_note_script", move |conn| { - queries::insert_note_script(conn, &script_root, &script) - }) - .await - } +/// Seeds a committed account row (and its `last_tx_id`) for tests that exercise the actor's landing +/// detection without driving a full committed block. +#[cfg(test)] +pub(crate) async fn upsert_account_for_test( + db: &Database, + account_id: miden_protocol::account::AccountId, + account: miden_protocol::account::Account, + last_tx_id: miden_protocol::transaction::TransactionId, +) -> Result<(), DatabaseError> { + db.write("test_upsert_account", move |tx| { + queries::upsert_account(tx, account_id, &account, last_tx_id) + }) + .await } #[cfg(test)] mod tests { + use miden_protocol::block::BlockNumber; + use super::*; + use crate::db::queries; use crate::test_utils::{mock_genesis_block, mock_genesis_block_with_network_account}; #[tokio::test] @@ -426,15 +179,16 @@ mod tests { let db_path = dir.path().join("ntx-builder.sqlite3"); let (genesis, account_id) = mock_genesis_block_with_network_account(); - Db::bootstrap(db_path.clone(), &genesis) + bootstrap(db_path.clone(), &genesis) .await .expect("bootstrap should succeed with a network account in genesis"); - let db = Db::load(db_path).await.expect("load should open the bootstrapped database"); - assert!( - db.get_account(account_id).await.expect("query should succeed").is_some(), - "genesis network account should be committed after bootstrap", - ); + let db = load(db_path).await.expect("load should open the bootstrapped database"); + let account = db + .read("get_account", move |tx| queries::get_account(tx, account_id)) + .await + .expect("query should succeed"); + assert!(account.is_some(), "genesis network account should be committed after bootstrap"); } #[tokio::test] @@ -442,13 +196,13 @@ mod tests { let dir = tempfile::tempdir().expect("failed to create temp directory"); let db_path = dir.path().join("ntx-builder.sqlite3"); - Db::bootstrap(db_path.clone(), &mock_genesis_block()) + bootstrap(db_path.clone(), &mock_genesis_block()) .await .expect("bootstrap should succeed on a fresh database"); - let db = Db::load(db_path).await.expect("load should open the bootstrapped database"); + let db = load(db_path).await.expect("load should open the bootstrapped database"); let (block_num, ..) = db - .get_chain_state() + .read("select_chain_state", queries::select_chain_state) .await .expect("query should succeed") .expect("chain state should be present after bootstrap"); @@ -461,11 +215,11 @@ mod tests { let dir = tempfile::tempdir().expect("failed to create temp directory"); let db_path = dir.path().join("ntx-builder.sqlite3"); - Db::bootstrap(db_path.clone(), &mock_genesis_block()) + bootstrap(db_path.clone(), &mock_genesis_block()) .await .expect("first bootstrap should succeed"); - let err = Db::bootstrap(db_path, &mock_genesis_block()) + let err = bootstrap(db_path, &mock_genesis_block()) .await .expect_err("second bootstrap should fail"); assert!( diff --git a/bin/ntx-builder/src/db/models/conv.rs b/bin/ntx-builder/src/db/models/conv.rs deleted file mode 100644 index c967c13607..0000000000 --- a/bin/ntx-builder/src/db/models/conv.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! Conversions between Miden domain types and database column types. - -use miden_node_db::DatabaseError; -use miden_protocol::Word; -use miden_protocol::account::{Account, AccountId}; -use miden_protocol::block::{BlockHeader, BlockNumber}; -use miden_protocol::note::{NoteId, NoteScript, Nullifier}; -use miden_protocol::transaction::TransactionId; -use miden_protocol::utils::serde::{Deserializable, Serializable}; - -// SERIALIZATION (domain → DB) -// ================================================================================================ - -pub fn account_to_bytes(account: &Account) -> Vec { - account.to_bytes() -} - -pub fn block_header_to_bytes(header: &BlockHeader) -> Vec { - header.to_bytes() -} - -pub fn account_id_to_bytes(id: AccountId) -> Vec { - id.to_bytes() -} - -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 transaction_id_to_bytes(tx_id: &TransactionId) -> Vec { - tx_id.to_bytes() -} - -pub fn block_num_to_i64(block_num: BlockNumber) -> i64 { - i64::from(block_num.as_u32()) -} - -#[expect(clippy::cast_sign_loss)] -pub fn block_num_from_i64(val: i64) -> BlockNumber { - BlockNumber::from(val as u32) -} - -// DESERIALIZATION (DB → domain) -// ================================================================================================ - -pub fn account_from_bytes(bytes: &[u8]) -> Result { - Account::read_from_bytes(bytes).map_err(|e| DatabaseError::deserialization("account", e)) -} - -pub fn account_id_from_bytes(bytes: &[u8]) -> Result { - AccountId::read_from_bytes(bytes).map_err(|e| DatabaseError::deserialization("account id", e)) -} - -pub fn transaction_id_from_bytes(bytes: &[u8]) -> Result { - TransactionId::read_from_bytes(bytes) - .map_err(|e| DatabaseError::deserialization("transaction id", e)) -} - -pub fn word_to_bytes(word: &Word) -> Vec { - word.to_bytes() -} - -pub fn note_script_to_bytes(script: &NoteScript) -> Vec { - script.to_bytes() -} - -pub fn note_script_from_bytes(bytes: &[u8]) -> Result { - NoteScript::read_from_bytes(bytes).map_err(|e| DatabaseError::deserialization("note script", e)) -} diff --git a/bin/ntx-builder/src/db/models/mod.rs b/bin/ntx-builder/src/db/models/mod.rs deleted file mode 100644 index 8279142b67..0000000000 --- a/bin/ntx-builder/src/db/models/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub(crate) mod account_effect; -pub(crate) mod conv; - -pub mod queries; diff --git a/bin/ntx-builder/src/db/models/queries/accounts.rs b/bin/ntx-builder/src/db/models/queries/accounts.rs deleted file mode 100644 index 6df10cfc4a..0000000000 --- a/bin/ntx-builder/src/db/models/queries/accounts.rs +++ /dev/null @@ -1,134 +0,0 @@ -//! Account-related queries and models. - -use diesel::prelude::*; -use miden_node_db::DatabaseError; -use miden_protocol::account::{Account, AccountId}; -use miden_protocol::transaction::TransactionId; - -use crate::db::models::conv as conversions; -use crate::db::schema; - -// MODELS -// ================================================================================================ - -#[derive(Debug, Clone, Insertable)] -#[diesel(table_name = schema::accounts)] -#[diesel(check_for_backend(diesel::sqlite::Sqlite))] -pub struct AccountInsert { - pub account_id: Vec, - pub account_data: Vec, - pub last_tx_id: Vec, -} - -#[derive(Debug, Clone, Queryable, Selectable)] -#[diesel(table_name = schema::accounts)] -#[diesel(check_for_backend(diesel::sqlite::Sqlite))] -pub struct AccountRow { - pub account_data: Vec, -} - -// QUERIES -// ================================================================================================ - -/// Inserts the committed account state, or updates an existing account's state. In both cases -/// `last_tx_id` is set to the transaction that produced this update. -/// -/// # Raw SQL -/// -/// ```sql -/// INSERT INTO accounts (account_id, account_data, last_tx_id) -/// VALUES (?1, ?2, ?3) -/// ON CONFLICT(account_id) DO UPDATE SET -/// account_data = excluded.account_data, -/// last_tx_id = excluded.last_tx_id -/// ``` -pub fn upsert_account( - conn: &mut SqliteConnection, - account_id: AccountId, - account: &Account, - last_tx_id: TransactionId, -) -> Result<(), DatabaseError> { - let row = AccountInsert { - account_id: conversions::account_id_to_bytes(account_id), - account_data: conversions::account_to_bytes(account), - last_tx_id: conversions::transaction_id_to_bytes(&last_tx_id), - }; - diesel::insert_into(schema::accounts::table) - .values(&row) - .on_conflict(schema::accounts::account_id) - .do_update() - .set(( - schema::accounts::account_data.eq(&row.account_data), - schema::accounts::last_tx_id.eq(&row.last_tx_id), - )) - .execute(conn)?; - Ok(()) -} - -/// Returns the latest transaction recorded against `account_id`, or `None` if the account is not -/// tracked locally. -/// -/// # Raw SQL -/// -/// ```sql -/// SELECT last_tx_id FROM accounts WHERE account_id = ?1 -/// ``` -pub fn account_last_tx( - conn: &mut SqliteConnection, - account_id: AccountId, -) -> Result, DatabaseError> { - let account_id_bytes = conversions::account_id_to_bytes(account_id); - - let last_tx_id: Option> = schema::accounts::table - .find(&account_id_bytes) - .select(schema::accounts::last_tx_id) - .first(conn) - .optional()?; - - last_tx_id - .map(|bytes| conversions::transaction_id_from_bytes(&bytes)) - .transpose() -} - -/// Returns `true` if a committed state for the given account is tracked locally. -/// -/// # Raw SQL -/// -/// ```sql -/// SELECT EXISTS (SELECT 1 FROM accounts WHERE account_id = ?1) -/// ``` -pub fn account_exists( - conn: &mut SqliteConnection, - account_id: AccountId, -) -> Result { - let account_id_bytes = conversions::account_id_to_bytes(account_id); - - let exists = - diesel::select(diesel::dsl::exists(schema::accounts::table.find(&account_id_bytes))) - .get_result(conn)?; - - Ok(exists) -} - -/// Returns the committed account state for the given network account. -/// -/// # Raw SQL -/// -/// ```sql -/// SELECT account_data FROM accounts WHERE account_id = ?1 -/// ``` -pub fn get_account( - conn: &mut SqliteConnection, - account_id: AccountId, -) -> Result, DatabaseError> { - let account_id_bytes = conversions::account_id_to_bytes(account_id); - - let row: Option = schema::accounts::table - .find(&account_id_bytes) - .select(AccountRow::as_select()) - .first(conn) - .optional()?; - - row.map(|AccountRow { account_data }| conversions::account_from_bytes(&account_data)) - .transpose() -} diff --git a/bin/ntx-builder/src/db/models/queries/chain_state.rs b/bin/ntx-builder/src/db/models/queries/chain_state.rs deleted file mode 100644 index 91c61182f5..0000000000 --- a/bin/ntx-builder/src/db/models/queries/chain_state.rs +++ /dev/null @@ -1,146 +0,0 @@ -//! Chain state queries and models. - -use diesel::prelude::*; -use miden_node_db::DatabaseError; -use miden_protocol::Word; -use miden_protocol::block::{BlockHeader, BlockNumber}; -use miden_protocol::crypto::merkle::mmr::PartialMmr; -use miden_protocol::utils::serde::{Deserializable, Serializable}; - -use crate::db::models::conv as conversions; -use crate::db::schema; - -// MODELS -// ================================================================================================ - -#[derive(Debug, Clone, Insertable)] -#[diesel(table_name = schema::chain_state)] -#[diesel(check_for_backend(diesel::sqlite::Sqlite))] -pub struct ChainStateInsert { - /// Singleton row ID. Always `0` to satisfy the `CHECK (id = 0)` constraint. - pub id: i32, - pub block_num: i64, - pub block_header: Vec, - pub chain_mmr: Vec, - pub genesis_commitment: Vec, -} - -#[derive(Debug, Clone, Queryable, Selectable)] -#[diesel(table_name = schema::chain_state)] -#[diesel(check_for_backend(diesel::sqlite::Sqlite))] -struct ChainStateRow { - block_num: i64, - block_header: Vec, - chain_mmr: Vec, -} - -// QUERIES -// ================================================================================================ - -/// Updates the tip columns (block number, header, and partial chain MMR) of the singleton chain -/// state row. The row is created once at bootstrap by [`insert_genesis_chain_state`], so this is a -/// plain update; the `genesis_commitment` column is set at bootstrap and never touched here. -/// -/// # Raw SQL -/// -/// ```sql -/// UPDATE chain_state -/// SET block_num = ?1, block_header = ?2, chain_mmr = ?3 -/// WHERE id = 0 -/// ``` -pub fn update_chain_state_tip( - conn: &mut SqliteConnection, - block_num: BlockNumber, - block_header: &BlockHeader, - chain_mmr: &PartialMmr, -) -> Result<(), DatabaseError> { - diesel::update(schema::chain_state::table.find(0i32)) - .set(( - schema::chain_state::block_num.eq(conversions::block_num_to_i64(block_num)), - schema::chain_state::block_header.eq(conversions::block_header_to_bytes(block_header)), - schema::chain_state::chain_mmr.eq(chain_mmr.to_bytes()), - )) - .execute(conn)?; - Ok(()) -} - -/// Inserts the singleton chain state row at bootstrap, seeding the tip columns from the genesis -/// block together with the genesis block commitment. The commitment satisfies the `NOT NULL` -/// constraint at insert time and is retained across all subsequent tip updates (see -/// [`update_chain_state_tip`]). -/// -/// # Raw SQL -/// -/// ```sql -/// INSERT INTO chain_state (id, block_num, block_header, chain_mmr, genesis_commitment) -/// VALUES (0, ?1, ?2, ?3, ?4) -/// ``` -pub fn insert_genesis_chain_state( - conn: &mut SqliteConnection, - genesis_block_header: &BlockHeader, - genesis_commitment: &Word, -) -> Result<(), DatabaseError> { - assert_eq!( - genesis_block_header.block_num(), - BlockNumber::GENESIS, - "bootstrap block number is not 0" - ); - let row = ChainStateInsert { - id: 0, - block_num: conversions::block_num_to_i64(genesis_block_header.block_num()), - block_header: conversions::block_header_to_bytes(genesis_block_header), - chain_mmr: PartialMmr::default().to_bytes(), - genesis_commitment: conversions::word_to_bytes(genesis_commitment), - }; - diesel::insert_into(schema::chain_state::table).values(&row).execute(conn)?; - Ok(()) -} - -/// Reads the genesis block commitment from the singleton chain state row. -/// -/// # Raw SQL -/// -/// ```sql -/// SELECT genesis_commitment FROM chain_state WHERE id = 0 -/// ``` -/// -/// # Errors -/// -/// - If the singleton chain state row does not exist (database not bootstrapped) -pub fn select_genesis_commitment(conn: &mut SqliteConnection) -> Result { - let commitment: Vec = schema::chain_state::table - .find(0i32) - .select(schema::chain_state::genesis_commitment) - .first(conn)?; - - Word::read_from_bytes(&commitment) - .map_err(|e| DatabaseError::deserialization("genesis commitment", e)) -} - -/// Reads the singleton chain state row, returning the persisted block number, header, and chain -/// MMR if any block has been applied locally. -/// -/// # Raw SQL -/// -/// ```sql -/// SELECT block_num, block_header, chain_mmr FROM chain_state WHERE id = 0 -/// ``` -pub fn select_chain_state( - conn: &mut SqliteConnection, -) -> Result, DatabaseError> { - let row: Option = schema::chain_state::table - .find(0i32) - .select(ChainStateRow::as_select()) - .first(conn) - .optional()?; - - row.map(|ChainStateRow { block_num, block_header, chain_mmr }| { - let block_num = conversions::block_num_from_i64(block_num); - let header = BlockHeader::read_from_bytes(&block_header) - .map_err(|e| DatabaseError::deserialization("block header", e))?; - let mmr = PartialMmr::read_from_bytes(&chain_mmr) - .map_err(|e| DatabaseError::deserialization("chain mmr", e))?; - Ok((block_num, header, mmr)) - }) - .transpose() -} diff --git a/bin/ntx-builder/src/db/models/queries/note_scripts.rs b/bin/ntx-builder/src/db/models/queries/note_scripts.rs deleted file mode 100644 index 09c03e4c1e..0000000000 --- a/bin/ntx-builder/src/db/models/queries/note_scripts.rs +++ /dev/null @@ -1,56 +0,0 @@ -//! Database queries for persisting and retrieving note scripts. - -use diesel::prelude::*; -use miden_node_db::DatabaseError; -use miden_protocol::Word; -use miden_protocol::note::NoteScript; - -use crate::db::models::conv as conversions; -use crate::db::schema; - -#[derive(Insertable)] -#[diesel(table_name = schema::note_scripts)] -struct NoteScriptInsert { - script_root: Vec, - script_data: Vec, -} - -#[derive(Queryable, Selectable)] -#[diesel(table_name = schema::note_scripts)] -struct NoteScriptRow { - script_data: Vec, -} - -/// Looks up a note script by its root hash. -pub fn lookup_note_script( - conn: &mut SqliteConnection, - script_root: &Word, -) -> Result, DatabaseError> { - let root_bytes = conversions::word_to_bytes(script_root); - - let row: Option = schema::note_scripts::table - .find(root_bytes) - .select(NoteScriptRow::as_select()) - .first(conn) - .optional()?; - - row.map(|r| conversions::note_script_from_bytes(&r.script_data)).transpose() -} - -/// Inserts a note script (idempotent via INSERT OR IGNORE). -pub fn insert_note_script( - conn: &mut SqliteConnection, - script_root: &Word, - script: &NoteScript, -) -> Result<(), DatabaseError> { - let insert = NoteScriptInsert { - script_root: conversions::word_to_bytes(script_root), - script_data: conversions::note_script_to_bytes(script), - }; - - diesel::insert_or_ignore_into(schema::note_scripts::table) - .values(&insert) - .execute(conn)?; - - Ok(()) -} diff --git a/bin/ntx-builder/src/db/models/queries/tests.rs b/bin/ntx-builder/src/db/models/queries/tests.rs deleted file mode 100644 index 6934c82799..0000000000 --- a/bin/ntx-builder/src/db/models/queries/tests.rs +++ /dev/null @@ -1,394 +0,0 @@ -//! DB-level tests for the committed-block-driven query layer. - -use std::sync::Arc; - -use diesel::prelude::*; -use miden_protocol::Word; -use miden_protocol::block::BlockNumber; -use miden_protocol::crypto::merkle::mmr::PartialMmr; - -use super::*; -use crate::NoteError; -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())) -} - -/// Creates a file-backed SQLite connection with migrations applied. -fn test_conn() -> (SqliteConnection, tempfile::TempDir) { - Db::test_conn() -} - -/// Counts the total number of rows in the `notes` table. -fn count_notes(conn: &mut SqliteConnection) -> i64 { - schema::notes::table.count().get_result(conn).unwrap() -} - -/// Counts the total number of rows in the `accounts` table. -fn count_accounts(conn: &mut SqliteConnection) -> i64 { - schema::accounts::table.count().get_result(conn).unwrap() -} - -// ACCOUNT UPSERT -// ================================================================================================ - -#[test] -fn upsert_account_replaces_existing_row() { - let (conn, _dir) = &mut test_conn(); - let account_id = mock_network_account_id(); - let account = mock_account(account_id); - - upsert_account(conn, account_id, &account, mock_transaction_id(1)).unwrap(); - upsert_account(conn, account_id, &account, mock_transaction_id(2)).unwrap(); - - assert_eq!(count_accounts(conn), 1, "second upsert must overwrite, not insert"); - assert!(get_account(conn, account_id).unwrap().is_some()); -} - -// NETWORK NOTE INSERT/DELETE -// ================================================================================================ - -#[test] -fn insert_network_notes_is_idempotent() { - let (conn, _dir) = &mut test_conn(); - let account_id = mock_network_account_id(); - let note = mock_single_target_note(account_id, 7); - - insert_network_notes(conn, std::slice::from_ref(¬e)).unwrap(); - // Re-applying the same block (e.g. on a subscription redelivery) must not error or duplicate. - insert_network_notes(conn, std::slice::from_ref(¬e)).unwrap(); - - assert_eq!(count_notes(conn), 1); -} - -#[test] -fn mark_notes_consumed_keeps_rows_and_sets_committed_at() { - let (conn, _dir) = &mut test_conn(); - let account_id = mock_network_account_id(); - let note_a = mock_single_target_note(account_id, 1); - let note_b = mock_single_target_note(account_id, 2); - - insert_network_notes(conn, &[note_a.clone(), note_b.clone()]).unwrap(); - assert_eq!(count_notes(conn), 2); - - let consumed_at = BlockNumber::from(42); - mark_notes_consumed(conn, &[note_a.as_note().nullifier()], consumed_at).unwrap(); - - // Both rows are still present so the gRPC status endpoint can report them. - assert_eq!(count_notes(conn), 2); - - let status_a = - get_note_status(conn, &crate::db::models::conv::note_id_to_bytes(¬e_a.as_note().id())) - .unwrap() - .unwrap(); - assert_eq!(status_a.committed_at, Some(i64::from(consumed_at.as_u32()))); - - let status_b = - get_note_status(conn, &crate::db::models::conv::note_id_to_bytes(¬e_b.as_note().id())) - .unwrap() - .unwrap(); - assert!(status_b.committed_at.is_none()); -} - -#[test] -fn mark_notes_consumed_is_noop_when_unknown() { - let (conn, _dir) = &mut test_conn(); - let account_id = mock_network_account_id(); - let note = mock_single_target_note(account_id, 3); - insert_network_notes(conn, std::slice::from_ref(¬e)).unwrap(); - - // A nullifier we never inserted should not affect existing rows. - let phantom = mock_single_target_note(account_id, 99).as_note().nullifier(); - mark_notes_consumed(conn, &[phantom], BlockNumber::from(5)).unwrap(); - - assert_eq!(count_notes(conn), 1); - let status = - get_note_status(conn, &crate::db::models::conv::note_id_to_bytes(¬e.as_note().id())) - .unwrap() - .unwrap(); - assert!(status.committed_at.is_none()); -} - -#[test] -fn available_notes_excludes_consumed_notes() { - let (conn, _dir) = &mut test_conn(); - let account_id = mock_network_account_id(); - let note = mock_single_target_note(account_id, 21); - insert_network_notes(conn, std::slice::from_ref(¬e)).unwrap(); - - assert_eq!(available_notes(conn, account_id, BlockNumber::from(1), 30).unwrap().len(), 1); - - mark_notes_consumed(conn, &[note.as_note().nullifier()], BlockNumber::from(7)).unwrap(); - - assert!( - available_notes(conn, account_id, BlockNumber::from(1000), 30) - .unwrap() - .is_empty() - ); -} - -// AVAILABLE NOTES + BACKOFF -// ================================================================================================ - -#[test] -fn available_notes_returns_unconsumed_under_attempt_cap() { - let (conn, _dir) = &mut test_conn(); - let account_id = mock_network_account_id(); - let note = mock_single_target_note(account_id, 11); - insert_network_notes(conn, std::slice::from_ref(¬e)).unwrap(); - - let available = available_notes(conn, account_id, BlockNumber::from(1), 30).unwrap(); - assert_eq!(available.len(), 1); -} - -#[test] -fn available_notes_excludes_attempts_at_cap() { - let (conn, _dir) = &mut test_conn(); - let account_id = mock_network_account_id(); - let note = mock_single_target_note(account_id, 13); - insert_network_notes(conn, std::slice::from_ref(¬e)).unwrap(); - - // Push attempt_count up to the cap. - let nullifier = note.as_note().nullifier(); - for _ in 0..30 { - notes_failed(conn, &[(nullifier, test_note_error("boom"))], BlockNumber::from(5)).unwrap(); - } - - let available = available_notes(conn, account_id, BlockNumber::from(1000), 30).unwrap(); - assert!(available.is_empty(), "notes at the attempt cap should not be available"); -} - -// CHAIN STATE -// ================================================================================================ - -#[test] -fn update_chain_state_tip_persists_and_roundtrips_mmr() { - let (conn, _dir) = &mut test_conn(); - let genesis = mock_block_header(BlockNumber::GENESIS); - let header = mock_block_header(BlockNumber::from(7)); - let mmr = PartialMmr::default(); - - insert_genesis_chain_state(conn, &genesis, &genesis.commitment()).unwrap(); - update_chain_state_tip(conn, header.block_num(), &header, &mmr).unwrap(); - - let (loaded_num, loaded_header, _loaded_mmr) = select_chain_state(conn).unwrap().unwrap(); - assert_eq!(loaded_num, header.block_num()); - assert_eq!(loaded_header.block_num(), header.block_num()); -} - -#[test] -fn update_chain_state_tip_keeps_singleton() { - let (conn, _dir) = &mut test_conn(); - let genesis = mock_block_header(BlockNumber::GENESIS); - let header_1 = mock_block_header(BlockNumber::from(1)); - let header_2 = mock_block_header(BlockNumber::from(2)); - let mmr = PartialMmr::default(); - - insert_genesis_chain_state(conn, &genesis, &genesis.commitment()).unwrap(); - update_chain_state_tip(conn, header_1.block_num(), &header_1, &mmr).unwrap(); - update_chain_state_tip(conn, header_2.block_num(), &header_2, &mmr).unwrap(); - - let (loaded_num, ..) = select_chain_state(conn).unwrap().unwrap(); - assert_eq!(loaded_num, header_2.block_num()); - - let row_count: i64 = schema::chain_state::table.count().get_result(conn).unwrap(); - assert_eq!(row_count, 1, "chain_state must remain a singleton"); -} - -#[test] -fn select_chain_state_returns_none_on_fresh_db() { - let (conn, _dir) = &mut test_conn(); - assert!(select_chain_state(conn).unwrap().is_none()); -} - -// NOTE SCRIPT CACHE -// ================================================================================================ - -#[test] -fn note_script_cache_roundtrip() { - let (conn, _dir) = &mut test_conn(); - let account_id = mock_network_account_id(); - let note = mock_single_target_note(account_id, 17); - let script = note.as_note().script().clone(); - let root: Word = script.root().into(); - - assert!(lookup_note_script(conn, &root).unwrap().is_none()); - insert_note_script(conn, &root, &script).unwrap(); - assert!(lookup_note_script(conn, &root).unwrap().is_some()); - - // Re-insert is idempotent. - insert_note_script(conn, &root, &script).unwrap(); -} - -// NOTE STATUS -// ================================================================================================ - -// ACCOUNTS WITH PENDING NOTES -// ================================================================================================ - -#[test] -fn accounts_with_pending_notes_distinct_and_filters_consumed_and_capped() { - let (conn, _dir) = &mut test_conn(); - let alice = mock_network_account_id(); - let bob = mock_network_account_id_seeded(42); - let carol = mock_network_account_id_seeded(99); - - let alice_note_1 = mock_single_target_note(alice, 1); - let alice_note_2 = mock_single_target_note(alice, 2); - let bob_note = mock_single_target_note(bob, 3); - let carol_note = mock_single_target_note(carol, 4); - - insert_network_notes( - conn, - &[alice_note_1.clone(), alice_note_2, bob_note.clone(), carol_note.clone()], - ) - .unwrap(); - - // Alice has two notes — must still appear exactly once (DISTINCT). Bob's only note is already - // consumed — exclude. - mark_notes_consumed(conn, &[bob_note.as_note().nullifier()], BlockNumber::from(7)).unwrap(); - // Carol's note has hit the attempt cap — exclude. - for _ in 0..30 { - notes_failed( - conn, - &[(carol_note.as_note().nullifier(), test_note_error("boom"))], - BlockNumber::from(5), - ) - .unwrap(); - } - - let pending = accounts_with_pending_notes(conn, 30).unwrap(); - assert_eq!(pending.len(), 1, "only alice should remain pending"); - assert_eq!(pending[0], alice); -} - -// SUBMITTED-TX LANDING -// ================================================================================================ - -#[test] -fn account_last_tx_roundtrips_and_updates() { - let (conn, _dir) = &mut test_conn(); - let account_id = mock_network_account_id(); - let account = mock_account(account_id); - - // The first upsert records its transaction id; a later upsert overwrites it. - let first = mock_transaction_id(1); - let second = mock_transaction_id(2); - upsert_account(conn, account_id, &account, first).unwrap(); - assert_eq!(account_last_tx(conn, account_id).unwrap(), Some(first)); - upsert_account(conn, account_id, &account, second).unwrap(); - assert_eq!(account_last_tx(conn, account_id).unwrap(), Some(second)); -} - -#[test] -fn account_last_tx_returns_none_for_untracked_account() { - let (conn, _dir) = &mut test_conn(); - let account_id = mock_network_account_id(); - - // No row exists for this account. - assert_eq!(account_last_tx(conn, account_id).unwrap(), None); -} - -// GENESIS APPLICATION -// ================================================================================================ - -/// Builds genesis-shaped effects: a full-state network-account update with no originating -/// transactions, at [`BlockNumber::GENESIS`]. -fn genesis_effects() -> CommittedBlockEffects { - let (account, details) = mock_network_account_update(); - CommittedBlockEffects { - header: mock_block_header(BlockNumber::GENESIS), - network_notes: vec![], - nullifiers: vec![], - network_account_updates: vec![(account.id(), details)], - account_transactions: vec![], - } -} - -#[test] -fn apply_committed_block_seeds_genesis_network_account() { - let (conn, _dir) = &mut test_conn(); - let effects = genesis_effects(); - let account_id = effects.network_account_updates[0].0; - - // Genesis has no transactions, so this used to panic on the "must originate from a transaction" - // invariant. It must now bootstrap the account successfully. - apply_committed_block(conn, &effects, &PartialMmr::default()).unwrap(); - - assert!( - get_account(conn, account_id).unwrap().is_some(), - "genesis account should be seeded" - ); - // The seeded account carries the zero sentinel: no transaction produced it. An actor never - // submits the zero id, so this can never be mistaken for a landed transaction. - assert_eq!( - account_last_tx(conn, account_id).unwrap(), - Some(TransactionId::from_raw(Word::empty())), - ); -} - -#[test] -#[should_panic(expected = "must originate from a transaction")] -fn apply_committed_block_panics_on_txless_update_after_genesis() { - let (conn, _dir) = &mut test_conn(); - // Same shape as genesis but at a non-genesis height: a committed account update with no - // originating transaction is a real block-producer invariant violation and must still panic. - let mut effects = genesis_effects(); - effects.header = mock_block_header(BlockNumber::from(1)); - - apply_committed_block(conn, &effects, &PartialMmr::default()).unwrap(); -} - -#[test] -fn notes_failed_increments_attempt_and_records_error() { - let (conn, _dir) = &mut test_conn(); - let account_id = mock_network_account_id(); - let note = mock_single_target_note(account_id, 19); - insert_network_notes(conn, std::slice::from_ref(¬e)).unwrap(); - - let nullifier = note.as_note().nullifier(); - notes_failed(conn, &[(nullifier, test_note_error("nope"))], BlockNumber::from(5)).unwrap(); - notes_failed(conn, &[(nullifier, test_note_error("nope"))], BlockNumber::from(6)).unwrap(); - - let row = - get_note_status(conn, &crate::db::models::conv::note_id_to_bytes(¬e.as_note().id())) - .unwrap() - .unwrap(); - assert_eq!(row.attempt_count, 2); - assert_eq!(row.last_attempt, Some(6)); - assert!(row.last_error.is_some()); -} - -#[test] -fn discard_notes_pins_attempts_to_cap_and_drops_from_pending() { - let (conn, _dir) = &mut test_conn(); - let account_id = mock_network_account_id(); - let note = mock_single_target_note(account_id, 23); - insert_network_notes(conn, std::slice::from_ref(¬e)).unwrap(); - - let nullifier = note.as_note().nullifier(); - discard_notes(conn, &[nullifier], BlockNumber::from(9), 30, "too big").unwrap(); - - // Pinned to the cap, so it is no longer pending or available for selection. - let row = - get_note_status(conn, &crate::db::models::conv::note_id_to_bytes(¬e.as_note().id())) - .unwrap() - .unwrap(); - assert_eq!(row.attempt_count, 30); - assert_eq!(row.last_attempt, Some(9)); - assert_eq!(row.last_error.as_deref(), Some("too big")); - - assert!( - available_notes(conn, account_id, BlockNumber::from(1000), 30) - .unwrap() - .is_empty(), - "a discarded note must not be selectable", - ); - assert!( - !accounts_with_pending_notes(conn, 30).unwrap().contains(&account_id), - "an account whose only note was discarded must not count as pending", - ); -} diff --git a/bin/ntx-builder/src/db/models/account_effect.rs b/bin/ntx-builder/src/db/queries/account_effect.rs similarity index 100% rename from bin/ntx-builder/src/db/models/account_effect.rs rename to bin/ntx-builder/src/db/queries/account_effect.rs diff --git a/bin/ntx-builder/src/db/queries/accounts.rs b/bin/ntx-builder/src/db/queries/accounts.rs new file mode 100644 index 0000000000..b3e1f4a668 --- /dev/null +++ b/bin/ntx-builder/src/db/queries/accounts.rs @@ -0,0 +1,52 @@ +//! Account-related queries. + +use miden_node_db::DatabaseError; +use miden_node_db::sqlite::{ReadTx, WriteTx}; +use miden_protocol::account::{Account, AccountId}; +use miden_protocol::transaction::TransactionId; + +use crate::db::sql; + +/// Inserts the committed account state, or updates an existing account's state. In both cases +/// `last_tx_id` is set to the transaction that produced this update. +pub fn upsert_account( + tx: &WriteTx<'_>, + account_id: AccountId, + account: &Account, + last_tx_id: TransactionId, +) -> Result<(), DatabaseError> { + tx.execute(sql::UPSERT_ACCOUNT, &[&account_id, account, &last_tx_id])?; + Ok(()) +} + +/// Returns the latest transaction recorded against `account_id`, or `None` if the account is not +/// tracked locally. +pub fn account_last_tx( + tx: &ReadTx<'_>, + account_id: AccountId, +) -> Result, DatabaseError> { + Ok(tx + .query(sql::ACCOUNT_LAST_TX, &[&account_id], |row| row.get::(0))? + .into_iter() + .next()) +} + +/// Returns `true` if a committed state for the given account is tracked locally. +pub fn account_exists(tx: &ReadTx<'_>, account_id: AccountId) -> Result { + Ok(tx + .query(sql::ACCOUNT_EXISTS, &[&account_id], |row| row.get::(0))? + .into_iter() + .next() + .unwrap_or(false)) +} + +/// Returns the committed account state for the given network account. +pub fn get_account( + tx: &ReadTx<'_>, + account_id: AccountId, +) -> Result, DatabaseError> { + Ok(tx + .query(sql::GET_ACCOUNT, &[&account_id], |row| row.get::(0))? + .into_iter() + .next()) +} diff --git a/bin/ntx-builder/src/db/queries/chain_state.rs b/bin/ntx-builder/src/db/queries/chain_state.rs new file mode 100644 index 0000000000..8c991c7506 --- /dev/null +++ b/bin/ntx-builder/src/db/queries/chain_state.rs @@ -0,0 +1,78 @@ +//! Chain state queries. + +use miden_node_db::DatabaseError; +use miden_node_db::sqlite::{ReadTx, WriteTx}; +use miden_protocol::Word; +use miden_protocol::block::{BlockHeader, BlockNumber}; +use miden_protocol::crypto::merkle::mmr::PartialMmr; + +use crate::db::queries::{block_num_from_i64, block_num_to_i64}; +use crate::db::sql; + +/// Updates the tip columns (block number, header, and partial chain MMR) of the singleton chain +/// state row. The row is created once at bootstrap by [`insert_genesis_chain_state`], so this is a +/// plain update; the `genesis_commitment` column is set at bootstrap and never touched here. +pub fn update_chain_state_tip( + tx: &WriteTx<'_>, + block_num: BlockNumber, + block_header: &BlockHeader, + chain_mmr: &PartialMmr, +) -> Result<(), DatabaseError> { + tx.execute( + sql::UPDATE_CHAIN_STATE_TIP, + &[&block_num_to_i64(block_num), block_header, chain_mmr], + )?; + Ok(()) +} + +/// Inserts the singleton chain state row at bootstrap, seeding the tip columns from the genesis +/// block together with the genesis block commitment. The commitment satisfies the `NOT NULL` +/// constraint at insert time and is retained across all subsequent tip updates (see +/// [`update_chain_state_tip`]). +pub fn insert_genesis_chain_state( + tx: &WriteTx<'_>, + genesis_block_header: &BlockHeader, + genesis_commitment: &Word, +) -> Result<(), DatabaseError> { + assert_eq!( + genesis_block_header.block_num(), + BlockNumber::GENESIS, + "bootstrap block number is not 0" + ); + tx.execute( + sql::INSERT_GENESIS_CHAIN_STATE, + &[ + &block_num_to_i64(genesis_block_header.block_num()), + genesis_block_header, + &PartialMmr::default(), + genesis_commitment, + ], + )?; + Ok(()) +} + +/// Reads the genesis block commitment from the singleton chain state row, or `None` if the database +/// has not been bootstrapped. +pub fn select_genesis_commitment(tx: &ReadTx<'_>) -> Result, DatabaseError> { + Ok(tx + .query(sql::SELECT_GENESIS_COMMITMENT, &[], |row| row.get::(0))? + .into_iter() + .next()) +} + +/// Reads the singleton chain state row, returning the persisted block number, header, and chain MMR +/// if any block has been applied locally. +pub fn select_chain_state( + tx: &ReadTx<'_>, +) -> Result, DatabaseError> { + Ok(tx + .query(sql::SELECT_CHAIN_STATE, &[], |row| { + Ok(( + block_num_from_i64(row.get::(0)?), + row.get::(1)?, + row.get::(2)?, + )) + })? + .into_iter() + .next()) +} diff --git a/bin/ntx-builder/src/db/models/queries/mod.rs b/bin/ntx-builder/src/db/queries/mod.rs similarity index 69% rename from bin/ntx-builder/src/db/models/queries/mod.rs rename to bin/ntx-builder/src/db/queries/mod.rs index 6566f2a9e4..2a149d74fd 100644 --- a/bin/ntx-builder/src/db/models/queries/mod.rs +++ b/bin/ntx-builder/src/db/queries/mod.rs @@ -1,17 +1,24 @@ //! Database query functions for the NTX builder. +//! +//! Each function takes a [`ReadTx`](miden_node_db::sqlite::ReadTx) or +//! [`WriteTx`](miden_node_db::sqlite::WriteTx) and is driven from a call site through +//! [`Database::read`](miden_node_db::sqlite::Database::read) / +//! [`Database::write`](miden_node_db::sqlite::Database::write). use std::collections::HashMap; -use diesel::prelude::*; use miden_node_db::DatabaseError; +use miden_node_db::sqlite::WriteTx; use miden_protocol::Word; use miden_protocol::account::AccountId; use miden_protocol::block::BlockNumber; use miden_protocol::crypto::merkle::mmr::PartialMmr; use miden_protocol::transaction::TransactionId; -use super::account_effect::NetworkAccountEffect; use crate::committed_block::CommittedBlockEffects; +use crate::db::queries::account_effect::NetworkAccountEffect; + +pub(crate) mod account_effect; mod accounts; pub use accounts::*; @@ -28,6 +35,22 @@ pub use notes::*; #[cfg(test)] mod tests; +// BLOCK NUMBER CODEC +// ================================================================================================ + +/// Serializes a [`BlockNumber`] to the `i64` used by the `block_num`/`committed_at`/`last_attempt` +/// columns. +pub(crate) fn block_num_to_i64(block_num: BlockNumber) -> i64 { + i64::from(block_num.as_u32()) +} + +/// Deserializes a `block_num`/`committed_at`/`last_attempt` column value back into a +/// [`BlockNumber`]. +#[expect(clippy::cast_sign_loss)] +pub(crate) fn block_num_from_i64(val: i64) -> BlockNumber { + BlockNumber::from(val as u32) +} + // COMMITTED BLOCK APPLICATION // ================================================================================================ @@ -45,7 +68,7 @@ mod tests; /// The account upserts apply each block's network-account effects to the local store so the actor's /// `account_last_tx` landing check and post-expiry reload see the authoritative committed state. pub fn apply_committed_block( - conn: &mut SqliteConnection, + tx: &WriteTx<'_>, effects: &CommittedBlockEffects, chain_mmr: &PartialMmr, ) -> Result<(), DatabaseError> { @@ -72,26 +95,26 @@ pub fn apply_committed_block( }); match effect { NetworkAccountEffect::Created(account) => { - upsert_account(conn, *account_id, &account, last_tx_id)?; + upsert_account(tx, *account_id, &account, last_tx_id)?; }, NetworkAccountEffect::Updated(patch) => { // If the account is not already tracked locally, skip it. - let Some(mut current) = get_account(conn, *account_id)? else { + let Some(mut current) = get_account(tx, *account_id)? else { continue; }; current .apply_patch(&patch) .expect("network account patch should apply since the block was committed"); - upsert_account(conn, *account_id, ¤t, last_tx_id)?; + upsert_account(tx, *account_id, ¤t, last_tx_id)?; }, } } - insert_network_notes(conn, &effects.network_notes)?; + insert_network_notes(tx, &effects.network_notes)?; - mark_notes_consumed(conn, &effects.nullifiers, effects.header.block_num())?; + mark_notes_consumed(tx, &effects.nullifiers, effects.header.block_num())?; - update_chain_state_tip(conn, effects.header.block_num(), &effects.header, chain_mmr)?; + update_chain_state_tip(tx, effects.header.block_num(), &effects.header, chain_mmr)?; Ok(()) } diff --git a/bin/ntx-builder/src/db/queries/note_scripts.rs b/bin/ntx-builder/src/db/queries/note_scripts.rs new file mode 100644 index 0000000000..0fabf9376d --- /dev/null +++ b/bin/ntx-builder/src/db/queries/note_scripts.rs @@ -0,0 +1,29 @@ +//! Queries for persisting and retrieving note scripts. + +use miden_node_db::DatabaseError; +use miden_node_db::sqlite::{ReadTx, WriteTx}; +use miden_protocol::Word; +use miden_protocol::note::NoteScript; + +use crate::db::sql; + +/// Looks up a note script by its root hash. +pub fn lookup_note_script( + tx: &ReadTx<'_>, + script_root: &Word, +) -> Result, DatabaseError> { + Ok(tx + .query(sql::LOOKUP_NOTE_SCRIPT, &[script_root], |row| row.get::(0))? + .into_iter() + .next()) +} + +/// Inserts a note script (idempotent via INSERT OR IGNORE). +pub fn insert_note_script( + tx: &WriteTx<'_>, + script_root: &Word, + script: &NoteScript, +) -> Result<(), DatabaseError> { + tx.execute(sql::INSERT_NOTE_SCRIPT, &[script_root, script])?; + Ok(()) +} diff --git a/bin/ntx-builder/src/db/models/queries/notes.rs b/bin/ntx-builder/src/db/queries/notes.rs similarity index 53% rename from bin/ntx-builder/src/db/models/queries/notes.rs rename to bin/ntx-builder/src/db/queries/notes.rs index 6279d2e62b..8e615b531b 100644 --- a/bin/ntx-builder/src/db/models/queries/notes.rs +++ b/bin/ntx-builder/src/db/queries/notes.rs @@ -1,79 +1,39 @@ -//! Note-related queries and models. +//! Note-related queries. -use diesel::prelude::*; use miden_node_db::DatabaseError; +use miden_node_db::sqlite::{ReadTx, WriteTx}; +use miden_node_utils::ErrorReport; use miden_protocol::account::AccountId; use miden_protocol::block::BlockNumber; -use miden_protocol::note::{Note, Nullifier}; -use miden_protocol::utils::serde::{Deserializable, Serializable}; +use miden_protocol::note::{Note, NoteId, Nullifier}; use miden_standards::note::AccountTargetNetworkNote; use crate::NoteError; -use crate::db::models::conv as conversions; -use crate::db::schema; +use crate::db::queries::{block_num_from_i64, block_num_to_i64}; +use crate::db::sql; -// MODELS -// ================================================================================================ - -/// Row read from `notes`. -#[derive(Debug, Clone, Queryable, Selectable)] -#[diesel(table_name = schema::notes)] -#[diesel(check_for_backend(diesel::sqlite::Sqlite))] -pub struct NoteRow { - pub note_data: Vec, - pub attempt_count: i32, - pub last_attempt: Option, -} - -/// Row for inserting into `notes`. -#[derive(Debug, Clone, Insertable)] -#[diesel(table_name = schema::notes)] -#[diesel(check_for_backend(diesel::sqlite::Sqlite))] -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 committed_at: Option, -} - -/// Row returned by `get_note_status()`. -#[derive(Debug, Clone, Queryable, Selectable)] -#[diesel(table_name = schema::notes)] -#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +/// Row returned by [`get_note_status`]. +#[derive(Debug, Clone)] pub struct NoteStatusRow { - pub note_id: Option>, pub last_error: Option, - pub attempt_count: i32, + pub attempt_count: i64, pub last_attempt: Option, pub committed_at: Option, } -// QUERIES -// ================================================================================================ - /// Inserts network notes from a committed block. Uses `INSERT OR IGNORE` so re-applying the same /// block (e.g. on a redelivery from the subscription stream) is a no-op rather than a constraint /// violation. pub fn insert_network_notes( - conn: &mut SqliteConnection, + tx: &WriteTx<'_>, notes: &[AccountTargetNetworkNote], ) -> Result<(), DatabaseError> { for note in notes { - let row = NoteInsert { - nullifier: conversions::nullifier_to_bytes(¬e.as_note().nullifier()), - account_id: conversions::account_id_to_bytes(note.target_account_id()), - 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, - committed_at: None, - }; - diesel::insert_or_ignore_into(schema::notes::table).values(&row).execute(conn)?; + let inner = note.as_note(); + tx.execute( + sql::INSERT_NETWORK_NOTE, + &[&inner.nullifier(), ¬e.target_account_id(), inner, &inner.id()], + )?; } Ok(()) } @@ -85,47 +45,50 @@ pub fn insert_network_notes( /// Rows are kept around (not deleted) so the `GetNetworkNoteStatus` endpoint can report the full /// lifecycle of any note the ntx-builder has ever seen. pub fn mark_notes_consumed( - conn: &mut SqliteConnection, + tx: &WriteTx<'_>, nullifiers: &[Nullifier], block_num: BlockNumber, ) -> Result<(), DatabaseError> { - let block_num_val = conversions::block_num_to_i64(block_num); + let block_num_val = block_num_to_i64(block_num); for nullifier in nullifiers { - let nullifier_bytes = conversions::nullifier_to_bytes(nullifier); - diesel::update(schema::notes::table.find(&nullifier_bytes)) - .filter(schema::notes::committed_at.is_null()) - .set(schema::notes::committed_at.eq(Some(block_num_val))) - .execute(conn)?; + tx.execute(sql::MARK_NOTE_CONSUMED, &[nullifier, &block_num_val])?; } Ok(()) } +/// Returns `true` if there is at least one note available for consumption by the given account. +pub fn has_available_notes( + tx: &ReadTx<'_>, + account_id: AccountId, + block_num: BlockNumber, + max_attempts: usize, +) -> Result { + Ok(!available_notes(tx, account_id, block_num, max_attempts)?.is_empty()) +} + /// Returns notes available for consumption by a given account. /// /// Selects unconsumed notes for the account (a row exists only while a note is unconsumed) whose /// `attempt_count` is below the cap, then applies execution-hint and backoff filtering in Rust. #[expect(clippy::cast_possible_wrap)] pub fn available_notes( - conn: &mut SqliteConnection, + tx: &ReadTx<'_>, account_id: AccountId, block_num: BlockNumber, max_attempts: usize, ) -> Result, DatabaseError> { - let account_id_bytes = conversions::account_id_to_bytes(account_id); - - let rows: Vec = schema::notes::table - .filter(schema::notes::account_id.eq(&account_id_bytes)) - .filter(schema::notes::committed_at.is_null()) - .filter(schema::notes::attempt_count.lt(max_attempts as i32)) - .select(NoteRow::as_select()) - .load(conn)?; + let rows = tx.query(sql::AVAILABLE_NOTES, &[&account_id, &(max_attempts as i64)], |row| { + Ok((row.get::(0)?, row.get::(1)?, row.get::>(2)?)) + })?; let mut result = Vec::new(); - for row in rows { + for (note, attempt_count, last_attempt) in rows { #[expect(clippy::cast_sign_loss)] - let attempt_count = row.attempt_count as usize; - let last_attempt = row.last_attempt.map(conversions::block_num_from_i64); - let note = deserialize_note(&row.note_data)?; + let attempt_count = attempt_count as usize; + let last_attempt = last_attempt.map(block_num_from_i64); + let note = AccountTargetNetworkNote::new(note).map_err(|source| { + DatabaseError::deserialization("failed to convert to network note", source) + })?; let execution_hint_ok = note.execution_hint().can_be_consumed(block_num).unwrap_or(true); if execution_hint_ok && has_backoff_passed(block_num, last_attempt, attempt_count) { @@ -139,23 +102,15 @@ pub fn available_notes( /// Marks notes as failed by incrementing `attempt_count`, setting `last_attempt`, and storing the /// latest error message. pub fn notes_failed( - conn: &mut SqliteConnection, + tx: &WriteTx<'_>, failed_notes: &[(Nullifier, NoteError)], block_num: BlockNumber, ) -> Result<(), DatabaseError> { - let block_num_val = conversions::block_num_to_i64(block_num); + let block_num_val = 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_report)), - )) - .execute(conn)?; + tx.execute(sql::NOTE_FAILED, &[nullifier, &block_num_val, &error_report])?; } Ok(()) } @@ -169,71 +124,56 @@ pub fn notes_failed( /// `last_error` records why. #[expect(clippy::cast_possible_wrap)] pub fn discard_notes( - conn: &mut SqliteConnection, + tx: &WriteTx<'_>, nullifiers: &[Nullifier], block_num: BlockNumber, max_attempts: usize, reason: &str, ) -> Result<(), DatabaseError> { - let block_num_val = conversions::block_num_to_i64(block_num); + let block_num_val = block_num_to_i64(block_num); + let reason = reason.to_string(); for nullifier in nullifiers { - let nullifier_bytes = conversions::nullifier_to_bytes(nullifier); - diesel::update(schema::notes::table.find(&nullifier_bytes)) - .set(( - schema::notes::attempt_count.eq(max_attempts as i32), - schema::notes::last_attempt.eq(Some(block_num_val)), - schema::notes::last_error.eq(Some(reason.to_string())), - )) - .execute(conn)?; + tx.execute( + sql::DISCARD_NOTE, + &[nullifier, &(max_attempts as i64), &block_num_val, &reason], + )?; } Ok(()) } /// Returns the status for a note identified by its note ID. pub fn get_note_status( - conn: &mut SqliteConnection, - note_id_bytes: &[u8], + tx: &ReadTx<'_>, + note_id: NoteId, ) -> Result, DatabaseError> { - schema::notes::table - .filter(schema::notes::note_id.eq(note_id_bytes)) - .select(NoteStatusRow::as_select()) - .first(conn) - .optional() - .map_err(Into::into) + Ok(tx + .query(sql::GET_NOTE_STATUS, &[¬e_id], |row| { + Ok(NoteStatusRow { + last_error: row.get::>(0)?, + attempt_count: row.get::(1)?, + last_attempt: row.get::>(2)?, + committed_at: row.get::>(3)?, + }) + })? + .into_iter() + .next()) } /// Returns the distinct set of network accounts that currently have at least one pending note /// (unconsumed and within the per-note attempt budget). #[expect(clippy::cast_possible_wrap)] pub fn accounts_with_pending_notes( - conn: &mut SqliteConnection, + tx: &ReadTx<'_>, max_attempts: usize, ) -> Result, DatabaseError> { - let account_id_blobs: Vec> = schema::notes::table - .filter(schema::notes::committed_at.is_null()) - .filter(schema::notes::attempt_count.lt(max_attempts as i32)) - .select(schema::notes::account_id) - .distinct() - .load(conn)?; - - account_id_blobs - .iter() - .map(|bytes| conversions::account_id_from_bytes(bytes)) - .collect() + tx.query(sql::ACCOUNTS_WITH_PENDING_NOTES, &[&(max_attempts as i64)], |row| { + row.get::(0) + }) } // HELPERS // ================================================================================================ -/// Deserializes an [`AccountTargetNetworkNote`] from raw note bytes. -fn deserialize_note(note_data: &[u8]) -> Result { - let note = Note::read_from_bytes(note_data) - .map_err(|source| DatabaseError::deserialization("failed to parse note", source))?; - AccountTargetNetworkNote::new(note).map_err(|source| { - DatabaseError::deserialization("failed to convert to network note", source) - }) -} - /// Checks if the backoff block period has passed. /// /// The number of blocks passed since the last attempt must be greater than or equal to diff --git a/bin/ntx-builder/src/db/queries/tests.rs b/bin/ntx-builder/src/db/queries/tests.rs new file mode 100644 index 0000000000..ef3c4ac3e4 --- /dev/null +++ b/bin/ntx-builder/src/db/queries/tests.rs @@ -0,0 +1,522 @@ +//! DB-level tests for the committed-block-driven query layer. +//! +//! Each query runs through the framework's [`Database::read`]/[`Database::write`], so every write +//! commits before the following read observes it. + +use std::sync::Arc; + +use miden_node_db::DatabaseError; +use miden_node_db::sqlite::Database; +use miden_protocol::Word; +use miden_protocol::account::{Account, AccountId}; +use miden_protocol::block::{BlockHeader, BlockNumber}; +use miden_protocol::crypto::merkle::mmr::PartialMmr; +use miden_protocol::note::{NoteId, NoteScript, Nullifier}; +use miden_protocol::transaction::TransactionId; +use miden_standards::note::AccountTargetNetworkNote; + +use super::*; +use crate::NoteError; +use crate::committed_block::CommittedBlockEffects; +use crate::db::test_setup; +use crate::test_utils::*; + +// TEST HARNESS +// ================================================================================================ + +/// 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())) +} + +/// Counts the rows returned by a `SELECT COUNT(*)` statement. +async fn count(db: &Database, sql: &'static str) -> i64 { + db.read("count", move |tx| { + let n = tx.query(sql, &[], |row| row.get::(0))?.into_iter().next().unwrap_or(0); + Ok::(n) + }) + .await + .unwrap() +} + +async fn count_notes(db: &Database) -> i64 { + count(db, "SELECT COUNT(*) FROM notes").await +} + +async fn count_accounts(db: &Database) -> i64 { + count(db, "SELECT COUNT(*) FROM accounts").await +} + +async fn count_chain_state(db: &Database) -> i64 { + count(db, "SELECT COUNT(*) FROM chain_state").await +} + +async fn do_upsert_account( + db: &Database, + account_id: AccountId, + account: Account, + last_tx_id: TransactionId, +) { + db.write("upsert_account", move |tx| upsert_account(tx, account_id, &account, last_tx_id)) + .await + .unwrap(); +} + +async fn do_get_account(db: &Database, account_id: AccountId) -> Option { + db.read("get_account", move |tx| get_account(tx, account_id)).await.unwrap() +} + +async fn do_account_last_tx(db: &Database, account_id: AccountId) -> Option { + db.read("account_last_tx", move |tx| account_last_tx(tx, account_id)) + .await + .unwrap() +} + +async fn do_insert_notes(db: &Database, notes: Vec) { + db.write("insert_network_notes", move |tx| insert_network_notes(tx, ¬es)) + .await + .unwrap(); +} + +async fn do_mark_consumed(db: &Database, nullifiers: Vec, block_num: BlockNumber) { + db.write("mark_notes_consumed", move |tx| mark_notes_consumed(tx, &nullifiers, block_num)) + .await + .unwrap(); +} + +async fn do_available_notes( + db: &Database, + account_id: AccountId, + block_num: BlockNumber, + max_attempts: usize, +) -> Vec { + db.read("available_notes", move |tx| { + available_notes(tx, account_id, block_num, max_attempts) + }) + .await + .unwrap() +} + +async fn do_notes_failed( + db: &Database, + failed: Vec<(Nullifier, NoteError)>, + block_num: BlockNumber, +) { + db.write("notes_failed", move |tx| notes_failed(tx, &failed, block_num)) + .await + .unwrap(); +} + +async fn do_discard_notes( + db: &Database, + nullifiers: Vec, + block_num: BlockNumber, + max_attempts: usize, + reason: &str, +) { + let reason = reason.to_string(); + db.write("discard_notes", move |tx| { + discard_notes(tx, &nullifiers, block_num, max_attempts, &reason) + }) + .await + .unwrap(); +} + +async fn do_get_note_status(db: &Database, note_id: NoteId) -> Option { + db.read("get_note_status", move |tx| get_note_status(tx, note_id)) + .await + .unwrap() +} + +async fn do_pending_accounts(db: &Database, max_attempts: usize) -> Vec { + db.read("accounts_with_pending_notes", move |tx| { + accounts_with_pending_notes(tx, max_attempts) + }) + .await + .unwrap() +} + +async fn do_insert_genesis(db: &Database, header: BlockHeader, commitment: Word) { + db.write("insert_genesis_chain_state", move |tx| { + insert_genesis_chain_state(tx, &header, &commitment) + }) + .await + .unwrap(); +} + +async fn do_update_tip(db: &Database, header: BlockHeader, mmr: PartialMmr) { + let block_num = header.block_num(); + db.write("update_chain_state_tip", move |tx| { + update_chain_state_tip(tx, block_num, &header, &mmr) + }) + .await + .unwrap(); +} + +async fn do_select_chain_state(db: &Database) -> Option<(BlockNumber, BlockHeader, PartialMmr)> { + db.read("select_chain_state", select_chain_state).await.unwrap() +} + +async fn do_lookup_script(db: &Database, root: Word) -> Option { + db.read("lookup_note_script", move |tx| lookup_note_script(tx, &root)) + .await + .unwrap() +} + +async fn do_insert_script(db: &Database, root: Word, script: NoteScript) { + db.write("insert_note_script", move |tx| insert_note_script(tx, &root, &script)) + .await + .unwrap(); +} + +async fn try_apply_block( + db: &Database, + effects: CommittedBlockEffects, + mmr: PartialMmr, +) -> Result<(), DatabaseError> { + db.write("apply_committed_block", move |tx| apply_committed_block(tx, &effects, &mmr)) + .await +} + +// ACCOUNT UPSERT +// ================================================================================================ + +#[tokio::test] +async fn upsert_account_replaces_existing_row() { + let (db, _dir) = test_setup().await; + let account_id = mock_network_account_id(); + let account = mock_account(account_id); + + do_upsert_account(&db, account_id, account.clone(), mock_transaction_id(1)).await; + do_upsert_account(&db, account_id, account, mock_transaction_id(2)).await; + + assert_eq!(count_accounts(&db).await, 1, "second upsert must overwrite, not insert"); + assert!(do_get_account(&db, account_id).await.is_some()); +} + +// NETWORK NOTE INSERT/DELETE +// ================================================================================================ + +#[tokio::test] +async fn insert_network_notes_is_idempotent() { + let (db, _dir) = test_setup().await; + let account_id = mock_network_account_id(); + let note = mock_single_target_note(account_id, 7); + + do_insert_notes(&db, vec![note.clone()]).await; + // Re-applying the same block (e.g. on a subscription redelivery) must not error or duplicate. + do_insert_notes(&db, vec![note]).await; + + assert_eq!(count_notes(&db).await, 1); +} + +#[tokio::test] +async fn mark_notes_consumed_keeps_rows_and_sets_committed_at() { + let (db, _dir) = test_setup().await; + let account_id = mock_network_account_id(); + let note_a = mock_single_target_note(account_id, 1); + let note_b = mock_single_target_note(account_id, 2); + + do_insert_notes(&db, vec![note_a.clone(), note_b.clone()]).await; + assert_eq!(count_notes(&db).await, 2); + + let consumed_at = BlockNumber::from(42); + do_mark_consumed(&db, vec![note_a.as_note().nullifier()], consumed_at).await; + + // Both rows are still present so the gRPC status endpoint can report them. + assert_eq!(count_notes(&db).await, 2); + + let status_a = do_get_note_status(&db, note_a.as_note().id()).await.unwrap(); + assert_eq!(status_a.committed_at, Some(i64::from(consumed_at.as_u32()))); + + let status_b = do_get_note_status(&db, note_b.as_note().id()).await.unwrap(); + assert!(status_b.committed_at.is_none()); +} + +#[tokio::test] +async fn mark_notes_consumed_is_noop_when_unknown() { + let (db, _dir) = test_setup().await; + let account_id = mock_network_account_id(); + let note = mock_single_target_note(account_id, 3); + do_insert_notes(&db, vec![note.clone()]).await; + + // A nullifier we never inserted should not affect existing rows. + let phantom = mock_single_target_note(account_id, 99).as_note().nullifier(); + do_mark_consumed(&db, vec![phantom], BlockNumber::from(5)).await; + + assert_eq!(count_notes(&db).await, 1); + let status = do_get_note_status(&db, note.as_note().id()).await.unwrap(); + assert!(status.committed_at.is_none()); +} + +#[tokio::test] +async fn available_notes_excludes_consumed_notes() { + let (db, _dir) = test_setup().await; + let account_id = mock_network_account_id(); + let note = mock_single_target_note(account_id, 21); + do_insert_notes(&db, vec![note.clone()]).await; + + assert_eq!(do_available_notes(&db, account_id, BlockNumber::from(1), 30).await.len(), 1); + + do_mark_consumed(&db, vec![note.as_note().nullifier()], BlockNumber::from(7)).await; + + assert!( + do_available_notes(&db, account_id, BlockNumber::from(1000), 30) + .await + .is_empty() + ); +} + +// AVAILABLE NOTES + BACKOFF +// ================================================================================================ + +#[tokio::test] +async fn available_notes_returns_unconsumed_under_attempt_cap() { + let (db, _dir) = test_setup().await; + let account_id = mock_network_account_id(); + let note = mock_single_target_note(account_id, 11); + do_insert_notes(&db, vec![note]).await; + + let available = do_available_notes(&db, account_id, BlockNumber::from(1), 30).await; + assert_eq!(available.len(), 1); +} + +#[tokio::test] +async fn available_notes_excludes_attempts_at_cap() { + let (db, _dir) = test_setup().await; + let account_id = mock_network_account_id(); + let note = mock_single_target_note(account_id, 13); + do_insert_notes(&db, vec![note.clone()]).await; + + // Push attempt_count up to the cap. + let nullifier = note.as_note().nullifier(); + for _ in 0..30 { + do_notes_failed(&db, vec![(nullifier, test_note_error("boom"))], BlockNumber::from(5)) + .await; + } + + let available = do_available_notes(&db, account_id, BlockNumber::from(1000), 30).await; + assert!(available.is_empty(), "notes at the attempt cap should not be available"); +} + +// CHAIN STATE +// ================================================================================================ + +#[tokio::test] +async fn update_chain_state_tip_persists_and_roundtrips_mmr() { + let (db, _dir) = test_setup().await; + let genesis = mock_block_header(BlockNumber::GENESIS); + let header = mock_block_header(BlockNumber::from(7)); + let mmr = PartialMmr::default(); + + do_insert_genesis(&db, genesis.clone(), genesis.commitment()).await; + do_update_tip(&db, header.clone(), mmr).await; + + let (loaded_num, loaded_header, _loaded_mmr) = do_select_chain_state(&db).await.unwrap(); + assert_eq!(loaded_num, header.block_num()); + assert_eq!(loaded_header.block_num(), header.block_num()); +} + +#[tokio::test] +async fn update_chain_state_tip_keeps_singleton() { + let (db, _dir) = test_setup().await; + let genesis = mock_block_header(BlockNumber::GENESIS); + let header_1 = mock_block_header(BlockNumber::from(1)); + let header_2 = mock_block_header(BlockNumber::from(2)); + let mmr = PartialMmr::default(); + + do_insert_genesis(&db, genesis.clone(), genesis.commitment()).await; + do_update_tip(&db, header_1, mmr.clone()).await; + do_update_tip(&db, header_2.clone(), mmr).await; + + let (loaded_num, ..) = do_select_chain_state(&db).await.unwrap(); + assert_eq!(loaded_num, header_2.block_num()); + + assert_eq!(count_chain_state(&db).await, 1, "chain_state must remain a singleton"); +} + +#[tokio::test] +async fn select_chain_state_returns_none_on_fresh_db() { + let (db, _dir) = test_setup().await; + assert!(do_select_chain_state(&db).await.is_none()); +} + +// NOTE SCRIPT CACHE +// ================================================================================================ + +#[tokio::test] +async fn note_script_cache_roundtrip() { + let (db, _dir) = test_setup().await; + let account_id = mock_network_account_id(); + let note = mock_single_target_note(account_id, 17); + let script = note.as_note().script().clone(); + let root: Word = script.root().into(); + + assert!(do_lookup_script(&db, root).await.is_none()); + do_insert_script(&db, root, script.clone()).await; + assert!(do_lookup_script(&db, root).await.is_some()); + + // Re-insert is idempotent. + do_insert_script(&db, root, script).await; +} + +// ACCOUNTS WITH PENDING NOTES +// ================================================================================================ + +#[tokio::test] +async fn accounts_with_pending_notes_distinct_and_filters_consumed_and_capped() { + let (db, _dir) = test_setup().await; + let alice = mock_network_account_id(); + let bob = mock_network_account_id_seeded(42); + let carol = mock_network_account_id_seeded(99); + + let alice_note_1 = mock_single_target_note(alice, 1); + let alice_note_2 = mock_single_target_note(alice, 2); + let bob_note = mock_single_target_note(bob, 3); + let carol_note = mock_single_target_note(carol, 4); + + do_insert_notes(&db, vec![alice_note_1, alice_note_2, bob_note.clone(), carol_note.clone()]) + .await; + + // Alice has two notes — must still appear exactly once (DISTINCT). Bob's only note is already + // consumed — exclude. + do_mark_consumed(&db, vec![bob_note.as_note().nullifier()], BlockNumber::from(7)).await; + // Carol's note has hit the attempt cap — exclude. + for _ in 0..30 { + do_notes_failed( + &db, + vec![(carol_note.as_note().nullifier(), test_note_error("boom"))], + BlockNumber::from(5), + ) + .await; + } + + let pending = do_pending_accounts(&db, 30).await; + assert_eq!(pending.len(), 1, "only alice should remain pending"); + assert_eq!(pending[0], alice); +} + +// SUBMITTED-TX LANDING +// ================================================================================================ + +#[tokio::test] +async fn account_last_tx_roundtrips_and_updates() { + let (db, _dir) = test_setup().await; + let account_id = mock_network_account_id(); + let account = mock_account(account_id); + + // The first upsert records its transaction id; a later upsert overwrites it. + let first = mock_transaction_id(1); + let second = mock_transaction_id(2); + do_upsert_account(&db, account_id, account.clone(), first).await; + assert_eq!(do_account_last_tx(&db, account_id).await, Some(first)); + do_upsert_account(&db, account_id, account, second).await; + assert_eq!(do_account_last_tx(&db, account_id).await, Some(second)); +} + +#[tokio::test] +async fn account_last_tx_returns_none_for_untracked_account() { + let (db, _dir) = test_setup().await; + let account_id = mock_network_account_id(); + + // No row exists for this account. + assert_eq!(do_account_last_tx(&db, account_id).await, None); +} + +// GENESIS APPLICATION +// ================================================================================================ + +/// Builds genesis-shaped effects: a full-state network-account update with no originating +/// transactions, at [`BlockNumber::GENESIS`]. +fn genesis_effects() -> CommittedBlockEffects { + let (account, details) = mock_network_account_update(); + CommittedBlockEffects { + header: mock_block_header(BlockNumber::GENESIS), + network_notes: vec![], + nullifiers: vec![], + network_account_updates: vec![(account.id(), details)], + account_transactions: vec![], + } +} + +#[tokio::test] +async fn apply_committed_block_seeds_genesis_network_account() { + let (db, _dir) = test_setup().await; + let effects = genesis_effects(); + let account_id = effects.network_account_updates[0].0; + + // Genesis has no transactions, so this used to panic on the "must originate from a transaction" + // invariant. It must now bootstrap the account successfully. + try_apply_block(&db, effects, PartialMmr::default()).await.unwrap(); + + assert!( + do_get_account(&db, account_id).await.is_some(), + "genesis account should be seeded" + ); + // The seeded account carries the zero sentinel: no transaction produced it. An actor never + // submits the zero id, so this can never be mistaken for a landed transaction. + assert_eq!( + do_account_last_tx(&db, account_id).await, + Some(TransactionId::from_raw(Word::empty())), + ); +} + +#[tokio::test] +async fn apply_committed_block_fails_on_txless_update_after_genesis() { + let (db, _dir) = test_setup().await; + // Same shape as genesis but at a non-genesis height: a committed account update with no + // originating transaction is a real block-producer invariant violation. The + // `apply_committed_block` assertion still fires; because the work runs on the pool's blocking + // thread, the panic surfaces as an error rather than unwinding the test thread. + let mut effects = genesis_effects(); + effects.header = mock_block_header(BlockNumber::from(1)); + + try_apply_block(&db, effects, PartialMmr::default()) + .await + .expect_err("a committed account update with no transaction must fail"); +} + +#[tokio::test] +async fn notes_failed_increments_attempt_and_records_error() { + let (db, _dir) = test_setup().await; + let account_id = mock_network_account_id(); + let note = mock_single_target_note(account_id, 19); + do_insert_notes(&db, vec![note.clone()]).await; + + let nullifier = note.as_note().nullifier(); + do_notes_failed(&db, vec![(nullifier, test_note_error("nope"))], BlockNumber::from(5)).await; + do_notes_failed(&db, vec![(nullifier, test_note_error("nope"))], BlockNumber::from(6)).await; + + let row = do_get_note_status(&db, note.as_note().id()).await.unwrap(); + assert_eq!(row.attempt_count, 2); + assert_eq!(row.last_attempt, Some(6)); + assert!(row.last_error.is_some()); +} + +#[tokio::test] +async fn discard_notes_pins_attempts_to_cap_and_drops_from_pending() { + let (db, _dir) = test_setup().await; + let account_id = mock_network_account_id(); + let note = mock_single_target_note(account_id, 23); + do_insert_notes(&db, vec![note.clone()]).await; + + let nullifier = note.as_note().nullifier(); + do_discard_notes(&db, vec![nullifier], BlockNumber::from(9), 30, "too big").await; + + // Pinned to the cap, so it is no longer pending or available for selection. + let row = do_get_note_status(&db, note.as_note().id()).await.unwrap(); + assert_eq!(row.attempt_count, 30); + assert_eq!(row.last_attempt, Some(9)); + assert_eq!(row.last_error.as_deref(), Some("too big")); + + assert!( + do_available_notes(&db, account_id, BlockNumber::from(1000), 30) + .await + .is_empty(), + "a discarded note must not be selectable", + ); + assert!( + !do_pending_accounts(&db, 30).await.contains(&account_id), + "an account whose only note was discarded must not count as pending", + ); +} diff --git a/bin/ntx-builder/src/db/schema.rs b/bin/ntx-builder/src/db/schema.rs deleted file mode 100644 index 22600d0392..0000000000 --- a/bin/ntx-builder/src/db/schema.rs +++ /dev/null @@ -1,41 +0,0 @@ -// @generated automatically by Diesel CLI. - -diesel::table! { - accounts (account_id) { - account_id -> Binary, - account_data -> Binary, - last_tx_id -> Binary, - } -} - -diesel::table! { - chain_state (id) { - id -> Integer, - block_num -> BigInt, - block_header -> Binary, - chain_mmr -> Binary, - genesis_commitment -> Binary, - } -} - -diesel::table! { - note_scripts (script_root) { - script_root -> Binary, - script_data -> Binary, - } -} - -diesel::table! { - notes (nullifier) { - nullifier -> Binary, - account_id -> Binary, - note_data -> Binary, - note_id -> Nullable, - attempt_count -> Integer, - last_attempt -> Nullable, - last_error -> Nullable, - committed_at -> Nullable, - } -} - -diesel::allow_tables_to_appear_in_same_query!(accounts, chain_state, note_scripts, notes,); diff --git a/bin/ntx-builder/src/db/sql/account_exists.sql b/bin/ntx-builder/src/db/sql/account_exists.sql new file mode 100644 index 0000000000..ad724b31c9 --- /dev/null +++ b/bin/ntx-builder/src/db/sql/account_exists.sql @@ -0,0 +1,2 @@ +-- Returns whether a committed state for the given account is tracked locally. +SELECT EXISTS (SELECT 1 FROM accounts WHERE account_id = ?1) diff --git a/bin/ntx-builder/src/db/sql/account_last_tx.sql b/bin/ntx-builder/src/db/sql/account_last_tx.sql new file mode 100644 index 0000000000..5b78957dc6 --- /dev/null +++ b/bin/ntx-builder/src/db/sql/account_last_tx.sql @@ -0,0 +1,2 @@ +-- Returns the latest transaction recorded against an account. +SELECT last_tx_id FROM accounts WHERE account_id = ?1 diff --git a/bin/ntx-builder/src/db/sql/accounts_with_pending_notes.sql b/bin/ntx-builder/src/db/sql/accounts_with_pending_notes.sql new file mode 100644 index 0000000000..369d022755 --- /dev/null +++ b/bin/ntx-builder/src/db/sql/accounts_with_pending_notes.sql @@ -0,0 +1,4 @@ +-- Returns the distinct set of network accounts that currently have at least one pending note +-- (unconsumed and within the per-note attempt budget). +SELECT DISTINCT account_id FROM notes +WHERE committed_at IS NULL AND attempt_count < ?1 diff --git a/bin/ntx-builder/src/db/sql/available_notes.sql b/bin/ntx-builder/src/db/sql/available_notes.sql new file mode 100644 index 0000000000..b6e9bf8ef6 --- /dev/null +++ b/bin/ntx-builder/src/db/sql/available_notes.sql @@ -0,0 +1,4 @@ +-- Selects unconsumed notes for the account (a row exists only while a note is unconsumed) whose +-- `attempt_count` is below the cap. Execution-hint and backoff filtering are applied in Rust. +SELECT note_data, attempt_count, last_attempt FROM notes +WHERE account_id = ?1 AND committed_at IS NULL AND attempt_count < ?2 diff --git a/bin/ntx-builder/src/db/sql/discard_note.sql b/bin/ntx-builder/src/db/sql/discard_note.sql new file mode 100644 index 0000000000..d37f3ff178 --- /dev/null +++ b/bin/ntx-builder/src/db/sql/discard_note.sql @@ -0,0 +1,5 @@ +-- Marks a note as permanently unconsumable by pinning `attempt_count` to `max_attempts`, recording +-- the block at which it was discarded in `last_attempt`, and storing the reason in `last_error`. +UPDATE notes +SET attempt_count = ?2, last_attempt = ?3, last_error = ?4 +WHERE nullifier = ?1 diff --git a/bin/ntx-builder/src/db/sql/get_account.sql b/bin/ntx-builder/src/db/sql/get_account.sql new file mode 100644 index 0000000000..fc9e96e50d --- /dev/null +++ b/bin/ntx-builder/src/db/sql/get_account.sql @@ -0,0 +1,2 @@ +-- Returns the committed account state for the given network account. +SELECT account_data FROM accounts WHERE account_id = ?1 diff --git a/bin/ntx-builder/src/db/sql/get_note_status.sql b/bin/ntx-builder/src/db/sql/get_note_status.sql new file mode 100644 index 0000000000..225a326f64 --- /dev/null +++ b/bin/ntx-builder/src/db/sql/get_note_status.sql @@ -0,0 +1,2 @@ +-- Returns the status for a note identified by its note ID. +SELECT last_error, attempt_count, last_attempt, committed_at FROM notes WHERE note_id = ?1 diff --git a/bin/ntx-builder/src/db/sql/insert_genesis_chain_state.sql b/bin/ntx-builder/src/db/sql/insert_genesis_chain_state.sql new file mode 100644 index 0000000000..14b42a54c3 --- /dev/null +++ b/bin/ntx-builder/src/db/sql/insert_genesis_chain_state.sql @@ -0,0 +1,5 @@ +-- Inserts the singleton chain state row at bootstrap, seeding the tip columns from the genesis block +-- together with the genesis block commitment. The commitment satisfies the `NOT NULL` constraint at +-- insert time and is retained across all subsequent tip updates (see `update_chain_state_tip`). +INSERT INTO chain_state (id, block_num, block_header, chain_mmr, genesis_commitment) +VALUES (0, ?1, ?2, ?3, ?4) diff --git a/bin/ntx-builder/src/db/sql/insert_network_note.sql b/bin/ntx-builder/src/db/sql/insert_network_note.sql new file mode 100644 index 0000000000..6107e97470 --- /dev/null +++ b/bin/ntx-builder/src/db/sql/insert_network_note.sql @@ -0,0 +1,6 @@ +-- Inserts a network note from a committed block. Uses `INSERT OR IGNORE` so re-applying the same +-- block (e.g. on a redelivery from the subscription stream) is a no-op rather than a constraint +-- violation. `attempt_count` defaults to 0 and the remaining backoff/lifecycle columns default to +-- NULL. +INSERT OR IGNORE INTO notes (nullifier, account_id, note_data, note_id) +VALUES (?1, ?2, ?3, ?4) diff --git a/bin/ntx-builder/src/db/sql/insert_note_script.sql b/bin/ntx-builder/src/db/sql/insert_note_script.sql new file mode 100644 index 0000000000..216c48fc95 --- /dev/null +++ b/bin/ntx-builder/src/db/sql/insert_note_script.sql @@ -0,0 +1,2 @@ +-- Inserts a note script (idempotent via INSERT OR IGNORE). +INSERT OR IGNORE INTO note_scripts (script_root, script_data) VALUES (?1, ?2) diff --git a/bin/ntx-builder/src/db/sql/lookup_note_script.sql b/bin/ntx-builder/src/db/sql/lookup_note_script.sql new file mode 100644 index 0000000000..5b9184e3c1 --- /dev/null +++ b/bin/ntx-builder/src/db/sql/lookup_note_script.sql @@ -0,0 +1,2 @@ +-- Looks up a note script by its root hash. +SELECT script_data FROM note_scripts WHERE script_root = ?1 diff --git a/bin/ntx-builder/src/db/sql/mark_note_consumed.sql b/bin/ntx-builder/src/db/sql/mark_note_consumed.sql new file mode 100644 index 0000000000..2b004f4b85 --- /dev/null +++ b/bin/ntx-builder/src/db/sql/mark_note_consumed.sql @@ -0,0 +1,7 @@ +-- Marks a note as consumed by setting `committed_at` to the block number whose committed body +-- contained its nullifier. Rows for nullifiers we never inserted are silently skipped (no match). +-- Rows are kept around (not deleted) so the `GetNetworkNoteStatus` endpoint can report the full +-- lifecycle of any note the ntx-builder has ever seen. +UPDATE notes +SET committed_at = ?2 +WHERE nullifier = ?1 AND committed_at IS NULL diff --git a/bin/ntx-builder/src/db/sql/note_failed.sql b/bin/ntx-builder/src/db/sql/note_failed.sql new file mode 100644 index 0000000000..ccce5d0923 --- /dev/null +++ b/bin/ntx-builder/src/db/sql/note_failed.sql @@ -0,0 +1,5 @@ +-- Marks a note as failed by incrementing `attempt_count`, setting `last_attempt`, and storing the +-- latest error message. +UPDATE notes +SET attempt_count = attempt_count + 1, last_attempt = ?2, last_error = ?3 +WHERE nullifier = ?1 diff --git a/bin/ntx-builder/src/db/sql/select_chain_state.sql b/bin/ntx-builder/src/db/sql/select_chain_state.sql new file mode 100644 index 0000000000..33c6fede63 --- /dev/null +++ b/bin/ntx-builder/src/db/sql/select_chain_state.sql @@ -0,0 +1,2 @@ +-- Reads the singleton chain state row, returning the persisted block number, header, and chain MMR. +SELECT block_num, block_header, chain_mmr FROM chain_state WHERE id = 0 diff --git a/bin/ntx-builder/src/db/sql/select_genesis_commitment.sql b/bin/ntx-builder/src/db/sql/select_genesis_commitment.sql new file mode 100644 index 0000000000..a681e7dbf6 --- /dev/null +++ b/bin/ntx-builder/src/db/sql/select_genesis_commitment.sql @@ -0,0 +1,2 @@ +-- Reads the genesis block commitment from the singleton chain state row. +SELECT genesis_commitment FROM chain_state WHERE id = 0 diff --git a/bin/ntx-builder/src/db/sql/update_chain_state_tip.sql b/bin/ntx-builder/src/db/sql/update_chain_state_tip.sql new file mode 100644 index 0000000000..61b2185150 --- /dev/null +++ b/bin/ntx-builder/src/db/sql/update_chain_state_tip.sql @@ -0,0 +1,5 @@ +-- Updates the tip columns of the singleton chain state row. The row is created once at bootstrap by +-- `insert_genesis_chain_state`, so this is a plain update; `genesis_commitment` is never touched. +UPDATE chain_state +SET block_num = ?1, block_header = ?2, chain_mmr = ?3 +WHERE id = 0 diff --git a/bin/ntx-builder/src/db/sql/upsert_account.sql b/bin/ntx-builder/src/db/sql/upsert_account.sql new file mode 100644 index 0000000000..7ad9e02a95 --- /dev/null +++ b/bin/ntx-builder/src/db/sql/upsert_account.sql @@ -0,0 +1,7 @@ +-- Inserts the committed account state, or updates an existing account's state. In both cases +-- `last_tx_id` is set to the transaction that produced this update. +INSERT INTO accounts (account_id, account_data, last_tx_id) +VALUES (?1, ?2, ?3) +ON CONFLICT(account_id) DO UPDATE SET + account_data = excluded.account_data, + last_tx_id = excluded.last_tx_id diff --git a/bin/ntx-builder/src/lib.rs b/bin/ntx-builder/src/lib.rs index 65541b348f..e0d818e724 100644 --- a/bin/ntx-builder/src/lib.rs +++ b/bin/ntx-builder/src/lib.rs @@ -7,7 +7,7 @@ use anyhow::Context; use builder::BlockStream; use chain_state::SharedChainState; use clients::{RemoteTransactionProver, RpcClient}; -use db::Db; +use miden_node_db::sqlite::Database; use miden_node_utils::ErrorReport; use miden_node_utils::lru_cache::LruCache; use miden_node_utils::shutdown::CancellationToken; @@ -48,12 +48,12 @@ pub use builder::NetworkTransactionBuilder; /// bootstrapped. pub async fn bootstrap(database_filepath: PathBuf, genesis: &SignedBlock) -> anyhow::Result<()> { validate_genesis_block(genesis).context("genesis block validation failed")?; - db::Db::bootstrap(database_filepath, genesis).await + db::bootstrap(database_filepath, genesis).await } /// Applies pending migrations to the ntx-builder database at `database_filepath`. pub fn migrate(database_filepath: impl AsRef) -> anyhow::Result<()> { - db::Db::migrate(database_filepath).context("failed to apply ntx-builder database migrations") + db::migrate(database_filepath).context("failed to apply ntx-builder database migrations") } fn validate_genesis_block(block: &SignedBlock) -> anyhow::Result<()> { @@ -369,26 +369,24 @@ impl NtxBuilderConfig { self, shutdown: CancellationToken, ) -> anyhow::Result { - // The event loop pins one connection for itself (so block application is never starved by - // the account actors), leaving the rest of the pool for actors and the gRPC server. That - // requires at least two connections. - anyhow::ensure!( - self.sqlite_connection_pool_size.get() >= 2, - "sqlite connection pool size must be at least 2 (the event loop pins one connection)", - ); - - // Set up the database (bootstrap + connection pool). - let db = Db::load_with_pool_size( + // Set up the database connection pool. Writes are serialized by the framework's single + // dedicated writer connection, so block application never contends with the account actors + // (which only read) for the shared reader pool. + let db = db::load_with_pool_size( self.database_filepath.clone(), self.sqlite_connection_pool_size, ) .await?; // Get the genesis commitment to send in the accept header - let genesis_commitment = db.get_genesis_commitment().await.context( - "failed to read genesis commitment; \ - run `miden-ntx-builder bootstrap` first", - )?; + let genesis_commitment = db + .read("select_genesis_commitment", db::queries::select_genesis_commitment) + .await + .context("failed to read genesis commitment")? + .context( + "ntx-builder database has not been bootstrapped; \ + run `miden-ntx-builder bootstrap` first", + )?; let rpc = match self.rpc_auth_header.clone() { Some(rpc_auth_header_value) => RpcClient::new_with_auth( @@ -409,8 +407,11 @@ impl NtxBuilderConfig { // The database is bootstrapped with the genesis block before startup (see // `miden-ntx-builder bootstrap`), so a persisted chain state is always present. Load it and // resume the subscription from the block after the last applied one. - let (last_applied_block, header, mmr) = - db.get_chain_state().await.context("failed to read chain state")?.context( + let (last_applied_block, header, mmr) = db + .read("select_chain_state", db::queries::select_chain_state) + .await + .context("failed to read chain state")? + .context( "ntx-builder database has not been bootstrapped; \ run `miden-ntx-builder bootstrap` first", )?; @@ -451,7 +452,7 @@ impl NtxBuilderConfig { fn build_coordinator( &self, rpc: RpcClient, - db: Db, + db: Database, chain: Arc, shutdown: CancellationToken, ) -> anyhow::Result<(Coordinator, mpsc::Receiver)> { diff --git a/bin/ntx-builder/src/server.rs b/bin/ntx-builder/src/server.rs index 2c91c06de4..bf09701843 100644 --- a/bin/ntx-builder/src/server.rs +++ b/bin/ntx-builder/src/server.rs @@ -1,4 +1,5 @@ use anyhow::Context; +use miden_node_db::sqlite::Database; use miden_node_proto::server::ntx_builder_api; use miden_node_proto_build::ntx_builder_api_descriptor; use miden_node_utils::panic::{CatchPanicLayer, catch_panic_layer_fn}; @@ -10,7 +11,6 @@ use tonic_reflection::server; use tower_http::trace::TraceLayer; use crate::COMPONENT; -use crate::db::Db; mod get_network_note_status; @@ -22,12 +22,12 @@ mod get_network_note_status; /// Exposes endpoints for querying network note status, useful for debugging /// network notes that fail to be consumed. pub struct NtxBuilderRpcServer { - db: Db, + db: Database, max_note_attempts: usize, } impl NtxBuilderRpcServer { - pub fn new(db: Db, max_note_attempts: usize) -> Self { + pub fn new(db: Database, max_note_attempts: usize) -> Self { Self { db, max_note_attempts } } diff --git a/bin/ntx-builder/src/server/get_network_note_status.rs b/bin/ntx-builder/src/server/get_network_note_status.rs index 10d9301267..be7621cd8e 100644 --- a/bin/ntx-builder/src/server/get_network_note_status.rs +++ b/bin/ntx-builder/src/server/get_network_note_status.rs @@ -35,10 +35,14 @@ impl grpc::server::ntx_builder_api::GetNetworkNoteStatus for NtxBuilderRpcServer _metadata: &tonic::metadata::MetadataMap, _extensions: &tonic::codegen::http::Extensions, ) -> tonic::Result { - let row = self.db.get_note_status(note_id).await.map_err(|err| { - tracing::error!(target: LOG_TARGET, error = %err, "Failed to query note status from DB"); - tonic::Status::internal("database error") - })?; + let row = self + .db + .read("get_note_status", move |tx| crate::db::queries::get_note_status(tx, note_id)) + .await + .map_err(|err| { + tracing::error!(target: LOG_TARGET, error = %err, "Failed to query note status from DB"); + tonic::Status::internal("database error") + })?; let Some(row) = row else { return Err(tonic::Status::not_found("note not found in ntx-builder database")); diff --git a/crates/db/src/sqlite/codec.rs b/crates/db/src/sqlite/codec.rs index 9c2c4ac224..0d83c42d18 100644 --- a/crates/db/src/sqlite/codec.rs +++ b/crates/db/src/sqlite/codec.rs @@ -243,8 +243,13 @@ macro_rules! impl_blob_codec { // rule does not force each consumer to redeclare them. impl_blob_codec!( miden_protocol::block::BlockHeader, + miden_protocol::account::Account, miden_protocol::account::AccountId, miden_protocol::transaction::TransactionId, + miden_protocol::note::Note, + miden_protocol::note::NoteId, + miden_protocol::note::NoteScript, miden_protocol::note::Nullifier, + miden_protocol::crypto::merkle::mmr::PartialMmr, miden_protocol::Word, ); From d8863cce98292798c7c105c6284a6781f442fad9 Mon Sep 17 00:00:00 2001 From: SantiagoPittella Date: Fri, 17 Jul 2026 11:30:54 -0300 Subject: [PATCH 2/4] review: use .first --- bin/ntx-builder/src/db/queries/note_scripts.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/ntx-builder/src/db/queries/note_scripts.rs b/bin/ntx-builder/src/db/queries/note_scripts.rs index 0fabf9376d..57e83147bf 100644 --- a/bin/ntx-builder/src/db/queries/note_scripts.rs +++ b/bin/ntx-builder/src/db/queries/note_scripts.rs @@ -14,8 +14,8 @@ pub fn lookup_note_script( ) -> Result, DatabaseError> { Ok(tx .query(sql::LOOKUP_NOTE_SCRIPT, &[script_root], |row| row.get::(0))? - .into_iter() - .next()) + .first() + .cloned()) } /// Inserts a note script (idempotent via INSERT OR IGNORE). From 8785b7a263d4b9ff169fe4cde5eb72c0b391dad2 Mon Sep 17 00:00:00 2001 From: SantiagoPittella Date: Fri, 17 Jul 2026 11:43:11 -0300 Subject: [PATCH 3/4] review: use existing codec for block number --- bin/ntx-builder/src/db/queries/chain_state.rs | 12 ++++-------- bin/ntx-builder/src/db/queries/mod.rs | 16 ---------------- bin/ntx-builder/src/db/queries/notes.rs | 11 +++++------ crates/db/src/errors.rs | 5 +++++ 4 files changed, 14 insertions(+), 30 deletions(-) diff --git a/bin/ntx-builder/src/db/queries/chain_state.rs b/bin/ntx-builder/src/db/queries/chain_state.rs index 8c991c7506..df95ef6e3e 100644 --- a/bin/ntx-builder/src/db/queries/chain_state.rs +++ b/bin/ntx-builder/src/db/queries/chain_state.rs @@ -1,12 +1,11 @@ //! Chain state queries. -use miden_node_db::DatabaseError; use miden_node_db::sqlite::{ReadTx, WriteTx}; +use miden_node_db::{DatabaseError, SqlTypeConvert}; use miden_protocol::Word; use miden_protocol::block::{BlockHeader, BlockNumber}; use miden_protocol::crypto::merkle::mmr::PartialMmr; -use crate::db::queries::{block_num_from_i64, block_num_to_i64}; use crate::db::sql; /// Updates the tip columns (block number, header, and partial chain MMR) of the singleton chain @@ -18,10 +17,7 @@ pub fn update_chain_state_tip( block_header: &BlockHeader, chain_mmr: &PartialMmr, ) -> Result<(), DatabaseError> { - tx.execute( - sql::UPDATE_CHAIN_STATE_TIP, - &[&block_num_to_i64(block_num), block_header, chain_mmr], - )?; + tx.execute(sql::UPDATE_CHAIN_STATE_TIP, &[&block_num.to_raw_sql(), block_header, chain_mmr])?; Ok(()) } @@ -42,7 +38,7 @@ pub fn insert_genesis_chain_state( tx.execute( sql::INSERT_GENESIS_CHAIN_STATE, &[ - &block_num_to_i64(genesis_block_header.block_num()), + &genesis_block_header.block_num().to_raw_sql(), genesis_block_header, &PartialMmr::default(), genesis_commitment, @@ -68,7 +64,7 @@ pub fn select_chain_state( Ok(tx .query(sql::SELECT_CHAIN_STATE, &[], |row| { Ok(( - block_num_from_i64(row.get::(0)?), + BlockNumber::from_raw_sql(row.get::(0)?)?, row.get::(1)?, row.get::(2)?, )) diff --git a/bin/ntx-builder/src/db/queries/mod.rs b/bin/ntx-builder/src/db/queries/mod.rs index 2a149d74fd..a50658eba7 100644 --- a/bin/ntx-builder/src/db/queries/mod.rs +++ b/bin/ntx-builder/src/db/queries/mod.rs @@ -35,22 +35,6 @@ pub use notes::*; #[cfg(test)] mod tests; -// BLOCK NUMBER CODEC -// ================================================================================================ - -/// Serializes a [`BlockNumber`] to the `i64` used by the `block_num`/`committed_at`/`last_attempt` -/// columns. -pub(crate) fn block_num_to_i64(block_num: BlockNumber) -> i64 { - i64::from(block_num.as_u32()) -} - -/// Deserializes a `block_num`/`committed_at`/`last_attempt` column value back into a -/// [`BlockNumber`]. -#[expect(clippy::cast_sign_loss)] -pub(crate) fn block_num_from_i64(val: i64) -> BlockNumber { - BlockNumber::from(val as u32) -} - // COMMITTED BLOCK APPLICATION // ================================================================================================ diff --git a/bin/ntx-builder/src/db/queries/notes.rs b/bin/ntx-builder/src/db/queries/notes.rs index 8e615b531b..a203ae906d 100644 --- a/bin/ntx-builder/src/db/queries/notes.rs +++ b/bin/ntx-builder/src/db/queries/notes.rs @@ -1,7 +1,7 @@ //! Note-related queries. -use miden_node_db::DatabaseError; use miden_node_db::sqlite::{ReadTx, WriteTx}; +use miden_node_db::{DatabaseError, SqlTypeConvert}; use miden_node_utils::ErrorReport; use miden_protocol::account::AccountId; use miden_protocol::block::BlockNumber; @@ -9,7 +9,6 @@ use miden_protocol::note::{Note, NoteId, Nullifier}; use miden_standards::note::AccountTargetNetworkNote; use crate::NoteError; -use crate::db::queries::{block_num_from_i64, block_num_to_i64}; use crate::db::sql; /// Row returned by [`get_note_status`]. @@ -49,7 +48,7 @@ pub fn mark_notes_consumed( nullifiers: &[Nullifier], block_num: BlockNumber, ) -> Result<(), DatabaseError> { - let block_num_val = block_num_to_i64(block_num); + let block_num_val = block_num.to_raw_sql(); for nullifier in nullifiers { tx.execute(sql::MARK_NOTE_CONSUMED, &[nullifier, &block_num_val])?; } @@ -85,7 +84,7 @@ pub fn available_notes( for (note, attempt_count, last_attempt) in rows { #[expect(clippy::cast_sign_loss)] let attempt_count = attempt_count as usize; - let last_attempt = last_attempt.map(block_num_from_i64); + let last_attempt = last_attempt.map(BlockNumber::from_raw_sql).transpose()?; let note = AccountTargetNetworkNote::new(note).map_err(|source| { DatabaseError::deserialization("failed to convert to network note", source) })?; @@ -106,7 +105,7 @@ pub fn notes_failed( failed_notes: &[(Nullifier, NoteError)], block_num: BlockNumber, ) -> Result<(), DatabaseError> { - let block_num_val = block_num_to_i64(block_num); + let block_num_val = block_num.to_raw_sql(); for (nullifier, error) in failed_notes { let error_report = error.as_report(); @@ -130,7 +129,7 @@ pub fn discard_notes( max_attempts: usize, reason: &str, ) -> Result<(), DatabaseError> { - let block_num_val = block_num_to_i64(block_num); + let block_num_val = block_num.to_raw_sql(); let reason = reason.to_string(); for nullifier in nullifiers { tx.execute( diff --git a/crates/db/src/errors.rs b/crates/db/src/errors.rs index 955d4a1151..ac63152085 100644 --- a/crates/db/src/errors.rs +++ b/crates/db/src/errors.rs @@ -4,6 +4,8 @@ use std::io; use deadpool_sync::InteractError; use thiserror::Error; +use crate::DatabaseTypeConversionError; + // SCHEMA VERIFICATION ERROR // ================================================================================================= @@ -37,6 +39,7 @@ pub enum DatabaseError { InteractError(String), #[error("setup deadpool connection pool failed")] ConnectionPoolObtainError(#[from] Box), + // This error can be removed after completing the removal of Diesel. #[error("conversion from SQL to rust type {to} failed")] ConversionSqlToRust { #[source] @@ -57,6 +60,8 @@ pub enum DatabaseError { PoolBuild(#[from] deadpool::managed::BuildError), #[error("Setup deadpool connection pool failed")] Pool(#[from] deadpool::managed::PoolError), + #[error("failed to cast")] + ConversionError(#[from] DatabaseTypeConversionError), } impl DatabaseError { From b6fd8fb7b095ceb08bb70d0ef463cb0a8edf7289 Mon Sep 17 00:00:00 2001 From: SantiagoPittella Date: Fri, 17 Jul 2026 16:35:27 -0300 Subject: [PATCH 4/4] review: re-order in folder per query --- bin/ntx-builder/src/actor/execute.rs | 25 +- bin/ntx-builder/src/actor/mod.rs | 37 +- bin/ntx-builder/src/builder.rs | 46 +-- bin/ntx-builder/src/coordinator.rs | 15 +- bin/ntx-builder/src/db/mod.rs | 352 +++++++++++++++--- .../account_exists}/account_exists.sql | 0 .../src/db/queries/account_exists/mod.rs | 16 + .../account_last_tx}/account_last_tx.sql | 0 .../src/db/queries/account_last_tx/mod.rs | 20 + bin/ntx-builder/src/db/queries/accounts.rs | 52 --- .../accounts_with_pending_notes.sql | 0 .../accounts_with_pending_notes/mod.rs | 17 + .../available_notes}/available_notes.sql | 0 .../src/db/queries/available_notes/mod.rs | 109 ++++++ bin/ntx-builder/src/db/queries/chain_state.rs | 74 ---- .../discard_notes}/discard_note.sql | 0 .../src/db/queries/discard_notes/mod.rs | 32 ++ .../get_account}/get_account.sql | 0 .../src/db/queries/get_account/mod.rs | 15 + .../get_note_status}/get_note_status.sql | 0 .../src/db/queries/get_note_status/mod.rs | 34 ++ .../insert_genesis_chain_state.sql | 0 .../queries/insert_genesis_chain_state/mod.rs | 35 ++ .../insert_network_note.sql | 0 .../db/queries/insert_network_notes/mod.rs | 21 ++ .../insert_note_script.sql | 0 .../src/db/queries/insert_note_scripts/mod.rs | 18 + .../lookup_note_script.sql | 0 .../src/db/queries/lookup_note_script/mod.rs | 16 + .../mark_note_consumed.sql | 0 .../src/db/queries/mark_notes_consumed/mod.rs | 26 ++ bin/ntx-builder/src/db/queries/mod.rs | 55 ++- .../src/db/queries/note_scripts.rs | 29 -- bin/ntx-builder/src/db/queries/notes.rs | 227 ----------- .../src/db/queries/notes_failed/mod.rs | 27 ++ .../notes_failed}/note_failed.sql | 0 .../src/db/queries/select_chain_state/mod.rs | 25 ++ .../select_chain_state.sql | 0 .../queries/select_genesis_commitment/mod.rs | 13 + .../select_genesis_commitment.sql | 0 bin/ntx-builder/src/db/queries/tests.rs | 316 +++++----------- .../db/queries/update_chain_state_tip/mod.rs | 22 ++ .../update_chain_state_tip.sql | 0 .../src/db/queries/upsert_account/mod.rs | 20 + .../upsert_account}/upsert_account.sql | 0 bin/ntx-builder/src/lib.rs | 13 +- bin/ntx-builder/src/server.rs | 6 +- .../src/server/get_network_note_status.rs | 4 +- 48 files changed, 949 insertions(+), 768 deletions(-) rename bin/ntx-builder/src/db/{sql => queries/account_exists}/account_exists.sql (100%) create mode 100644 bin/ntx-builder/src/db/queries/account_exists/mod.rs rename bin/ntx-builder/src/db/{sql => queries/account_last_tx}/account_last_tx.sql (100%) create mode 100644 bin/ntx-builder/src/db/queries/account_last_tx/mod.rs delete mode 100644 bin/ntx-builder/src/db/queries/accounts.rs rename bin/ntx-builder/src/db/{sql => queries/accounts_with_pending_notes}/accounts_with_pending_notes.sql (100%) create mode 100644 bin/ntx-builder/src/db/queries/accounts_with_pending_notes/mod.rs rename bin/ntx-builder/src/db/{sql => queries/available_notes}/available_notes.sql (100%) create mode 100644 bin/ntx-builder/src/db/queries/available_notes/mod.rs delete mode 100644 bin/ntx-builder/src/db/queries/chain_state.rs rename bin/ntx-builder/src/db/{sql => queries/discard_notes}/discard_note.sql (100%) create mode 100644 bin/ntx-builder/src/db/queries/discard_notes/mod.rs rename bin/ntx-builder/src/db/{sql => queries/get_account}/get_account.sql (100%) create mode 100644 bin/ntx-builder/src/db/queries/get_account/mod.rs rename bin/ntx-builder/src/db/{sql => queries/get_note_status}/get_note_status.sql (100%) create mode 100644 bin/ntx-builder/src/db/queries/get_note_status/mod.rs rename bin/ntx-builder/src/db/{sql => queries/insert_genesis_chain_state}/insert_genesis_chain_state.sql (100%) create mode 100644 bin/ntx-builder/src/db/queries/insert_genesis_chain_state/mod.rs rename bin/ntx-builder/src/db/{sql => queries/insert_network_notes}/insert_network_note.sql (100%) create mode 100644 bin/ntx-builder/src/db/queries/insert_network_notes/mod.rs rename bin/ntx-builder/src/db/{sql => queries/insert_note_scripts}/insert_note_script.sql (100%) create mode 100644 bin/ntx-builder/src/db/queries/insert_note_scripts/mod.rs rename bin/ntx-builder/src/db/{sql => queries/lookup_note_script}/lookup_note_script.sql (100%) create mode 100644 bin/ntx-builder/src/db/queries/lookup_note_script/mod.rs rename bin/ntx-builder/src/db/{sql => queries/mark_notes_consumed}/mark_note_consumed.sql (100%) create mode 100644 bin/ntx-builder/src/db/queries/mark_notes_consumed/mod.rs delete mode 100644 bin/ntx-builder/src/db/queries/note_scripts.rs delete mode 100644 bin/ntx-builder/src/db/queries/notes.rs create mode 100644 bin/ntx-builder/src/db/queries/notes_failed/mod.rs rename bin/ntx-builder/src/db/{sql => queries/notes_failed}/note_failed.sql (100%) create mode 100644 bin/ntx-builder/src/db/queries/select_chain_state/mod.rs rename bin/ntx-builder/src/db/{sql => queries/select_chain_state}/select_chain_state.sql (100%) create mode 100644 bin/ntx-builder/src/db/queries/select_genesis_commitment/mod.rs rename bin/ntx-builder/src/db/{sql => queries/select_genesis_commitment}/select_genesis_commitment.sql (100%) create mode 100644 bin/ntx-builder/src/db/queries/update_chain_state_tip/mod.rs rename bin/ntx-builder/src/db/{sql => queries/update_chain_state_tip}/update_chain_state_tip.sql (100%) create mode 100644 bin/ntx-builder/src/db/queries/upsert_account/mod.rs rename bin/ntx-builder/src/db/{sql => queries/upsert_account}/upsert_account.sql (100%) diff --git a/bin/ntx-builder/src/actor/execute.rs b/bin/ntx-builder/src/actor/execute.rs index 202bf57916..7337aa2ba2 100644 --- a/bin/ntx-builder/src/actor/execute.rs +++ b/bin/ntx-builder/src/actor/execute.rs @@ -3,7 +3,6 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; use backon::ExponentialBuilder; -use miden_node_db::sqlite::Database; use miden_node_utils::ErrorReport; use miden_node_utils::lru_cache::LruCache; use miden_node_utils::retry::{self, Retryable}; @@ -57,7 +56,7 @@ use tracing::Instrument; use crate::actor::candidate::TransactionCandidate; use crate::clients::{RemoteTransactionProver, RpcClient, RpcError}; -use crate::db::queries; +use crate::db::NtxDb; use crate::{COMPONENT, LOG_TARGET}; #[derive(Debug, thiserror::Error)] @@ -168,7 +167,7 @@ pub struct NtxContext { script_cache: LruCache, /// Local database for persistent note script caching. - db: Database, + db: NtxDb, /// Maximum number of VM execution cycles for network transactions. max_cycles: u32, @@ -191,7 +190,7 @@ impl NtxContext { prover: RemoteTransactionProver, rpc: RpcClient, script_cache: LruCache, - db: Database, + db: NtxDb, max_cycles: u32, tx_args: TransactionArgs, request_backoff_initial: Duration, @@ -576,7 +575,7 @@ struct NtxDataStore { /// LRU cache for storing retrieved note scripts to avoid repeated RPC calls. script_cache: LruCache, /// Local database for persistent note script. - db: Database, + db: NtxDb, /// Scripts fetched from the remote RPC service during execution, to be persisted by the /// coordinator. fetched_scripts: Arc>>, @@ -614,7 +613,7 @@ impl NtxDataStore { chain_mmr: Arc, rpc: RpcClient, script_cache: LruCache, - db: Database, + db: NtxDb, request_backoff: ExponentialBuilder, ) -> Self { let mast_store = TransactionMastStore::new(); @@ -819,17 +818,9 @@ impl DataStore for NtxDataStore { } // 2. Local DB. - if let Some(script) = self - .db - .read("lookup_note_script", move |tx| queries::lookup_note_script(tx, &script_root)) - .await - .map_err(|err| { - DataStoreError::other_with_source( - "failed to look up note script in local DB", - err, - ) - })? - { + if let Some(script) = self.db.lookup_note_script(script_root).await.map_err(|err| { + DataStoreError::other_with_source("failed to look up note script in local DB", err) + })? { self.script_cache.put(script_root, script.clone()); return Ok(Some(script)); } diff --git a/bin/ntx-builder/src/actor/mod.rs b/bin/ntx-builder/src/actor/mod.rs index b13d90561e..fdc4ec4ca9 100644 --- a/bin/ntx-builder/src/actor/mod.rs +++ b/bin/ntx-builder/src/actor/mod.rs @@ -10,7 +10,6 @@ use allowlist::{NoteScriptNotAllowlisted, partition_by_allowlist}; use anyhow::Context; use candidate::TransactionCandidate; use futures::FutureExt; -use miden_node_db::sqlite::Database; use miden_node_utils::ErrorReport; use miden_node_utils::lru_cache::LruCache; use miden_node_utils::shutdown::CancellationToken; @@ -26,7 +25,7 @@ use tokio::sync::{Notify, Semaphore, mpsc}; use crate::chain_state::{ChainState, SharedChainState}; use crate::clients::{RemoteTransactionProver, RpcClient}; -use crate::db::queries; +use crate::db::NtxDb; use crate::{LOG_TARGET, NoteError}; /// Builds the [`TransactionArgs`] shared by every network transaction. @@ -87,7 +86,7 @@ pub struct GrpcClients { #[derive(Clone)] pub struct State { /// Local database for account state, notes, and transaction tracking. - pub db: Database, + pub db: NtxDb, /// The latest chain state. A single chain state is shared among all actors. pub chain: Arc, /// Shared LRU cache for storing retrieved note scripts to avoid repeated RPC calls. @@ -141,7 +140,7 @@ impl AccountActorContext { /// /// The URLs are fake and actors spawned with this context will fail on their first gRPC call, /// but this is sufficient for testing coordinator logic (registry, deactivation, etc.). - pub fn test(db: &Database) -> Self { + pub fn test(db: &NtxDb) -> Self { use miden_protocol::crypto::merkle::mmr::{Forest, MmrPeaks, PartialMmr}; use url::Url; @@ -305,8 +304,7 @@ impl AccountActor { // for accounts whose creation has been committed, so the account must exist. let mut account = self .state - .db - .read("get_account", move |tx| queries::get_account(tx, account_id)) + .db.get_account(account_id) .await .context("failed to load committed account")? .context("no committed state for the account; the coordinator must only spawn actors for committed accounts")?; @@ -317,9 +315,7 @@ impl AccountActor { let has_notes = self .state .db - .read("has_available_notes", move |tx| { - queries::has_available_notes(tx, account_id, block_num, max_note_attempts) - }) + .has_available_notes(account_id, block_num, max_note_attempts) .await .context("failed to check for available notes")?; let mut mode = if has_notes { @@ -416,7 +412,7 @@ impl AccountActor { let landed = self .state .db - .read("account_last_tx", move |tx| queries::account_last_tx(tx, account_id)) + .account_last_tx(account_id) .await .context("failed to check submitted tx landing")? == Some(submitted_tx_id); @@ -451,7 +447,7 @@ impl AccountActor { if let Some(latest) = self .state .db - .read("get_account", move |tx| queries::get_account(tx, account_id)) + .get_account(account_id) .await .context("failed to reload account after submission expiry")? { @@ -481,9 +477,7 @@ impl AccountActor { let notes = self .state .db - .read("available_notes", move |tx| { - queries::available_notes(tx, account_id, block_num, max_note_attempts) - }) + .available_notes(account_id, block_num, max_note_attempts) .await .context("failed to query DB for available notes")?; @@ -806,7 +800,7 @@ mod tests { } /// Builds an actor wired to `db` for the given account, plus the in-memory account to drive. - fn test_actor(db: &Database, account: &Account) -> AccountActor { + fn test_actor(db: &NtxDb, account: &Account) -> AccountActor { let ctx = AccountActorContext::test(db); AccountActor::new(account.id(), &ctx, Arc::new(Notify::new())) } @@ -821,7 +815,7 @@ mod tests { let submitted = mock_transaction_id(7); // Seed the committed row so the landing check sees our submission as the latest tx. - crate::db::upsert_account_for_test(&db, account_id, account.clone(), submitted) + db.upsert_account_for_test(account_id, account.clone(), submitted) .await .unwrap(); @@ -903,14 +897,9 @@ mod tests { // Seed the committed account but no notes, so the actor starts and stays in NoViableNotes: // every wake re-checks the DB, finds nothing, and returns to the idle state. - crate::db::upsert_account_for_test( - &db, - account_id, - account.clone(), - mock_transaction_id(1), - ) - .await - .unwrap(); + db.upsert_account_for_test(account_id, account.clone(), mock_transaction_id(1)) + .await + .unwrap(); let mut ctx = AccountActorContext::test(&db); // Short idle timeout keeps the test fast. diff --git a/bin/ntx-builder/src/builder.rs b/bin/ntx-builder/src/builder.rs index ed74b4deef..30caf20fc1 100644 --- a/bin/ntx-builder/src/builder.rs +++ b/bin/ntx-builder/src/builder.rs @@ -3,7 +3,6 @@ use std::sync::Arc; use anyhow::Context; use futures::Stream; -use miden_node_db::sqlite::Database; use miden_node_utils::shutdown::CancellationToken; use miden_node_utils::tasks::Tasks; use miden_node_utils::tracing::miden_instrument; @@ -17,7 +16,7 @@ use crate::chain_state::SharedChainState; use crate::clients::RpcError; use crate::committed_block::CommittedBlockEffects; use crate::coordinator::Coordinator; -use crate::db::queries; +use crate::db::NtxDb; use crate::server::NtxBuilderRpcServer; use crate::{LOG_TARGET, NtxBuilderConfig}; @@ -58,7 +57,7 @@ pub struct NetworkTransactionBuilder { /// Configuration for the builder. config: NtxBuilderConfig, /// Database for persistent state. - db: Database, + db: NtxDb, /// Stream of committed blocks from the node RPC service. block_stream: BlockStream, /// Highest block number applied to the DB so far. @@ -78,7 +77,7 @@ pub struct NetworkTransactionBuilder { impl NetworkTransactionBuilder { pub(crate) fn new( config: NtxBuilderConfig, - db: Database, + db: NtxDb, block_stream: BlockStream, last_applied_block: BlockNumber, chain: Arc, @@ -154,9 +153,7 @@ impl NetworkTransactionBuilder { let max_note_attempts = self.config.max_note_attempts; let pending_accounts = self .db - .read("accounts_with_pending_notes", move |tx| { - queries::accounts_with_pending_notes(tx, max_note_attempts) - }) + .accounts_with_pending_notes(max_note_attempts) .await .context("failed to load accounts with pending notes at catch-up")?; tracing::info!( @@ -265,9 +262,7 @@ impl NetworkTransactionBuilder { let effects_for_db = effects.clone(); self.db - .write("apply_committed_block", move |tx| { - queries::apply_committed_block(tx, &effects_for_db, &next_mmr) - }) + .apply_committed_block(effects_for_db, next_mmr) .await .context("failed to apply committed block to DB")?; @@ -277,45 +272,30 @@ impl NetworkTransactionBuilder { } } -/// Reason recorded in a note's `last_error` when it is discarded for exceeding the per-tx cycle -/// budget on its own. -const OVERSIZED_NOTE_DISCARD_REASON: &str = - "note consumption exceeds the per-transaction cycle budget; it can never be consumed"; - /// Handles a single actor request then acknowledges the actor. All writes go through the /// framework's single writer connection, so the actors' reads cannot starve them. async fn handle_actor_request( - db: &Database, + db: &NtxDb, request: ActorRequest, max_note_attempts: usize, ) -> anyhow::Result<()> { match request { ActorRequest::NotesFailed { failed_notes, block_num, ack_tx } => { - db.write("notes_failed", move |tx| queries::notes_failed(tx, &failed_notes, block_num)) + db.notes_failed(failed_notes, block_num) .await .context("failed to persist note failure")?; let _ = ack_tx.send(()); }, ActorRequest::NotesDiscarded { nullifiers, block_num, ack_tx } => { - db.write("discard_notes", move |tx| { - queries::discard_notes( - tx, - &nullifiers, - block_num, - max_note_attempts, - OVERSIZED_NOTE_DISCARD_REASON, - ) - }) - .await - .context("failed to persist note discard")?; + db.discard_notes(nullifiers, block_num, max_note_attempts) + .await + .context("failed to persist note discard")?; let _ = ack_tx.send(()); }, ActorRequest::CacheNoteScript { script_root, script } => { - db.write("insert_note_script", move |tx| { - queries::insert_note_script(tx, &script_root, &script) - }) - .await - .context("failed to cache note script")?; + db.insert_note_scripts(script_root, script) + .await + .context("failed to cache note script")?; }, } Ok(()) diff --git a/bin/ntx-builder/src/coordinator.rs b/bin/ntx-builder/src/coordinator.rs index 628b04f234..f84d6b781e 100644 --- a/bin/ntx-builder/src/coordinator.rs +++ b/bin/ntx-builder/src/coordinator.rs @@ -193,7 +193,7 @@ impl Coordinator { .actor_context .state .db - .read("account_exists", move |tx| crate::db::queries::account_exists(tx, account_id)) + .account_exists(account_id) .await .context("failed to check for committed account state")?; @@ -334,14 +334,9 @@ mod tests { /// Seeds a committed row for `account_id` so the coordinator's spawn check sees the account. async fn seed_committed_account(coordinator: &Coordinator, account_id: AccountId) { let db = coordinator.actor_context.state.db.clone(); - crate::db::upsert_account_for_test( - &db, - account_id, - mock_account(account_id), - mock_transaction_id(0), - ) - .await - .unwrap(); + db.upsert_account_for_test(account_id, mock_account(account_id), mock_transaction_id(0)) + .await + .unwrap(); } #[tokio::test] @@ -398,7 +393,7 @@ mod tests { // The creation commits in a later block; the builder persists the block's effects to the DB // before handing them to the coordinator. let db = coordinator.actor_context.state.db.clone(); - crate::db::upsert_account_for_test(&db, account_id, account, mock_transaction_id(0)) + db.upsert_account_for_test(account_id, account, mock_transaction_id(0)) .await .unwrap(); let effects = CommittedBlockEffects { diff --git a/bin/ntx-builder/src/db/mod.rs b/bin/ntx-builder/src/db/mod.rs index add2bf79b4..f503d3448d 100644 --- a/bin/ntx-builder/src/db/mod.rs +++ b/bin/ntx-builder/src/db/mod.rs @@ -5,40 +5,203 @@ use anyhow::Context; use miden_node_db::DatabaseError; use miden_node_db::sqlite::Database; use miden_node_utils::tracing::miden_instrument; -use miden_protocol::block::SignedBlock; +use miden_protocol::Word; +use miden_protocol::account::{Account, AccountId}; +use miden_protocol::block::{BlockHeader, BlockNumber, SignedBlock}; use miden_protocol::crypto::merkle::mmr::PartialMmr; +use miden_protocol::note::{NoteId, NoteScript, Nullifier}; +use miden_protocol::transaction::TransactionId; +use miden_standards::note::AccountTargetNetworkNote; use tracing::info; -use crate::COMPONENT; use crate::committed_block::CommittedBlockEffects; use crate::db::migrations::{bootstrap_database, migrate_database, verify_latest_schema}; +use crate::db::queries::NoteStatusRow; +use crate::{COMPONENT, NoteError, db}; pub(crate) mod queries; mod migrations; -/// SQL statements, kept in dedicated `.sql` files (under `sql/`). -pub(crate) mod sql { - pub(crate) const UPSERT_ACCOUNT: &str = include_str!("sql/upsert_account.sql"); - pub(crate) const ACCOUNT_LAST_TX: &str = include_str!("sql/account_last_tx.sql"); - pub(crate) const ACCOUNT_EXISTS: &str = include_str!("sql/account_exists.sql"); - pub(crate) const GET_ACCOUNT: &str = include_str!("sql/get_account.sql"); - pub(crate) const UPDATE_CHAIN_STATE_TIP: &str = include_str!("sql/update_chain_state_tip.sql"); - pub(crate) const INSERT_GENESIS_CHAIN_STATE: &str = - include_str!("sql/insert_genesis_chain_state.sql"); - pub(crate) const SELECT_GENESIS_COMMITMENT: &str = - include_str!("sql/select_genesis_commitment.sql"); - pub(crate) const SELECT_CHAIN_STATE: &str = include_str!("sql/select_chain_state.sql"); - pub(crate) const LOOKUP_NOTE_SCRIPT: &str = include_str!("sql/lookup_note_script.sql"); - pub(crate) const INSERT_NOTE_SCRIPT: &str = include_str!("sql/insert_note_script.sql"); - pub(crate) const INSERT_NETWORK_NOTE: &str = include_str!("sql/insert_network_note.sql"); - pub(crate) const MARK_NOTE_CONSUMED: &str = include_str!("sql/mark_note_consumed.sql"); - pub(crate) const AVAILABLE_NOTES: &str = include_str!("sql/available_notes.sql"); - pub(crate) const NOTE_FAILED: &str = include_str!("sql/note_failed.sql"); - pub(crate) const DISCARD_NOTE: &str = include_str!("sql/discard_note.sql"); - pub(crate) const GET_NOTE_STATUS: &str = include_str!("sql/get_note_status.sql"); - pub(crate) const ACCOUNTS_WITH_PENDING_NOTES: &str = - include_str!("sql/accounts_with_pending_notes.sql"); +/// Reason recorded in a note's `last_error` when it is discarded for exceeding the per-tx cycle +/// budget on its own. +pub(crate) const OVERSIZED_NOTE_DISCARD_REASON: &str = + "note consumption exceeds the per-transaction cycle budget; it can never be consumed"; + +// NTX BUILDER DATABASE +// ================================================================================================ + +/// Wrapper of the miden-node-db database. +#[derive(Clone)] +pub(crate) struct NtxDb { + db: Database, +} + +impl NtxDb { + fn new(db: Database) -> Self { + Self { db } + } +} + +impl NtxDb { + pub(crate) async fn insert_genesis_chain_state( + &self, + genesis_header: BlockHeader, + genesis_commitment: Word, + ) -> Result<(), DatabaseError> { + self.db + .write("insert_genesis_chain_state", move |tx| { + queries::insert_genesis_chain_state(tx, &genesis_header, &genesis_commitment) + }) + .await + } + + pub(crate) async fn apply_committed_block( + &self, + effects: CommittedBlockEffects, + chain_mmr: PartialMmr, + ) -> Result<(), DatabaseError> { + self.db + .write("apply_committed_block", move |tx| { + queries::apply_committed_block(tx, &effects, &chain_mmr) + }) + .await + } + + pub(crate) async fn select_genesis_commitment(&self) -> Result, DatabaseError> { + self.db + .read("select_genesis_commitment", db::queries::select_genesis_commitment) + .await + } + + pub(crate) async fn get_account( + &self, + account_id: AccountId, + ) -> Result, DatabaseError> { + self.db + .read("get_account", move |tx| queries::get_account(tx, account_id)) + .await + } + + pub(crate) async fn has_available_notes( + &self, + account_id: AccountId, + block_num: BlockNumber, + max_note_attempts: usize, + ) -> Result { + self.db + .read("has_available_notes", move |tx| { + queries::has_available_notes(tx, account_id, block_num, max_note_attempts) + }) + .await + } + + pub(crate) async fn available_notes( + &self, + account_id: AccountId, + block_num: BlockNumber, + max_note_attempts: usize, + ) -> Result, DatabaseError> { + self.db + .read("available_notes", move |tx| { + queries::available_notes(tx, account_id, block_num, max_note_attempts) + }) + .await + } + + pub(crate) async fn select_chain_state( + &self, + ) -> Result, DatabaseError> { + self.db.read("select_chain_state", queries::select_chain_state).await + } + + pub(crate) async fn account_exists( + &self, + account_id: AccountId, + ) -> Result { + self.db + .read("account_exists", move |tx| db::queries::account_exists(tx, account_id)) + .await + } + + pub(crate) async fn accounts_with_pending_notes( + &self, + max_note_attempts: usize, + ) -> Result, DatabaseError> { + self.db + .read("accounts_with_pending_notes", move |tx| { + queries::accounts_with_pending_notes(tx, max_note_attempts) + }) + .await + } + + pub(crate) async fn notes_failed( + &self, + failed_notes: Vec<(Nullifier, NoteError)>, + block_num: BlockNumber, + ) -> Result<(), DatabaseError> { + self.db + .write("notes_failed", move |tx| queries::notes_failed(tx, &failed_notes, block_num)) + .await + } + + pub(crate) async fn discard_notes( + &self, + nullifiers: Vec, + block_num: BlockNumber, + max_attempts: usize, + ) -> Result<(), DatabaseError> { + self.db + .write("discard_notes", move |tx| { + queries::discard_notes( + tx, + &nullifiers, + block_num, + max_attempts, + OVERSIZED_NOTE_DISCARD_REASON, + ) + }) + .await + } + + pub(crate) async fn insert_note_scripts( + &self, + script_root: Word, + script: NoteScript, + ) -> Result<(), DatabaseError> { + self.db + .write("insert_note_script", move |tx| { + queries::insert_note_script(tx, &script_root, &script) + }) + .await + } + + pub(crate) async fn account_last_tx( + &self, + account_id: AccountId, + ) -> Result, DatabaseError> { + self.db + .read("account_last_tx", move |tx| queries::account_last_tx(tx, account_id)) + .await + } + + pub(crate) async fn lookup_note_script( + &self, + script_root: Word, + ) -> Result, DatabaseError> { + self.db + .read("lookup_note_script", move |tx| queries::lookup_note_script(tx, &script_root)) + .await + } + + pub(crate) async fn get_note_status( + &self, + note_id: NoteId, + ) -> Result, DatabaseError> { + self.db + .read("get_note_status", move |tx| crate::db::queries::get_note_status(tx, note_id)) + .await + } } // LIFECYCLE @@ -52,7 +215,7 @@ pub(crate) mod sql { fields(path=%database_filepath.display()), err, )] -pub async fn load(database_filepath: PathBuf) -> anyhow::Result { +pub async fn load(database_filepath: PathBuf) -> anyhow::Result { load_with_pool_size(database_filepath, miden_node_db::default_connection_pool_size()).await } @@ -68,7 +231,7 @@ pub async fn load(database_filepath: PathBuf) -> anyhow::Result { pub async fn load_with_pool_size( database_filepath: PathBuf, connection_pool_size: NonZeroUsize, -) -> anyhow::Result { +) -> anyhow::Result { verify_latest_schema(&database_filepath).context("failed to verify database schema")?; open_with_pool_size(&database_filepath, connection_pool_size) @@ -84,7 +247,7 @@ pub fn migrate(database_filepath: impl AsRef) -> Result<(), DatabaseError> fn open_with_pool_size( database_filepath: &Path, connection_pool_size: NonZeroUsize, -) -> anyhow::Result { +) -> anyhow::Result { let db = Database::new_with_pool_size(database_filepath, connection_pool_size) .context("failed to build connection pool")?; @@ -95,7 +258,7 @@ fn open_with_pool_size( "Connected to the database" ); - Ok(db) + Ok(NtxDb::new(db)) } /// Creates and initializes the database, then seeds it with the signed genesis block. @@ -121,18 +284,14 @@ pub async fn bootstrap(database_filepath: PathBuf, genesis: &SignedBlock) -> any let genesis_commitment = genesis.header().commitment(); let genesis_header = genesis.header().clone(); - db.write("insert_genesis_chain_state", move |tx| { - queries::insert_genesis_chain_state(tx, &genesis_header, &genesis_commitment) - }) - .await - .context("failed to seed genesis chain state")?; + db.insert_genesis_chain_state(genesis_header, genesis_commitment) + .await + .context("failed to seed genesis chain state")?; let effects = CommittedBlockEffects::from_signed_block(genesis); - db.write("apply_committed_block", move |tx| { - queries::apply_committed_block(tx, &effects, &PartialMmr::default()) - }) - .await - .context("failed to insert genesis block")?; + db.apply_committed_block(effects, PartialMmr::default()) + .await + .context("failed to insert genesis block")?; Ok(()) } @@ -142,7 +301,7 @@ pub async fn bootstrap(database_filepath: PathBuf, genesis: &SignedBlock) -> any /// Creates a schema-migrated (but un-seeded) database backed by a temp file for testing. #[cfg(test)] -pub(crate) async fn test_setup() -> (Database, tempfile::TempDir) { +pub(crate) async fn test_setup() -> (NtxDb, tempfile::TempDir) { let dir = tempfile::tempdir().expect("failed to create temp directory"); let db_path = dir.path().join("test.sqlite3"); bootstrap_database(&db_path).expect("database should bootstrap"); @@ -150,19 +309,102 @@ pub(crate) async fn test_setup() -> (Database, tempfile::TempDir) { (db, dir) } -/// Seeds a committed account row (and its `last_tx_id`) for tests that exercise the actor's landing -/// detection without driving a full committed block. +/// Test-only query helpers. +/// +/// These wrap queries that have no production [`NtxDb`] method (raw row counts, and the individual +/// writes that production code only ever performs as part of [`NtxDb::apply_committed_block`]), so +/// that tests still reach the database exclusively through the wrapper rather than the raw +/// [`Database`]. Production queries that already have a method are exercised through those directly. #[cfg(test)] -pub(crate) async fn upsert_account_for_test( - db: &Database, - account_id: miden_protocol::account::AccountId, - account: miden_protocol::account::Account, - last_tx_id: miden_protocol::transaction::TransactionId, -) -> Result<(), DatabaseError> { - db.write("test_upsert_account", move |tx| { - queries::upsert_account(tx, account_id, &account, last_tx_id) - }) - .await +impl NtxDb { + /// Seeds a committed account row (and its `last_tx_id`) for tests that exercise the actor's + /// landing detection without driving a full committed block. + pub(crate) async fn upsert_account_for_test( + &self, + account_id: AccountId, + account: Account, + last_tx_id: TransactionId, + ) -> Result<(), DatabaseError> { + self.db + .write("test_upsert_account", move |tx| { + queries::upsert_account(tx, account_id, &account, last_tx_id) + }) + .await + } + + /// Counts the rows returned by a `SELECT COUNT(*)` statement. + pub(crate) async fn count(&self, sql: &'static str) -> i64 { + self.db + .read("count", move |tx| { + let n = + tx.query(sql, &[], |row| row.get::(0))?.into_iter().next().unwrap_or(0); + Ok::(n) + }) + .await + .unwrap() + } + + pub(crate) async fn count_notes(&self) -> i64 { + self.count("SELECT COUNT(*) FROM notes").await + } + + pub(crate) async fn count_accounts(&self) -> i64 { + self.count("SELECT COUNT(*) FROM accounts").await + } + + pub(crate) async fn count_chain_state(&self) -> i64 { + self.count("SELECT COUNT(*) FROM chain_state").await + } + + pub(crate) async fn insert_network_notes( + &self, + notes: Vec, + ) -> Result<(), DatabaseError> { + self.db + .write("insert_network_notes", move |tx| queries::insert_network_notes(tx, ¬es)) + .await + } + + pub(crate) async fn mark_notes_consumed( + &self, + nullifiers: Vec, + block_num: BlockNumber, + ) -> Result<(), DatabaseError> { + self.db + .write("mark_notes_consumed", move |tx| { + queries::mark_notes_consumed(tx, &nullifiers, block_num) + }) + .await + } + + pub(crate) async fn update_chain_state_tip( + &self, + block_header: BlockHeader, + chain_mmr: PartialMmr, + ) -> Result<(), DatabaseError> { + let block_num = block_header.block_num(); + self.db + .write("update_chain_state_tip", move |tx| { + queries::update_chain_state_tip(tx, block_num, &block_header, &chain_mmr) + }) + .await + } + + /// Discards notes with an explicit reason, unlike [`NtxDb::discard_notes`] which always records + /// [`OVERSIZED_NOTE_DISCARD_REASON`]. + pub(crate) async fn discard_notes_with_reason( + &self, + nullifiers: Vec, + block_num: BlockNumber, + max_attempts: usize, + reason: String, + ) -> Result<(), DatabaseError> { + self.db + .write("discard_notes", move |tx| { + queries::discard_notes(tx, &nullifiers, block_num, max_attempts, &reason) + }) + .await + } } #[cfg(test)] @@ -170,7 +412,6 @@ mod tests { use miden_protocol::block::BlockNumber; use super::*; - use crate::db::queries; use crate::test_utils::{mock_genesis_block, mock_genesis_block_with_network_account}; #[tokio::test] @@ -184,10 +425,7 @@ mod tests { .expect("bootstrap should succeed with a network account in genesis"); let db = load(db_path).await.expect("load should open the bootstrapped database"); - let account = db - .read("get_account", move |tx| queries::get_account(tx, account_id)) - .await - .expect("query should succeed"); + let account = db.get_account(account_id).await.expect("query should succeed"); assert!(account.is_some(), "genesis network account should be committed after bootstrap"); } @@ -202,7 +440,7 @@ mod tests { let db = load(db_path).await.expect("load should open the bootstrapped database"); let (block_num, ..) = db - .read("select_chain_state", queries::select_chain_state) + .select_chain_state() .await .expect("query should succeed") .expect("chain state should be present after bootstrap"); diff --git a/bin/ntx-builder/src/db/sql/account_exists.sql b/bin/ntx-builder/src/db/queries/account_exists/account_exists.sql similarity index 100% rename from bin/ntx-builder/src/db/sql/account_exists.sql rename to bin/ntx-builder/src/db/queries/account_exists/account_exists.sql diff --git a/bin/ntx-builder/src/db/queries/account_exists/mod.rs b/bin/ntx-builder/src/db/queries/account_exists/mod.rs new file mode 100644 index 0000000000..21b7a8ac85 --- /dev/null +++ b/bin/ntx-builder/src/db/queries/account_exists/mod.rs @@ -0,0 +1,16 @@ +//! Checks whether a network account is tracked locally. + +use miden_node_db::DatabaseError; +use miden_node_db::sqlite::ReadTx; +use miden_protocol::account::AccountId; + +const SQL: &str = include_str!("account_exists.sql"); + +/// Returns `true` if a committed state for the given account is tracked locally. +pub fn account_exists(tx: &ReadTx<'_>, account_id: AccountId) -> Result { + Ok(tx + .query(SQL, &[&account_id], |row| row.get::(0))? + .into_iter() + .next() + .unwrap_or(false)) +} diff --git a/bin/ntx-builder/src/db/sql/account_last_tx.sql b/bin/ntx-builder/src/db/queries/account_last_tx/account_last_tx.sql similarity index 100% rename from bin/ntx-builder/src/db/sql/account_last_tx.sql rename to bin/ntx-builder/src/db/queries/account_last_tx/account_last_tx.sql diff --git a/bin/ntx-builder/src/db/queries/account_last_tx/mod.rs b/bin/ntx-builder/src/db/queries/account_last_tx/mod.rs new file mode 100644 index 0000000000..af65a82693 --- /dev/null +++ b/bin/ntx-builder/src/db/queries/account_last_tx/mod.rs @@ -0,0 +1,20 @@ +//! Returns the latest transaction recorded against a network account. + +use miden_node_db::DatabaseError; +use miden_node_db::sqlite::ReadTx; +use miden_protocol::account::AccountId; +use miden_protocol::transaction::TransactionId; + +const SQL: &str = include_str!("account_last_tx.sql"); + +/// Returns the latest transaction recorded against `account_id`, or `None` if the account is not +/// tracked locally. +pub fn account_last_tx( + tx: &ReadTx<'_>, + account_id: AccountId, +) -> Result, DatabaseError> { + Ok(tx + .query(SQL, &[&account_id], |row| row.get::(0))? + .into_iter() + .next()) +} diff --git a/bin/ntx-builder/src/db/queries/accounts.rs b/bin/ntx-builder/src/db/queries/accounts.rs deleted file mode 100644 index b3e1f4a668..0000000000 --- a/bin/ntx-builder/src/db/queries/accounts.rs +++ /dev/null @@ -1,52 +0,0 @@ -//! Account-related queries. - -use miden_node_db::DatabaseError; -use miden_node_db::sqlite::{ReadTx, WriteTx}; -use miden_protocol::account::{Account, AccountId}; -use miden_protocol::transaction::TransactionId; - -use crate::db::sql; - -/// Inserts the committed account state, or updates an existing account's state. In both cases -/// `last_tx_id` is set to the transaction that produced this update. -pub fn upsert_account( - tx: &WriteTx<'_>, - account_id: AccountId, - account: &Account, - last_tx_id: TransactionId, -) -> Result<(), DatabaseError> { - tx.execute(sql::UPSERT_ACCOUNT, &[&account_id, account, &last_tx_id])?; - Ok(()) -} - -/// Returns the latest transaction recorded against `account_id`, or `None` if the account is not -/// tracked locally. -pub fn account_last_tx( - tx: &ReadTx<'_>, - account_id: AccountId, -) -> Result, DatabaseError> { - Ok(tx - .query(sql::ACCOUNT_LAST_TX, &[&account_id], |row| row.get::(0))? - .into_iter() - .next()) -} - -/// Returns `true` if a committed state for the given account is tracked locally. -pub fn account_exists(tx: &ReadTx<'_>, account_id: AccountId) -> Result { - Ok(tx - .query(sql::ACCOUNT_EXISTS, &[&account_id], |row| row.get::(0))? - .into_iter() - .next() - .unwrap_or(false)) -} - -/// Returns the committed account state for the given network account. -pub fn get_account( - tx: &ReadTx<'_>, - account_id: AccountId, -) -> Result, DatabaseError> { - Ok(tx - .query(sql::GET_ACCOUNT, &[&account_id], |row| row.get::(0))? - .into_iter() - .next()) -} diff --git a/bin/ntx-builder/src/db/sql/accounts_with_pending_notes.sql b/bin/ntx-builder/src/db/queries/accounts_with_pending_notes/accounts_with_pending_notes.sql similarity index 100% rename from bin/ntx-builder/src/db/sql/accounts_with_pending_notes.sql rename to bin/ntx-builder/src/db/queries/accounts_with_pending_notes/accounts_with_pending_notes.sql diff --git a/bin/ntx-builder/src/db/queries/accounts_with_pending_notes/mod.rs b/bin/ntx-builder/src/db/queries/accounts_with_pending_notes/mod.rs new file mode 100644 index 0000000000..7f1e8b9f86 --- /dev/null +++ b/bin/ntx-builder/src/db/queries/accounts_with_pending_notes/mod.rs @@ -0,0 +1,17 @@ +//! Returns the network accounts that currently have pending notes. + +use miden_node_db::DatabaseError; +use miden_node_db::sqlite::ReadTx; +use miden_protocol::account::AccountId; + +const SQL: &str = include_str!("accounts_with_pending_notes.sql"); + +/// Returns the distinct set of network accounts that currently have at least one pending note +/// (unconsumed and within the per-note attempt budget). +#[expect(clippy::cast_possible_wrap)] +pub fn accounts_with_pending_notes( + tx: &ReadTx<'_>, + max_attempts: usize, +) -> Result, DatabaseError> { + tx.query(SQL, &[&(max_attempts as i64)], |row| row.get::(0)) +} diff --git a/bin/ntx-builder/src/db/sql/available_notes.sql b/bin/ntx-builder/src/db/queries/available_notes/available_notes.sql similarity index 100% rename from bin/ntx-builder/src/db/sql/available_notes.sql rename to bin/ntx-builder/src/db/queries/available_notes/available_notes.sql diff --git a/bin/ntx-builder/src/db/queries/available_notes/mod.rs b/bin/ntx-builder/src/db/queries/available_notes/mod.rs new file mode 100644 index 0000000000..f9cfa36e97 --- /dev/null +++ b/bin/ntx-builder/src/db/queries/available_notes/mod.rs @@ -0,0 +1,109 @@ +//! Selects notes available for consumption by a network account. + +use miden_node_db::sqlite::ReadTx; +use miden_node_db::{DatabaseError, SqlTypeConvert}; +use miden_protocol::account::AccountId; +use miden_protocol::block::BlockNumber; +use miden_protocol::note::Note; +use miden_standards::note::AccountTargetNetworkNote; + +const SQL: &str = include_str!("available_notes.sql"); + +/// Returns `true` if there is at least one note available for consumption by the given account. +pub fn has_available_notes( + tx: &ReadTx<'_>, + account_id: AccountId, + block_num: BlockNumber, + max_attempts: usize, +) -> Result { + Ok(!available_notes(tx, account_id, block_num, max_attempts)?.is_empty()) +} + +/// Returns notes available for consumption by a given account. +/// +/// Selects unconsumed notes for the account (a row exists only while a note is unconsumed) whose +/// `attempt_count` is below the cap, then applies execution-hint and backoff filtering in Rust. +#[expect(clippy::cast_possible_wrap)] +pub fn available_notes( + tx: &ReadTx<'_>, + account_id: AccountId, + block_num: BlockNumber, + max_attempts: usize, +) -> Result, DatabaseError> { + let rows = tx.query(SQL, &[&account_id, &(max_attempts as i64)], |row| { + Ok((row.get::(0)?, row.get::(1)?, row.get::>(2)?)) + })?; + + let mut result = Vec::new(); + for (note, attempt_count, last_attempt) in rows { + #[expect(clippy::cast_sign_loss)] + let attempt_count = attempt_count as usize; + let last_attempt = last_attempt.map(BlockNumber::from_raw_sql).transpose()?; + let note = AccountTargetNetworkNote::new(note).map_err(|source| { + DatabaseError::deserialization("failed to convert to network note", source) + })?; + + let execution_hint_ok = note.execution_hint().can_be_consumed(block_num).unwrap_or(true); + if execution_hint_ok && has_backoff_passed(block_num, last_attempt, attempt_count) { + result.push(note); + } + } + + Ok(result) +} + +// HELPERS +// ================================================================================================ + +/// Checks if the backoff block period has passed. +/// +/// The number of blocks passed since the last attempt must be greater than or equal to +/// e^(0.25 * `attempt_count`) rounded to the nearest integer. +#[expect(clippy::cast_precision_loss, clippy::cast_sign_loss)] +fn has_backoff_passed( + chain_tip: BlockNumber, + last_attempt: Option, + attempts: usize, +) -> bool { + if attempts == 0 { + return true; + } + let blocks_passed = last_attempt + .and_then(|last| chain_tip.checked_sub(last.as_u32())) + .unwrap_or_default(); + + let backoff_threshold = (0.25 * attempts as f64).exp().round() as usize; + + blocks_passed.as_usize() > backoff_threshold +} + +#[cfg(test)] +mod tests { + use miden_protocol::block::BlockNumber; + + use super::has_backoff_passed; + + #[rstest::rstest] + #[test] + #[case::all_zero(Some(BlockNumber::GENESIS), BlockNumber::GENESIS, 0, true)] + #[case::no_attempts(None, BlockNumber::GENESIS, 0, true)] + #[case::one_attempt(Some(BlockNumber::GENESIS), BlockNumber::from(2), 1, true)] + #[case::three_attempts(Some(BlockNumber::GENESIS), BlockNumber::from(3), 3, true)] + #[case::ten_attempts(Some(BlockNumber::GENESIS), BlockNumber::from(13), 10, true)] + #[case::twenty_attempts(Some(BlockNumber::GENESIS), BlockNumber::from(149), 20, true)] + #[case::one_attempt_false(Some(BlockNumber::GENESIS), BlockNumber::from(1), 1, false)] + #[case::three_attempts_false(Some(BlockNumber::GENESIS), BlockNumber::from(2), 3, false)] + #[case::ten_attempts_false(Some(BlockNumber::GENESIS), BlockNumber::from(12), 10, false)] + #[case::twenty_attempts_false(Some(BlockNumber::GENESIS), BlockNumber::from(148), 20, false)] + fn backoff_has_passed( + #[case] last_attempt_block_num: Option, + #[case] current_block_num: BlockNumber, + #[case] attempt_count: usize, + #[case] backoff_should_have_passed: bool, + ) { + assert_eq!( + backoff_should_have_passed, + has_backoff_passed(current_block_num, last_attempt_block_num, attempt_count) + ); + } +} diff --git a/bin/ntx-builder/src/db/queries/chain_state.rs b/bin/ntx-builder/src/db/queries/chain_state.rs deleted file mode 100644 index df95ef6e3e..0000000000 --- a/bin/ntx-builder/src/db/queries/chain_state.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! Chain state queries. - -use miden_node_db::sqlite::{ReadTx, WriteTx}; -use miden_node_db::{DatabaseError, SqlTypeConvert}; -use miden_protocol::Word; -use miden_protocol::block::{BlockHeader, BlockNumber}; -use miden_protocol::crypto::merkle::mmr::PartialMmr; - -use crate::db::sql; - -/// Updates the tip columns (block number, header, and partial chain MMR) of the singleton chain -/// state row. The row is created once at bootstrap by [`insert_genesis_chain_state`], so this is a -/// plain update; the `genesis_commitment` column is set at bootstrap and never touched here. -pub fn update_chain_state_tip( - tx: &WriteTx<'_>, - block_num: BlockNumber, - block_header: &BlockHeader, - chain_mmr: &PartialMmr, -) -> Result<(), DatabaseError> { - tx.execute(sql::UPDATE_CHAIN_STATE_TIP, &[&block_num.to_raw_sql(), block_header, chain_mmr])?; - Ok(()) -} - -/// Inserts the singleton chain state row at bootstrap, seeding the tip columns from the genesis -/// block together with the genesis block commitment. The commitment satisfies the `NOT NULL` -/// constraint at insert time and is retained across all subsequent tip updates (see -/// [`update_chain_state_tip`]). -pub fn insert_genesis_chain_state( - tx: &WriteTx<'_>, - genesis_block_header: &BlockHeader, - genesis_commitment: &Word, -) -> Result<(), DatabaseError> { - assert_eq!( - genesis_block_header.block_num(), - BlockNumber::GENESIS, - "bootstrap block number is not 0" - ); - tx.execute( - sql::INSERT_GENESIS_CHAIN_STATE, - &[ - &genesis_block_header.block_num().to_raw_sql(), - genesis_block_header, - &PartialMmr::default(), - genesis_commitment, - ], - )?; - Ok(()) -} - -/// Reads the genesis block commitment from the singleton chain state row, or `None` if the database -/// has not been bootstrapped. -pub fn select_genesis_commitment(tx: &ReadTx<'_>) -> Result, DatabaseError> { - Ok(tx - .query(sql::SELECT_GENESIS_COMMITMENT, &[], |row| row.get::(0))? - .into_iter() - .next()) -} - -/// Reads the singleton chain state row, returning the persisted block number, header, and chain MMR -/// if any block has been applied locally. -pub fn select_chain_state( - tx: &ReadTx<'_>, -) -> Result, DatabaseError> { - Ok(tx - .query(sql::SELECT_CHAIN_STATE, &[], |row| { - Ok(( - BlockNumber::from_raw_sql(row.get::(0)?)?, - row.get::(1)?, - row.get::(2)?, - )) - })? - .into_iter() - .next()) -} diff --git a/bin/ntx-builder/src/db/sql/discard_note.sql b/bin/ntx-builder/src/db/queries/discard_notes/discard_note.sql similarity index 100% rename from bin/ntx-builder/src/db/sql/discard_note.sql rename to bin/ntx-builder/src/db/queries/discard_notes/discard_note.sql diff --git a/bin/ntx-builder/src/db/queries/discard_notes/mod.rs b/bin/ntx-builder/src/db/queries/discard_notes/mod.rs new file mode 100644 index 0000000000..eab9fab015 --- /dev/null +++ b/bin/ntx-builder/src/db/queries/discard_notes/mod.rs @@ -0,0 +1,32 @@ +//! Marks notes as permanently unconsumable. + +use miden_node_db::sqlite::WriteTx; +use miden_node_db::{DatabaseError, SqlTypeConvert}; +use miden_protocol::block::BlockNumber; +use miden_protocol::note::Nullifier; + +const SQL: &str = include_str!("discard_note.sql"); + +/// Marks notes as permanently unconsumable by pinning `attempt_count` to `max_attempts`. +/// +/// A note whose own consumption exceeds the per-transaction cycle budget can never be consumed in +/// any transaction, so retrying it is pointless. Setting `attempt_count` to `max_attempts` takes it +/// out of the pending set immediately (`available_notes`/`account_has_pending_notes` filter on +/// `attempt_count < max_attempts`) and makes +/// [`get_note_status`](super::get_note_status) derive it as `Discarded`, while `last_error` records +/// why. +#[expect(clippy::cast_possible_wrap)] +pub fn discard_notes( + tx: &WriteTx<'_>, + nullifiers: &[Nullifier], + block_num: BlockNumber, + max_attempts: usize, + reason: &str, +) -> Result<(), DatabaseError> { + let block_num_val = block_num.to_raw_sql(); + let reason = reason.to_string(); + for nullifier in nullifiers { + tx.execute(SQL, &[nullifier, &(max_attempts as i64), &block_num_val, &reason])?; + } + Ok(()) +} diff --git a/bin/ntx-builder/src/db/sql/get_account.sql b/bin/ntx-builder/src/db/queries/get_account/get_account.sql similarity index 100% rename from bin/ntx-builder/src/db/sql/get_account.sql rename to bin/ntx-builder/src/db/queries/get_account/get_account.sql diff --git a/bin/ntx-builder/src/db/queries/get_account/mod.rs b/bin/ntx-builder/src/db/queries/get_account/mod.rs new file mode 100644 index 0000000000..674d842d29 --- /dev/null +++ b/bin/ntx-builder/src/db/queries/get_account/mod.rs @@ -0,0 +1,15 @@ +//! Returns the committed account state for a given network account. + +use miden_node_db::DatabaseError; +use miden_node_db::sqlite::ReadTx; +use miden_protocol::account::{Account, AccountId}; + +const SQL: &str = include_str!("get_account.sql"); + +/// Returns the committed account state for the given network account. +pub fn get_account( + tx: &ReadTx<'_>, + account_id: AccountId, +) -> Result, DatabaseError> { + Ok(tx.query(SQL, &[&account_id], |row| row.get::(0))?.into_iter().next()) +} diff --git a/bin/ntx-builder/src/db/sql/get_note_status.sql b/bin/ntx-builder/src/db/queries/get_note_status/get_note_status.sql similarity index 100% rename from bin/ntx-builder/src/db/sql/get_note_status.sql rename to bin/ntx-builder/src/db/queries/get_note_status/get_note_status.sql diff --git a/bin/ntx-builder/src/db/queries/get_note_status/mod.rs b/bin/ntx-builder/src/db/queries/get_note_status/mod.rs new file mode 100644 index 0000000000..4c5b192c79 --- /dev/null +++ b/bin/ntx-builder/src/db/queries/get_note_status/mod.rs @@ -0,0 +1,34 @@ +//! Returns the persisted status of a note by its note ID. + +use miden_node_db::DatabaseError; +use miden_node_db::sqlite::ReadTx; +use miden_protocol::note::NoteId; + +const SQL: &str = include_str!("get_note_status.sql"); + +/// Row returned by [`get_note_status`]. +#[derive(Debug, Clone)] +pub struct NoteStatusRow { + pub last_error: Option, + pub attempt_count: i64, + pub last_attempt: Option, + pub committed_at: Option, +} + +/// Returns the status for a note identified by its note ID. +pub fn get_note_status( + tx: &ReadTx<'_>, + note_id: NoteId, +) -> Result, DatabaseError> { + Ok(tx + .query(SQL, &[¬e_id], |row| { + Ok(NoteStatusRow { + last_error: row.get::>(0)?, + attempt_count: row.get::(1)?, + last_attempt: row.get::>(2)?, + committed_at: row.get::>(3)?, + }) + })? + .into_iter() + .next()) +} diff --git a/bin/ntx-builder/src/db/sql/insert_genesis_chain_state.sql b/bin/ntx-builder/src/db/queries/insert_genesis_chain_state/insert_genesis_chain_state.sql similarity index 100% rename from bin/ntx-builder/src/db/sql/insert_genesis_chain_state.sql rename to bin/ntx-builder/src/db/queries/insert_genesis_chain_state/insert_genesis_chain_state.sql diff --git a/bin/ntx-builder/src/db/queries/insert_genesis_chain_state/mod.rs b/bin/ntx-builder/src/db/queries/insert_genesis_chain_state/mod.rs new file mode 100644 index 0000000000..af409a676e --- /dev/null +++ b/bin/ntx-builder/src/db/queries/insert_genesis_chain_state/mod.rs @@ -0,0 +1,35 @@ +//! Seeds the singleton chain state row at bootstrap. + +use miden_node_db::sqlite::WriteTx; +use miden_node_db::{DatabaseError, SqlTypeConvert}; +use miden_protocol::Word; +use miden_protocol::block::{BlockHeader, BlockNumber}; +use miden_protocol::crypto::merkle::mmr::PartialMmr; + +const SQL: &str = include_str!("insert_genesis_chain_state.sql"); + +/// Inserts the singleton chain state row at bootstrap, seeding the tip columns from the genesis +/// block together with the genesis block commitment. The commitment satisfies the `NOT NULL` +/// constraint at insert time and is retained across all subsequent tip updates (see +/// [`update_chain_state_tip`](super::update_chain_state_tip)). +pub fn insert_genesis_chain_state( + tx: &WriteTx<'_>, + genesis_block_header: &BlockHeader, + genesis_commitment: &Word, +) -> Result<(), DatabaseError> { + assert_eq!( + genesis_block_header.block_num(), + BlockNumber::GENESIS, + "bootstrap block number is not 0" + ); + tx.execute( + SQL, + &[ + &genesis_block_header.block_num().to_raw_sql(), + genesis_block_header, + &PartialMmr::default(), + genesis_commitment, + ], + )?; + Ok(()) +} diff --git a/bin/ntx-builder/src/db/sql/insert_network_note.sql b/bin/ntx-builder/src/db/queries/insert_network_notes/insert_network_note.sql similarity index 100% rename from bin/ntx-builder/src/db/sql/insert_network_note.sql rename to bin/ntx-builder/src/db/queries/insert_network_notes/insert_network_note.sql diff --git a/bin/ntx-builder/src/db/queries/insert_network_notes/mod.rs b/bin/ntx-builder/src/db/queries/insert_network_notes/mod.rs new file mode 100644 index 0000000000..dd0acf7ffb --- /dev/null +++ b/bin/ntx-builder/src/db/queries/insert_network_notes/mod.rs @@ -0,0 +1,21 @@ +//! Inserts network notes from a committed block. + +use miden_node_db::DatabaseError; +use miden_node_db::sqlite::WriteTx; +use miden_standards::note::AccountTargetNetworkNote; + +const SQL: &str = include_str!("insert_network_note.sql"); + +/// Inserts network notes from a committed block. Uses `INSERT OR IGNORE` so re-applying the same +/// block (e.g. on a redelivery from the subscription stream) is a no-op rather than a constraint +/// violation. +pub fn insert_network_notes( + tx: &WriteTx<'_>, + notes: &[AccountTargetNetworkNote], +) -> Result<(), DatabaseError> { + for note in notes { + let inner = note.as_note(); + tx.execute(SQL, &[&inner.nullifier(), ¬e.target_account_id(), inner, &inner.id()])?; + } + Ok(()) +} diff --git a/bin/ntx-builder/src/db/sql/insert_note_script.sql b/bin/ntx-builder/src/db/queries/insert_note_scripts/insert_note_script.sql similarity index 100% rename from bin/ntx-builder/src/db/sql/insert_note_script.sql rename to bin/ntx-builder/src/db/queries/insert_note_scripts/insert_note_script.sql diff --git a/bin/ntx-builder/src/db/queries/insert_note_scripts/mod.rs b/bin/ntx-builder/src/db/queries/insert_note_scripts/mod.rs new file mode 100644 index 0000000000..aded96c2ff --- /dev/null +++ b/bin/ntx-builder/src/db/queries/insert_note_scripts/mod.rs @@ -0,0 +1,18 @@ +//! Inserts a note script (idempotent via INSERT OR IGNORE). + +use miden_node_db::DatabaseError; +use miden_node_db::sqlite::WriteTx; +use miden_protocol::Word; +use miden_protocol::note::NoteScript; + +const SQL: &str = include_str!("insert_note_script.sql"); + +/// Inserts a note script (idempotent via INSERT OR IGNORE). +pub fn insert_note_script( + tx: &WriteTx<'_>, + script_root: &Word, + script: &NoteScript, +) -> Result<(), DatabaseError> { + tx.execute(SQL, &[script_root, script])?; + Ok(()) +} diff --git a/bin/ntx-builder/src/db/sql/lookup_note_script.sql b/bin/ntx-builder/src/db/queries/lookup_note_script/lookup_note_script.sql similarity index 100% rename from bin/ntx-builder/src/db/sql/lookup_note_script.sql rename to bin/ntx-builder/src/db/queries/lookup_note_script/lookup_note_script.sql diff --git a/bin/ntx-builder/src/db/queries/lookup_note_script/mod.rs b/bin/ntx-builder/src/db/queries/lookup_note_script/mod.rs new file mode 100644 index 0000000000..4a965b6ef0 --- /dev/null +++ b/bin/ntx-builder/src/db/queries/lookup_note_script/mod.rs @@ -0,0 +1,16 @@ +//! Looks up a note script by its root hash. + +use miden_node_db::DatabaseError; +use miden_node_db::sqlite::ReadTx; +use miden_protocol::Word; +use miden_protocol::note::NoteScript; + +const SQL: &str = include_str!("lookup_note_script.sql"); + +/// Looks up a note script by its root hash. +pub fn lookup_note_script( + tx: &ReadTx<'_>, + script_root: &Word, +) -> Result, DatabaseError> { + Ok(tx.query(SQL, &[script_root], |row| row.get::(0))?.first().cloned()) +} diff --git a/bin/ntx-builder/src/db/sql/mark_note_consumed.sql b/bin/ntx-builder/src/db/queries/mark_notes_consumed/mark_note_consumed.sql similarity index 100% rename from bin/ntx-builder/src/db/sql/mark_note_consumed.sql rename to bin/ntx-builder/src/db/queries/mark_notes_consumed/mark_note_consumed.sql diff --git a/bin/ntx-builder/src/db/queries/mark_notes_consumed/mod.rs b/bin/ntx-builder/src/db/queries/mark_notes_consumed/mod.rs new file mode 100644 index 0000000000..59f3344565 --- /dev/null +++ b/bin/ntx-builder/src/db/queries/mark_notes_consumed/mod.rs @@ -0,0 +1,26 @@ +//! Marks notes as consumed by the block that contained their nullifier. + +use miden_node_db::sqlite::WriteTx; +use miden_node_db::{DatabaseError, SqlTypeConvert}; +use miden_protocol::block::BlockNumber; +use miden_protocol::note::Nullifier; + +const SQL: &str = include_str!("mark_note_consumed.sql"); + +/// Marks notes as consumed by setting `committed_at` to the block number whose committed body +/// contained their nullifier. Rows for nullifiers we never inserted (notes whose targets are not +/// network accounts, or notes that arrived before our subscription cursor) are silently skipped. +/// +/// Rows are kept around (not deleted) so the `GetNetworkNoteStatus` endpoint can report the full +/// lifecycle of any note the ntx-builder has ever seen. +pub fn mark_notes_consumed( + tx: &WriteTx<'_>, + nullifiers: &[Nullifier], + block_num: BlockNumber, +) -> Result<(), DatabaseError> { + let block_num_val = block_num.to_raw_sql(); + for nullifier in nullifiers { + tx.execute(SQL, &[nullifier, &block_num_val])?; + } + Ok(()) +} diff --git a/bin/ntx-builder/src/db/queries/mod.rs b/bin/ntx-builder/src/db/queries/mod.rs index a50658eba7..32728c0ce2 100644 --- a/bin/ntx-builder/src/db/queries/mod.rs +++ b/bin/ntx-builder/src/db/queries/mod.rs @@ -20,17 +20,56 @@ use crate::db::queries::account_effect::NetworkAccountEffect; pub(crate) mod account_effect; -mod accounts; -pub use accounts::*; +mod account_exists; +pub use account_exists::account_exists; -mod chain_state; -pub use chain_state::*; +mod account_last_tx; +pub use account_last_tx::account_last_tx; -mod note_scripts; -pub use note_scripts::*; +mod accounts_with_pending_notes; +pub use accounts_with_pending_notes::accounts_with_pending_notes; -mod notes; -pub use notes::*; +mod available_notes; +pub use available_notes::{available_notes, has_available_notes}; + +mod discard_notes; +pub use discard_notes::discard_notes; + +mod get_account; +pub use get_account::get_account; + +mod get_note_status; +pub use get_note_status::{NoteStatusRow, get_note_status}; + +mod insert_genesis_chain_state; +pub use insert_genesis_chain_state::insert_genesis_chain_state; + +mod insert_network_notes; +pub use insert_network_notes::insert_network_notes; + +mod insert_note_scripts; +pub use insert_note_scripts::insert_note_script; + +mod lookup_note_script; +pub use lookup_note_script::lookup_note_script; + +mod mark_notes_consumed; +pub use mark_notes_consumed::mark_notes_consumed; + +mod notes_failed; +pub use notes_failed::notes_failed; + +mod select_chain_state; +pub use select_chain_state::select_chain_state; + +mod select_genesis_commitment; +pub use select_genesis_commitment::select_genesis_commitment; + +mod update_chain_state_tip; +pub use update_chain_state_tip::update_chain_state_tip; + +mod upsert_account; +pub use upsert_account::upsert_account; #[cfg(test)] mod tests; diff --git a/bin/ntx-builder/src/db/queries/note_scripts.rs b/bin/ntx-builder/src/db/queries/note_scripts.rs deleted file mode 100644 index 57e83147bf..0000000000 --- a/bin/ntx-builder/src/db/queries/note_scripts.rs +++ /dev/null @@ -1,29 +0,0 @@ -//! Queries for persisting and retrieving note scripts. - -use miden_node_db::DatabaseError; -use miden_node_db::sqlite::{ReadTx, WriteTx}; -use miden_protocol::Word; -use miden_protocol::note::NoteScript; - -use crate::db::sql; - -/// Looks up a note script by its root hash. -pub fn lookup_note_script( - tx: &ReadTx<'_>, - script_root: &Word, -) -> Result, DatabaseError> { - Ok(tx - .query(sql::LOOKUP_NOTE_SCRIPT, &[script_root], |row| row.get::(0))? - .first() - .cloned()) -} - -/// Inserts a note script (idempotent via INSERT OR IGNORE). -pub fn insert_note_script( - tx: &WriteTx<'_>, - script_root: &Word, - script: &NoteScript, -) -> Result<(), DatabaseError> { - tx.execute(sql::INSERT_NOTE_SCRIPT, &[script_root, script])?; - Ok(()) -} diff --git a/bin/ntx-builder/src/db/queries/notes.rs b/bin/ntx-builder/src/db/queries/notes.rs deleted file mode 100644 index a203ae906d..0000000000 --- a/bin/ntx-builder/src/db/queries/notes.rs +++ /dev/null @@ -1,227 +0,0 @@ -//! Note-related queries. - -use miden_node_db::sqlite::{ReadTx, WriteTx}; -use miden_node_db::{DatabaseError, SqlTypeConvert}; -use miden_node_utils::ErrorReport; -use miden_protocol::account::AccountId; -use miden_protocol::block::BlockNumber; -use miden_protocol::note::{Note, NoteId, Nullifier}; -use miden_standards::note::AccountTargetNetworkNote; - -use crate::NoteError; -use crate::db::sql; - -/// Row returned by [`get_note_status`]. -#[derive(Debug, Clone)] -pub struct NoteStatusRow { - pub last_error: Option, - pub attempt_count: i64, - pub last_attempt: Option, - pub committed_at: Option, -} - -/// Inserts network notes from a committed block. Uses `INSERT OR IGNORE` so re-applying the same -/// block (e.g. on a redelivery from the subscription stream) is a no-op rather than a constraint -/// violation. -pub fn insert_network_notes( - tx: &WriteTx<'_>, - notes: &[AccountTargetNetworkNote], -) -> Result<(), DatabaseError> { - for note in notes { - let inner = note.as_note(); - tx.execute( - sql::INSERT_NETWORK_NOTE, - &[&inner.nullifier(), ¬e.target_account_id(), inner, &inner.id()], - )?; - } - Ok(()) -} - -/// Marks notes as consumed by setting `committed_at` to the block number whose committed body -/// contained their nullifier. Rows for nullifiers we never inserted (notes whose targets are not -/// network accounts, or notes that arrived before our subscription cursor) are silently skipped. -/// -/// Rows are kept around (not deleted) so the `GetNetworkNoteStatus` endpoint can report the full -/// lifecycle of any note the ntx-builder has ever seen. -pub fn mark_notes_consumed( - tx: &WriteTx<'_>, - nullifiers: &[Nullifier], - block_num: BlockNumber, -) -> Result<(), DatabaseError> { - let block_num_val = block_num.to_raw_sql(); - for nullifier in nullifiers { - tx.execute(sql::MARK_NOTE_CONSUMED, &[nullifier, &block_num_val])?; - } - Ok(()) -} - -/// Returns `true` if there is at least one note available for consumption by the given account. -pub fn has_available_notes( - tx: &ReadTx<'_>, - account_id: AccountId, - block_num: BlockNumber, - max_attempts: usize, -) -> Result { - Ok(!available_notes(tx, account_id, block_num, max_attempts)?.is_empty()) -} - -/// Returns notes available for consumption by a given account. -/// -/// Selects unconsumed notes for the account (a row exists only while a note is unconsumed) whose -/// `attempt_count` is below the cap, then applies execution-hint and backoff filtering in Rust. -#[expect(clippy::cast_possible_wrap)] -pub fn available_notes( - tx: &ReadTx<'_>, - account_id: AccountId, - block_num: BlockNumber, - max_attempts: usize, -) -> Result, DatabaseError> { - let rows = tx.query(sql::AVAILABLE_NOTES, &[&account_id, &(max_attempts as i64)], |row| { - Ok((row.get::(0)?, row.get::(1)?, row.get::>(2)?)) - })?; - - let mut result = Vec::new(); - for (note, attempt_count, last_attempt) in rows { - #[expect(clippy::cast_sign_loss)] - let attempt_count = attempt_count as usize; - let last_attempt = last_attempt.map(BlockNumber::from_raw_sql).transpose()?; - let note = AccountTargetNetworkNote::new(note).map_err(|source| { - DatabaseError::deserialization("failed to convert to network note", source) - })?; - - let execution_hint_ok = note.execution_hint().can_be_consumed(block_num).unwrap_or(true); - if execution_hint_ok && has_backoff_passed(block_num, last_attempt, attempt_count) { - result.push(note); - } - } - - Ok(result) -} - -/// Marks notes as failed by incrementing `attempt_count`, setting `last_attempt`, and storing the -/// latest error message. -pub fn notes_failed( - tx: &WriteTx<'_>, - failed_notes: &[(Nullifier, NoteError)], - block_num: BlockNumber, -) -> Result<(), DatabaseError> { - let block_num_val = block_num.to_raw_sql(); - - for (nullifier, error) in failed_notes { - let error_report = error.as_report(); - tx.execute(sql::NOTE_FAILED, &[nullifier, &block_num_val, &error_report])?; - } - Ok(()) -} - -/// Marks notes as permanently unconsumable by pinning `attempt_count` to `max_attempts`. -/// -/// A note whose own consumption exceeds the per-transaction cycle budget can never be consumed in -/// any transaction, so retrying it is pointless. Setting `attempt_count` to `max_attempts` takes it -/// out of the pending set immediately (`available_notes`/`account_has_pending_notes` filter on -/// `attempt_count < max_attempts`) and makes [`get_note_status`] derive it as `Discarded`, while -/// `last_error` records why. -#[expect(clippy::cast_possible_wrap)] -pub fn discard_notes( - tx: &WriteTx<'_>, - nullifiers: &[Nullifier], - block_num: BlockNumber, - max_attempts: usize, - reason: &str, -) -> Result<(), DatabaseError> { - let block_num_val = block_num.to_raw_sql(); - let reason = reason.to_string(); - for nullifier in nullifiers { - tx.execute( - sql::DISCARD_NOTE, - &[nullifier, &(max_attempts as i64), &block_num_val, &reason], - )?; - } - Ok(()) -} - -/// Returns the status for a note identified by its note ID. -pub fn get_note_status( - tx: &ReadTx<'_>, - note_id: NoteId, -) -> Result, DatabaseError> { - Ok(tx - .query(sql::GET_NOTE_STATUS, &[¬e_id], |row| { - Ok(NoteStatusRow { - last_error: row.get::>(0)?, - attempt_count: row.get::(1)?, - last_attempt: row.get::>(2)?, - committed_at: row.get::>(3)?, - }) - })? - .into_iter() - .next()) -} - -/// Returns the distinct set of network accounts that currently have at least one pending note -/// (unconsumed and within the per-note attempt budget). -#[expect(clippy::cast_possible_wrap)] -pub fn accounts_with_pending_notes( - tx: &ReadTx<'_>, - max_attempts: usize, -) -> Result, DatabaseError> { - tx.query(sql::ACCOUNTS_WITH_PENDING_NOTES, &[&(max_attempts as i64)], |row| { - row.get::(0) - }) -} - -// HELPERS -// ================================================================================================ - -/// Checks if the backoff block period has passed. -/// -/// The number of blocks passed since the last attempt must be greater than or equal to -/// e^(0.25 * `attempt_count`) rounded to the nearest integer. -#[expect(clippy::cast_precision_loss, clippy::cast_sign_loss)] -fn has_backoff_passed( - chain_tip: BlockNumber, - last_attempt: Option, - attempts: usize, -) -> bool { - if attempts == 0 { - return true; - } - let blocks_passed = last_attempt - .and_then(|last| chain_tip.checked_sub(last.as_u32())) - .unwrap_or_default(); - - let backoff_threshold = (0.25 * attempts as f64).exp().round() as usize; - - blocks_passed.as_usize() > backoff_threshold -} - -#[cfg(test)] -mod tests { - use miden_protocol::block::BlockNumber; - - use super::has_backoff_passed; - - #[rstest::rstest] - #[test] - #[case::all_zero(Some(BlockNumber::GENESIS), BlockNumber::GENESIS, 0, true)] - #[case::no_attempts(None, BlockNumber::GENESIS, 0, true)] - #[case::one_attempt(Some(BlockNumber::GENESIS), BlockNumber::from(2), 1, true)] - #[case::three_attempts(Some(BlockNumber::GENESIS), BlockNumber::from(3), 3, true)] - #[case::ten_attempts(Some(BlockNumber::GENESIS), BlockNumber::from(13), 10, true)] - #[case::twenty_attempts(Some(BlockNumber::GENESIS), BlockNumber::from(149), 20, true)] - #[case::one_attempt_false(Some(BlockNumber::GENESIS), BlockNumber::from(1), 1, false)] - #[case::three_attempts_false(Some(BlockNumber::GENESIS), BlockNumber::from(2), 3, false)] - #[case::ten_attempts_false(Some(BlockNumber::GENESIS), BlockNumber::from(12), 10, false)] - #[case::twenty_attempts_false(Some(BlockNumber::GENESIS), BlockNumber::from(148), 20, false)] - fn backoff_has_passed( - #[case] last_attempt_block_num: Option, - #[case] current_block_num: BlockNumber, - #[case] attempt_count: usize, - #[case] backoff_should_have_passed: bool, - ) { - assert_eq!( - backoff_should_have_passed, - has_backoff_passed(current_block_num, last_attempt_block_num, attempt_count) - ); - } -} diff --git a/bin/ntx-builder/src/db/queries/notes_failed/mod.rs b/bin/ntx-builder/src/db/queries/notes_failed/mod.rs new file mode 100644 index 0000000000..11ef4154e4 --- /dev/null +++ b/bin/ntx-builder/src/db/queries/notes_failed/mod.rs @@ -0,0 +1,27 @@ +//! Records a failed consumption attempt against a set of notes. + +use miden_node_db::sqlite::WriteTx; +use miden_node_db::{DatabaseError, SqlTypeConvert}; +use miden_node_utils::ErrorReport; +use miden_protocol::block::BlockNumber; +use miden_protocol::note::Nullifier; + +use crate::NoteError; + +const SQL: &str = include_str!("note_failed.sql"); + +/// Marks notes as failed by incrementing `attempt_count`, setting `last_attempt`, and storing the +/// latest error message. +pub fn notes_failed( + tx: &WriteTx<'_>, + failed_notes: &[(Nullifier, NoteError)], + block_num: BlockNumber, +) -> Result<(), DatabaseError> { + let block_num_val = block_num.to_raw_sql(); + + for (nullifier, error) in failed_notes { + let error_report = error.as_report(); + tx.execute(SQL, &[nullifier, &block_num_val, &error_report])?; + } + Ok(()) +} diff --git a/bin/ntx-builder/src/db/sql/note_failed.sql b/bin/ntx-builder/src/db/queries/notes_failed/note_failed.sql similarity index 100% rename from bin/ntx-builder/src/db/sql/note_failed.sql rename to bin/ntx-builder/src/db/queries/notes_failed/note_failed.sql diff --git a/bin/ntx-builder/src/db/queries/select_chain_state/mod.rs b/bin/ntx-builder/src/db/queries/select_chain_state/mod.rs new file mode 100644 index 0000000000..002b3400d4 --- /dev/null +++ b/bin/ntx-builder/src/db/queries/select_chain_state/mod.rs @@ -0,0 +1,25 @@ +//! Reads the singleton chain state row. + +use miden_node_db::sqlite::ReadTx; +use miden_node_db::{DatabaseError, SqlTypeConvert}; +use miden_protocol::block::{BlockHeader, BlockNumber}; +use miden_protocol::crypto::merkle::mmr::PartialMmr; + +const SQL: &str = include_str!("select_chain_state.sql"); + +/// Reads the singleton chain state row, returning the persisted block number, header, and chain MMR +/// if any block has been applied locally. +pub fn select_chain_state( + tx: &ReadTx<'_>, +) -> Result, DatabaseError> { + Ok(tx + .query(SQL, &[], |row| { + Ok(( + BlockNumber::from_raw_sql(row.get::(0)?)?, + row.get::(1)?, + row.get::(2)?, + )) + })? + .into_iter() + .next()) +} diff --git a/bin/ntx-builder/src/db/sql/select_chain_state.sql b/bin/ntx-builder/src/db/queries/select_chain_state/select_chain_state.sql similarity index 100% rename from bin/ntx-builder/src/db/sql/select_chain_state.sql rename to bin/ntx-builder/src/db/queries/select_chain_state/select_chain_state.sql diff --git a/bin/ntx-builder/src/db/queries/select_genesis_commitment/mod.rs b/bin/ntx-builder/src/db/queries/select_genesis_commitment/mod.rs new file mode 100644 index 0000000000..668a06dbb8 --- /dev/null +++ b/bin/ntx-builder/src/db/queries/select_genesis_commitment/mod.rs @@ -0,0 +1,13 @@ +//! Reads the genesis block commitment from the singleton chain state row. + +use miden_node_db::DatabaseError; +use miden_node_db::sqlite::ReadTx; +use miden_protocol::Word; + +const SQL: &str = include_str!("select_genesis_commitment.sql"); + +/// Reads the genesis block commitment from the singleton chain state row, or `None` if the database +/// has not been bootstrapped. +pub fn select_genesis_commitment(tx: &ReadTx<'_>) -> Result, DatabaseError> { + Ok(tx.query(SQL, &[], |row| row.get::(0))?.into_iter().next()) +} diff --git a/bin/ntx-builder/src/db/sql/select_genesis_commitment.sql b/bin/ntx-builder/src/db/queries/select_genesis_commitment/select_genesis_commitment.sql similarity index 100% rename from bin/ntx-builder/src/db/sql/select_genesis_commitment.sql rename to bin/ntx-builder/src/db/queries/select_genesis_commitment/select_genesis_commitment.sql diff --git a/bin/ntx-builder/src/db/queries/tests.rs b/bin/ntx-builder/src/db/queries/tests.rs index ef3c4ac3e4..a79d0f1a6a 100644 --- a/bin/ntx-builder/src/db/queries/tests.rs +++ b/bin/ntx-builder/src/db/queries/tests.rs @@ -1,21 +1,16 @@ //! DB-level tests for the committed-block-driven query layer. //! -//! Each query runs through the framework's [`Database::read`]/[`Database::write`], so every write -//! commits before the following read observes it. +//! Each query runs through the [`NtxDb`](crate::db::NtxDb) wrapper (production methods where they +//! exist, test-only helpers otherwise), so every write commits before the following read observes +//! it. use std::sync::Arc; -use miden_node_db::DatabaseError; -use miden_node_db::sqlite::Database; use miden_protocol::Word; -use miden_protocol::account::{Account, AccountId}; -use miden_protocol::block::{BlockHeader, BlockNumber}; +use miden_protocol::block::BlockNumber; use miden_protocol::crypto::merkle::mmr::PartialMmr; -use miden_protocol::note::{NoteId, NoteScript, Nullifier}; use miden_protocol::transaction::TransactionId; -use miden_standards::note::AccountTargetNetworkNote; -use super::*; use crate::NoteError; use crate::committed_block::CommittedBlockEffects; use crate::db::test_setup; @@ -29,155 +24,6 @@ fn test_note_error(msg: &str) -> NoteError { Arc::new(std::io::Error::other(msg.to_string())) } -/// Counts the rows returned by a `SELECT COUNT(*)` statement. -async fn count(db: &Database, sql: &'static str) -> i64 { - db.read("count", move |tx| { - let n = tx.query(sql, &[], |row| row.get::(0))?.into_iter().next().unwrap_or(0); - Ok::(n) - }) - .await - .unwrap() -} - -async fn count_notes(db: &Database) -> i64 { - count(db, "SELECT COUNT(*) FROM notes").await -} - -async fn count_accounts(db: &Database) -> i64 { - count(db, "SELECT COUNT(*) FROM accounts").await -} - -async fn count_chain_state(db: &Database) -> i64 { - count(db, "SELECT COUNT(*) FROM chain_state").await -} - -async fn do_upsert_account( - db: &Database, - account_id: AccountId, - account: Account, - last_tx_id: TransactionId, -) { - db.write("upsert_account", move |tx| upsert_account(tx, account_id, &account, last_tx_id)) - .await - .unwrap(); -} - -async fn do_get_account(db: &Database, account_id: AccountId) -> Option { - db.read("get_account", move |tx| get_account(tx, account_id)).await.unwrap() -} - -async fn do_account_last_tx(db: &Database, account_id: AccountId) -> Option { - db.read("account_last_tx", move |tx| account_last_tx(tx, account_id)) - .await - .unwrap() -} - -async fn do_insert_notes(db: &Database, notes: Vec) { - db.write("insert_network_notes", move |tx| insert_network_notes(tx, ¬es)) - .await - .unwrap(); -} - -async fn do_mark_consumed(db: &Database, nullifiers: Vec, block_num: BlockNumber) { - db.write("mark_notes_consumed", move |tx| mark_notes_consumed(tx, &nullifiers, block_num)) - .await - .unwrap(); -} - -async fn do_available_notes( - db: &Database, - account_id: AccountId, - block_num: BlockNumber, - max_attempts: usize, -) -> Vec { - db.read("available_notes", move |tx| { - available_notes(tx, account_id, block_num, max_attempts) - }) - .await - .unwrap() -} - -async fn do_notes_failed( - db: &Database, - failed: Vec<(Nullifier, NoteError)>, - block_num: BlockNumber, -) { - db.write("notes_failed", move |tx| notes_failed(tx, &failed, block_num)) - .await - .unwrap(); -} - -async fn do_discard_notes( - db: &Database, - nullifiers: Vec, - block_num: BlockNumber, - max_attempts: usize, - reason: &str, -) { - let reason = reason.to_string(); - db.write("discard_notes", move |tx| { - discard_notes(tx, &nullifiers, block_num, max_attempts, &reason) - }) - .await - .unwrap(); -} - -async fn do_get_note_status(db: &Database, note_id: NoteId) -> Option { - db.read("get_note_status", move |tx| get_note_status(tx, note_id)) - .await - .unwrap() -} - -async fn do_pending_accounts(db: &Database, max_attempts: usize) -> Vec { - db.read("accounts_with_pending_notes", move |tx| { - accounts_with_pending_notes(tx, max_attempts) - }) - .await - .unwrap() -} - -async fn do_insert_genesis(db: &Database, header: BlockHeader, commitment: Word) { - db.write("insert_genesis_chain_state", move |tx| { - insert_genesis_chain_state(tx, &header, &commitment) - }) - .await - .unwrap(); -} - -async fn do_update_tip(db: &Database, header: BlockHeader, mmr: PartialMmr) { - let block_num = header.block_num(); - db.write("update_chain_state_tip", move |tx| { - update_chain_state_tip(tx, block_num, &header, &mmr) - }) - .await - .unwrap(); -} - -async fn do_select_chain_state(db: &Database) -> Option<(BlockNumber, BlockHeader, PartialMmr)> { - db.read("select_chain_state", select_chain_state).await.unwrap() -} - -async fn do_lookup_script(db: &Database, root: Word) -> Option { - db.read("lookup_note_script", move |tx| lookup_note_script(tx, &root)) - .await - .unwrap() -} - -async fn do_insert_script(db: &Database, root: Word, script: NoteScript) { - db.write("insert_note_script", move |tx| insert_note_script(tx, &root, &script)) - .await - .unwrap(); -} - -async fn try_apply_block( - db: &Database, - effects: CommittedBlockEffects, - mmr: PartialMmr, -) -> Result<(), DatabaseError> { - db.write("apply_committed_block", move |tx| apply_committed_block(tx, &effects, &mmr)) - .await -} - // ACCOUNT UPSERT // ================================================================================================ @@ -187,11 +33,15 @@ async fn upsert_account_replaces_existing_row() { let account_id = mock_network_account_id(); let account = mock_account(account_id); - do_upsert_account(&db, account_id, account.clone(), mock_transaction_id(1)).await; - do_upsert_account(&db, account_id, account, mock_transaction_id(2)).await; + db.upsert_account_for_test(account_id, account.clone(), mock_transaction_id(1)) + .await + .unwrap(); + db.upsert_account_for_test(account_id, account, mock_transaction_id(2)) + .await + .unwrap(); - assert_eq!(count_accounts(&db).await, 1, "second upsert must overwrite, not insert"); - assert!(do_get_account(&db, account_id).await.is_some()); + assert_eq!(db.count_accounts().await, 1, "second upsert must overwrite, not insert"); + assert!(db.get_account(account_id).await.unwrap().is_some()); } // NETWORK NOTE INSERT/DELETE @@ -203,11 +53,11 @@ async fn insert_network_notes_is_idempotent() { let account_id = mock_network_account_id(); let note = mock_single_target_note(account_id, 7); - do_insert_notes(&db, vec![note.clone()]).await; + db.insert_network_notes(vec![note.clone()]).await.unwrap(); // Re-applying the same block (e.g. on a subscription redelivery) must not error or duplicate. - do_insert_notes(&db, vec![note]).await; + db.insert_network_notes(vec![note]).await.unwrap(); - assert_eq!(count_notes(&db).await, 1); + assert_eq!(db.count_notes().await, 1); } #[tokio::test] @@ -217,19 +67,21 @@ async fn mark_notes_consumed_keeps_rows_and_sets_committed_at() { let note_a = mock_single_target_note(account_id, 1); let note_b = mock_single_target_note(account_id, 2); - do_insert_notes(&db, vec![note_a.clone(), note_b.clone()]).await; - assert_eq!(count_notes(&db).await, 2); + db.insert_network_notes(vec![note_a.clone(), note_b.clone()]).await.unwrap(); + assert_eq!(db.count_notes().await, 2); let consumed_at = BlockNumber::from(42); - do_mark_consumed(&db, vec![note_a.as_note().nullifier()], consumed_at).await; + db.mark_notes_consumed(vec![note_a.as_note().nullifier()], consumed_at) + .await + .unwrap(); // Both rows are still present so the gRPC status endpoint can report them. - assert_eq!(count_notes(&db).await, 2); + assert_eq!(db.count_notes().await, 2); - let status_a = do_get_note_status(&db, note_a.as_note().id()).await.unwrap(); + let status_a = db.get_note_status(note_a.as_note().id()).await.unwrap().unwrap(); assert_eq!(status_a.committed_at, Some(i64::from(consumed_at.as_u32()))); - let status_b = do_get_note_status(&db, note_b.as_note().id()).await.unwrap(); + let status_b = db.get_note_status(note_b.as_note().id()).await.unwrap().unwrap(); assert!(status_b.committed_at.is_none()); } @@ -238,14 +90,14 @@ async fn mark_notes_consumed_is_noop_when_unknown() { let (db, _dir) = test_setup().await; let account_id = mock_network_account_id(); let note = mock_single_target_note(account_id, 3); - do_insert_notes(&db, vec![note.clone()]).await; + db.insert_network_notes(vec![note.clone()]).await.unwrap(); // A nullifier we never inserted should not affect existing rows. let phantom = mock_single_target_note(account_id, 99).as_note().nullifier(); - do_mark_consumed(&db, vec![phantom], BlockNumber::from(5)).await; + db.mark_notes_consumed(vec![phantom], BlockNumber::from(5)).await.unwrap(); - assert_eq!(count_notes(&db).await, 1); - let status = do_get_note_status(&db, note.as_note().id()).await.unwrap(); + assert_eq!(db.count_notes().await, 1); + let status = db.get_note_status(note.as_note().id()).await.unwrap().unwrap(); assert!(status.committed_at.is_none()); } @@ -254,15 +106,18 @@ async fn available_notes_excludes_consumed_notes() { let (db, _dir) = test_setup().await; let account_id = mock_network_account_id(); let note = mock_single_target_note(account_id, 21); - do_insert_notes(&db, vec![note.clone()]).await; + db.insert_network_notes(vec![note.clone()]).await.unwrap(); - assert_eq!(do_available_notes(&db, account_id, BlockNumber::from(1), 30).await.len(), 1); + assert_eq!(db.available_notes(account_id, BlockNumber::from(1), 30).await.unwrap().len(), 1); - do_mark_consumed(&db, vec![note.as_note().nullifier()], BlockNumber::from(7)).await; + db.mark_notes_consumed(vec![note.as_note().nullifier()], BlockNumber::from(7)) + .await + .unwrap(); assert!( - do_available_notes(&db, account_id, BlockNumber::from(1000), 30) + db.available_notes(account_id, BlockNumber::from(1000), 30) .await + .unwrap() .is_empty() ); } @@ -275,9 +130,9 @@ async fn available_notes_returns_unconsumed_under_attempt_cap() { let (db, _dir) = test_setup().await; let account_id = mock_network_account_id(); let note = mock_single_target_note(account_id, 11); - do_insert_notes(&db, vec![note]).await; + db.insert_network_notes(vec![note]).await.unwrap(); - let available = do_available_notes(&db, account_id, BlockNumber::from(1), 30).await; + let available = db.available_notes(account_id, BlockNumber::from(1), 30).await.unwrap(); assert_eq!(available.len(), 1); } @@ -286,16 +141,17 @@ async fn available_notes_excludes_attempts_at_cap() { let (db, _dir) = test_setup().await; let account_id = mock_network_account_id(); let note = mock_single_target_note(account_id, 13); - do_insert_notes(&db, vec![note.clone()]).await; + db.insert_network_notes(vec![note.clone()]).await.unwrap(); // Push attempt_count up to the cap. let nullifier = note.as_note().nullifier(); for _ in 0..30 { - do_notes_failed(&db, vec![(nullifier, test_note_error("boom"))], BlockNumber::from(5)) - .await; + db.notes_failed(vec![(nullifier, test_note_error("boom"))], BlockNumber::from(5)) + .await + .unwrap(); } - let available = do_available_notes(&db, account_id, BlockNumber::from(1000), 30).await; + let available = db.available_notes(account_id, BlockNumber::from(1000), 30).await.unwrap(); assert!(available.is_empty(), "notes at the attempt cap should not be available"); } @@ -309,10 +165,12 @@ async fn update_chain_state_tip_persists_and_roundtrips_mmr() { let header = mock_block_header(BlockNumber::from(7)); let mmr = PartialMmr::default(); - do_insert_genesis(&db, genesis.clone(), genesis.commitment()).await; - do_update_tip(&db, header.clone(), mmr).await; + db.insert_genesis_chain_state(genesis.clone(), genesis.commitment()) + .await + .unwrap(); + db.update_chain_state_tip(header.clone(), mmr).await.unwrap(); - let (loaded_num, loaded_header, _loaded_mmr) = do_select_chain_state(&db).await.unwrap(); + let (loaded_num, loaded_header, _loaded_mmr) = db.select_chain_state().await.unwrap().unwrap(); assert_eq!(loaded_num, header.block_num()); assert_eq!(loaded_header.block_num(), header.block_num()); } @@ -325,20 +183,22 @@ async fn update_chain_state_tip_keeps_singleton() { let header_2 = mock_block_header(BlockNumber::from(2)); let mmr = PartialMmr::default(); - do_insert_genesis(&db, genesis.clone(), genesis.commitment()).await; - do_update_tip(&db, header_1, mmr.clone()).await; - do_update_tip(&db, header_2.clone(), mmr).await; + db.insert_genesis_chain_state(genesis.clone(), genesis.commitment()) + .await + .unwrap(); + db.update_chain_state_tip(header_1, mmr.clone()).await.unwrap(); + db.update_chain_state_tip(header_2.clone(), mmr).await.unwrap(); - let (loaded_num, ..) = do_select_chain_state(&db).await.unwrap(); + let (loaded_num, ..) = db.select_chain_state().await.unwrap().unwrap(); assert_eq!(loaded_num, header_2.block_num()); - assert_eq!(count_chain_state(&db).await, 1, "chain_state must remain a singleton"); + assert_eq!(db.count_chain_state().await, 1, "chain_state must remain a singleton"); } #[tokio::test] async fn select_chain_state_returns_none_on_fresh_db() { let (db, _dir) = test_setup().await; - assert!(do_select_chain_state(&db).await.is_none()); + assert!(db.select_chain_state().await.unwrap().is_none()); } // NOTE SCRIPT CACHE @@ -352,12 +212,12 @@ async fn note_script_cache_roundtrip() { let script = note.as_note().script().clone(); let root: Word = script.root().into(); - assert!(do_lookup_script(&db, root).await.is_none()); - do_insert_script(&db, root, script.clone()).await; - assert!(do_lookup_script(&db, root).await.is_some()); + assert!(db.lookup_note_script(root).await.unwrap().is_none()); + db.insert_note_scripts(root, script.clone()).await.unwrap(); + assert!(db.lookup_note_script(root).await.unwrap().is_some()); // Re-insert is idempotent. - do_insert_script(&db, root, script).await; + db.insert_note_scripts(root, script).await.unwrap(); } // ACCOUNTS WITH PENDING NOTES @@ -375,23 +235,26 @@ async fn accounts_with_pending_notes_distinct_and_filters_consumed_and_capped() let bob_note = mock_single_target_note(bob, 3); let carol_note = mock_single_target_note(carol, 4); - do_insert_notes(&db, vec![alice_note_1, alice_note_2, bob_note.clone(), carol_note.clone()]) - .await; + db.insert_network_notes(vec![alice_note_1, alice_note_2, bob_note.clone(), carol_note.clone()]) + .await + .unwrap(); // Alice has two notes — must still appear exactly once (DISTINCT). Bob's only note is already // consumed — exclude. - do_mark_consumed(&db, vec![bob_note.as_note().nullifier()], BlockNumber::from(7)).await; + db.mark_notes_consumed(vec![bob_note.as_note().nullifier()], BlockNumber::from(7)) + .await + .unwrap(); // Carol's note has hit the attempt cap — exclude. for _ in 0..30 { - do_notes_failed( - &db, + db.notes_failed( vec![(carol_note.as_note().nullifier(), test_note_error("boom"))], BlockNumber::from(5), ) - .await; + .await + .unwrap(); } - let pending = do_pending_accounts(&db, 30).await; + let pending = db.accounts_with_pending_notes(30).await.unwrap(); assert_eq!(pending.len(), 1, "only alice should remain pending"); assert_eq!(pending[0], alice); } @@ -408,10 +271,10 @@ async fn account_last_tx_roundtrips_and_updates() { // The first upsert records its transaction id; a later upsert overwrites it. let first = mock_transaction_id(1); let second = mock_transaction_id(2); - do_upsert_account(&db, account_id, account.clone(), first).await; - assert_eq!(do_account_last_tx(&db, account_id).await, Some(first)); - do_upsert_account(&db, account_id, account, second).await; - assert_eq!(do_account_last_tx(&db, account_id).await, Some(second)); + db.upsert_account_for_test(account_id, account.clone(), first).await.unwrap(); + assert_eq!(db.account_last_tx(account_id).await.unwrap(), Some(first)); + db.upsert_account_for_test(account_id, account, second).await.unwrap(); + assert_eq!(db.account_last_tx(account_id).await.unwrap(), Some(second)); } #[tokio::test] @@ -420,7 +283,7 @@ async fn account_last_tx_returns_none_for_untracked_account() { let account_id = mock_network_account_id(); // No row exists for this account. - assert_eq!(do_account_last_tx(&db, account_id).await, None); + assert_eq!(db.account_last_tx(account_id).await.unwrap(), None); } // GENESIS APPLICATION @@ -447,16 +310,16 @@ async fn apply_committed_block_seeds_genesis_network_account() { // Genesis has no transactions, so this used to panic on the "must originate from a transaction" // invariant. It must now bootstrap the account successfully. - try_apply_block(&db, effects, PartialMmr::default()).await.unwrap(); + db.apply_committed_block(effects, PartialMmr::default()).await.unwrap(); assert!( - do_get_account(&db, account_id).await.is_some(), + db.get_account(account_id).await.unwrap().is_some(), "genesis account should be seeded" ); // The seeded account carries the zero sentinel: no transaction produced it. An actor never // submits the zero id, so this can never be mistaken for a landed transaction. assert_eq!( - do_account_last_tx(&db, account_id).await, + db.account_last_tx(account_id).await.unwrap(), Some(TransactionId::from_raw(Word::empty())), ); } @@ -471,7 +334,7 @@ async fn apply_committed_block_fails_on_txless_update_after_genesis() { let mut effects = genesis_effects(); effects.header = mock_block_header(BlockNumber::from(1)); - try_apply_block(&db, effects, PartialMmr::default()) + db.apply_committed_block(effects, PartialMmr::default()) .await .expect_err("a committed account update with no transaction must fail"); } @@ -481,13 +344,17 @@ async fn notes_failed_increments_attempt_and_records_error() { let (db, _dir) = test_setup().await; let account_id = mock_network_account_id(); let note = mock_single_target_note(account_id, 19); - do_insert_notes(&db, vec![note.clone()]).await; + db.insert_network_notes(vec![note.clone()]).await.unwrap(); let nullifier = note.as_note().nullifier(); - do_notes_failed(&db, vec![(nullifier, test_note_error("nope"))], BlockNumber::from(5)).await; - do_notes_failed(&db, vec![(nullifier, test_note_error("nope"))], BlockNumber::from(6)).await; + db.notes_failed(vec![(nullifier, test_note_error("nope"))], BlockNumber::from(5)) + .await + .unwrap(); + db.notes_failed(vec![(nullifier, test_note_error("nope"))], BlockNumber::from(6)) + .await + .unwrap(); - let row = do_get_note_status(&db, note.as_note().id()).await.unwrap(); + let row = db.get_note_status(note.as_note().id()).await.unwrap().unwrap(); assert_eq!(row.attempt_count, 2); assert_eq!(row.last_attempt, Some(6)); assert!(row.last_error.is_some()); @@ -498,25 +365,28 @@ async fn discard_notes_pins_attempts_to_cap_and_drops_from_pending() { let (db, _dir) = test_setup().await; let account_id = mock_network_account_id(); let note = mock_single_target_note(account_id, 23); - do_insert_notes(&db, vec![note.clone()]).await; + db.insert_network_notes(vec![note.clone()]).await.unwrap(); let nullifier = note.as_note().nullifier(); - do_discard_notes(&db, vec![nullifier], BlockNumber::from(9), 30, "too big").await; + db.discard_notes_with_reason(vec![nullifier], BlockNumber::from(9), 30, "too big".to_string()) + .await + .unwrap(); // Pinned to the cap, so it is no longer pending or available for selection. - let row = do_get_note_status(&db, note.as_note().id()).await.unwrap(); + let row = db.get_note_status(note.as_note().id()).await.unwrap().unwrap(); assert_eq!(row.attempt_count, 30); assert_eq!(row.last_attempt, Some(9)); assert_eq!(row.last_error.as_deref(), Some("too big")); assert!( - do_available_notes(&db, account_id, BlockNumber::from(1000), 30) + db.available_notes(account_id, BlockNumber::from(1000), 30) .await + .unwrap() .is_empty(), "a discarded note must not be selectable", ); assert!( - !do_pending_accounts(&db, 30).await.contains(&account_id), + !db.accounts_with_pending_notes(30).await.unwrap().contains(&account_id), "an account whose only note was discarded must not count as pending", ); } diff --git a/bin/ntx-builder/src/db/queries/update_chain_state_tip/mod.rs b/bin/ntx-builder/src/db/queries/update_chain_state_tip/mod.rs new file mode 100644 index 0000000000..f62be7b847 --- /dev/null +++ b/bin/ntx-builder/src/db/queries/update_chain_state_tip/mod.rs @@ -0,0 +1,22 @@ +//! Updates the tip columns of the singleton chain state row. + +use miden_node_db::sqlite::WriteTx; +use miden_node_db::{DatabaseError, SqlTypeConvert}; +use miden_protocol::block::{BlockHeader, BlockNumber}; +use miden_protocol::crypto::merkle::mmr::PartialMmr; + +const SQL: &str = include_str!("update_chain_state_tip.sql"); + +/// Updates the tip columns (block number, header, and partial chain MMR) of the singleton chain +/// state row. The row is created once at bootstrap by +/// [`insert_genesis_chain_state`](super::insert_genesis_chain_state), so this is a plain update; +/// the `genesis_commitment` column is set at bootstrap and never touched here. +pub fn update_chain_state_tip( + tx: &WriteTx<'_>, + block_num: BlockNumber, + block_header: &BlockHeader, + chain_mmr: &PartialMmr, +) -> Result<(), DatabaseError> { + tx.execute(SQL, &[&block_num.to_raw_sql(), block_header, chain_mmr])?; + Ok(()) +} diff --git a/bin/ntx-builder/src/db/sql/update_chain_state_tip.sql b/bin/ntx-builder/src/db/queries/update_chain_state_tip/update_chain_state_tip.sql similarity index 100% rename from bin/ntx-builder/src/db/sql/update_chain_state_tip.sql rename to bin/ntx-builder/src/db/queries/update_chain_state_tip/update_chain_state_tip.sql diff --git a/bin/ntx-builder/src/db/queries/upsert_account/mod.rs b/bin/ntx-builder/src/db/queries/upsert_account/mod.rs new file mode 100644 index 0000000000..e4dfaf9f0a --- /dev/null +++ b/bin/ntx-builder/src/db/queries/upsert_account/mod.rs @@ -0,0 +1,20 @@ +//! Inserts or updates the committed state of a network account. + +use miden_node_db::DatabaseError; +use miden_node_db::sqlite::WriteTx; +use miden_protocol::account::{Account, AccountId}; +use miden_protocol::transaction::TransactionId; + +const SQL: &str = include_str!("upsert_account.sql"); + +/// Inserts the committed account state, or updates an existing account's state. In both cases +/// `last_tx_id` is set to the transaction that produced this update. +pub fn upsert_account( + tx: &WriteTx<'_>, + account_id: AccountId, + account: &Account, + last_tx_id: TransactionId, +) -> Result<(), DatabaseError> { + tx.execute(SQL, &[&account_id, account, &last_tx_id])?; + Ok(()) +} diff --git a/bin/ntx-builder/src/db/sql/upsert_account.sql b/bin/ntx-builder/src/db/queries/upsert_account/upsert_account.sql similarity index 100% rename from bin/ntx-builder/src/db/sql/upsert_account.sql rename to bin/ntx-builder/src/db/queries/upsert_account/upsert_account.sql diff --git a/bin/ntx-builder/src/lib.rs b/bin/ntx-builder/src/lib.rs index e0d818e724..1463713ad4 100644 --- a/bin/ntx-builder/src/lib.rs +++ b/bin/ntx-builder/src/lib.rs @@ -7,7 +7,6 @@ use anyhow::Context; use builder::BlockStream; use chain_state::SharedChainState; use clients::{RemoteTransactionProver, RpcClient}; -use miden_node_db::sqlite::Database; use miden_node_utils::ErrorReport; use miden_node_utils::lru_cache::LruCache; use miden_node_utils::shutdown::CancellationToken; @@ -18,6 +17,7 @@ use url::Url; use crate::actor::{AccountActorContext, ActorConfig, GrpcClients, State}; use crate::coordinator::Coordinator; +use crate::db::NtxDb; pub(crate) type NoteError = Arc; @@ -380,7 +380,7 @@ impl NtxBuilderConfig { // Get the genesis commitment to send in the accept header let genesis_commitment = db - .read("select_genesis_commitment", db::queries::select_genesis_commitment) + .select_genesis_commitment() .await .context("failed to read genesis commitment")? .context( @@ -407,11 +407,8 @@ impl NtxBuilderConfig { // The database is bootstrapped with the genesis block before startup (see // `miden-ntx-builder bootstrap`), so a persisted chain state is always present. Load it and // resume the subscription from the block after the last applied one. - let (last_applied_block, header, mmr) = db - .read("select_chain_state", db::queries::select_chain_state) - .await - .context("failed to read chain state")? - .context( + let (last_applied_block, header, mmr) = + db.select_chain_state().await.context("failed to read chain state")?.context( "ntx-builder database has not been bootstrapped; \ run `miden-ntx-builder bootstrap` first", )?; @@ -452,7 +449,7 @@ impl NtxBuilderConfig { fn build_coordinator( &self, rpc: RpcClient, - db: Database, + db: NtxDb, chain: Arc, shutdown: CancellationToken, ) -> anyhow::Result<(Coordinator, mpsc::Receiver)> { diff --git a/bin/ntx-builder/src/server.rs b/bin/ntx-builder/src/server.rs index bf09701843..dfd1f261f6 100644 --- a/bin/ntx-builder/src/server.rs +++ b/bin/ntx-builder/src/server.rs @@ -1,5 +1,4 @@ use anyhow::Context; -use miden_node_db::sqlite::Database; use miden_node_proto::server::ntx_builder_api; use miden_node_proto_build::ntx_builder_api_descriptor; use miden_node_utils::panic::{CatchPanicLayer, catch_panic_layer_fn}; @@ -11,6 +10,7 @@ use tonic_reflection::server; use tower_http::trace::TraceLayer; use crate::COMPONENT; +use crate::db::NtxDb; mod get_network_note_status; @@ -22,12 +22,12 @@ mod get_network_note_status; /// Exposes endpoints for querying network note status, useful for debugging /// network notes that fail to be consumed. pub struct NtxBuilderRpcServer { - db: Database, + db: NtxDb, max_note_attempts: usize, } impl NtxBuilderRpcServer { - pub fn new(db: Database, max_note_attempts: usize) -> Self { + pub(crate) fn new(db: NtxDb, max_note_attempts: usize) -> Self { Self { db, max_note_attempts } } diff --git a/bin/ntx-builder/src/server/get_network_note_status.rs b/bin/ntx-builder/src/server/get_network_note_status.rs index be7621cd8e..f6352e3ecb 100644 --- a/bin/ntx-builder/src/server/get_network_note_status.rs +++ b/bin/ntx-builder/src/server/get_network_note_status.rs @@ -36,9 +36,7 @@ impl grpc::server::ntx_builder_api::GetNetworkNoteStatus for NtxBuilderRpcServer _extensions: &tonic::codegen::http::Extensions, ) -> tonic::Result { let row = self - .db - .read("get_note_status", move |tx| crate::db::queries::get_note_status(tx, note_id)) - .await + .db.get_note_status(note_id).await .map_err(|err| { tracing::error!(target: LOG_TARGET, error = %err, "Failed to query note status from DB"); tonic::Status::internal("database error")