diff --git a/CHANGELOG.md b/CHANGELOG.md index c285a35d0..b68ac5944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ * [BREAKING][param][rust] `NodeRpcClient` models encrypted submissions: `submit_proven_transaction` now takes `SealedTransactionInputs` instead of `TransactionInputs`, `submit_proven_batch` now takes `Vec` (one per transaction, each sealed against its own transaction ID), and implementations must provide the new `get_transaction_encryption_key` method ([#2341](https://github.com/0xMiden/rust-sdk/pull/2341)). * [BREAKING][type][rust] Added the `NoteFilter::ScriptRoots` variant, so exhaustive matches on `NoteFilter` in `Store` implementations must handle it ([#2335](https://github.com/0xMiden/rust-sdk/pull/2335)). * [BREAKING][behavior][store] The SQLite base schema now declares an index on `input_notes(script_root)`. This changes the schema fingerprint, so opening a database created before this change fails with `SchemaHashMismatch` and existing stores must be recreated ([#2335](https://github.com/0xMiden/rust-sdk/pull/2335)). +* [BREAKING][behavior][store] The SQLite base schema now indexes `code_commitment` on `latest_account_headers`, `historical_account_headers` and `foreign_account_code`, and the `input_notes` consumption index now leads with `consumer_account_id`. This changes the schema fingerprint, so opening a database created before this change fails with `SchemaHashMismatch` and existing stores must be recreated ([#2364](https://github.com/0xMiden/rust-sdk/pull/2364)). ### Enhancements @@ -24,6 +25,10 @@ * [rust] Added `PartialBlockchainUpdates::block_headers_to_store`, which narrows the staged headers to the ones a sync must persist: those marked as relevant, genesis, and the block at the sync height. `block_headers` still yields all staged headers ([#2297](https://github.com/0xMiden/rust-sdk/pull/2297)). * [rust] State sync now authenticates every relevant note block but only persists block headers and MMR authentication nodes for blocks containing notes that remain unspent or that a `NoteObserver` explicitly marks as relevant ([#2297](https://github.com/0xMiden/rust-sdk/pull/2297)). +### Fixes + +* [FIX][store] `SQLite` store operations no longer get slower as the database grows. Note scripts are updated in place instead of replaced, so persisting them no longer forces a foreign key check against every note referencing the script, the note and block header queries were rewritten to match the indices that serve them, and account code garbage collection no longer scans three tables per candidate commitment. Listing unspent nullifiers also no longer fails when a note's nullifier isn't known yet ([#2364](https://github.com/0xMiden/rust-sdk/pull/2364)). + ## 0.16.0-alpha.1 (2026-07-17) ### Breaking Changes diff --git a/crates/sqlite-store/src/chain_data.rs b/crates/sqlite-store/src/chain_data.rs index c61ac6d5f..339cbc85c 100644 --- a/crates/sqlite-store/src/chain_data.rs +++ b/crates/sqlite-store/src/chain_data.rs @@ -65,7 +65,9 @@ impl SqliteStore { pub(crate) fn get_tracked_block_headers( conn: &mut Connection, ) -> Result, StoreError> { - const QUERY: &str = "SELECT block_num, header, has_client_notes FROM block_headers WHERE has_client_notes=true"; + // `has_client_notes=1` rather than `=true`: SQLite only matches the partial index + // `idx_block_headers_has_notes` when the predicate is written the same way it is declared. + const QUERY: &str = "SELECT block_num, header, has_client_notes FROM block_headers WHERE has_client_notes=1"; conn.prepare(QUERY) .into_store_error()? .query_map(params![], parse_block_headers_columns) @@ -81,7 +83,7 @@ impl SqliteStore { pub(crate) fn get_tracked_block_header_numbers( conn: &mut Connection, ) -> Result, StoreError> { - const QUERY: &str = "SELECT block_num FROM block_headers WHERE has_client_notes=true"; + const QUERY: &str = "SELECT block_num FROM block_headers WHERE has_client_notes=1"; conn.prepare(QUERY) .into_store_error()? .query_map(params![], |row| row.get::<_, u32>(0)) @@ -386,7 +388,7 @@ pub(crate) fn set_block_header_has_client_notes( const QUERY: &str = "\ UPDATE block_headers SET has_client_notes=? - WHERE block_num=? AND has_client_notes=FALSE;"; + WHERE block_num=? AND has_client_notes=0;"; tx.execute(QUERY, params![has_client_notes, block_num]).into_store_error()?; Ok(()) } diff --git a/crates/sqlite-store/src/note/filters.rs b/crates/sqlite-store/src/note/filters.rs index 51d520458..668203577 100644 --- a/crates/sqlite-store/src/note/filters.rs +++ b/crates/sqlite-store/src/note/filters.rs @@ -7,9 +7,14 @@ use miden_client::account::AccountId; use miden_client::note::BlockNumber; use miden_client::store::{InputNoteState, NoteFilter, OutputNoteState}; use miden_client::utils::Serializable; -use rusqlite::types::Value; +use rusqlite::types::{ToSqlOutput, Value}; -type NoteQueryParams = Vec>>; +type NoteQueryParams = Vec>; + +/// Wraps a value list as an `rarray` pointer parameter. +fn array_param(values: Vec) -> ToSqlOutput<'static> { + ToSqlOutput::Array(Rc::new(values)) +} /// Returns the output notes query for a specific `NoteFilter` pub(super) fn note_filter_to_query_output_notes(filter: &NoteFilter) -> (String, NoteQueryParams) { @@ -55,7 +60,7 @@ pub(super) fn note_filter_output_notes_condition(filter: &NoteFilter) -> (String }, NoteFilter::Unique(note_id) => { let note_ids_list = vec![Value::Blob(note_id.as_word().to_bytes())]; - params.push(Rc::new(note_ids_list)); + params.push(array_param(note_ids_list)); "note.note_id IN rarray(?)".to_string() }, NoteFilter::List(note_ids) => { @@ -64,7 +69,7 @@ pub(super) fn note_filter_output_notes_condition(filter: &NoteFilter) -> (String .map(|note_id| Value::Blob(note_id.as_word().to_bytes())) .collect::>(); - params.push(Rc::new(note_ids_list)); + params.push(array_param(note_ids_list)); "note.note_id IN rarray(?)".to_string() }, NoteFilter::DetailsCommitments(commitments) => { @@ -73,7 +78,7 @@ pub(super) fn note_filter_output_notes_condition(filter: &NoteFilter) -> (String .map(|commitment| Value::Blob(commitment.to_bytes())) .collect::>(); - params.push(Rc::new(commitments_list)); + params.push(array_param(commitments_list)); "note.details_commitment IN rarray(?)".to_string() }, NoteFilter::Nullifiers(nullifiers) => { @@ -82,7 +87,7 @@ pub(super) fn note_filter_output_notes_condition(filter: &NoteFilter) -> (String .map(|nullifier| Value::Blob(nullifier.to_bytes())) .collect::>(); - params.push(Rc::new(nullifiers_list)); + params.push(array_param(nullifiers_list)); "note.nullifier IN rarray(?)".to_string() }, NoteFilter::Unspent => { @@ -142,8 +147,10 @@ pub(super) fn note_filter_to_query_input_note_by_offset( use core::fmt::Write; let (mut condition, mut params) = note_filter_input_notes_condition(filter); - params.push(Rc::new(vec![Value::Blob(consumer.to_bytes())])); - condition.push_str(" AND note.consumer_account_id IN rarray(?)"); + // Matching a single consumer with `=` rather than a one-element `rarray` is what lets + // `idx_input_notes_consumption` serve both this predicate and most of the ORDER BY below. + params.push(ToSqlOutput::from(consumer.to_bytes())); + condition.push_str(" AND note.consumer_account_id = ?"); condition.push_str(" AND note.consumed_tx_order IS NOT NULL"); if let Some(start) = block_start { @@ -190,7 +197,7 @@ pub(super) fn note_filter_input_notes_condition(filter: &NoteFilter) -> (String, }, NoteFilter::Unique(note_id) => { let note_ids_list = vec![Value::Blob(note_id.as_word().to_bytes())]; - params.push(Rc::new(note_ids_list)); + params.push(array_param(note_ids_list)); "(note.note_id IN rarray(?))".to_string() }, NoteFilter::List(note_ids) => { @@ -199,7 +206,7 @@ pub(super) fn note_filter_input_notes_condition(filter: &NoteFilter) -> (String, .map(|note_id| Value::Blob(note_id.as_word().to_bytes())) .collect::>(); - params.push(Rc::new(note_ids_list)); + params.push(array_param(note_ids_list)); "(note.note_id IN rarray(?))".to_string() }, NoteFilter::DetailsCommitments(commitments) => { @@ -208,7 +215,7 @@ pub(super) fn note_filter_input_notes_condition(filter: &NoteFilter) -> (String, .map(|commitment| Value::Blob(commitment.to_bytes())) .collect::>(); - params.push(Rc::new(commitments_list)); + params.push(array_param(commitments_list)); "(note.details_commitment IN rarray(?))".to_string() }, NoteFilter::Nullifiers(nullifiers) => { @@ -217,7 +224,7 @@ pub(super) fn note_filter_input_notes_condition(filter: &NoteFilter) -> (String, .map(|nullifier| Value::Blob(nullifier.to_bytes())) .collect::>(); - params.push(Rc::new(nullifiers_list)); + params.push(array_param(nullifiers_list)); "(note.nullifier IN rarray(?))".to_string() }, NoteFilter::ScriptRoots(script_roots) => { @@ -226,7 +233,7 @@ pub(super) fn note_filter_input_notes_condition(filter: &NoteFilter) -> (String, .map(|script_root| Value::Blob(script_root.to_bytes())) .collect::>(); - params.push(Rc::new(script_roots_list)); + params.push(array_param(script_roots_list)); "(note.script_root IN rarray(?))".to_string() }, NoteFilter::Unverified => { diff --git a/crates/sqlite-store/src/note/mod.rs b/crates/sqlite-store/src/note/mod.rs index c12adf5c1..2e81d44c6 100644 --- a/crates/sqlite-store/src/note/mod.rs +++ b/crates/sqlite-store/src/note/mod.rs @@ -49,6 +49,17 @@ const INPUT_NOTE_BATCH_SIZE: usize = 50; const OUTPUT_NOTE_BATCH_SIZE: usize = 80; const SCRIPT_BATCH_SIZE: usize = 200; +// NOTE SCRIPT UPSERT +// ================================================================================================ + +// `input_notes.script_root` references `notes_scripts(script_root)`, so replacing a script row +// deletes the parent and forces a foreign key check against every referencing note. Updating the +// row in place keeps the parent alive, so no check runs at all. +const UPSERT_NOTE_SCRIPT_QUERY: &str = "INSERT INTO `notes_scripts` \ + (`script_root`, `serialized_note_script`) VALUES (?, ?) \ + ON CONFLICT(`script_root`) DO UPDATE SET \ + `serialized_note_script` = excluded.`serialized_note_script`"; + #[cfg(test)] mod tests; @@ -222,12 +233,17 @@ impl SqliteStore { pub(crate) fn get_unspent_input_note_nullifiers( conn: &mut Connection, ) -> Result, StoreError> { - const QUERY: &str = - "SELECT nullifier FROM input_notes WHERE state_discriminant NOT IN rarray(?)"; + // Listing the unspent states positively lets the query use `idx_input_notes_state`; a + // negated `NOT IN` over the consumed states cannot use it and scans the table. + const QUERY: &str = "SELECT nullifier FROM input_notes \ + WHERE state_discriminant IN rarray(?) AND nullifier IS NOT NULL"; let unspent_filters = Rc::new(vec![ - Value::from(InputNoteState::STATE_CONSUMED_AUTHENTICATED_LOCAL), - Value::from(InputNoteState::STATE_CONSUMED_UNAUTHENTICATED_LOCAL), - Value::from(InputNoteState::STATE_CONSUMED_EXTERNAL), + Value::from(InputNoteState::STATE_EXPECTED), + Value::from(InputNoteState::STATE_UNVERIFIED), + Value::from(InputNoteState::STATE_COMMITTED), + Value::from(InputNoteState::STATE_INVALID), + Value::from(InputNoteState::STATE_PROCESSING_AUTHENTICATED), + Value::from(InputNoteState::STATE_PROCESSING_UNAUTHENTICATED), ]); conn.prepare(QUERY) .into_store_error()? @@ -302,9 +318,7 @@ pub(super) fn upsert_input_note_tx( consumer_account_id, } = serialize_input_note(note); - const SCRIPT_QUERY: &str = - insert_sql!(notes_scripts { script_root, serialized_note_script } | REPLACE); - tx.prepare_cached(SCRIPT_QUERY) + tx.prepare_cached(UPSERT_NOTE_SCRIPT_QUERY) .into_store_error()? .execute(params![script_root, script]) .into_store_error()?; @@ -610,7 +624,7 @@ pub(crate) fn apply_note_updates_tx( Ok(()) } -/// Batch-insert note scripts using multi-row INSERT OR REPLACE. +/// Batch-upsert note scripts using a multi-row insert. /// Multi-row inserts reduce per-statement overhead and show faster insertion times than /// individual inserts. fn batch_upsert_scripts( @@ -625,8 +639,10 @@ fn batch_upsert_scripts( for chunk in entries.chunks(SCRIPT_BATCH_SIZE) { let placeholders = vec!["(?, ?)"; chunk.len()].join(", "); let query = format!( - "INSERT OR REPLACE INTO `notes_scripts` (`script_root`, `serialized_note_script`) \ - VALUES {placeholders}" + "INSERT INTO `notes_scripts` (`script_root`, `serialized_note_script`) \ + VALUES {placeholders} \ + ON CONFLICT(`script_root`) DO UPDATE SET \ + `serialized_note_script` = excluded.`serialized_note_script`" ); let mut param_values: Vec = Vec::with_capacity(chunk.len() * 2); for (root, script) in chunk { @@ -792,14 +808,12 @@ fn batch_update_output_note_states( } /// Inserts the provided note script into the database, if the script already exists, it will be -/// replaced. +/// updated. pub(super) fn upsert_note_script_tx( tx: &Transaction<'_>, note_script: &NoteScript, ) -> Result<(), StoreError> { - const QUERY: &str = - insert_sql!(notes_scripts { script_root, serialized_note_script } | REPLACE); - tx.prepare_cached(QUERY) + tx.prepare_cached(UPSERT_NOTE_SCRIPT_QUERY) .into_store_error()? .execute(params![note_script.root().to_bytes(), note_script.to_bytes()]) .into_store_error()?; diff --git a/crates/sqlite-store/src/note/tests.rs b/crates/sqlite-store/src/note/tests.rs index fe5c223ae..ec54fd1c5 100644 --- a/crates/sqlite-store/src/note/tests.rs +++ b/crates/sqlite-store/src/note/tests.rs @@ -90,6 +90,33 @@ fn create_expected_input_note_with_script(index: u32, script: NoteScript) -> Inp InputNoteRecord::new(details, NoteAttachments::empty(), Some(0), state.into()) } +/// Helper to create an expected (non-consumed) input note that carries metadata, so it has a +/// known nullifier. +fn create_expected_input_note_with_metadata(index: u32) -> InputNoteRecord { + let serial_number: Word = + [Felt::new_unchecked(u64::from(index) + 9000), ZERO, ZERO, ZERO].into(); + let assets = NoteAssets::new(vec![]).unwrap(); + let recipient = NoteRecipient::new( + serial_number, + StandardNote::SWAP.script(), + NoteStorage::new(vec![]).unwrap(), + ); + let details = NoteDetails::new(assets, recipient); + + let sender = AccountId::try_from(ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE).unwrap(); + let partial_metadata = + PartialNoteMetadata::new(sender, NoteType::Public).with_tag(NoteTag::from(index)); + let metadata = NoteMetadata::new(partial_metadata, &NoteAttachments::empty()); + + let state = ExpectedNoteState { + metadata: Some(metadata), + after_block_num: BlockNumber::from(0u32), + tag: None, + }; + + InputNoteRecord::new(details, NoteAttachments::empty(), Some(0), state.into()) +} + /// Helper to create an expected output note with a specific script. fn create_expected_output_note_with_script(index: u32, script: NoteScript) -> OutputNoteRecord { let serial_number: Word = @@ -518,3 +545,54 @@ async fn output_notes_never_match_script_root_filter() { .unwrap(); assert!(notes.is_empty()); } + +// UNSPENT NULLIFIER TESTS +// ================================================================================================ + +#[tokio::test] +async fn unspent_nullifiers_skip_notes_without_metadata() { + let store = create_test_store().await; + + // An expected note without metadata has no nullifier, so its column is NULL. + let without_metadata = create_expected_input_note(0); + let with_metadata = create_expected_input_note_with_metadata(1); + assert!(without_metadata.nullifier().is_none()); + + store + .upsert_input_notes(&[without_metadata, with_metadata.clone()]) + .await + .unwrap(); + + let nullifiers = store.get_unspent_input_note_nullifiers().await.unwrap(); + assert_eq!(nullifiers, vec![with_metadata.nullifier().unwrap()]); +} + +#[tokio::test] +async fn unspent_nullifiers_are_empty_when_no_note_has_metadata() { + let store = create_test_store().await; + + let notes: Vec<_> = (0..3u32).map(create_expected_input_note).collect(); + store.upsert_input_notes(¬es).await.unwrap(); + + let nullifiers = store.get_unspent_input_note_nullifiers().await.unwrap(); + assert!(nullifiers.is_empty()); +} + +#[tokio::test] +async fn unspent_nullifiers_exclude_consumed_notes() { + let store = create_test_store().await; + + let consumer = AccountId::try_from(ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE).unwrap(); + let consumed_local = create_consumed_input_note_with_consumer(consumer, 0, 1, 0); + let consumed_external = create_consumed_external_input_note(1, 1, Some(consumer)); + let unspent = create_expected_input_note_with_metadata(2); + assert!(consumed_local.nullifier().is_some()); + + store + .upsert_input_notes(&[consumed_local, consumed_external, unspent.clone()]) + .await + .unwrap(); + + let nullifiers = store.get_unspent_input_note_nullifiers().await.unwrap(); + assert_eq!(nullifiers, vec![unspent.nullifier().unwrap()]); +} diff --git a/crates/sqlite-store/src/store.sql b/crates/sqlite-store/src/store.sql index 207d766f4..38d57f504 100644 --- a/crates/sqlite-store/src/store.sql +++ b/crates/sqlite-store/src/store.sql @@ -30,6 +30,9 @@ CREATE TABLE latest_account_headers ( PRIMARY KEY (id), FOREIGN KEY (code_commitment) REFERENCES account_code(commitment) ); +-- SQLite does not index foreign key child columns automatically. Without this, account code garbage +-- collection scans the whole table once per candidate commitment. +CREATE INDEX idx_latest_account_headers_code_commitment ON latest_account_headers(code_commitment); -- Historical account headers: stores old headers that were replaced by newer states. -- Each row represents a previous account state that was superseded at replaced_at_nonce. @@ -49,6 +52,7 @@ CREATE TABLE historical_account_headers ( CONSTRAINT check_seed_nonzero CHECK (NOT (nonce = 0 AND account_seed IS NULL)) ); CREATE INDEX idx_historical_account_headers_id_replaced_at ON historical_account_headers(id, replaced_at_nonce DESC); +CREATE INDEX idx_historical_account_headers_code_commitment ON historical_account_headers(code_commitment); -- ── Account storage (latest + historical) ──────────────────────────────── @@ -119,6 +123,7 @@ CREATE TABLE foreign_account_code( PRIMARY KEY (account_id), FOREIGN KEY (code_commitment) REFERENCES account_code(commitment) ); +CREATE INDEX idx_foreign_account_code_code_commitment ON foreign_account_code(code_commitment); -- ── Transactions ───────────────────────────────────────────────────────── @@ -166,7 +171,7 @@ CREATE TABLE input_notes ( CREATE INDEX idx_input_notes_state ON input_notes(state_discriminant); CREATE INDEX idx_input_notes_nullifier ON input_notes(nullifier); CREATE INDEX idx_input_notes_note_id ON input_notes(note_id); -CREATE INDEX idx_input_notes_consumption ON input_notes(consumed_block_height, consumed_tx_order); +CREATE INDEX idx_input_notes_consumption ON input_notes(consumer_account_id, consumed_block_height, consumed_tx_order); CREATE INDEX idx_input_notes_script_root ON input_notes(script_root); CREATE TABLE output_notes (