Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<SealedTransactionInputs>` (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

Expand All @@ -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
Expand Down
8 changes: 5 additions & 3 deletions crates/sqlite-store/src/chain_data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ impl SqliteStore {
pub(crate) fn get_tracked_block_headers(
conn: &mut Connection,
) -> Result<Vec<BlockHeader>, 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)
Expand All @@ -81,7 +83,7 @@ impl SqliteStore {
pub(crate) fn get_tracked_block_header_numbers(
conn: &mut Connection,
) -> Result<BTreeSet<usize>, 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))
Expand Down Expand Up @@ -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(())
}
Expand Down
33 changes: 20 additions & 13 deletions crates/sqlite-store/src/note/filters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Rc<Vec<Value>>>;
type NoteQueryParams = Vec<ToSqlOutput<'static>>;

/// Wraps a value list as an `rarray` pointer parameter.
fn array_param(values: Vec<Value>) -> 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) {
Expand Down Expand Up @@ -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) => {
Expand All @@ -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::<Vec<Value>>();

params.push(Rc::new(note_ids_list));
params.push(array_param(note_ids_list));
"note.note_id IN rarray(?)".to_string()
},
NoteFilter::DetailsCommitments(commitments) => {
Expand All @@ -73,7 +78,7 @@ pub(super) fn note_filter_output_notes_condition(filter: &NoteFilter) -> (String
.map(|commitment| Value::Blob(commitment.to_bytes()))
.collect::<Vec<Value>>();

params.push(Rc::new(commitments_list));
params.push(array_param(commitments_list));
"note.details_commitment IN rarray(?)".to_string()
},
NoteFilter::Nullifiers(nullifiers) => {
Expand All @@ -82,7 +87,7 @@ pub(super) fn note_filter_output_notes_condition(filter: &NoteFilter) -> (String
.map(|nullifier| Value::Blob(nullifier.to_bytes()))
.collect::<Vec<Value>>();

params.push(Rc::new(nullifiers_list));
params.push(array_param(nullifiers_list));
"note.nullifier IN rarray(?)".to_string()
},
NoteFilter::Unspent => {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) => {
Expand All @@ -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::<Vec<Value>>();

params.push(Rc::new(note_ids_list));
params.push(array_param(note_ids_list));
"(note.note_id IN rarray(?))".to_string()
},
NoteFilter::DetailsCommitments(commitments) => {
Expand All @@ -208,7 +215,7 @@ pub(super) fn note_filter_input_notes_condition(filter: &NoteFilter) -> (String,
.map(|commitment| Value::Blob(commitment.to_bytes()))
.collect::<Vec<Value>>();

params.push(Rc::new(commitments_list));
params.push(array_param(commitments_list));
"(note.details_commitment IN rarray(?))".to_string()
},
NoteFilter::Nullifiers(nullifiers) => {
Expand All @@ -217,7 +224,7 @@ pub(super) fn note_filter_input_notes_condition(filter: &NoteFilter) -> (String,
.map(|nullifier| Value::Blob(nullifier.to_bytes()))
.collect::<Vec<Value>>();

params.push(Rc::new(nullifiers_list));
params.push(array_param(nullifiers_list));
"(note.nullifier IN rarray(?))".to_string()
},
NoteFilter::ScriptRoots(script_roots) => {
Expand All @@ -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::<Vec<Value>>();

params.push(Rc::new(script_roots_list));
params.push(array_param(script_roots_list));
"(note.script_root IN rarray(?))".to_string()
},
NoteFilter::Unverified => {
Expand Down
44 changes: 29 additions & 15 deletions crates/sqlite-store/src/note/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -222,12 +233,17 @@ impl SqliteStore {
pub(crate) fn get_unspent_input_note_nullifiers(
conn: &mut Connection,
) -> Result<Vec<Nullifier>, 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()?
Expand Down Expand Up @@ -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()?;
Expand Down Expand Up @@ -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(
Expand All @@ -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<Value> = Vec::with_capacity(chunk.len() * 2);
for (root, script) in chunk {
Expand Down Expand Up @@ -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()?;
Expand Down
78 changes: 78 additions & 0 deletions crates/sqlite-store/src/note/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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(&notes).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()]);
}
7 changes: 6 additions & 1 deletion crates/sqlite-store/src/store.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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) ────────────────────────────────

Expand Down Expand Up @@ -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 ─────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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 (
Expand Down
Loading