From 2e1de176f177836914658bfee21305e71180bd63 Mon Sep 17 00:00:00 2001 From: gabrielbosio Date: Wed, 5 Aug 2026 19:08:32 -0300 Subject: [PATCH 01/11] Index account code references for history pruning --- crates/sqlite-store/src/store.sql | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/sqlite-store/src/store.sql b/crates/sqlite-store/src/store.sql index 207d766f4..8fb1ae87f 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 ───────────────────────────────────────────────────────── From 843f459ccf9b64b516624978bd827e901f5a6596 Mon Sep 17 00:00:00 2001 From: gabrielbosio Date: Wed, 5 Aug 2026 19:08:49 -0300 Subject: [PATCH 02/11] Match a single note consumer with = instead of a list --- crates/sqlite-store/src/note/filters.rs | 33 +++++++++++++++---------- crates/sqlite-store/src/store.sql | 2 +- 2 files changed, 21 insertions(+), 14 deletions(-) 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/store.sql b/crates/sqlite-store/src/store.sql index 8fb1ae87f..38d57f504 100644 --- a/crates/sqlite-store/src/store.sql +++ b/crates/sqlite-store/src/store.sql @@ -171,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 ( From 6be75f0863dfe597fd093175d8f3bfbe4199c701 Mon Sep 17 00:00:00 2001 From: gabrielbosio Date: Wed, 5 Aug 2026 19:09:05 -0300 Subject: [PATCH 03/11] Query unspent note nullifiers by a positive state list --- crates/sqlite-store/src/note/mod.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/sqlite-store/src/note/mod.rs b/crates/sqlite-store/src/note/mod.rs index c12adf5c1..8e1571c26 100644 --- a/crates/sqlite-store/src/note/mod.rs +++ b/crates/sqlite-store/src/note/mod.rs @@ -222,12 +222,17 @@ impl SqliteStore { pub(crate) fn get_unspent_input_note_nullifiers( conn: &mut Connection, ) -> Result, StoreError> { + // 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 NOT IN rarray(?)"; + "SELECT nullifier FROM input_notes WHERE state_discriminant IN rarray(?)"; 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()? From 3c31a4742ceb00ac997a53475b10b1e3b1ac2f71 Mon Sep 17 00:00:00 2001 From: gabrielbosio Date: Wed, 5 Aug 2026 19:09:40 -0300 Subject: [PATCH 04/11] Update note scripts in place instead of replacing them --- crates/sqlite-store/src/note/mod.rs | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/crates/sqlite-store/src/note/mod.rs b/crates/sqlite-store/src/note/mod.rs index 8e1571c26..a744981e7 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; @@ -307,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()?; @@ -615,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( @@ -630,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 { @@ -797,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()?; From 806ac64d323089104b8ed4c6db48e65d15e9c003 Mon Sep 17 00:00:00 2001 From: gabrielbosio Date: Wed, 5 Aug 2026 19:09:45 -0300 Subject: [PATCH 05/11] Compare has_client_notes to 1 so the partial index applies --- crates/sqlite-store/src/chain_data.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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(()) } From bbbda78e01b11105908ab32482993b0dfb56f2fa Mon Sep 17 00:00:00 2001 From: gabrielbosio Date: Wed, 5 Aug 2026 19:09:50 -0300 Subject: [PATCH 06/11] Add changelog entries for SQL store query fixes --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c285a35d0..5c5431f5a 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 (#TBD). ### Enhancements @@ -21,6 +22,7 @@ * [FEATURE][rust] `Client::get_consumable_notes(Some(account_id))` now screens only that account instead of screening every tracked account and discarding the rest, so its cost no longer grows with the number of tracked accounts. Added `NoteScreener::get_batch_consumability_for_account` to screen notes against a single account ([#2338](https://github.com/0xMiden/rust-sdk/pull/2338)). * [FEATURE][rust] Added the `miden_client::rpc::encryption` module backing encrypted submissions: `TransactionEncryptionKey`, `AttestedTransactionEncryptionKey` (whose `verify` is the only path to a usable key), `ValidatorAttestation`, `NextTransactionEncryptionKey`, `SealedTransactionInputs` and `seal_transaction_inputs`, along with re-exports of the validator DSA key types reachable from this API ([#2341](https://github.com/0xMiden/rust-sdk/pull/2341)). * [FEATURE][rust,store] Added `NoteFilter::ScriptRoots` to query input notes by their note script root directly at the store level, without loading and screening unrelated notes. The filter doesn't apply to output notes: querying output notes with it returns an empty list ([#2335](https://github.com/0xMiden/rust-sdk/pull/2335)). +* [FIX][store] Store operations whose cost grew with the size of the database now stay flat. Note script rows are updated in place instead of replaced, so persisting them no longer deletes the row `input_notes.script_root` references and checks every note referencing it: one 50-script batch cost 0.7ms at 25k notes, 3ms at 100k and 16ms at 400k, against a flat 0.05ms in place, on every sync. `get_unspent_input_note_nullifiers` lists the unspent states positively instead of negating the consumed ones, `get_input_note_by_offset` matches a single consumer with `=` instead of a one-element list so the consumption index serves its ordering too, the tracked block header queries compare `has_client_notes` to `1` so the partial index applies, and account code garbage collection no longer scans three tables per candidate commitment (#TBD). * [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)). From b499d37ba08ec12deb0c9a83b1b5547d13868b08 Mon Sep 17 00:00:00 2001 From: gabrielbosio Date: Wed, 5 Aug 2026 19:13:16 -0300 Subject: [PATCH 07/11] Link changelog entries to PR 2364 --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c5431f5a..a59336ea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +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 (#TBD). +* [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 @@ -22,7 +22,7 @@ * [FEATURE][rust] `Client::get_consumable_notes(Some(account_id))` now screens only that account instead of screening every tracked account and discarding the rest, so its cost no longer grows with the number of tracked accounts. Added `NoteScreener::get_batch_consumability_for_account` to screen notes against a single account ([#2338](https://github.com/0xMiden/rust-sdk/pull/2338)). * [FEATURE][rust] Added the `miden_client::rpc::encryption` module backing encrypted submissions: `TransactionEncryptionKey`, `AttestedTransactionEncryptionKey` (whose `verify` is the only path to a usable key), `ValidatorAttestation`, `NextTransactionEncryptionKey`, `SealedTransactionInputs` and `seal_transaction_inputs`, along with re-exports of the validator DSA key types reachable from this API ([#2341](https://github.com/0xMiden/rust-sdk/pull/2341)). * [FEATURE][rust,store] Added `NoteFilter::ScriptRoots` to query input notes by their note script root directly at the store level, without loading and screening unrelated notes. The filter doesn't apply to output notes: querying output notes with it returns an empty list ([#2335](https://github.com/0xMiden/rust-sdk/pull/2335)). -* [FIX][store] Store operations whose cost grew with the size of the database now stay flat. Note script rows are updated in place instead of replaced, so persisting them no longer deletes the row `input_notes.script_root` references and checks every note referencing it: one 50-script batch cost 0.7ms at 25k notes, 3ms at 100k and 16ms at 400k, against a flat 0.05ms in place, on every sync. `get_unspent_input_note_nullifiers` lists the unspent states positively instead of negating the consumed ones, `get_input_note_by_offset` matches a single consumer with `=` instead of a one-element list so the consumption index serves its ordering too, the tracked block header queries compare `has_client_notes` to `1` so the partial index applies, and account code garbage collection no longer scans three tables per candidate commitment (#TBD). +* [FIX][store] Store operations whose cost grew with the size of the database now stay flat. Note script rows are updated in place instead of replaced, so persisting them no longer deletes the row `input_notes.script_root` references and checks every note referencing it: one 50-script batch cost 0.7ms at 25k notes, 3ms at 100k and 16ms at 400k, against a flat 0.05ms in place, on every sync. `get_unspent_input_note_nullifiers` lists the unspent states positively instead of negating the consumed ones, `get_input_note_by_offset` matches a single consumer with `=` instead of a one-element list so the consumption index serves its ordering too, the tracked block header queries compare `has_client_notes` to `1` so the partial index applies, and account code garbage collection no longer scans three tables per candidate commitment ([#2364](https://github.com/0xMiden/rust-sdk/pull/2364)). * [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)). From 5a1300a67b8f779336c58f1f526c12cb667131f3 Mon Sep 17 00:00:00 2001 From: gabrielbosio Date: Fri, 7 Aug 2026 18:57:28 -0300 Subject: [PATCH 08/11] Skip notes without a nullifier when listing unspent ones --- crates/sqlite-store/src/note/mod.rs | 4 +- crates/sqlite-store/src/note/tests.rs | 78 +++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/crates/sqlite-store/src/note/mod.rs b/crates/sqlite-store/src/note/mod.rs index a744981e7..2e81d44c6 100644 --- a/crates/sqlite-store/src/note/mod.rs +++ b/crates/sqlite-store/src/note/mod.rs @@ -235,8 +235,8 @@ impl SqliteStore { ) -> Result, StoreError> { // 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(?)"; + 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_EXPECTED), Value::from(InputNoteState::STATE_UNVERIFIED), 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()]); +} From d3026c100800e0f11ca1879572b937d8f09fe49f Mon Sep 17 00:00:00 2001 From: gabrielbosio Date: Fri, 7 Aug 2026 18:57:32 -0300 Subject: [PATCH 09/11] Move the SQL store fix entry to the Fixes section --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a59336ea8..47f75f087 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,10 +22,13 @@ * [FEATURE][rust] `Client::get_consumable_notes(Some(account_id))` now screens only that account instead of screening every tracked account and discarding the rest, so its cost no longer grows with the number of tracked accounts. Added `NoteScreener::get_batch_consumability_for_account` to screen notes against a single account ([#2338](https://github.com/0xMiden/rust-sdk/pull/2338)). * [FEATURE][rust] Added the `miden_client::rpc::encryption` module backing encrypted submissions: `TransactionEncryptionKey`, `AttestedTransactionEncryptionKey` (whose `verify` is the only path to a usable key), `ValidatorAttestation`, `NextTransactionEncryptionKey`, `SealedTransactionInputs` and `seal_transaction_inputs`, along with re-exports of the validator DSA key types reachable from this API ([#2341](https://github.com/0xMiden/rust-sdk/pull/2341)). * [FEATURE][rust,store] Added `NoteFilter::ScriptRoots` to query input notes by their note script root directly at the store level, without loading and screening unrelated notes. The filter doesn't apply to output notes: querying output notes with it returns an empty list ([#2335](https://github.com/0xMiden/rust-sdk/pull/2335)). -* [FIX][store] Store operations whose cost grew with the size of the database now stay flat. Note script rows are updated in place instead of replaced, so persisting them no longer deletes the row `input_notes.script_root` references and checks every note referencing it: one 50-script batch cost 0.7ms at 25k notes, 3ms at 100k and 16ms at 400k, against a flat 0.05ms in place, on every sync. `get_unspent_input_note_nullifiers` lists the unspent states positively instead of negating the consumed ones, `get_input_note_by_offset` matches a single consumer with `=` instead of a one-element list so the consumption index serves its ordering too, the tracked block header queries compare `has_client_notes` to `1` so the partial index applies, and account code garbage collection no longer scans three tables per candidate commitment ([#2364](https://github.com/0xMiden/rust-sdk/pull/2364)). * [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] Store operations whose cost grew with the size of the database now stay flat. Note script rows are updated in place instead of replaced, so persisting them no longer deletes the row `input_notes.script_root` references and checks every note referencing it: one 50-script batch cost 0.7ms at 25k notes, 3ms at 100k and 16ms at 400k, against a flat 0.05ms in place, on every sync. `get_unspent_input_note_nullifiers` lists the unspent states positively instead of negating the consumed ones, `get_input_note_by_offset` matches a single consumer with `=` instead of a one-element list so the consumption index serves its ordering too, the tracked block header queries compare `has_client_notes` to `1` so the partial index applies, and account code garbage collection no longer scans three tables per candidate commitment ([#2364](https://github.com/0xMiden/rust-sdk/pull/2364)). + ## 0.16.0-alpha.1 (2026-07-17) ### Breaking Changes From 43206fca11b32cc70a0b489aa2316f807a2f60fd Mon Sep 17 00:00:00 2001 From: gabrielbosio Date: Fri, 7 Aug 2026 19:13:13 -0300 Subject: [PATCH 10/11] Summarize the SQL store fix entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47f75f087..bd7628dfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ ### Fixes -* [FIX][store] Store operations whose cost grew with the size of the database now stay flat. Note script rows are updated in place instead of replaced, so persisting them no longer deletes the row `input_notes.script_root` references and checks every note referencing it: one 50-script batch cost 0.7ms at 25k notes, 3ms at 100k and 16ms at 400k, against a flat 0.05ms in place, on every sync. `get_unspent_input_note_nullifiers` lists the unspent states positively instead of negating the consumed ones, `get_input_note_by_offset` matches a single consumer with `=` instead of a one-element list so the consumption index serves its ordering too, the tracked block header queries compare `has_client_notes` to `1` so the partial index applies, and account code garbage collection no longer scans three tables per candidate commitment ([#2364](https://github.com/0xMiden/rust-sdk/pull/2364)). +* [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, and the note, block header and account code queries were rewritten to match the indices that serve them. 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) From b88c32805f6ebd28b4944525691af83f8c476c03 Mon Sep 17 00:00:00 2001 From: gabrielbosio Date: Fri, 7 Aug 2026 19:17:59 -0300 Subject: [PATCH 11/11] Correct the account code claim in the SQL store fix entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd7628dfa..b68ac5944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ ### 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, and the note, block header and account code queries were rewritten to match the indices that serve them. 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)). +* [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)