Skip to main content

miden_node_store/db/models/queries/
accounts.rs

1use std::collections::{BTreeMap, HashMap, HashSet};
2use std::num::NonZeroUsize;
3use std::ops::RangeInclusive;
4
5use diesel::prelude::{Queryable, QueryableByName};
6use diesel::query_dsl::methods::SelectDsl;
7use diesel::sqlite::Sqlite;
8use diesel::{
9    AsChangeset,
10    BoolExpressionMethods,
11    ExpressionMethods,
12    Insertable,
13    JoinOnDsl,
14    NullableExpressionMethods,
15    OptionalExtension,
16    QueryDsl,
17    RunQueryDsl,
18    Selectable,
19    SelectableHelper,
20    SqliteConnection,
21};
22use miden_node_proto::domain::account::{AccountInfo, AccountSummary, AccountVaultDetails};
23use miden_node_utils::limiter::{
24    MAX_RESPONSE_PAYLOAD_BYTES,
25    QueryParamAccountIdLimit,
26    QueryParamLimiter,
27};
28use miden_node_utils::tracing::miden_instrument;
29use miden_protocol::Word;
30use miden_protocol::account::{
31    Account,
32    AccountCode,
33    AccountId,
34    AccountPatch,
35    AccountStorage,
36    AccountStorageHeader,
37    AccountUpdateDetails,
38    StorageMap,
39    StorageMapKey,
40    StorageMapPatchEntries,
41    StorageSlot,
42    StorageSlotContent,
43    StorageSlotName,
44    StorageSlotType,
45};
46use miden_protocol::asset::{Asset, AssetId, AssetVault};
47use miden_protocol::block::{BlockAccountUpdate, BlockNumber};
48use miden_protocol::utils::serde::{Deserializable, Serializable};
49use miden_standards::account::auth::NetworkAccount;
50
51use crate::COMPONENT;
52use crate::db::models::conv::{SqlTypeConvert, nonce_to_raw_sql, raw_sql_to_nonce};
53#[cfg(test)]
54use crate::db::models::vec_raw_try_into;
55use crate::db::{AccountVaultValue, schema};
56use crate::errors::DatabaseError;
57
58mod at_block;
59pub(crate) use at_block::select_account_header_with_storage_header_at_block;
60
61mod delta;
62use delta::{
63    AccountStateForInsert,
64    LatestAccountStateRow,
65    PartialAccountState,
66    PrecomputedFullAccountState,
67    apply_storage_patch_with_roots,
68    select_latest_account_state,
69};
70
71#[cfg(test)]
72mod tests;
73
74type StorageMapValueRow = (i64, String, Vec<u8>, Vec<u8>);
75type StorageHeaderWithEntries =
76    (AccountStorageHeader, HashMap<StorageSlotName, BTreeMap<StorageMapKey, Word>>);
77
78/// Sentinel `valid_until` value marking a row as the current, open-ended version of its key.
79///
80/// Versioned rows (`accounts`, `account_vault_assets`, `account_storage_map_values`) are
81/// applicable for blocks in `[block_num, valid_until)`; updating a key closes the previous row's
82/// interval by setting its `valid_until` to the new row's `block_num`. The open end is `i64::MAX`
83/// rather than NULL so every validity predicate is a single range comparison that partial indexes
84/// can serve.
85pub(crate) const VALID_FOREVER: i64 = i64::MAX;
86
87// NETWORK ACCOUNT TYPE
88// ================================================================================================
89
90/// Classifies accounts for database storage based on whether they are network accounts.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub(crate) enum NetworkAccountType {
93    /// Not a network account.
94    None,
95    /// A network account.
96    Network,
97}
98
99// ACCOUNT CODE
100// ================================================================================================
101
102/// Select account code by its commitment hash from the `account_codes` table.
103///
104/// # Returns
105///
106/// The account code bytes if found, or `None` if no code exists with that commitment.
107///
108/// # Raw SQL
109///
110/// ```sql
111/// SELECT code FROM account_codes WHERE code_commitment = ?1
112/// ```
113pub(crate) fn select_account_code_by_commitment(
114    conn: &mut SqliteConnection,
115    code_commitment: Word,
116) -> Result<Option<Vec<u8>>, DatabaseError> {
117    use schema::account_codes;
118
119    let code_commitment_bytes = code_commitment.to_bytes();
120
121    let result: Option<Vec<u8>> = SelectDsl::select(
122        account_codes::table.filter(account_codes::code_commitment.eq(&code_commitment_bytes)),
123        account_codes::code,
124    )
125    .first(conn)
126    .optional()?;
127
128    Ok(result)
129}
130
131// ACCOUNT RETRIEVAL
132// ================================================================================================
133
134/// Select account by ID from the DB using the given [`SqliteConnection`].
135///
136/// # Returns
137///
138/// The latest account info, or an error.
139///
140/// # Raw SQL
141///
142/// ```sql
143/// SELECT
144///     accounts.account_id,
145///     accounts.account_commitment,
146///     accounts.block_num
147/// FROM
148///     accounts
149/// WHERE
150///     account_id = ?1
151///     AND valid_until = {VALID_FOREVER}
152/// ```
153pub(crate) fn select_account(
154    conn: &mut SqliteConnection,
155    account_id: AccountId,
156) -> Result<AccountInfo, DatabaseError> {
157    let raw = SelectDsl::select(schema::accounts::table, AccountSummaryRaw::as_select())
158        .filter(schema::accounts::account_id.eq(account_id.to_bytes()))
159        .filter(schema::accounts::valid_until.eq(VALID_FOREVER))
160        .get_result::<AccountSummaryRaw>(conn)
161        .optional()?
162        .ok_or(DatabaseError::AccountNotFoundInDb(account_id))?;
163
164    let summary: AccountSummary = raw.try_into()?;
165
166    // Backfill account details from database For private accounts, we don't store full details in
167    // the database
168    let details = if account_id.is_public() {
169        Some(select_full_account(conn, account_id)?)
170    } else {
171        None
172    };
173
174    Ok(AccountInfo { summary, details })
175}
176
177/// Reconstruct full Account from database tables for the latest account state
178///
179/// This function queries the database tables to reconstruct a complete Account object:
180/// - Code from `account_codes` table
181/// - Nonce and storage header from `accounts` table
182/// - Storage map entries from `account_storage_map_values` table
183/// - Vault from `account_vault_assets` table
184///
185/// # Note
186///
187/// A stop-gap solution to retain store API and construct `AccountInfo` types.
188/// The function should ultimately be removed, and any queries be served from the
189/// `State` which contains an `SmtForest` to serve the latest and most recent
190/// historical data.
191// TODO: remove eventually once refactoring is complete
192pub(crate) fn select_full_account(
193    conn: &mut SqliteConnection,
194    account_id: AccountId,
195) -> Result<Account, DatabaseError> {
196    // Get account metadata (nonce, code_commitment) and code in a single join query
197    let joined = schema::accounts::table.inner_join(schema::account_codes::table.on(
198        schema::accounts::code_commitment.eq(schema::account_codes::code_commitment.nullable()),
199    ));
200
201    let (nonce, code_bytes): (Option<i64>, Vec<u8>) =
202        SelectDsl::select(joined, (schema::accounts::nonce, schema::account_codes::code))
203            .filter(schema::accounts::account_id.eq(account_id.to_bytes()))
204            .filter(schema::accounts::valid_until.eq(VALID_FOREVER))
205            .get_result(conn)
206            .optional()?
207            .ok_or(DatabaseError::AccountNotFoundInDb(account_id))?;
208
209    let nonce = raw_sql_to_nonce(nonce.ok_or_else(|| {
210        DatabaseError::DataCorrupted(format!("No nonce found for account {account_id}"))
211    })?);
212
213    let code = AccountCode::read_from_bytes(&code_bytes)?;
214
215    // Reconstruct storage using existing helper function
216    let storage = select_latest_account_storage(conn, account_id)?;
217
218    // Reconstruct vault from account_vault_assets table
219    let vault_entries: Vec<(Vec<u8>, Option<Vec<u8>>)> = SelectDsl::select(
220        schema::account_vault_assets::table,
221        (schema::account_vault_assets::vault_key, schema::account_vault_assets::asset),
222    )
223    .filter(schema::account_vault_assets::account_id.eq(account_id.to_bytes()))
224    .filter(schema::account_vault_assets::valid_until.eq(VALID_FOREVER))
225    .load(conn)?;
226
227    let mut assets = Vec::new();
228    for (_key_bytes, maybe_asset_bytes) in vault_entries {
229        if let Some(asset_bytes) = maybe_asset_bytes {
230            let asset = Asset::read_from_bytes(&asset_bytes)?;
231            assets.push(asset);
232        }
233    }
234
235    let vault = AssetVault::new(&assets)?;
236
237    Ok(Account::new(account_id, vault, storage, code, nonce, None)?)
238}
239
240/// Page of account commitments returned by [`select_account_commitments_paged`].
241#[derive(Debug)]
242pub struct AccountCommitmentsPage {
243    /// The account commitments in this page.
244    pub commitments: Vec<(AccountId, Word)>,
245    /// If `Some`, there are more results. Use this as the `after_account_id` for the next page.
246    pub next_cursor: Option<AccountId>,
247}
248
249/// Selects account commitments with pagination.
250///
251/// Returns up to `page_size` account commitments, starting after `after_account_id` if provided.
252/// Results are ordered by `account_id` for stable pagination.
253///
254/// # Raw SQL
255///
256/// ```sql
257/// SELECT
258///     account_id,
259///     account_commitment
260/// FROM
261///     accounts
262/// WHERE
263///     valid_until = {VALID_FOREVER}
264///     AND (account_id > :after_account_id OR :after_account_id IS NULL)
265/// ORDER BY
266///     account_id ASC
267/// LIMIT :page_size + 1
268/// ```
269pub(crate) fn select_account_commitments_paged(
270    conn: &mut SqliteConnection,
271    page_size: NonZeroUsize,
272    after_account_id: Option<AccountId>,
273) -> Result<AccountCommitmentsPage, DatabaseError> {
274    // Fetch one extra to determine if there are more results
275    #[expect(clippy::cast_possible_wrap)]
276    let limit = (page_size.get() + 1) as i64;
277
278    let mut query = SelectDsl::select(
279        schema::accounts::table,
280        (schema::accounts::account_id, schema::accounts::account_commitment),
281    )
282    .filter(schema::accounts::valid_until.eq(VALID_FOREVER))
283    .order_by(schema::accounts::account_id.asc())
284    .limit(limit)
285    .into_boxed();
286
287    if let Some(cursor) = after_account_id {
288        query = query.filter(schema::accounts::account_id.gt(cursor.to_bytes()));
289    }
290
291    let raw = query.load::<(Vec<u8>, Vec<u8>)>(conn)?;
292
293    let mut commitments = Result::<Vec<_>, DatabaseError>::from_iter(raw.into_iter().map(
294        |(ref account, ref commitment)| {
295            Ok((AccountId::read_from_bytes(account)?, Word::read_from_bytes(commitment)?))
296        },
297    ))?;
298
299    // If we got more than page_size, there are more results
300    let next_cursor = if commitments.len() > page_size.get() {
301        commitments.pop(); // Remove the extra element
302        commitments.last().map(|(id, _)| *id)
303    } else {
304        None
305    };
306
307    Ok(AccountCommitmentsPage { commitments, next_cursor })
308}
309
310/// Page of public account IDs returned by [`select_public_account_ids_paged`].
311#[derive(Debug)]
312pub struct PublicAccountIdsPage {
313    /// The public account IDs in this page.
314    pub account_ids: Vec<AccountId>,
315    /// If `Some`, there are more results. Use this as the `after_account_id` for the next page.
316    pub next_cursor: Option<AccountId>,
317}
318
319/// Latest account state forest roots for a public account.
320#[derive(Debug)]
321pub struct PublicAccountStateRoots {
322    pub account_id: AccountId,
323    pub vault_root: Word,
324    pub storage_header: AccountStorageHeader,
325}
326
327/// Page of public account state roots returned by [`select_public_account_state_roots_paged`].
328#[derive(Debug)]
329pub struct PublicAccountStateRootsPage {
330    /// The public account state roots in this page.
331    pub accounts: Vec<PublicAccountStateRoots>,
332    /// If `Some`, there are more results. Use this as the `after_account_id` for the next page.
333    pub next_cursor: Option<AccountId>,
334}
335
336/// Public account state commitments computed by the account state forest before SQLite writes.
337#[derive(Debug, Clone, PartialEq, Eq)]
338pub(crate) struct PrecomputedPublicAccountState {
339    pub(crate) vault_root: Word,
340    pub(crate) storage_map_roots: BTreeMap<StorageSlotName, Word>,
341}
342
343pub(crate) type PrecomputedPublicAccountStates = BTreeMap<AccountId, PrecomputedPublicAccountState>;
344
345/// Selects public account IDs with pagination.
346///
347/// Returns up to `page_size` public account IDs, starting after `after_account_id` if provided.
348/// Results are ordered by `account_id` for stable pagination.
349///
350/// Public accounts are those with `AccountType::Public`. We identify them by checking
351/// against the store. Public accounts store their `code_commitment`, while private accounts only
352/// store the `account_commitment`.
353///
354/// # Raw SQL
355///
356/// ```sql
357/// SELECT
358///     account_id
359/// FROM
360///     accounts
361/// WHERE
362///     valid_until = {VALID_FOREVER}
363///     AND code_commitment IS NOT NULL
364///     AND (account_id > :after_account_id OR :after_account_id IS NULL)
365/// ORDER BY
366///     account_id ASC
367/// LIMIT :page_size + 1
368/// ```
369pub(crate) fn select_public_account_ids_paged(
370    conn: &mut SqliteConnection,
371    page_size: NonZeroUsize,
372    after_account_id: Option<AccountId>,
373) -> Result<PublicAccountIdsPage, DatabaseError> {
374    #[expect(clippy::cast_possible_wrap)]
375    let limit = (page_size.get() + 1) as i64;
376
377    let mut query = SelectDsl::select(schema::accounts::table, schema::accounts::account_id)
378        .filter(schema::accounts::valid_until.eq(VALID_FOREVER))
379        .filter(schema::accounts::code_commitment.is_not_null())
380        .order_by(schema::accounts::account_id.asc())
381        .limit(limit)
382        .into_boxed();
383
384    if let Some(cursor) = after_account_id {
385        query = query.filter(schema::accounts::account_id.gt(cursor.to_bytes()));
386    }
387
388    let raw = query.load::<Vec<u8>>(conn)?;
389
390    let mut account_ids: Vec<AccountId> = Result::from_iter(raw.into_iter().map(|bytes| {
391        AccountId::read_from_bytes(&bytes).map_err(DatabaseError::DeserializationError)
392    }))?;
393
394    // If we got more than page_size, there are more results
395    let next_cursor = if account_ids.len() > page_size.get() {
396        account_ids.pop(); // Remove the extra element
397        account_ids.last().copied()
398    } else {
399        None
400    };
401
402    Ok(PublicAccountIdsPage { account_ids, next_cursor })
403}
404
405/// Selects public account vault roots and storage headers with pagination.
406///
407/// Returns up to `page_size` public account states, starting after `after_account_id` if provided.
408/// Results are ordered by `account_id` for stable pagination.
409///
410/// Public accounts are those with `AccountType::Public`. We identify them by checking
411/// against the store. Public accounts store their `code_commitment`, while private accounts only
412/// store the `account_commitment`.
413///
414/// # Raw SQL
415///
416/// ```sql
417/// SELECT
418///     account_id,
419///     vault_root,
420///     storage_header
421/// FROM
422///     accounts
423/// WHERE
424///     valid_until = {VALID_FOREVER}
425///     AND code_commitment IS NOT NULL
426///     AND (account_id > :after_account_id OR :after_account_id IS NULL)
427/// ORDER BY
428///     account_id ASC
429/// LIMIT :page_size + 1
430/// ```
431pub(crate) fn select_public_account_state_roots_paged(
432    conn: &mut SqliteConnection,
433    page_size: NonZeroUsize,
434    after_account_id: Option<AccountId>,
435) -> Result<PublicAccountStateRootsPage, DatabaseError> {
436    #[expect(clippy::cast_possible_wrap)]
437    let limit = (page_size.get() + 1) as i64;
438
439    let mut query = SelectDsl::select(
440        schema::accounts::table,
441        (
442            schema::accounts::account_id,
443            schema::accounts::vault_root,
444            schema::accounts::storage_header,
445        ),
446    )
447    .filter(schema::accounts::valid_until.eq(VALID_FOREVER))
448    .filter(schema::accounts::code_commitment.is_not_null())
449    .order_by(schema::accounts::account_id.asc())
450    .limit(limit)
451    .into_boxed();
452
453    if let Some(cursor) = after_account_id {
454        query = query.filter(schema::accounts::account_id.gt(cursor.to_bytes()));
455    }
456
457    let raw = query.load::<(Vec<u8>, Option<Vec<u8>>, Option<Vec<u8>>)>(conn)?;
458
459    let mut accounts: Vec<PublicAccountStateRoots> = Result::from_iter(raw.into_iter().map(
460        |(account_id_bytes, vault_root_bytes, storage_header_bytes)| {
461            let account_id = AccountId::read_from_bytes(&account_id_bytes)
462                .map_err(DatabaseError::DeserializationError)?;
463            let vault_root_bytes = vault_root_bytes.ok_or_else(|| {
464                DatabaseError::DataCorrupted(format!(
465                    "public account {account_id} is missing a vault root"
466                ))
467            })?;
468            let storage_header_bytes = storage_header_bytes.ok_or_else(|| {
469                DatabaseError::DataCorrupted(format!(
470                    "public account {account_id} is missing a storage header"
471                ))
472            })?;
473
474            Ok::<_, DatabaseError>(PublicAccountStateRoots {
475                account_id,
476                vault_root: Word::read_from_bytes(&vault_root_bytes)?,
477                storage_header: AccountStorageHeader::read_from_bytes(&storage_header_bytes)?,
478            })
479        },
480    ))?;
481
482    // If we got more than page_size, there are more results.
483    let next_cursor = if accounts.len() > page_size.get() {
484        accounts.pop();
485        accounts.last().map(|account| account.account_id)
486    } else {
487        None
488    };
489
490    Ok(PublicAccountStateRootsPage { accounts, next_cursor })
491}
492
493/// Select account vault assets within a block range (inclusive).
494///
495/// # Parameters
496/// * `account_id`: Account ID to query
497/// * `block_from`: Starting block number
498/// * `block_to`: Ending block number
499/// * Response payload size: 0 <= size <= 2MB
500/// * Vault assets per response: 0 <= count <= (2MB / (2*Word + u32)) + 1
501///
502/// # Raw SQL
503///
504/// ```sql
505/// SELECT
506///     block_num,
507///     vault_key,
508///     asset
509/// FROM
510///     account_vault_assets
511/// WHERE
512///     account_id = ?1
513///     AND block_num >= ?2
514///     AND block_num <= ?3
515/// ORDER BY
516///     block_num ASC
517/// LIMIT
518///     ?4
519/// ```
520pub(crate) fn select_account_vault_assets(
521    conn: &mut SqliteConnection,
522    account_id: AccountId,
523    block_range: RangeInclusive<BlockNumber>,
524) -> Result<(BlockNumber, Vec<AccountVaultValue>), DatabaseError> {
525    use schema::account_vault_assets as t;
526    // TODO: These limits should be given by the protocol. See miden-protocol/issues/1770 for more
527    // details
528    const ROW_OVERHEAD_BYTES: usize = 2 * size_of::<Word>() + size_of::<u32>(); // key + asset + block_num
529    const MAX_ROWS: usize = MAX_RESPONSE_PAYLOAD_BYTES / ROW_OVERHEAD_BYTES;
530
531    if !account_id.is_public() {
532        return Err(DatabaseError::AccountNotPublic(account_id));
533    }
534
535    if block_range.is_empty() {
536        return Err(DatabaseError::InvalidBlockRange {
537            from: *block_range.start(),
538            to: *block_range.end(),
539        });
540    }
541
542    let raw: Vec<(i64, Vec<u8>, Option<Vec<u8>>)> =
543        SelectDsl::select(t::table, (t::block_num, t::vault_key, t::asset))
544            .filter(
545                t::account_id
546                    .eq(account_id.to_bytes())
547                    .and(t::block_num.ge(block_range.start().to_raw_sql()))
548                    .and(t::block_num.le(block_range.end().to_raw_sql())),
549            )
550            .order(t::block_num.asc())
551            .limit(i64::try_from(MAX_ROWS + 1).expect("should fit within i64"))
552            .load::<(i64, Vec<u8>, Option<Vec<u8>>)>(conn)?;
553
554    // If we got more rows than the limit, the last block may be incomplete so we drop it entirely
555    // and derive last_block_included from the remaining rows.
556    let (last_block_included, values) = if let Some(&(last_block_num, ..)) = raw.last()
557        && raw.len() > MAX_ROWS
558    {
559        let values = raw
560            .into_iter()
561            .take_while(|(bn, ..)| *bn != last_block_num)
562            .map(AccountVaultValue::from_raw_row)
563            .collect::<Result<Vec<_>, DatabaseError>>()?;
564
565        let last_block_included = values.last().map_or(*block_range.start(), |v| v.block_num);
566
567        (last_block_included, values)
568    } else {
569        (
570            *block_range.end(),
571            raw.into_iter().map(AccountVaultValue::from_raw_row).collect::<Result<_, _>>()?,
572        )
573    };
574
575    Ok((last_block_included, values))
576}
577
578/// Query vault assets at a specific block by finding the most recent update for each `vault_key`.
579///
580/// Selects, per vault key, the row whose validity interval covers `block_num`:
581/// ```sql
582/// SELECT asset FROM account_vault_assets
583/// WHERE account_id = ?1 AND block_num <= ?2 AND valid_until > ?2
584/// LIMIT ?3
585/// ```
586///
587/// The read is bounded to [`AccountVaultDetails::MAX_RETURN_ENTRIES`] + 1 rows so an over-the-limit
588/// vault can be detected without materializing the whole set.
589pub(crate) fn select_account_vault_at_block(
590    conn: &mut SqliteConnection,
591    account_id: AccountId,
592    block_num: BlockNumber,
593) -> Result<Vec<Asset>, DatabaseError> {
594    use diesel::sql_types::{BigInt, Binary};
595
596    let account_id_bytes = account_id.to_bytes();
597    let block_num_sql = block_num.to_raw_sql();
598    let limit_sql =
599        i64::try_from(AccountVaultDetails::MAX_RETURN_ENTRIES + 1).expect("should fit within i64");
600
601    let entries: Vec<Option<Vec<u8>>> = diesel::sql_query(
602        r"
603        SELECT asset FROM account_vault_assets
604        WHERE account_id = ?1 AND block_num <= ?2 AND valid_until > ?2
605        LIMIT ?3
606        ",
607    )
608    .bind::<Binary, _>(&account_id_bytes)
609    .bind::<BigInt, _>(block_num_sql)
610    .bind::<BigInt, _>(limit_sql)
611    .load::<AssetRow>(conn)?
612    .into_iter()
613    .map(|row| row.asset)
614    .collect();
615
616    // Convert to assets, filtering out deletions (None values)
617    let mut assets = Vec::new();
618    for asset_bytes in entries.into_iter().flatten() {
619        let asset = Asset::read_from_bytes(&asset_bytes)?;
620        assets.push(asset);
621    }
622
623    Ok(assets)
624}
625
626#[derive(QueryableByName)]
627struct AssetRow {
628    #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Binary>)]
629    asset: Option<Vec<u8>>,
630}
631
632/// Select all accounts from the DB using the given [`SqliteConnection`].
633///
634/// # Returns
635///
636/// A vector with accounts, or an error.
637///
638/// # Raw SQL
639///
640/// ```sql
641/// SELECT
642///     accounts.account_id,
643///     accounts.account_commitment,
644///     accounts.block_num
645/// FROM
646///     accounts
647/// WHERE
648///     valid_until = {VALID_FOREVER}
649/// ORDER BY
650///     block_num ASC
651/// ```
652#[cfg(test)]
653pub(crate) fn select_all_accounts(
654    conn: &mut SqliteConnection,
655) -> Result<Vec<AccountInfo>, DatabaseError> {
656    let raw = SelectDsl::select(schema::accounts::table, AccountSummaryRaw::as_select())
657        .filter(schema::accounts::valid_until.eq(VALID_FOREVER))
658        .order_by(schema::accounts::block_num.asc())
659        .load::<AccountSummaryRaw>(conn)?;
660
661    let summaries: Vec<AccountSummary> = vec_raw_try_into(raw)?;
662
663    // Backfill account details from database
664    let account_infos = summaries
665        .into_iter()
666        .map(|summary| {
667            let account_id = summary.account_id;
668            let details = select_full_account(conn, account_id).ok();
669            AccountInfo { summary, details }
670        })
671        .collect();
672
673    Ok(account_infos)
674}
675
676#[derive(Debug, Clone, PartialEq, Eq)]
677pub struct StorageMapValue {
678    pub block_num: BlockNumber,
679    pub slot_name: StorageSlotName,
680    pub key: StorageMapKey,
681    pub value: Word,
682}
683
684#[derive(Debug, Clone, PartialEq, Eq)]
685pub struct StorageMapValuesPage {
686    /// Highest block number included in `rows`. If the page is empty, this will be `block_from`.
687    pub last_block_included: BlockNumber,
688    /// Storage map values
689    pub values: Vec<StorageMapValue>,
690}
691
692impl StorageMapValue {
693    pub fn from_raw_row(row: StorageMapValueRow) -> Result<Self, DatabaseError> {
694        let (block_num, slot_name, key, value) = row;
695        Ok(Self {
696            block_num: BlockNumber::from_raw_sql(block_num)?,
697            slot_name: StorageSlotName::from_raw_sql(slot_name)?,
698            key: StorageMapKey::read_from_bytes(&key)?,
699            value: Word::read_from_bytes(&value)?,
700        })
701    }
702}
703
704/// Select account storage map values from the DB using the given [`SqliteConnection`].
705///
706/// # Returns
707///
708/// A vector of tuples containing `(block_num, slot, key, value)` for the given account.
709/// Each row contains one of:
710///
711/// - the historical value for a slot and key specifically on block `block_to`
712/// - the latest updated value for the slot and key combination, alongside the block number in which
713///   it was updated
714///
715/// # Raw SQL
716///
717/// ```sql
718/// SELECT
719///     block_num,
720///     slot,
721///     key,
722///     value
723/// FROM
724///     account_storage_map_values
725/// WHERE
726///     account_id = ?1
727///     AND block_num >= ?2
728///     AND block_num <= ?3
729/// ORDER BY
730///     block_num ASC
731/// LIMIT
732///     ?4
733/// ```
734/// Select account storage map values within a block range (inclusive).
735///
736/// ## Parameters
737///
738/// * `account_id`: Account ID to query
739/// * `block_range`: Range of block numbers (inclusive)
740///
741/// ## Response
742///
743/// * Response payload size: 0 <= size <= 2MB
744/// * Storage map values per response: 0 <= count <= (2MB / (2*Word + u32 + u8)) + 1
745pub(crate) fn select_account_storage_map_values_paged(
746    conn: &mut SqliteConnection,
747    account_id: AccountId,
748    block_range: RangeInclusive<BlockNumber>,
749    limit: usize,
750) -> Result<StorageMapValuesPage, DatabaseError> {
751    use schema::account_storage_map_values as t;
752
753    if !account_id.is_public() {
754        return Err(DatabaseError::AccountNotPublic(account_id));
755    }
756
757    if block_range.is_empty() {
758        return Err(DatabaseError::InvalidBlockRange {
759            from: *block_range.start(),
760            to: *block_range.end(),
761        });
762    }
763
764    let raw: Vec<StorageMapValueRow> =
765        SelectDsl::select(t::table, (t::block_num, t::slot_name, t::key, t::value))
766            .filter(
767                t::account_id
768                    .eq(account_id.to_bytes())
769                    .and(t::block_num.ge(block_range.start().to_raw_sql()))
770                    .and(t::block_num.le(block_range.end().to_raw_sql())),
771            )
772            .order(t::block_num.asc())
773            .limit(i64::try_from(limit + 1).expect("limit fits within i64"))
774            .load(conn)?;
775
776    // If we got more rows than the limit, the last block may be incomplete so we drop it entirely
777    // and derive last_block_included from the remaining rows.
778    let (last_block_included, values) = if let Some(&(last_block_num, ..)) = raw.last()
779        && raw.len() > limit
780    {
781        let values = raw
782            .into_iter()
783            .take_while(|(bn, ..)| *bn != last_block_num)
784            .map(StorageMapValue::from_raw_row)
785            .collect::<Result<Vec<_>, DatabaseError>>()?;
786
787        let last_block_included = values.last().map_or(*block_range.start(), |v| v.block_num);
788
789        (last_block_included, values)
790    } else {
791        (
792            *block_range.end(),
793            raw.into_iter()
794                .map(StorageMapValue::from_raw_row)
795                .collect::<Result<Vec<_>, _>>()?,
796        )
797    };
798
799    Ok(StorageMapValuesPage { last_block_included, values })
800}
801
802/// Select latest account storage by querying `accounts.storage_header` for the account's
803/// open-ended row and reconstructing full storage from the header plus map values from
804/// `account_storage_map_values`.
805///
806/// Attention: For large accounts it is prohibitively expensive!
807pub(crate) fn select_latest_account_storage(
808    conn: &mut SqliteConnection,
809    account_id: AccountId,
810) -> Result<AccountStorage, DatabaseError> {
811    let (storage_header, map_entries_by_slot) =
812        select_latest_account_storage_components(conn, account_id)?;
813    // Reconstruct StorageSlots from header slots + map entries
814    let slots =
815        Result::<Vec<_>, DatabaseError>::from_iter(storage_header.slots().map(|slot_header| {
816            let slot = match slot_header.slot_type() {
817                StorageSlotType::Value => {
818                    // For value slots, the header value IS the slot value
819                    StorageSlot::with_value(slot_header.name().clone(), slot_header.value())
820                },
821                StorageSlotType::Map => {
822                    // For map slots, reconstruct from map entries
823                    let entries =
824                        map_entries_by_slot.get(slot_header.name()).cloned().unwrap_or_default();
825                    let storage_map = StorageMap::with_entries(entries)?;
826                    StorageSlot::with_map(slot_header.name().clone(), storage_map)
827                },
828            };
829            Ok(slot)
830        }))?;
831
832    Ok(AccountStorage::new(slots)?)
833}
834
835/// Fetch account storage header and all storage maps
836pub(crate) fn select_latest_account_storage_components(
837    conn: &mut SqliteConnection,
838    account_id: AccountId,
839) -> Result<StorageHeaderWithEntries, DatabaseError> {
840    let account_id_bytes = account_id.to_bytes();
841
842    // Query storage header blob for this account's current (open-ended) row
843    let storage_blob: Option<Vec<u8>> =
844        SelectDsl::select(schema::accounts::table, schema::accounts::storage_header)
845            .filter(schema::accounts::account_id.eq(&account_id_bytes))
846            .filter(schema::accounts::valid_until.eq(VALID_FOREVER))
847            .first(conn)
848            .optional()?
849            .flatten();
850
851    let header = match storage_blob {
852        Some(blob) => AccountStorageHeader::read_from_bytes(&blob)?,
853        None => AccountStorageHeader::new(Vec::new())?,
854    };
855
856    let entries = select_latest_storage_map_entries_all(conn, &account_id)?;
857    Ok((header, entries))
858}
859
860// TODO this is expensive and should only be called from tests
861fn select_latest_storage_map_entries_all(
862    conn: &mut SqliteConnection,
863    account_id: &AccountId,
864) -> Result<HashMap<StorageSlotName, BTreeMap<StorageMapKey, Word>>, DatabaseError> {
865    use schema::account_storage_map_values as t;
866
867    let map_values: Vec<(String, Vec<u8>, Vec<u8>)> =
868        SelectDsl::select(t::table, (t::slot_name, t::key, t::value))
869            .filter(t::account_id.eq(&account_id.to_bytes()))
870            .filter(t::valid_until.eq(VALID_FOREVER))
871            .load(conn)?;
872
873    group_storage_map_entries(map_values)
874}
875
876fn group_storage_map_entries(
877    map_values: Vec<(String, Vec<u8>, Vec<u8>)>,
878) -> Result<HashMap<StorageSlotName, BTreeMap<StorageMapKey, Word>>, DatabaseError> {
879    let mut map_entries_by_slot: HashMap<StorageSlotName, BTreeMap<StorageMapKey, Word>> =
880        HashMap::new();
881    for (slot_name_str, key_bytes, value_bytes) in map_values {
882        let slot_name: StorageSlotName = slot_name_str.parse().map_err(|_| {
883            DatabaseError::DataCorrupted(format!("Invalid slot name: {slot_name_str}"))
884        })?;
885        let key = StorageMapKey::read_from_bytes(&key_bytes)?;
886        let value = Word::read_from_bytes(&value_bytes)?;
887        map_entries_by_slot.entry(slot_name).or_default().insert(key, value);
888    }
889
890    Ok(map_entries_by_slot)
891}
892
893// ACCOUNT MUTATION
894// ================================================================================================
895
896#[derive(Queryable, Selectable)]
897#[diesel(table_name = crate::db::schema::account_vault_assets)]
898#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
899pub struct AccountVaultUpdateRaw {
900    pub vault_key: Vec<u8>,
901    pub asset: Option<Vec<u8>>,
902    pub block_num: i64,
903}
904
905impl TryFrom<AccountVaultUpdateRaw> for AccountVaultValue {
906    type Error = DatabaseError;
907
908    fn try_from(raw: AccountVaultUpdateRaw) -> Result<Self, Self::Error> {
909        let vault_key = AssetId::try_from(Word::read_from_bytes(&raw.vault_key)?)?;
910        let asset = raw.asset.map(|bytes| Asset::read_from_bytes(&bytes)).transpose()?;
911        let block_num = BlockNumber::from_raw_sql(raw.block_num)?;
912
913        Ok(AccountVaultValue { block_num, vault_key, asset })
914    }
915}
916
917#[derive(Debug, Clone, PartialEq, Eq, Selectable, Queryable, QueryableByName)]
918#[diesel(table_name = schema::accounts)]
919#[diesel(check_for_backend(Sqlite))]
920pub struct AccountSummaryRaw {
921    account_id: Vec<u8>,         // AccountId,
922    account_commitment: Vec<u8>, //RpoDigest,
923    block_num: i64,              //BlockNumber,
924}
925
926impl TryInto<AccountSummary> for AccountSummaryRaw {
927    type Error = DatabaseError;
928    fn try_into(self) -> Result<AccountSummary, Self::Error> {
929        let account_id = AccountId::read_from_bytes(&self.account_id[..])?;
930        let account_commitment = Word::read_from_bytes(&self.account_commitment[..])?;
931        let block_num = BlockNumber::from_raw_sql(self.block_num)?;
932
933        Ok(AccountSummary {
934            account_id,
935            account_commitment,
936            block_num,
937        })
938    }
939}
940
941/// Insert an account vault asset row into the DB using the given [`SqliteConnection`].
942///
943/// The new row is inserted open-ended (`valid_until = VALID_FOREVER`); any existing open row
944/// with the same `(account_id, vault_key)` tuple has its validity interval closed at `block_num`.
945///
946/// # Returns
947///
948/// The number of affected rows.
949pub(crate) fn insert_account_vault_asset(
950    conn: &mut SqliteConnection,
951    account_id: AccountId,
952    block_num: BlockNumber,
953    vault_key: AssetId,
954    asset: Option<Asset>,
955) -> Result<usize, DatabaseError> {
956    let record = AccountAssetRowInsert::new(&account_id, &vault_key, block_num, asset);
957
958    diesel::Connection::transaction(conn, |conn| {
959        // Close the previous version's validity interval at the new row's block.
960        let vault_key: Word = vault_key.into();
961        let vault_key_bytes = vault_key.to_bytes();
962        let account_id_bytes = account_id.to_bytes();
963        let update_count = diesel::update(schema::account_vault_assets::table)
964            .filter(
965                schema::account_vault_assets::account_id
966                    .eq(account_id_bytes)
967                    .and(schema::account_vault_assets::vault_key.eq(vault_key_bytes))
968                    .and(schema::account_vault_assets::valid_until.eq(VALID_FOREVER)),
969            )
970            .set(schema::account_vault_assets::valid_until.eq(block_num.to_raw_sql()))
971            .execute(conn)?;
972
973        // Insert the new open-ended row
974        let insert_count = diesel::insert_into(schema::account_vault_assets::table)
975            .values(record)
976            .execute(conn)?;
977
978        Ok(update_count + insert_count)
979    })
980}
981
982/// Inserts a versioned account storage-map value using the given [`SqliteConnection`].
983///
984/// The new row is inserted open-ended, and any previous open row for the same
985/// `(account_id, slot_name, key)` tuple has its validity interval closed at `block_num` first.
986///
987/// # Returns
988///
989/// The total number of inserted and invalidated rows.
990///
991/// # Errors
992///
993/// Returns an error if the previous row cannot be invalidated or the new row cannot be inserted.
994pub(crate) fn insert_account_storage_map_value(
995    conn: &mut SqliteConnection,
996    account_id: AccountId,
997    block_num: BlockNumber,
998    slot_name: StorageSlotName,
999    key: StorageMapKey,
1000    value: Word,
1001) -> Result<usize, DatabaseError> {
1002    insert_account_storage_map_value_inner(conn, account_id, block_num, slot_name, key, value, true)
1003}
1004
1005/// Inserts a versioned account storage-map value with optional previous-row invalidation.
1006///
1007/// `invalidate_previous` may be disabled when inserting state for a new account, for which no
1008/// previous open row can exist. The inserted row is always open-ended.
1009///
1010/// # Returns
1011///
1012/// The total number of inserted and invalidated rows.
1013///
1014/// # Errors
1015///
1016/// Returns an error if the requested invalidation or insertion fails.
1017fn insert_account_storage_map_value_inner(
1018    conn: &mut SqliteConnection,
1019    account_id: AccountId,
1020    block_num: BlockNumber,
1021    slot_name: StorageSlotName,
1022    key: StorageMapKey,
1023    value: Word,
1024    invalidate_previous: bool,
1025) -> Result<usize, DatabaseError> {
1026    let account_id = account_id.to_bytes();
1027    let key = key.to_bytes();
1028    let value = value.to_bytes();
1029    let slot_name = slot_name.to_raw_sql();
1030    let block_num = block_num.to_raw_sql();
1031
1032    let update_count = if invalidate_previous {
1033        diesel::update(schema::account_storage_map_values::table)
1034            .filter(
1035                schema::account_storage_map_values::account_id
1036                    .eq(&account_id)
1037                    .and(schema::account_storage_map_values::slot_name.eq(&slot_name))
1038                    .and(schema::account_storage_map_values::key.eq(&key))
1039                    .and(schema::account_storage_map_values::valid_until.eq(VALID_FOREVER)),
1040            )
1041            .set(schema::account_storage_map_values::valid_until.eq(block_num))
1042            .execute(conn)?
1043    } else {
1044        0
1045    };
1046
1047    let record = AccountStorageMapRowInsert {
1048        account_id,
1049        key,
1050        value,
1051        slot_name,
1052        block_num,
1053        valid_until: VALID_FOREVER,
1054    };
1055    let insert_count = diesel::insert_into(schema::account_storage_map_values::table)
1056        .values(record)
1057        .execute(conn)?;
1058
1059    Ok(update_count + insert_count)
1060}
1061
1062type PendingStorageInserts = Vec<(AccountId, StorageSlotName, StorageMapKey, Word)>;
1063type PendingAssetInserts = Vec<(AccountId, AssetId, Option<Asset>)>;
1064
1065fn prepare_full_account_update(
1066    update: &BlockAccountUpdate,
1067    account: Account,
1068) -> Result<(AccountStateForInsert, PendingStorageInserts, PendingAssetInserts), DatabaseError> {
1069    let account_id = account.id();
1070
1071    // sanity check the commitment of account matches the final state commitment
1072    if account.to_commitment() != update.final_state_commitment() {
1073        return Err(DatabaseError::AccountCommitmentsMismatch {
1074            calculated: account.to_commitment(),
1075            expected: update.final_state_commitment(),
1076        });
1077    }
1078
1079    // collect storage-map inserts to apply after account upsert
1080    let mut storage = Vec::new();
1081    for slot in account.storage().slots() {
1082        if let StorageSlotContent::Map(storage_map) = slot.content() {
1083            for (key, value) in storage_map.entries() {
1084                storage.push((account_id, slot.name().clone(), *key, *value));
1085            }
1086        }
1087    }
1088
1089    // collect vault-asset inserts to apply after account upsert
1090    let mut assets = Vec::new();
1091    for asset in account.vault().assets() {
1092        // Only insert assets with non-zero values for fungible assets
1093        let should_insert = match asset {
1094            Asset::Fungible(fungible) => fungible.amount().as_u64() > 0,
1095            Asset::NonFungible(_) => true,
1096        };
1097        if should_insert {
1098            assets.push((account_id, asset.id(), Some(asset)));
1099        }
1100    }
1101
1102    Ok((AccountStateForInsert::FullAccount(account), storage, assets))
1103}
1104
1105/// Prepares a full public-account insertion using roots computed by the account-state forest.
1106///
1107/// This avoids reconstructing the account's vault and storage maps in SQLite. The returned state
1108/// contains the account-row fields, while storage-map entries and vault assets are returned
1109/// separately for insertion after the account row has satisfied their foreign-key dependency.
1110/// Empty-word map entries and assets are omitted from the pending inserts.
1111///
1112/// # Errors
1113///
1114/// Returns an error if the full-state patch is missing its code or nonce, a required precomputed
1115/// storage root is absent, an asset is invalid, or the reconstructed account header does not match
1116/// the update's final state commitment.
1117fn prepare_precomputed_full_account_update(
1118    update: &BlockAccountUpdate,
1119    patch: &AccountPatch,
1120    precomputed: &PrecomputedPublicAccountState,
1121) -> Result<(AccountStateForInsert, PendingStorageInserts, PendingAssetInserts), DatabaseError> {
1122    let account_id = patch.id();
1123    let code = patch.code().cloned().ok_or_else(|| {
1124        DatabaseError::DataCorrupted(format!(
1125            "full-state patch for account {account_id} is missing account code"
1126        ))
1127    })?;
1128    let nonce = patch.final_nonce().ok_or_else(|| {
1129        DatabaseError::DataCorrupted(format!(
1130            "full-state patch for account {account_id} is missing final nonce"
1131        ))
1132    })?;
1133
1134    let storage_header = apply_storage_patch_with_roots(
1135        &AccountStorageHeader::new(Vec::new())?,
1136        patch.storage(),
1137        &precomputed.storage_map_roots,
1138    )?;
1139    let account_header = miden_protocol::account::AccountHeader::new(
1140        account_id,
1141        nonce,
1142        precomputed.vault_root,
1143        storage_header.to_commitment(),
1144        code.commitment(),
1145    );
1146    if account_header.to_commitment() != update.final_state_commitment() {
1147        return Err(DatabaseError::AccountCommitmentsMismatch {
1148            calculated: account_header.to_commitment(),
1149            expected: update.final_state_commitment(),
1150        });
1151    }
1152
1153    let storage = patch
1154        .storage()
1155        .maps()
1156        .flat_map(|(slot_name, map_patch)| {
1157            map_patch.entries().into_iter().flat_map(move |entries| {
1158                entries
1159                    .as_map()
1160                    .iter()
1161                    .filter(|(_key, value)| **value != Word::empty())
1162                    .map(move |(key, value)| (account_id, slot_name.clone(), *key, *value))
1163            })
1164        })
1165        .collect();
1166    let assets = patch
1167        .vault()
1168        .iter()
1169        .filter(|(_asset_id, value)| **value != Word::empty())
1170        .map(|(asset_id, value)| {
1171            Asset::from_id_and_value(*asset_id, *value)
1172                .map(|asset| (account_id, *asset_id, Some(asset)))
1173        })
1174        .collect::<Result<Vec<_>, _>>()?;
1175
1176    // The patch carries full state, so it can be turned back into an account and classified with
1177    // the canonical check.
1178    let is_network_account = NetworkAccount::new(Account::try_from(patch)?).is_ok();
1179    let state = PrecomputedFullAccountState {
1180        nonce,
1181        code,
1182        storage_header,
1183        vault_root: precomputed.vault_root,
1184        is_network_account,
1185    };
1186
1187    Ok((AccountStateForInsert::PrecomputedFullState(state), storage, assets))
1188}
1189
1190/// Prepares a partial public-account update using the latest row and precomputed forest roots.
1191///
1192/// Unchanged header fields are carried forward from `existing`. The returned partial state is used
1193/// for the next account row, while storage-map values and vault asset updates are returned
1194/// separately for insertion after that row. Empty vault values are represented as removals.
1195///
1196/// # Errors
1197///
1198/// Returns an error if the existing row is invalid, a required precomputed storage root is absent,
1199/// a patched asset is invalid, or the reconstructed account header does not match the update's
1200/// final state commitment.
1201fn prepare_partial_account_update(
1202    update: &BlockAccountUpdate,
1203    account_id: AccountId,
1204    patch: &AccountPatch,
1205    precomputed: &PrecomputedPublicAccountState,
1206    existing: &LatestAccountStateRow,
1207) -> Result<(AccountStateForInsert, PendingStorageInserts, PendingAssetInserts), DatabaseError> {
1208    // Build the minimal account state needed for partial patch application from the latest row that
1209    // was loaded with the account's creation metadata.
1210    let state_headers = existing.state_headers(account_id)?;
1211
1212    // --- Process asset updates. --------------------------------- The patch carries absolute final
1213    // values, so encode `Some` as update and `None` (an empty value word) as removal.
1214    let mut assets = Vec::new();
1215    for (vault_key, value) in patch.vault().iter() {
1216        let update_or_remove = if *value == Word::empty() {
1217            None
1218        } else {
1219            Some(Asset::from_id_and_value(*vault_key, *value)?)
1220        };
1221        assets.push((account_id, *vault_key, update_or_remove));
1222    }
1223
1224    // --- Collect storage map updates. ---------------------------
1225
1226    let mut storage = Vec::new();
1227    for (slot_name, map_patch) in patch.storage().maps() {
1228        for (key, value) in map_patch.entries().into_iter().flat_map(StorageMapPatchEntries::as_map)
1229        {
1230            storage.push((account_id, slot_name.clone(), *key, *value));
1231        }
1232    }
1233
1234    // Apply the patch storage to the given storage header.
1235    let new_storage_header = apply_storage_patch_with_roots(
1236        &state_headers.storage_header,
1237        patch.storage(),
1238        &precomputed.storage_map_roots,
1239    )?;
1240
1241    let new_vault_root = precomputed.vault_root;
1242
1243    // --- Compute updated account state for the accounts row. --- Use the absolute final nonce.
1244    let new_nonce = patch.final_nonce().unwrap_or(state_headers.nonce);
1245
1246    // Create minimal account state data for the row insert.
1247    let account_state = PartialAccountState {
1248        nonce: new_nonce,
1249        code_commitment: state_headers.code_commitment,
1250        storage_header: new_storage_header,
1251        vault_root: new_vault_root,
1252    };
1253
1254    let account_header = miden_protocol::account::AccountHeader::new(
1255        account_id,
1256        account_state.nonce,
1257        account_state.vault_root,
1258        account_state.storage_header.to_commitment(),
1259        account_state.code_commitment,
1260    );
1261
1262    if account_header.to_commitment() != update.final_state_commitment() {
1263        return Err(DatabaseError::AccountCommitmentsMismatch {
1264            calculated: account_header.to_commitment(),
1265            expected: update.final_state_commitment(),
1266        });
1267    }
1268
1269    Ok((AccountStateForInsert::PartialState(account_state), storage, assets))
1270}
1271
1272/// Returns the subset of `account_ids` whose latest committed state is a network account.
1273///
1274/// Unknown ids and non-network accounts are silently omitted.
1275pub(crate) fn select_network_accounts_subset(
1276    conn: &mut SqliteConnection,
1277    account_ids: &[AccountId],
1278) -> Result<HashSet<AccountId>, DatabaseError> {
1279    QueryParamAccountIdLimit::check(account_ids.len())?;
1280    let id_bytes: Vec<Vec<u8>> =
1281        account_ids.iter().map(miden_crypto::utils::Serializable::to_bytes).collect();
1282
1283    let rows: Vec<Vec<u8>> =
1284        SelectDsl::select(schema::accounts::table, schema::accounts::account_id)
1285            .filter(
1286                schema::accounts::account_id
1287                    .eq_any(&id_bytes)
1288                    .and(
1289                        schema::accounts::network_account_type
1290                            .eq(NetworkAccountType::Network.to_raw_sql()),
1291                    )
1292                    .and(schema::accounts::valid_until.eq(VALID_FOREVER)),
1293            )
1294            .load::<Vec<u8>>(conn)
1295            .map_err(DatabaseError::Diesel)?;
1296
1297    rows.into_iter()
1298        .map(|bytes| {
1299            AccountId::read_from_bytes(&bytes).map_err(DatabaseError::DeserializationError)
1300        })
1301        .collect()
1302}
1303
1304/// Attention: Assumes the account details are NOT null! The schema explicitly allows this though!
1305#[miden_instrument(
1306    target = COMPONENT,
1307    err,
1308)]
1309pub(crate) fn upsert_accounts(
1310    conn: &mut SqliteConnection,
1311    accounts: &[BlockAccountUpdate],
1312    block_num: BlockNumber,
1313    precomputed_public_states: &PrecomputedPublicAccountStates,
1314) -> Result<usize, DatabaseError> {
1315    let mut count = 0;
1316    for update in accounts {
1317        let account_id = update.account_id();
1318        let account_id_bytes = account_id.to_bytes();
1319
1320        // Pull the latest row once. Partial updates consume the state headers below, while every
1321        // update carries forward creation metadata.
1322        let existing = select_latest_account_state(conn, account_id)?;
1323        let account_is_new = existing.is_none();
1324
1325        let created_at_block = match &existing {
1326            Some(row) => row.created_at_block()?,
1327            None => block_num,
1328        };
1329
1330        // NOTE: we collect storage / asset inserts to apply them only after the account row is
1331        // written. The storage and vault tables have FKs pointing to accounts `(account_id,
1332        // block_num)`, so inserting them earlier would violate those constraints when inserting a
1333        // brand-new account.
1334        let (account_state, pending_storage_inserts, pending_asset_inserts) = match update.details()
1335        {
1336            AccountUpdateDetails::Private => (AccountStateForInsert::Private, vec![], vec![]),
1337
1338            // New account is always a full account, but also comes as an update
1339            AccountUpdateDetails::Public(patch) if patch.is_full_state() => {
1340                if block_num == BlockNumber::GENESIS {
1341                    let account = Account::try_from(patch)
1342                        .expect("Patch to full account always works for full state patches");
1343                    debug_assert_eq!(account_id, account.id());
1344                    prepare_full_account_update(update, account)?
1345                } else {
1346                    let precomputed =
1347                        precomputed_public_states.get(&account_id).ok_or_else(|| {
1348                            DatabaseError::DataCorrupted(format!(
1349                                "missing precomputed public account state for account {account_id}"
1350                            ))
1351                        })?;
1352                    prepare_precomputed_full_account_update(update, patch, precomputed)?
1353                }
1354            },
1355
1356            // Update of an existing account
1357            AccountUpdateDetails::Public(patch) => {
1358                let precomputed = precomputed_public_states.get(&account_id).ok_or_else(|| {
1359                    DatabaseError::DataCorrupted(format!(
1360                        "missing precomputed public account state for account {account_id}"
1361                    ))
1362                })?;
1363                let existing =
1364                    existing.as_ref().ok_or(DatabaseError::AccountNotFoundInDb(account_id))?;
1365                prepare_partial_account_update(update, account_id, patch, precomputed, existing)?
1366            },
1367        };
1368
1369        // Inherit the classification when the account already exists; otherwise classify it once at
1370        // creation based on the new state.
1371        let network_account_type = match &existing {
1372            Some(row) => row.network_account_type()?,
1373            None => match &account_state {
1374                AccountStateForInsert::FullAccount(account)
1375                    if NetworkAccount::new(account.clone()).is_ok() =>
1376                {
1377                    NetworkAccountType::Network
1378                },
1379                AccountStateForInsert::PrecomputedFullState(state) if state.is_network_account => {
1380                    NetworkAccountType::Network
1381                },
1382                _ => NetworkAccountType::None,
1383            },
1384        };
1385
1386        // Insert account _code_ for full accounts (new account creation)
1387        if let AccountStateForInsert::FullAccount(ref account) = account_state {
1388            let code = account.code();
1389            let code_value = AccountCodeRowInsert {
1390                code_commitment: code.commitment().to_bytes(),
1391                code: code.to_bytes(),
1392            };
1393            diesel::insert_into(schema::account_codes::table)
1394                .values(&code_value)
1395                .on_conflict(schema::account_codes::code_commitment)
1396                .do_nothing()
1397                .execute(conn)?;
1398        }
1399        if let AccountStateForInsert::PrecomputedFullState(ref state) = account_state {
1400            let code_value = AccountCodeRowInsert {
1401                code_commitment: state.code.commitment().to_bytes(),
1402                code: state.code.to_bytes(),
1403            };
1404            diesel::insert_into(schema::account_codes::table)
1405                .values(&code_value)
1406                .on_conflict(schema::account_codes::code_commitment)
1407                .do_nothing()
1408                .execute(conn)?;
1409        }
1410
1411        // close the previous row's validity interval and insert NEW account row
1412        diesel::update(schema::accounts::table)
1413            .filter(
1414                schema::accounts::account_id
1415                    .eq(&account_id_bytes)
1416                    .and(schema::accounts::valid_until.eq(VALID_FOREVER)),
1417            )
1418            .set(schema::accounts::valid_until.eq(block_num.to_raw_sql()))
1419            .execute(conn)?;
1420
1421        let account_value = match &account_state {
1422            AccountStateForInsert::Private => AccountRowInsert::new_private(
1423                account_id,
1424                network_account_type,
1425                update.final_state_commitment(),
1426                block_num,
1427                created_at_block,
1428            ),
1429            AccountStateForInsert::FullAccount(account) => AccountRowInsert::new_from_account(
1430                account_id,
1431                network_account_type,
1432                update.final_state_commitment(),
1433                block_num,
1434                created_at_block,
1435                account,
1436            ),
1437            AccountStateForInsert::PrecomputedFullState(state) => {
1438                AccountRowInsert::new_from_precomputed_full_state(
1439                    account_id,
1440                    network_account_type,
1441                    update.final_state_commitment(),
1442                    block_num,
1443                    created_at_block,
1444                    state,
1445                )
1446            },
1447            AccountStateForInsert::PartialState(state) => AccountRowInsert::new_from_partial(
1448                account_id,
1449                network_account_type,
1450                update.final_state_commitment(),
1451                block_num,
1452                created_at_block,
1453                state,
1454            ),
1455        };
1456
1457        diesel::insert_into(schema::accounts::table)
1458            .values(&account_value)
1459            .on_conflict((schema::accounts::account_id, schema::accounts::block_num))
1460            .do_update()
1461            .set(&account_value)
1462            .execute(conn)?;
1463
1464        // insert pending storage map entries TODO consider batching
1465        for (acc_id, slot_name, key, value) in pending_storage_inserts {
1466            if account_is_new {
1467                insert_account_storage_map_value_inner(
1468                    conn, acc_id, block_num, slot_name, key, value, false,
1469                )?;
1470            } else {
1471                insert_account_storage_map_value(conn, acc_id, block_num, slot_name, key, value)?;
1472            }
1473        }
1474
1475        for (acc_id, vault_key, update) in pending_asset_inserts {
1476            insert_account_vault_asset(conn, acc_id, block_num, vault_key, update)?;
1477        }
1478
1479        count += 1;
1480    }
1481
1482    Ok(count)
1483}
1484
1485#[derive(Insertable, Debug, Clone)]
1486#[diesel(table_name = schema::account_codes)]
1487pub(crate) struct AccountCodeRowInsert {
1488    pub(crate) code_commitment: Vec<u8>,
1489    pub(crate) code: Vec<u8>,
1490}
1491
1492#[derive(Insertable, AsChangeset, Debug, Clone)]
1493#[diesel(table_name = schema::accounts)]
1494pub(crate) struct AccountRowInsert {
1495    pub(crate) account_id: Vec<u8>,
1496    pub(crate) network_account_type: i32,
1497    pub(crate) block_num: i64,
1498    pub(crate) account_commitment: Vec<u8>,
1499    pub(crate) code_commitment: Option<Vec<u8>>,
1500    pub(crate) nonce: Option<i64>,
1501    pub(crate) storage_header: Option<Vec<u8>>,
1502    pub(crate) vault_root: Option<Vec<u8>>,
1503    pub(crate) created_at_block: i64,
1504    pub(crate) valid_until: i64,
1505}
1506
1507impl AccountRowInsert {
1508    /// Creates an insert row for a private account (no public state).
1509    pub(crate) fn new_private(
1510        account_id: AccountId,
1511        network_account_type: NetworkAccountType,
1512        account_commitment: Word,
1513        block_num: BlockNumber,
1514        created_at_block: BlockNumber,
1515    ) -> Self {
1516        Self {
1517            account_id: account_id.to_bytes(),
1518            network_account_type: network_account_type.to_raw_sql(),
1519            account_commitment: account_commitment.to_bytes(),
1520            block_num: block_num.to_raw_sql(),
1521            nonce: None,
1522            code_commitment: None,
1523            storage_header: None,
1524            vault_root: None,
1525            created_at_block: created_at_block.to_raw_sql(),
1526            valid_until: VALID_FOREVER,
1527        }
1528    }
1529
1530    /// Creates an insert row from a full account (new account creation).
1531    fn new_from_account(
1532        account_id: AccountId,
1533        network_account_type: NetworkAccountType,
1534        account_commitment: Word,
1535        block_num: BlockNumber,
1536        created_at_block: BlockNumber,
1537        account: &Account,
1538    ) -> Self {
1539        Self {
1540            account_id: account_id.to_bytes(),
1541            network_account_type: network_account_type.to_raw_sql(),
1542            account_commitment: account_commitment.to_bytes(),
1543            block_num: block_num.to_raw_sql(),
1544            nonce: Some(nonce_to_raw_sql(account.nonce())),
1545            code_commitment: Some(account.code().commitment().to_bytes()),
1546            storage_header: Some(account.storage().to_header().to_bytes()),
1547            vault_root: Some(account.vault().root().to_bytes()),
1548            created_at_block: created_at_block.to_raw_sql(),
1549            valid_until: VALID_FOREVER,
1550        }
1551    }
1552
1553    fn new_from_precomputed_full_state(
1554        account_id: AccountId,
1555        network_account_type: NetworkAccountType,
1556        account_commitment: Word,
1557        block_num: BlockNumber,
1558        created_at_block: BlockNumber,
1559        state: &PrecomputedFullAccountState,
1560    ) -> Self {
1561        Self {
1562            account_id: account_id.to_bytes(),
1563            network_account_type: network_account_type.to_raw_sql(),
1564            block_num: block_num.to_raw_sql(),
1565            account_commitment: account_commitment.to_bytes(),
1566            code_commitment: Some(state.code.commitment().to_bytes()),
1567            nonce: Some(nonce_to_raw_sql(state.nonce)),
1568            storage_header: Some(state.storage_header.to_bytes()),
1569            vault_root: Some(state.vault_root.to_bytes()),
1570            created_at_block: created_at_block.to_raw_sql(),
1571            valid_until: VALID_FOREVER,
1572        }
1573    }
1574
1575    /// Creates an insert row from a partial account state (patch update).
1576    fn new_from_partial(
1577        account_id: AccountId,
1578        network_account_type: NetworkAccountType,
1579        account_commitment: Word,
1580        block_num: BlockNumber,
1581        created_at_block: BlockNumber,
1582        state: &PartialAccountState,
1583    ) -> Self {
1584        Self {
1585            account_id: account_id.to_bytes(),
1586            network_account_type: network_account_type.to_raw_sql(),
1587            account_commitment: account_commitment.to_bytes(),
1588            block_num: block_num.to_raw_sql(),
1589            nonce: Some(nonce_to_raw_sql(state.nonce)),
1590            code_commitment: Some(state.code_commitment.to_bytes()),
1591            storage_header: Some(state.storage_header.to_bytes()),
1592            vault_root: Some(state.vault_root.to_bytes()),
1593            created_at_block: created_at_block.to_raw_sql(),
1594            valid_until: VALID_FOREVER,
1595        }
1596    }
1597}
1598
1599#[derive(Insertable, AsChangeset, Debug, Clone)]
1600#[diesel(table_name = schema::account_vault_assets)]
1601pub(crate) struct AccountAssetRowInsert {
1602    pub(crate) account_id: Vec<u8>,
1603    pub(crate) block_num: i64,
1604    pub(crate) vault_key: Vec<u8>,
1605    pub(crate) asset: Option<Vec<u8>>,
1606    pub(crate) valid_until: i64,
1607}
1608
1609impl AccountAssetRowInsert {
1610    pub(crate) fn new(
1611        account_id: &AccountId,
1612        vault_key: &AssetId,
1613        block_num: BlockNumber,
1614        asset: Option<Asset>,
1615    ) -> Self {
1616        let account_id = account_id.to_bytes();
1617        let vault_key: Word = (*vault_key).into();
1618        let vault_key = vault_key.to_bytes();
1619        let block_num = block_num.to_raw_sql();
1620        let asset = asset.map(|asset| asset.to_bytes());
1621        Self {
1622            account_id,
1623            block_num,
1624            vault_key,
1625            asset,
1626            valid_until: VALID_FOREVER,
1627        }
1628    }
1629}
1630
1631#[derive(Insertable, AsChangeset, Debug, Clone)]
1632#[diesel(table_name = schema::account_storage_map_values)]
1633pub(crate) struct AccountStorageMapRowInsert {
1634    pub(crate) account_id: Vec<u8>,
1635    pub(crate) block_num: i64,
1636    pub(crate) slot_name: String,
1637    pub(crate) key: Vec<u8>,
1638    pub(crate) value: Vec<u8>,
1639    pub(crate) valid_until: i64,
1640}
1641
1642// CLEANUP FUNCTIONS
1643// ================================================================================================
1644
1645/// Number of historical blocks to retain for vault assets, storage map values, and account codes.
1646/// Rows whose validity interval ends at or below `prune_tip - HISTORICAL_BLOCK_RETENTION` will be
1647/// deleted; rows still valid anywhere inside the retention window (including all open-ended rows)
1648/// are retained.
1649pub const HISTORICAL_BLOCK_RETENTION: u32 = 50;
1650
1651/// Clean up old entries for all accounts, deleting entries that can no longer affect state
1652/// reconstruction at any block within the retention window.
1653///
1654/// A row is applicable for blocks in `[block_num, valid_until)`, so it is deletable exactly when
1655/// its interval ends at or below the cutoff (`prune_tip - HISTORICAL_BLOCK_RETENTION`): it then
1656/// cannot cover any block inside the window. `prune_tip` is the effective tip for retention — it
1657/// lags the chain tip while old snapshot generations are still pinned by readers (see
1658/// [`crate::db::Db::apply_block`]). Account codes follow the same rule — a code is deleted only
1659/// when no account row whose interval reaches past the cutoff references it.
1660///
1661/// # Returns
1662/// A tuple of `(vault_assets_deleted, storage_map_values_deleted, account_codes_deleted)`
1663#[miden_instrument(
1664    target = COMPONENT,
1665    err,
1666    fields(
1667        cutoff_block,
1668    ),
1669)]
1670pub(crate) fn prune_history(
1671    conn: &mut SqliteConnection,
1672    prune_tip: BlockNumber,
1673) -> Result<(usize, usize, usize), DatabaseError> {
1674    let cutoff_block = i64::from(prune_tip.as_u32().saturating_sub(HISTORICAL_BLOCK_RETENTION));
1675    tracing::Span::current().record("cutoff_block", cutoff_block);
1676    let vault_deleted = prune_account_vault_assets(conn, cutoff_block)?;
1677    let storage_deleted = prune_account_storage_map_values(conn, cutoff_block)?;
1678    let codes_deleted = prune_account_codes(conn, cutoff_block)?;
1679
1680    Ok((vault_deleted, storage_deleted, codes_deleted))
1681}
1682
1683#[miden_instrument(
1684    target = COMPONENT,
1685    err,
1686    fields(
1687        cutoff_block,
1688    ),
1689)]
1690fn prune_account_vault_assets(
1691    conn: &mut SqliteConnection,
1692    cutoff_block: i64,
1693) -> Result<usize, DatabaseError> {
1694    use diesel::sql_types::BigInt;
1695
1696    // The literal `!= VALID_FOREVER` term (rather than a bound parameter) lets SQLite prove the
1697    // predicate implies `idx_vault_cleanup`'s partial-index condition.
1698    diesel::sql_query(format!(
1699        "DELETE FROM account_vault_assets \
1700         WHERE valid_until != {VALID_FOREVER} \
1701           AND valid_until <= ?1"
1702    ))
1703    .bind::<BigInt, _>(cutoff_block)
1704    .execute(conn)
1705    .map_err(DatabaseError::Diesel)
1706}
1707
1708#[miden_instrument(
1709    target = COMPONENT,
1710    err,
1711    fields(
1712        cutoff_block,
1713    ),
1714)]
1715fn prune_account_storage_map_values(
1716    conn: &mut SqliteConnection,
1717    cutoff_block: i64,
1718) -> Result<usize, DatabaseError> {
1719    use diesel::sql_types::BigInt;
1720
1721    // The literal `!= VALID_FOREVER` term (rather than a bound parameter) lets SQLite prove the
1722    // predicate implies `idx_storage_cleanup`'s partial-index condition.
1723    diesel::sql_query(format!(
1724        "DELETE FROM account_storage_map_values \
1725         WHERE valid_until != {VALID_FOREVER} \
1726           AND valid_until <= ?1"
1727    ))
1728    .bind::<BigInt, _>(cutoff_block)
1729    .execute(conn)
1730    .map_err(DatabaseError::Diesel)
1731}
1732
1733/// Deletes account codes that are no longer referenced by any account row that can serve a read
1734/// within the retention window.
1735///
1736/// An account code is safe to delete when no `accounts` row whose validity interval reaches past
1737/// the cutoff (`valid_until > cutoff_block`) references it. That single predicate covers rows
1738/// inside the window, all open-ended (current) rows, and each account's baseline row — the row
1739/// still valid at the cutoff even though it was written before it.
1740///
1741/// Rather than re-checking every code on every prune, only codes whose deletability could have
1742/// changed since the previous prune are examined. A code survived the previous prune because at
1743/// least one `accounts` row with `valid_until > prev_cutoff` referenced it. For it to be
1744/// deletable now, all such rows must have expired by the new cutoff — including the longest-lived
1745/// one, whose `valid_until` therefore lands inside `(prev_cutoff, cutoff_block]`. Scanning the
1746/// rows that expired in that window thus finds every code that could have become deletable. The
1747/// scan is an `idx_accounts_code_validity` index range, so its cost scales with the number of
1748/// account updates since the previous prune, not with total history. Each candidate is deleted
1749/// only if the `idx_accounts_code_probe` existence probe finds no row still referencing it with
1750/// `valid_until > cutoff_block`. The previous cutoff is persisted in `prune_progress` within the
1751/// same transaction; when absent (first prune after migration, or a fresh database) a full pass
1752/// over all rows valid past the cutoff runs instead.
1753///
1754/// Correctness of the windowed candidate set rests on two invariants:
1755/// - Rows are only ever closed to the `block_num` of the block currently being applied, which is
1756///   always above the cutoff, so every expiry crosses the window of some later prune. A write path
1757///   that back-dated `valid_until` below the current cutoff would leak the code forever.
1758/// - Every `account_codes` row is inserted alongside an `accounts` row referencing it (see
1759///   [`upsert_accounts`]); an orphan code with no referencing row would never become a candidate.
1760#[miden_instrument(
1761    target = COMPONENT,
1762    err,
1763    fields(
1764        cutoff_block,
1765    ),
1766)]
1767fn prune_account_codes(
1768    conn: &mut SqliteConnection,
1769    cutoff_block: i64,
1770) -> Result<usize, DatabaseError> {
1771    use diesel::sql_types::BigInt;
1772
1773    let prev_cutoff: Option<i64> =
1774        SelectDsl::select(schema::prune_progress::table, schema::prune_progress::codes_cutoff)
1775            .first(conn)
1776            .optional()
1777            .map_err(DatabaseError::Diesel)?;
1778
1779    let deleted = match prev_cutoff {
1780        // Codes are already pruned through this cutoff and nothing can become collectable while the
1781        // cutoff stands still. Equality is the common case: the cutoff is clamped to zero for the
1782        // first `HISTORICAL_BLOCK_RETENTION` blocks, and a pinned snapshot freezes the prune tip
1783        // across consecutive blocks. A strictly greater `prev_cutoff` is unreachable through
1784        // `apply_block` (the prune tip never regresses) but is guarded against so an out-of-order
1785        // caller cannot move the marker backwards or run the delete with an inverted window.
1786        Some(prev_cutoff) if prev_cutoff >= cutoff_block => return Ok(0),
1787        Some(prev_cutoff) => diesel::sql_query(
1788            "DELETE FROM account_codes \
1789             WHERE code_commitment IN ( \
1790                 SELECT DISTINCT code_commitment \
1791                 FROM accounts INDEXED BY idx_accounts_code_validity \
1792                 WHERE code_commitment IS NOT NULL \
1793                   AND valid_until > ?1 \
1794                   AND valid_until <= ?2 \
1795             ) \
1796             AND NOT EXISTS ( \
1797                 SELECT 1 \
1798                 FROM accounts INDEXED BY idx_accounts_code_probe \
1799                 WHERE accounts.code_commitment = account_codes.code_commitment \
1800                   AND accounts.valid_until > ?2 \
1801             )",
1802        )
1803        .bind::<BigInt, _>(prev_cutoff)
1804        .bind::<BigInt, _>(cutoff_block)
1805        .execute(conn)
1806        .map_err(DatabaseError::Diesel)?,
1807        // No recorded cutoff: full pass. The forced `idx_accounts_code_validity` covering index
1808        // keeps the subquery an index-only range scan, sized by rows valid at or after the cutoff
1809        // rather than total history.
1810        None => diesel::sql_query(
1811            "DELETE FROM account_codes \
1812             WHERE code_commitment NOT IN ( \
1813                 SELECT DISTINCT code_commitment \
1814                 FROM accounts INDEXED BY idx_accounts_code_validity \
1815                 WHERE code_commitment IS NOT NULL \
1816                   AND valid_until > ?1 \
1817             )",
1818        )
1819        .bind::<BigInt, _>(cutoff_block)
1820        .execute(conn)
1821        .map_err(DatabaseError::Diesel)?,
1822    };
1823
1824    diesel::insert_into(schema::prune_progress::table)
1825        .values((
1826            schema::prune_progress::id.eq(0),
1827            schema::prune_progress::codes_cutoff.eq(cutoff_block),
1828        ))
1829        .on_conflict(schema::prune_progress::id)
1830        .do_update()
1831        .set(schema::prune_progress::codes_cutoff.eq(cutoff_block))
1832        .execute(conn)
1833        .map_err(DatabaseError::Diesel)?;
1834
1835    Ok(deleted)
1836}