Skip to main content

miden_node_store/db/
mod.rs

1use std::collections::{BTreeMap, BTreeSet, HashSet};
2use std::mem::size_of;
3use std::num::NonZeroUsize;
4use std::ops::{Deref, DerefMut, RangeInclusive};
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7
8use anyhow::Context;
9use diesel::{Connection, SqliteConnection};
10use miden_crypto::dsa::ecdsa_k256_keccak::Signature;
11use miden_node_proto::domain::account::AccountInfo;
12use miden_node_utils::limiter::MAX_RESPONSE_PAYLOAD_BYTES;
13use miden_node_utils::tracing::miden_instrument;
14use miden_protocol::Word;
15use miden_protocol::account::{AccountHeader, AccountId, AccountStorageHeader, StorageMapKey};
16use miden_protocol::asset::{Asset, AssetVaultKey};
17use miden_protocol::block::{BlockHeader, BlockNoteIndex, BlockNumber, SignedBlock};
18use miden_protocol::crypto::merkle::SparseMerklePath;
19use miden_protocol::note::{
20    NoteAttachments,
21    NoteDetails,
22    NoteId,
23    NoteInclusionProof,
24    NoteMetadata,
25    NoteScript,
26    Nullifier,
27};
28use miden_protocol::transaction::TransactionHeader;
29use miden_protocol::utils::serde::Deserializable;
30use tokio::sync::oneshot;
31use tracing::info;
32
33use crate::db::migrations::{migrate_database, verify_latest_schema};
34use crate::db::models::conv::SqlTypeConvert;
35use crate::db::models::queries;
36pub use crate::db::models::queries::{
37    AccountCommitmentsPage,
38    NullifiersPage,
39    PublicAccountIdsPage,
40    PublicAccountStateRootsPage,
41};
42use crate::db::models::queries::{BlockHeaderCommitment, StorageMapValuesPage};
43use crate::errors::{DatabaseError, NoteSyncError};
44use crate::genesis::GenesisBlock;
45use crate::{COMPONENT, LOG_TARGET};
46
47const STORAGE_MAP_VALUE_PER_ROW_BYTES: usize =
48    2 * size_of::<Word>() + size_of::<u32>() + size_of::<u8>();
49
50fn default_storage_map_entries_limit() -> usize {
51    MAX_RESPONSE_PAYLOAD_BYTES / STORAGE_MAP_VALUE_PER_ROW_BYTES
52}
53
54mod migrations;
55#[cfg(test)]
56pub(crate) use migrations::bootstrap_database;
57
58#[cfg(test)]
59mod tests;
60
61pub(crate) mod models;
62
63/// [diesel](https://diesel.rs) generated schema
64///
65/// The ignored `diesel_schema_is_in_sync_with_migrations` test verifies that this file matches the
66/// schema produced by the current migrations.
67pub(crate) mod schema;
68
69pub type Result<T, E = DatabaseError> = std::result::Result<T, E>;
70
71/// Database options used by the store state.
72#[derive(Copy, Clone, Debug, PartialEq, Eq)]
73pub struct DatabaseOptions {
74    /// Maximum number of SQLite connections in the connection pool.
75    pub connection_pool_size: NonZeroUsize,
76}
77
78impl Default for DatabaseOptions {
79    fn default() -> Self {
80        Self {
81            connection_pool_size: miden_node_db::default_connection_pool_size(),
82        }
83    }
84}
85
86/// The Store's database.
87///
88/// Extends the underlying [`miden_node_db::Db`] type with functionality specific to the Store.
89pub struct Db {
90    db: miden_node_db::Db,
91}
92
93impl Deref for Db {
94    type Target = miden_node_db::Db;
95
96    fn deref(&self) -> &Self::Target {
97        &self.db
98    }
99}
100
101impl DerefMut for Db {
102    fn deref_mut(&mut self) -> &mut Self::Target {
103        &mut self.db
104    }
105}
106
107/// Describes the value of an asset for an account ID at `block_num` specifically.
108///
109/// If `asset` is `None`, the asset was removed.
110#[derive(Debug, Clone)]
111pub struct AccountVaultValue {
112    pub block_num: BlockNumber,
113    pub vault_key: AssetVaultKey,
114    /// None if the asset was removed
115    pub asset: Option<Asset>,
116}
117
118impl AccountVaultValue {
119    pub fn from_raw_row(row: (i64, Vec<u8>, Option<Vec<u8>>)) -> Result<Self, DatabaseError> {
120        let (block_num, vault_key, asset) = row;
121        let vault_key = Word::read_from_bytes(&vault_key)?;
122        Ok(Self {
123            block_num: BlockNumber::from_raw_sql(block_num)?,
124            vault_key: AssetVaultKey::try_from(vault_key)?,
125            asset: asset.map(|b| Asset::read_from_bytes(&b)).transpose()?,
126        })
127    }
128}
129
130#[derive(Debug, PartialEq)]
131pub struct NullifierInfo {
132    pub nullifier: Nullifier,
133    pub block_num: BlockNumber,
134}
135
136impl PartialEq<(Nullifier, BlockNumber)> for NullifierInfo {
137    fn eq(&self, (nullifier, block_num): &(Nullifier, BlockNumber)) -> bool {
138        &self.nullifier == nullifier && &self.block_num == block_num
139    }
140}
141
142#[derive(Debug, PartialEq)]
143pub struct TransactionRecord {
144    pub block_num: BlockNumber,
145    pub header: TransactionHeader,
146    /// Inclusion proofs for committed output notes. Notes in `header.output_notes()` without a
147    /// corresponding proof here were erased (created and consumed within the same batch).
148    pub output_note_proofs: Vec<NoteSyncRecord>,
149    /// Maps each consumed input note's nullifier to its note ID, for public notes the node could
150    /// resolve. This is to enable the recover of notes by their id.
151    pub consumed_note_refs: Vec<(Nullifier, NoteId)>,
152}
153
154#[derive(Debug, Clone, PartialEq)]
155pub struct NoteRecord {
156    pub block_num: BlockNumber,
157    pub note_index: BlockNoteIndex,
158    pub note_id: Word,
159    pub metadata: NoteMetadata,
160    pub details: Option<NoteDetails>,
161    pub attachments: NoteAttachments,
162    pub inclusion_path: SparseMerklePath,
163}
164
165#[derive(Debug, PartialEq)]
166pub struct NoteSyncUpdate {
167    pub notes: Vec<NoteSyncRecord>,
168    pub block_header: BlockHeader,
169}
170
171#[derive(Debug, Clone, PartialEq)]
172pub struct NoteSyncRecord {
173    pub block_num: BlockNumber,
174    pub note_index: BlockNoteIndex,
175    pub note_id: NoteId,
176    pub metadata: NoteMetadata,
177    pub inclusion_path: SparseMerklePath,
178}
179
180impl From<NoteRecord> for NoteSyncRecord {
181    fn from(note: NoteRecord) -> Self {
182        Self {
183            block_num: note.block_num,
184            note_index: note.note_index,
185            note_id: NoteId::from_raw(note.note_id),
186            metadata: note.metadata,
187            inclusion_path: note.inclusion_path,
188        }
189    }
190}
191
192impl Db {
193    /// Creates a new database and inserts the genesis block.
194    #[miden_instrument(
195        target = COMPONENT,
196        name = "store.database.bootstrap",
197        skip_all,
198        fields(path=%database_filepath.display())
199        err,
200    )]
201    pub fn bootstrap(database_filepath: PathBuf, genesis: GenesisBlock) -> anyhow::Result<()> {
202        migrations::bootstrap_database(&database_filepath)
203            .context("failed to bootstrap database schema")?;
204
205        let mut conn: SqliteConnection = diesel::sqlite::SqliteConnection::establish(
206            database_filepath.to_str().context("database filepath is invalid")?,
207        )
208        .context("failed to open a database connection")?;
209
210        miden_node_db::configure_connection_on_creation(&mut conn)?;
211
212        // Insert genesis block data.
213        let genesis_block = genesis.into_inner();
214        conn.transaction(move |conn| models::queries::apply_block(conn, &genesis_block, &[]))
215            .context("failed to insert genesis block")?;
216        Ok(())
217    }
218
219    /// Open a connection to the DB after verifying that it is at the latest schema version.
220    #[miden_instrument(
221        target = COMPONENT,
222        skip_all,
223    )]
224    pub async fn load(database_filepath: PathBuf) -> Result<Self, DatabaseError> {
225        Self::load_with_pool_size(database_filepath, miden_node_db::default_connection_pool_size())
226            .await
227    }
228
229    /// Open a connection to the DB with a specific pool size after verifying that it is at the
230    /// latest schema version.
231    #[miden_instrument(
232        target = COMPONENT,
233        skip_all,
234    )]
235    pub async fn load_with_pool_size(
236        database_filepath: PathBuf,
237        connection_pool_size: NonZeroUsize,
238    ) -> Result<Self, DatabaseError> {
239        verify_latest_schema(&database_filepath)?;
240
241        let db = miden_node_db::Db::new_with_pool_size(&database_filepath, connection_pool_size)?;
242        info!(
243            target: LOG_TARGET,
244            sqlite= %database_filepath.display(),
245            connection_pool_size = %connection_pool_size,
246            "Connected to the database"
247        );
248
249        Ok(Self { db })
250    }
251
252    /// Applies all pending migrations to an existing DB.
253    #[miden_instrument(
254        target = COMPONENT,
255        skip_all,
256    )]
257    pub fn migrate(database_filepath: impl AsRef<Path>) -> Result<(), DatabaseError> {
258        migrate_database(database_filepath.as_ref())?;
259        Ok(())
260    }
261
262    /// Returns a page of nullifiers for tree rebuilding.
263    #[miden_instrument(
264        level = "debug",
265        target = COMPONENT,
266        skip_all,
267        err,
268    )]
269    pub async fn select_nullifiers_paged(
270        &self,
271        page_size: std::num::NonZeroUsize,
272        after_nullifier: Option<Nullifier>,
273    ) -> Result<NullifiersPage> {
274        self.transact("read nullifiers paged", move |conn| {
275            queries::select_nullifiers_paged(conn, page_size, after_nullifier)
276        })
277        .await
278    }
279
280    /// Loads the nullifiers that match the prefixes from the DB.
281    #[miden_instrument(
282        level = "debug",
283        target = COMPONENT,
284        skip_all,
285        fields(
286            prefix_len,
287            prefixes = nullifier_prefixes.len(),
288        ),
289        err,
290    )]
291    pub async fn select_nullifiers_by_prefix(
292        &self,
293        prefix_len: u32,
294        nullifier_prefixes: Vec<u32>,
295        block_range: RangeInclusive<BlockNumber>,
296    ) -> Result<(Vec<NullifierInfo>, BlockNumber)> {
297        assert_eq!(prefix_len, 16, "Only 16-bit prefixes are supported");
298
299        self.transact("nullifieres by prefix", move |conn| {
300            let nullifier_prefixes =
301                Vec::from_iter(nullifier_prefixes.into_iter().map(|prefix| prefix as u16));
302            queries::select_nullifiers_by_prefix(
303                conn,
304                prefix_len as u8,
305                &nullifier_prefixes[..],
306                block_range,
307            )
308        })
309        .await
310    }
311
312    /// Search for a [`BlockHeader`] from the database by its `block_num`.
313    ///
314    /// When `block_number` is [None], the latest block header is returned.
315    #[miden_instrument(
316        level = "debug",
317        target = COMPONENT,
318        skip_all,
319        err,
320    )]
321    pub async fn select_block_header_by_block_num(
322        &self,
323        maybe_block_number: Option<BlockNumber>,
324    ) -> Result<Option<BlockHeader>> {
325        self.transact("block headers by block number", move |conn| {
326            let val = queries::select_block_header_by_block_num(conn, maybe_block_number)?;
327            Ok(val)
328        })
329        .await
330    }
331
332    /// Search for a [`BlockHeader`] and its [`Signature`] from the database by its `block_num`.
333    #[miden_instrument(
334        level = "debug",
335        target = COMPONENT,
336        skip_all,
337        err,
338    )]
339    pub async fn select_block_header_and_signature_by_block_num(
340        &self,
341        block_number: BlockNumber,
342    ) -> Result<Option<(BlockHeader, Signature)>> {
343        self.transact("block headers and signature by block number", move |conn| {
344            let val = queries::select_block_header_and_signature_by_block_num(conn, block_number)?;
345            Ok(val)
346        })
347        .await
348    }
349
350    /// Loads multiple block headers from the DB.
351    #[miden_instrument(
352        level = "debug",
353        target = COMPONENT,
354        skip_all,
355        err,
356    )]
357    pub async fn select_block_headers(
358        &self,
359        blocks: impl Iterator<Item = BlockNumber> + Send + 'static,
360    ) -> Result<Vec<BlockHeader>> {
361        self.transact("block headers from given block numbers", move |conn| {
362            let raw = queries::select_block_headers(conn, blocks)?;
363            Ok(raw)
364        })
365        .await
366    }
367
368    /// Loads all the block headers from the DB.
369    #[miden_instrument(
370        level = "debug",
371        target = COMPONENT,
372        skip_all,
373        err,
374    )]
375    pub async fn select_all_block_header_commitments(&self) -> Result<Vec<BlockHeaderCommitment>> {
376        self.transact("all block headers", |conn| {
377            let raw = queries::select_all_block_header_commitments(conn)?;
378            Ok(raw)
379        })
380        .await
381    }
382
383    /// Returns a page of account commitments for tree rebuilding.
384    #[miden_instrument(
385        level = "debug",
386        target = COMPONENT,
387        skip_all,
388        err,
389    )]
390    pub async fn select_account_commitments_paged(
391        &self,
392        page_size: std::num::NonZeroUsize,
393        after_account_id: Option<AccountId>,
394    ) -> Result<AccountCommitmentsPage> {
395        self.transact("read account commitments paged", move |conn| {
396            queries::select_account_commitments_paged(conn, page_size, after_account_id)
397        })
398        .await
399    }
400
401    /// Returns a page of public account IDs for forest rebuilding.
402    #[miden_instrument(
403        level = "debug",
404        target = COMPONENT,
405        skip_all,
406        err,
407    )]
408    pub async fn select_public_account_ids_paged(
409        &self,
410        page_size: std::num::NonZeroUsize,
411        after_account_id: Option<AccountId>,
412    ) -> Result<PublicAccountIdsPage> {
413        self.transact("read public account IDs paged", move |conn| {
414            queries::select_public_account_ids_paged(conn, page_size, after_account_id)
415        })
416        .await
417    }
418
419    /// Returns a page of public account state roots for forest consistency verification.
420    #[miden_instrument(
421        level = "debug",
422        target = COMPONENT,
423        skip_all,
424        err,
425    )]
426    pub async fn select_public_account_state_roots_paged(
427        &self,
428        page_size: std::num::NonZeroUsize,
429        after_account_id: Option<AccountId>,
430    ) -> Result<PublicAccountStateRootsPage> {
431        self.transact("read public account state roots paged", move |conn| {
432            queries::select_public_account_state_roots_paged(conn, page_size, after_account_id)
433        })
434        .await
435    }
436
437    /// Loads public account details from the DB.
438    #[miden_instrument(
439        level = "debug",
440        target = COMPONENT,
441        skip_all,
442        err,
443    )]
444    pub async fn select_account(&self, id: AccountId) -> Result<AccountInfo> {
445        self.transact("Get account details", move |conn| queries::select_account(conn, id))
446            .await
447    }
448
449    /// Returns the subset of the provided account IDs that classify as network accounts.
450    #[miden_instrument(
451        level = "debug",
452        target = COMPONENT,
453        skip_all,
454        err,
455    )]
456    pub async fn select_network_accounts_subset(
457        &self,
458        account_ids: Vec<AccountId>,
459    ) -> Result<HashSet<AccountId>> {
460        self.transact("Filter network accounts subset", move |conn| {
461            queries::select_network_accounts_subset(conn, &account_ids)
462        })
463        .await
464    }
465
466    /// Queries the account code by its commitment hash.
467    ///
468    /// Returns `None` if no code exists with that commitment.
469    #[miden_instrument(
470        target = COMPONENT,
471        skip_all,
472    )]
473    pub async fn select_account_code_by_commitment(
474        &self,
475        code_commitment: Word,
476    ) -> Result<Option<Vec<u8>>> {
477        self.transact("Get account code by commitment", move |conn| {
478            queries::select_account_code_by_commitment(conn, code_commitment)
479        })
480        .await
481    }
482
483    /// Queries the account header and storage header for a specific account at a block.
484    ///
485    /// Returns both in a single query to avoid querying the database twice.
486    /// Returns `None` if the account doesn't exist at that block.
487    #[miden_instrument(
488        target = COMPONENT,
489        skip_all,
490    )]
491    pub async fn select_account_header_with_storage_header_at_block(
492        &self,
493        account_id: AccountId,
494        block_num: BlockNumber,
495    ) -> Result<Option<(AccountHeader, AccountStorageHeader)>> {
496        self.transact("Get account header with storage header at block", move |conn| {
497            queries::select_account_header_with_storage_header_at_block(conn, account_id, block_num)
498        })
499        .await
500    }
501
502    #[miden_instrument(
503        level = "debug",
504        target = COMPONENT,
505        skip_all,
506        err,
507    )]
508    pub async fn get_note_sync_multi(
509        &self,
510        block_range: RangeInclusive<BlockNumber>,
511        note_tags: Arc<[u32]>,
512    ) -> Result<Vec<NoteSyncUpdate>, NoteSyncError> {
513        self.transact("notes sync task", move |conn| {
514            queries::get_note_sync_multi(conn, &note_tags, block_range, MAX_RESPONSE_PAYLOAD_BYTES)
515        })
516        .await
517    }
518
519    /// Loads all the [`miden_protocol::note::Note`]s matching a certain [`NoteId`] from the
520    /// database.
521    #[miden_instrument(
522        level = "debug",
523        target = COMPONENT,
524        skip_all,
525        err,
526    )]
527    pub async fn select_notes_by_id(&self, note_ids: Vec<NoteId>) -> Result<Vec<NoteRecord>> {
528        self.transact("note by id", move |conn| {
529            queries::select_notes_by_id(conn, note_ids.as_slice())
530        })
531        .await
532    }
533
534    /// Returns all note commitments from the DB that match the provided ones.
535    #[miden_instrument(
536        level = "debug",
537        target = COMPONENT,
538        skip_all,
539        err,
540    )]
541    pub async fn select_existing_note_commitments(
542        &self,
543        note_commitments: Vec<Word>,
544    ) -> Result<HashSet<Word>> {
545        self.transact("note by commitment", move |conn| {
546            queries::select_existing_note_commitments(conn, note_commitments.as_slice())
547        })
548        .await
549    }
550
551    /// Loads inclusion proofs for notes matching the given note commitments.
552    #[miden_instrument(
553        level = "debug",
554        target = COMPONENT,
555        skip_all,
556        err,
557    )]
558    pub async fn select_note_inclusion_proofs(
559        &self,
560        note_commitments: BTreeSet<Word>,
561    ) -> Result<BTreeMap<NoteId, NoteInclusionProof>> {
562        self.transact("block note inclusion proofs by commitment", move |conn| {
563            models::queries::select_note_inclusion_proofs(conn, &note_commitments)
564        })
565        .await
566    }
567
568    /// Inserts the data of a new block into the DB.
569    ///
570    /// `allow_acquire` and `acquire_done` are used to synchronize writes to the DB with writes to
571    /// the in-memory trees. Further details available on [`super::state::State::apply_block`].
572    // TODO: This span is logged in a root span, we should connect it to the parent one.
573    #[miden_instrument(
574        target = COMPONENT,
575        skip_all,
576        err,
577    )]
578    pub async fn apply_block(
579        &self,
580        allow_acquire: oneshot::Sender<()>,
581        acquire_done: oneshot::Receiver<()>,
582        signed_block: SignedBlock,
583        notes: Vec<(NoteRecord, Option<Nullifier>)>,
584    ) -> Result<()> {
585        self.transact("apply block", move |conn| -> Result<()> {
586            models::queries::apply_block(conn, &signed_block, &notes)?;
587
588            // XXX FIXME TODO free floating mutex MUST NOT exist it doesn't bind it properly to the
589            // data locked!
590            {
591                let _span = tracing::info_span!(target: COMPONENT, "acquire_write_lock").entered();
592                if allow_acquire.send(()).is_err() {
593                    tracing::warn!(target: COMPONENT, "failed to send notification for successful block application, potential deadlock");
594                }
595            }
596
597            models::queries::prune_history(conn, signed_block.header().block_num())?;
598
599            let _span =
600                tracing::info_span!(target: COMPONENT, "acquire_done_lock").entered();
601            acquire_done.blocking_recv()?;
602
603            Ok(())
604        })
605        .await
606    }
607
608    /// Selects storage map values for syncing storage maps for a specific account ID.
609    ///
610    /// The returned values are the latest known values up to `block_range.end()`, and no values
611    /// earlier than `block_range.start()` are returned.
612    pub(crate) async fn select_storage_map_sync_values(
613        &self,
614        account_id: AccountId,
615        block_range: RangeInclusive<BlockNumber>,
616        entries_limit: Option<usize>,
617    ) -> Result<StorageMapValuesPage> {
618        let entries_limit = entries_limit.unwrap_or_else(default_storage_map_entries_limit);
619
620        self.transact("select storage map sync values", move |conn| {
621            models::queries::select_account_storage_map_values_paged(
622                conn,
623                account_id,
624                block_range,
625                entries_limit,
626            )
627        })
628        .await
629    }
630
631    /// Reconstructs storage map details from the database for a specific slot at a block.
632    ///
633    /// Used as fallback when `AccountStateForest` cache misses (historical or evicted queries).
634    /// Rebuilds all entries by querying the DB and filtering to the specific slot.
635    ///
636    /// Returns:
637    ///     - `::LimitExceeded` when too many entries are present
638    ///     - `::AllEntries` if the size is less than or equal given `entries_limit`, if any
639    #[miden_instrument(
640        target = COMPONENT,
641        skip_all,
642    )]
643    pub(crate) async fn reconstruct_storage_map_from_db(
644        &self,
645        account_id: AccountId,
646        slot_name: miden_protocol::account::StorageSlotName,
647        block_num: BlockNumber,
648        entries_limit: Option<usize>,
649    ) -> Result<miden_node_proto::domain::account::AccountStorageMapDetails> {
650        use miden_node_proto::domain::account::{AccountStorageMapDetails, StorageMapEntries};
651        use miden_protocol::EMPTY_WORD;
652
653        // TODO this remains expensive with a large history until we implement pruning for DB
654        // columns
655        let mut values = Vec::new();
656        let mut block_range_start = BlockNumber::GENESIS;
657        let entries_limit = entries_limit.unwrap_or_else(default_storage_map_entries_limit);
658
659        let mut page = self
660            .select_storage_map_sync_values(
661                account_id,
662                block_range_start..=block_num,
663                Some(entries_limit),
664            )
665            .await?;
666
667        values.extend(page.values);
668        let mut last_block_included = page.last_block_included;
669
670        // If the first page returned no values, the block at block_range_start has more entries
671        // than the limit allows (e.g. genesis accounts with large storage maps).
672        if values.is_empty() && last_block_included == block_range_start {
673            return Ok(AccountStorageMapDetails::limit_exceeded(slot_name));
674        }
675
676        loop {
677            if page.last_block_included == block_num || page.last_block_included < block_range_start
678            {
679                break;
680            }
681
682            block_range_start = page.last_block_included.child();
683            page = self
684                .select_storage_map_sync_values(
685                    account_id,
686                    block_range_start..=block_num,
687                    Some(entries_limit),
688                )
689                .await?;
690
691            if page.last_block_included <= last_block_included {
692                return Ok(AccountStorageMapDetails::limit_exceeded(slot_name));
693            }
694
695            last_block_included = page.last_block_included;
696            values.extend(page.values);
697        }
698
699        if page.last_block_included != block_num {
700            return Ok(AccountStorageMapDetails::limit_exceeded(slot_name));
701        }
702
703        // Filter to the specific slot and collect latest values per key
704        let mut latest_values = BTreeMap::<StorageMapKey, Word>::new();
705        for value in values {
706            if value.slot_name == slot_name {
707                let raw_key = value.key;
708                latest_values.insert(raw_key, value.value);
709            }
710        }
711
712        // Remove EMPTY_WORD entries (deletions)
713        latest_values.retain(|_, v| *v != EMPTY_WORD);
714
715        if latest_values.len() > AccountStorageMapDetails::MAX_RETURN_ENTRIES {
716            return Ok(AccountStorageMapDetails::limit_exceeded(slot_name));
717        }
718
719        let entries = Vec::from_iter(latest_values.into_iter());
720        Ok(AccountStorageMapDetails {
721            slot_name,
722            entries: StorageMapEntries::AllEntries(entries),
723        })
724    }
725
726    pub async fn get_account_vault_sync(
727        &self,
728        account_id: AccountId,
729        block_range: RangeInclusive<BlockNumber>,
730    ) -> Result<(BlockNumber, Vec<AccountVaultValue>)> {
731        self.transact("account vault sync", move |conn| {
732            queries::select_account_vault_assets(conn, account_id, block_range)
733        })
734        .await
735    }
736
737    /// Returns the script for a note by its root.
738    pub async fn select_note_script_by_root(&self, root: Word) -> Result<Option<NoteScript>> {
739        self.transact("note script by root", move |conn| {
740            queries::select_note_script_by_root(conn, root)
741        })
742        .await
743    }
744
745    /// Returns the complete transaction records for the specified accounts within the specified
746    /// block range, including state commitments and note IDs.
747    ///
748    /// Note: This method is size-limited (~5MB) and may not return all matching transactions
749    /// if the limit is exceeded. Transactions from partial blocks are excluded to maintain
750    /// consistency.
751    pub async fn select_transactions_records(
752        &self,
753        account_ids: Vec<AccountId>,
754        block_range: RangeInclusive<BlockNumber>,
755    ) -> Result<(BlockNumber, Vec<TransactionRecord>)> {
756        self.transact("full transactions records", move |conn| {
757            queries::select_transactions_records(conn, &account_ids, block_range)
758        })
759        .await
760    }
761}