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