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};
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::account::delta::AccountUpdateDetails;
30use miden_protocol::account::{
31    Account,
32    AccountCode,
33    AccountId,
34    AccountStorage,
35    AccountStorageHeader,
36    NonFungibleDeltaAction,
37    StorageMap,
38    StorageMapKey,
39    StorageSlot,
40    StorageSlotContent,
41    StorageSlotName,
42    StorageSlotType,
43};
44use miden_protocol::asset::{Asset, AssetVault, AssetVaultKey, FungibleAsset};
45use miden_protocol::block::{BlockAccountUpdate, BlockNumber};
46use miden_protocol::utils::serde::{Deserializable, Serializable};
47use miden_protocol::{Felt, Word};
48use miden_standards::account::auth::NetworkAccount;
49
50use crate::COMPONENT;
51use crate::db::models::conv::{SqlTypeConvert, nonce_to_raw_sql, raw_sql_to_nonce};
52#[cfg(test)]
53use crate::db::models::vec_raw_try_into;
54use crate::db::{AccountVaultValue, schema};
55use crate::errors::DatabaseError;
56
57mod at_block;
58pub(crate) use at_block::select_account_header_with_storage_header_at_block;
59
60mod delta;
61use delta::{
62    AccountStateForInsert,
63    PartialAccountState,
64    apply_storage_delta,
65    select_latest_vault_assets,
66    select_minimal_account_state_headers,
67    select_vault_balances_by_vault_keys,
68};
69
70#[cfg(test)]
71mod tests;
72
73type StorageMapValueRow = (i64, String, Vec<u8>, Vec<u8>);
74type StorageHeaderWithEntries =
75    (AccountStorageHeader, HashMap<StorageSlotName, BTreeMap<StorageMapKey, Word>>);
76
77// NETWORK ACCOUNT TYPE
78// ================================================================================================
79
80/// Classifies accounts for database storage based on whether they are network accounts.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub(crate) enum NetworkAccountType {
83    /// Not a network account.
84    None,
85    /// A network account.
86    Network,
87}
88
89// ACCOUNT CODE
90// ================================================================================================
91
92/// Select account code by its commitment hash from the `account_codes` table.
93///
94/// # Returns
95///
96/// The account code bytes if found, or `None` if no code exists with that commitment.
97///
98/// # Raw SQL
99///
100/// ```sql
101/// SELECT code FROM account_codes WHERE code_commitment = ?1
102/// ```
103pub(crate) fn select_account_code_by_commitment(
104    conn: &mut SqliteConnection,
105    code_commitment: Word,
106) -> Result<Option<Vec<u8>>, DatabaseError> {
107    use schema::account_codes;
108
109    let code_commitment_bytes = code_commitment.to_bytes();
110
111    let result: Option<Vec<u8>> = SelectDsl::select(
112        account_codes::table.filter(account_codes::code_commitment.eq(&code_commitment_bytes)),
113        account_codes::code,
114    )
115    .first(conn)
116    .optional()?;
117
118    Ok(result)
119}
120
121// ACCOUNT RETRIEVAL
122// ================================================================================================
123
124/// Select account by ID from the DB using the given [`SqliteConnection`].
125///
126/// # Returns
127///
128/// The latest account info, or an error.
129///
130/// # Raw SQL
131///
132/// ```sql
133/// SELECT
134///     accounts.account_id,
135///     accounts.account_commitment,
136///     accounts.block_num
137/// FROM
138///     accounts
139/// WHERE
140///     account_id = ?1
141///     AND is_latest = 1
142/// ```
143pub(crate) fn select_account(
144    conn: &mut SqliteConnection,
145    account_id: AccountId,
146) -> Result<AccountInfo, DatabaseError> {
147    let raw = SelectDsl::select(schema::accounts::table, AccountSummaryRaw::as_select())
148        .filter(schema::accounts::account_id.eq(account_id.to_bytes()))
149        .filter(schema::accounts::is_latest.eq(true))
150        .get_result::<AccountSummaryRaw>(conn)
151        .optional()?
152        .ok_or(DatabaseError::AccountNotFoundInDb(account_id))?;
153
154    let summary: AccountSummary = raw.try_into()?;
155
156    // Backfill account details from database For private accounts, we don't store full details in
157    // the database
158    let details = if account_id.is_public() {
159        Some(select_full_account(conn, account_id)?)
160    } else {
161        None
162    };
163
164    Ok(AccountInfo { summary, details })
165}
166
167/// Reconstruct full Account from database tables for the latest account state
168///
169/// This function queries the database tables to reconstruct a complete Account object:
170/// - Code from `account_codes` table
171/// - Nonce and storage header from `accounts` table
172/// - Storage map entries from `account_storage_map_values` table
173/// - Vault from `account_vault_assets` table
174///
175/// # Note
176///
177/// A stop-gap solution to retain store API and construct `AccountInfo` types.
178/// The function should ultimately be removed, and any queries be served from the
179/// `State` which contains an `SmtForest` to serve the latest and most recent
180/// historical data.
181// TODO: remove eventually once refactoring is complete
182pub(crate) fn select_full_account(
183    conn: &mut SqliteConnection,
184    account_id: AccountId,
185) -> Result<Account, DatabaseError> {
186    // Get account metadata (nonce, code_commitment) and code in a single join query
187    let joined = schema::accounts::table.inner_join(schema::account_codes::table.on(
188        schema::accounts::code_commitment.eq(schema::account_codes::code_commitment.nullable()),
189    ));
190
191    let (nonce, code_bytes): (Option<i64>, Vec<u8>) =
192        SelectDsl::select(joined, (schema::accounts::nonce, schema::account_codes::code))
193            .filter(schema::accounts::account_id.eq(account_id.to_bytes()))
194            .filter(schema::accounts::is_latest.eq(true))
195            .get_result(conn)
196            .optional()?
197            .ok_or(DatabaseError::AccountNotFoundInDb(account_id))?;
198
199    let nonce = raw_sql_to_nonce(nonce.ok_or_else(|| {
200        DatabaseError::DataCorrupted(format!("No nonce found for account {account_id}"))
201    })?);
202
203    let code = AccountCode::read_from_bytes(&code_bytes)?;
204
205    // Reconstruct storage using existing helper function
206    let storage = select_latest_account_storage(conn, account_id)?;
207
208    // Reconstruct vault from account_vault_assets table
209    let vault_entries: Vec<(Vec<u8>, Option<Vec<u8>>)> = SelectDsl::select(
210        schema::account_vault_assets::table,
211        (schema::account_vault_assets::vault_key, schema::account_vault_assets::asset),
212    )
213    .filter(schema::account_vault_assets::account_id.eq(account_id.to_bytes()))
214    .filter(schema::account_vault_assets::is_latest.eq(true))
215    .load(conn)?;
216
217    let mut assets = Vec::new();
218    for (_key_bytes, maybe_asset_bytes) in vault_entries {
219        if let Some(asset_bytes) = maybe_asset_bytes {
220            let asset = Asset::read_from_bytes(&asset_bytes)?;
221            assets.push(asset);
222        }
223    }
224
225    let vault = AssetVault::new(&assets)?;
226
227    Ok(Account::new(account_id, vault, storage, code, nonce, None)?)
228}
229
230/// Page of account commitments returned by [`select_account_commitments_paged`].
231#[derive(Debug)]
232pub struct AccountCommitmentsPage {
233    /// The account commitments in this page.
234    pub commitments: Vec<(AccountId, Word)>,
235    /// If `Some`, there are more results. Use this as the `after_account_id` for the next page.
236    pub next_cursor: Option<AccountId>,
237}
238
239/// Selects account commitments with pagination.
240///
241/// Returns up to `page_size` account commitments, starting after `after_account_id` if provided.
242/// Results are ordered by `account_id` for stable pagination.
243///
244/// # Raw SQL
245///
246/// ```sql
247/// SELECT
248///     account_id,
249///     account_commitment
250/// FROM
251///     accounts
252/// WHERE
253///     is_latest = 1
254///     AND (account_id > :after_account_id OR :after_account_id IS NULL)
255/// ORDER BY
256///     account_id ASC
257/// LIMIT :page_size + 1
258/// ```
259pub(crate) fn select_account_commitments_paged(
260    conn: &mut SqliteConnection,
261    page_size: NonZeroUsize,
262    after_account_id: Option<AccountId>,
263) -> Result<AccountCommitmentsPage, DatabaseError> {
264    // Fetch one extra to determine if there are more results
265    #[expect(clippy::cast_possible_wrap)]
266    let limit = (page_size.get() + 1) as i64;
267
268    let mut query = SelectDsl::select(
269        schema::accounts::table,
270        (schema::accounts::account_id, schema::accounts::account_commitment),
271    )
272    .filter(schema::accounts::is_latest.eq(true))
273    .order_by(schema::accounts::account_id.asc())
274    .limit(limit)
275    .into_boxed();
276
277    if let Some(cursor) = after_account_id {
278        query = query.filter(schema::accounts::account_id.gt(cursor.to_bytes()));
279    }
280
281    let raw = query.load::<(Vec<u8>, Vec<u8>)>(conn)?;
282
283    let mut commitments = Result::<Vec<_>, DatabaseError>::from_iter(raw.into_iter().map(
284        |(ref account, ref commitment)| {
285            Ok((AccountId::read_from_bytes(account)?, Word::read_from_bytes(commitment)?))
286        },
287    ))?;
288
289    // If we got more than page_size, there are more results
290    let next_cursor = if commitments.len() > page_size.get() {
291        commitments.pop(); // Remove the extra element
292        commitments.last().map(|(id, _)| *id)
293    } else {
294        None
295    };
296
297    Ok(AccountCommitmentsPage { commitments, next_cursor })
298}
299
300/// Page of public account IDs returned by [`select_public_account_ids_paged`].
301#[derive(Debug)]
302pub struct PublicAccountIdsPage {
303    /// The public account IDs in this page.
304    pub account_ids: Vec<AccountId>,
305    /// If `Some`, there are more results. Use this as the `after_account_id` for the next page.
306    pub next_cursor: Option<AccountId>,
307}
308
309/// Latest account state forest roots for a public account.
310#[derive(Debug)]
311pub struct PublicAccountStateRoots {
312    pub account_id: AccountId,
313    pub vault_root: Word,
314    pub storage_header: AccountStorageHeader,
315}
316
317/// Page of public account state roots returned by [`select_public_account_state_roots_paged`].
318#[derive(Debug)]
319pub struct PublicAccountStateRootsPage {
320    /// The public account state roots in this page.
321    pub accounts: Vec<PublicAccountStateRoots>,
322    /// If `Some`, there are more results. Use this as the `after_account_id` for the next page.
323    pub next_cursor: Option<AccountId>,
324}
325
326/// Selects public account IDs with pagination.
327///
328/// Returns up to `page_size` public account IDs, starting after `after_account_id` if provided.
329/// Results are ordered by `account_id` for stable pagination.
330///
331/// Public accounts are those with `AccountType::Public`. We identify them by checking
332/// against the store. Public accounts store their `code_commitment`, while private accounts only
333/// store the `account_commitment`.
334///
335/// # Raw SQL
336///
337/// ```sql
338/// SELECT
339///     account_id
340/// FROM
341///     accounts
342/// WHERE
343///     is_latest = 1
344///     AND code_commitment IS NOT NULL
345///     AND (account_id > :after_account_id OR :after_account_id IS NULL)
346/// ORDER BY
347///     account_id ASC
348/// LIMIT :page_size + 1
349/// ```
350pub(crate) fn select_public_account_ids_paged(
351    conn: &mut SqliteConnection,
352    page_size: NonZeroUsize,
353    after_account_id: Option<AccountId>,
354) -> Result<PublicAccountIdsPage, DatabaseError> {
355    #[expect(clippy::cast_possible_wrap)]
356    let limit = (page_size.get() + 1) as i64;
357
358    let mut query = SelectDsl::select(schema::accounts::table, schema::accounts::account_id)
359        .filter(schema::accounts::is_latest.eq(true))
360        .filter(schema::accounts::code_commitment.is_not_null())
361        .order_by(schema::accounts::account_id.asc())
362        .limit(limit)
363        .into_boxed();
364
365    if let Some(cursor) = after_account_id {
366        query = query.filter(schema::accounts::account_id.gt(cursor.to_bytes()));
367    }
368
369    let raw = query.load::<Vec<u8>>(conn)?;
370
371    let mut account_ids: Vec<AccountId> = Result::from_iter(raw.into_iter().map(|bytes| {
372        AccountId::read_from_bytes(&bytes).map_err(DatabaseError::DeserializationError)
373    }))?;
374
375    // If we got more than page_size, there are more results
376    let next_cursor = if account_ids.len() > page_size.get() {
377        account_ids.pop(); // Remove the extra element
378        account_ids.last().copied()
379    } else {
380        None
381    };
382
383    Ok(PublicAccountIdsPage { account_ids, next_cursor })
384}
385
386/// Selects public account vault roots and storage headers with pagination.
387///
388/// Returns up to `page_size` public account states, starting after `after_account_id` if provided.
389/// Results are ordered by `account_id` for stable pagination.
390///
391/// Public accounts are those with `AccountType::Public`. We identify them by checking
392/// against the store. Public accounts store their `code_commitment`, while private accounts only
393/// store the `account_commitment`.
394///
395/// # Raw SQL
396///
397/// ```sql
398/// SELECT
399///     account_id,
400///     vault_root,
401///     storage_header
402/// FROM
403///     accounts
404/// WHERE
405///     is_latest = 1
406///     AND code_commitment IS NOT NULL
407///     AND (account_id > :after_account_id OR :after_account_id IS NULL)
408/// ORDER BY
409///     account_id ASC
410/// LIMIT :page_size + 1
411/// ```
412pub(crate) fn select_public_account_state_roots_paged(
413    conn: &mut SqliteConnection,
414    page_size: NonZeroUsize,
415    after_account_id: Option<AccountId>,
416) -> Result<PublicAccountStateRootsPage, DatabaseError> {
417    #[expect(clippy::cast_possible_wrap)]
418    let limit = (page_size.get() + 1) as i64;
419
420    let mut query = SelectDsl::select(
421        schema::accounts::table,
422        (
423            schema::accounts::account_id,
424            schema::accounts::vault_root,
425            schema::accounts::storage_header,
426        ),
427    )
428    .filter(schema::accounts::is_latest.eq(true))
429    .filter(schema::accounts::code_commitment.is_not_null())
430    .order_by(schema::accounts::account_id.asc())
431    .limit(limit)
432    .into_boxed();
433
434    if let Some(cursor) = after_account_id {
435        query = query.filter(schema::accounts::account_id.gt(cursor.to_bytes()));
436    }
437
438    let raw = query.load::<(Vec<u8>, Option<Vec<u8>>, Option<Vec<u8>>)>(conn)?;
439
440    let mut accounts: Vec<PublicAccountStateRoots> = Result::from_iter(raw.into_iter().map(
441        |(account_id_bytes, vault_root_bytes, storage_header_bytes)| {
442            let account_id = AccountId::read_from_bytes(&account_id_bytes)
443                .map_err(DatabaseError::DeserializationError)?;
444            let vault_root_bytes = vault_root_bytes.ok_or_else(|| {
445                DatabaseError::DataCorrupted(format!(
446                    "public account {account_id} is missing a vault root"
447                ))
448            })?;
449            let storage_header_bytes = storage_header_bytes.ok_or_else(|| {
450                DatabaseError::DataCorrupted(format!(
451                    "public account {account_id} is missing a storage header"
452                ))
453            })?;
454
455            Ok::<_, DatabaseError>(PublicAccountStateRoots {
456                account_id,
457                vault_root: Word::read_from_bytes(&vault_root_bytes)?,
458                storage_header: AccountStorageHeader::read_from_bytes(&storage_header_bytes)?,
459            })
460        },
461    ))?;
462
463    // If we got more than page_size, there are more results.
464    let next_cursor = if accounts.len() > page_size.get() {
465        accounts.pop();
466        accounts.last().map(|account| account.account_id)
467    } else {
468        None
469    };
470
471    Ok(PublicAccountStateRootsPage { accounts, next_cursor })
472}
473
474/// Select account vault assets within a block range (inclusive).
475///
476/// # Parameters
477/// * `account_id`: Account ID to query
478/// * `block_from`: Starting block number
479/// * `block_to`: Ending block number
480/// * Response payload size: 0 <= size <= 2MB
481/// * Vault assets per response: 0 <= count <= (2MB / (2*Word + u32)) + 1
482///
483/// # Raw SQL
484///
485/// ```sql
486/// SELECT
487///     block_num,
488///     vault_key,
489///     asset
490/// FROM
491///     account_vault_assets
492/// WHERE
493///     account_id = ?1
494///     AND block_num >= ?2
495///     AND block_num <= ?3
496/// ORDER BY
497///     block_num ASC
498/// LIMIT
499///     ?4
500/// ```
501pub(crate) fn select_account_vault_assets(
502    conn: &mut SqliteConnection,
503    account_id: AccountId,
504    block_range: RangeInclusive<BlockNumber>,
505) -> Result<(BlockNumber, Vec<AccountVaultValue>), DatabaseError> {
506    use schema::account_vault_assets as t;
507    // TODO: These limits should be given by the protocol. See miden-protocol/issues/1770 for more
508    // details
509    const ROW_OVERHEAD_BYTES: usize = 2 * size_of::<Word>() + size_of::<u32>(); // key + asset + block_num
510    const MAX_ROWS: usize = MAX_RESPONSE_PAYLOAD_BYTES / ROW_OVERHEAD_BYTES;
511
512    if !account_id.is_public() {
513        return Err(DatabaseError::AccountNotPublic(account_id));
514    }
515
516    if block_range.is_empty() {
517        return Err(DatabaseError::InvalidBlockRange {
518            from: *block_range.start(),
519            to: *block_range.end(),
520        });
521    }
522
523    let raw: Vec<(i64, Vec<u8>, Option<Vec<u8>>)> =
524        SelectDsl::select(t::table, (t::block_num, t::vault_key, t::asset))
525            .filter(
526                t::account_id
527                    .eq(account_id.to_bytes())
528                    .and(t::block_num.ge(block_range.start().to_raw_sql()))
529                    .and(t::block_num.le(block_range.end().to_raw_sql())),
530            )
531            .order(t::block_num.asc())
532            .limit(i64::try_from(MAX_ROWS + 1).expect("should fit within i64"))
533            .load::<(i64, Vec<u8>, Option<Vec<u8>>)>(conn)?;
534
535    // If we got more rows than the limit, the last block may be incomplete so we drop it entirely
536    // and derive last_block_included from the remaining rows.
537    let (last_block_included, values) = if let Some(&(last_block_num, ..)) = raw.last()
538        && raw.len() > MAX_ROWS
539    {
540        let values = raw
541            .into_iter()
542            .take_while(|(bn, ..)| *bn != last_block_num)
543            .map(AccountVaultValue::from_raw_row)
544            .collect::<Result<Vec<_>, DatabaseError>>()?;
545
546        let last_block_included = values.last().map_or(*block_range.start(), |v| v.block_num);
547
548        (last_block_included, values)
549    } else {
550        (
551            *block_range.end(),
552            raw.into_iter().map(AccountVaultValue::from_raw_row).collect::<Result<_, _>>()?,
553        )
554    };
555
556    Ok((last_block_included, values))
557}
558
559/// Select all accounts from the DB using the given [`SqliteConnection`].
560///
561/// # Returns
562///
563/// A vector with accounts, or an error.
564///
565/// # Raw SQL
566///
567/// ```sql
568/// SELECT
569///     accounts.account_id,
570///     accounts.account_commitment,
571///     accounts.block_num
572/// FROM
573///     accounts
574/// WHERE
575///     is_latest = 1
576/// ORDER BY
577///     block_num ASC
578/// ```
579#[cfg(test)]
580pub(crate) fn select_all_accounts(
581    conn: &mut SqliteConnection,
582) -> Result<Vec<AccountInfo>, DatabaseError> {
583    let raw = SelectDsl::select(schema::accounts::table, AccountSummaryRaw::as_select())
584        .filter(schema::accounts::is_latest.eq(true))
585        .order_by(schema::accounts::block_num.asc())
586        .load::<AccountSummaryRaw>(conn)?;
587
588    let summaries: Vec<AccountSummary> = vec_raw_try_into(raw)?;
589
590    // Backfill account details from database
591    let account_infos = summaries
592        .into_iter()
593        .map(|summary| {
594            let account_id = summary.account_id;
595            let details = select_full_account(conn, account_id).ok();
596            AccountInfo { summary, details }
597        })
598        .collect();
599
600    Ok(account_infos)
601}
602
603#[derive(Debug, Clone, PartialEq, Eq)]
604pub struct StorageMapValue {
605    pub block_num: BlockNumber,
606    pub slot_name: StorageSlotName,
607    pub key: StorageMapKey,
608    pub value: Word,
609}
610
611#[derive(Debug, Clone, PartialEq, Eq)]
612pub struct StorageMapValuesPage {
613    /// Highest block number included in `rows`. If the page is empty, this will be `block_from`.
614    pub last_block_included: BlockNumber,
615    /// Storage map values
616    pub values: Vec<StorageMapValue>,
617}
618
619impl StorageMapValue {
620    pub fn from_raw_row(row: StorageMapValueRow) -> Result<Self, DatabaseError> {
621        let (block_num, slot_name, key, value) = row;
622        Ok(Self {
623            block_num: BlockNumber::from_raw_sql(block_num)?,
624            slot_name: StorageSlotName::from_raw_sql(slot_name)?,
625            key: StorageMapKey::read_from_bytes(&key)?,
626            value: Word::read_from_bytes(&value)?,
627        })
628    }
629}
630
631/// Select account storage map values from the DB using the given [`SqliteConnection`].
632///
633/// # Returns
634///
635/// A vector of tuples containing `(slot, key, value, is_latest)` for the given account.
636/// Each row contains one of:
637///
638/// - the historical value for a slot and key specifically on block `block_to`
639/// - the latest updated value for the slot and key combination, alongside the block number in which
640///   it was updated
641///
642/// # Raw SQL
643///
644/// ```sql
645/// SELECT
646///     block_num,
647///     slot,
648///     key,
649///     value
650/// FROM
651///     account_storage_map_values
652/// WHERE
653///     account_id = ?1
654///     AND block_num >= ?2
655///     AND block_num <= ?3
656/// ORDER BY
657///     block_num ASC
658/// LIMIT
659///     ?4
660/// ```
661/// Select account storage map values within a block range (inclusive).
662///
663/// ## Parameters
664///
665/// * `account_id`: Account ID to query
666/// * `block_range`: Range of block numbers (inclusive)
667///
668/// ## Response
669///
670/// * Response payload size: 0 <= size <= 2MB
671/// * Storage map values per response: 0 <= count <= (2MB / (2*Word + u32 + u8)) + 1
672pub(crate) fn select_account_storage_map_values_paged(
673    conn: &mut SqliteConnection,
674    account_id: AccountId,
675    block_range: RangeInclusive<BlockNumber>,
676    limit: usize,
677) -> Result<StorageMapValuesPage, DatabaseError> {
678    use schema::account_storage_map_values as t;
679
680    if !account_id.is_public() {
681        return Err(DatabaseError::AccountNotPublic(account_id));
682    }
683
684    if block_range.is_empty() {
685        return Err(DatabaseError::InvalidBlockRange {
686            from: *block_range.start(),
687            to: *block_range.end(),
688        });
689    }
690
691    let raw: Vec<StorageMapValueRow> =
692        SelectDsl::select(t::table, (t::block_num, t::slot_name, t::key, t::value))
693            .filter(
694                t::account_id
695                    .eq(account_id.to_bytes())
696                    .and(t::block_num.ge(block_range.start().to_raw_sql()))
697                    .and(t::block_num.le(block_range.end().to_raw_sql())),
698            )
699            .order(t::block_num.asc())
700            .limit(i64::try_from(limit + 1).expect("limit fits within i64"))
701            .load(conn)?;
702
703    // If we got more rows than the limit, the last block may be incomplete so we drop it entirely
704    // and derive last_block_included from the remaining rows.
705    let (last_block_included, values) = if let Some(&(last_block_num, ..)) = raw.last()
706        && raw.len() > limit
707    {
708        let values = raw
709            .into_iter()
710            .take_while(|(bn, ..)| *bn != last_block_num)
711            .map(StorageMapValue::from_raw_row)
712            .collect::<Result<Vec<_>, DatabaseError>>()?;
713
714        let last_block_included = values.last().map_or(*block_range.start(), |v| v.block_num);
715
716        (last_block_included, values)
717    } else {
718        (
719            *block_range.end(),
720            raw.into_iter()
721                .map(StorageMapValue::from_raw_row)
722                .collect::<Result<Vec<_>, _>>()?,
723        )
724    };
725
726    Ok(StorageMapValuesPage { last_block_included, values })
727}
728
729/// Select latest account storage by querying `accounts.storage_header` where `is_latest=true`
730/// and reconstructing full storage from the header plus map values from
731/// `account_storage_map_values`.
732///
733/// Attention: For large accounts it is prohibitively expensive!
734pub(crate) fn select_latest_account_storage(
735    conn: &mut SqliteConnection,
736    account_id: AccountId,
737) -> Result<AccountStorage, DatabaseError> {
738    let (storage_header, map_entries_by_slot) =
739        select_latest_account_storage_components(conn, account_id)?;
740    // Reconstruct StorageSlots from header slots + map entries
741    let slots =
742        Result::<Vec<_>, DatabaseError>::from_iter(storage_header.slots().map(|slot_header| {
743            let slot = match slot_header.slot_type() {
744                StorageSlotType::Value => {
745                    // For value slots, the header value IS the slot value
746                    StorageSlot::with_value(slot_header.name().clone(), slot_header.value())
747                },
748                StorageSlotType::Map => {
749                    // For map slots, reconstruct from map entries
750                    let entries =
751                        map_entries_by_slot.get(slot_header.name()).cloned().unwrap_or_default();
752                    let storage_map = StorageMap::with_entries(entries.into_iter())?;
753                    StorageSlot::with_map(slot_header.name().clone(), storage_map)
754                },
755            };
756            Ok(slot)
757        }))?;
758
759    Ok(AccountStorage::new(slots)?)
760}
761
762/// Fetch account storage header and all storage maps
763pub(crate) fn select_latest_account_storage_components(
764    conn: &mut SqliteConnection,
765    account_id: AccountId,
766) -> Result<StorageHeaderWithEntries, DatabaseError> {
767    let account_id_bytes = account_id.to_bytes();
768
769    // Query storage header blob for this account where is_latest = true
770    let storage_blob: Option<Vec<u8>> =
771        SelectDsl::select(schema::accounts::table, schema::accounts::storage_header)
772            .filter(schema::accounts::account_id.eq(&account_id_bytes))
773            .filter(schema::accounts::is_latest.eq(true))
774            .first(conn)
775            .optional()?
776            .flatten();
777
778    let header = match storage_blob {
779        Some(blob) => AccountStorageHeader::read_from_bytes(&blob)?,
780        None => AccountStorageHeader::new(Vec::new())?,
781    };
782
783    let entries = select_latest_storage_map_entries_all(conn, &account_id)?;
784    Ok((header, entries))
785}
786
787// TODO this is expensive and should only be called from tests
788fn select_latest_storage_map_entries_all(
789    conn: &mut SqliteConnection,
790    account_id: &AccountId,
791) -> Result<HashMap<StorageSlotName, BTreeMap<StorageMapKey, Word>>, DatabaseError> {
792    use schema::account_storage_map_values as t;
793
794    let map_values: Vec<(String, Vec<u8>, Vec<u8>)> =
795        SelectDsl::select(t::table, (t::slot_name, t::key, t::value))
796            .filter(t::account_id.eq(&account_id.to_bytes()))
797            .filter(t::is_latest.eq(true))
798            .load(conn)?;
799
800    group_storage_map_entries(map_values)
801}
802
803fn select_latest_storage_map_entries_for_slots(
804    conn: &mut SqliteConnection,
805    account_id: &AccountId,
806    slot_names: &[StorageSlotName],
807) -> Result<HashMap<StorageSlotName, BTreeMap<StorageMapKey, Word>>, DatabaseError> {
808    use schema::account_storage_map_values as t;
809
810    if slot_names.is_empty() {
811        return Ok(HashMap::new());
812    }
813
814    if let [slot_name] = slot_names {
815        let entries = select_latest_storage_map_entries_for_slot(conn, account_id, slot_name)?;
816        if entries.is_empty() {
817            return Ok(HashMap::new());
818        }
819
820        let mut map_entries = HashMap::new();
821        map_entries.insert(slot_name.clone(), entries);
822        return Ok(map_entries);
823    }
824
825    let slot_names = Vec::from_iter(slot_names.iter().cloned().map(StorageSlotName::to_raw_sql));
826    let map_values: Vec<(String, Vec<u8>, Vec<u8>)> =
827        SelectDsl::select(t::table, (t::slot_name, t::key, t::value))
828            .filter(t::account_id.eq(&account_id.to_bytes()))
829            .filter(t::is_latest.eq(true))
830            .filter(t::slot_name.eq_any(slot_names))
831            .load(conn)?;
832
833    group_storage_map_entries(map_values)
834}
835
836fn select_latest_storage_map_entries_for_slot(
837    conn: &mut SqliteConnection,
838    account_id: &AccountId,
839    slot_name: &StorageSlotName,
840) -> Result<BTreeMap<StorageMapKey, Word>, DatabaseError> {
841    use schema::account_storage_map_values as t;
842
843    let map_values: Vec<(String, Vec<u8>, Vec<u8>)> =
844        SelectDsl::select(t::table, (t::slot_name, t::key, t::value))
845            .filter(t::account_id.eq(&account_id.to_bytes()))
846            .filter(t::is_latest.eq(true))
847            .filter(t::slot_name.eq(slot_name.clone().to_raw_sql()))
848            .load(conn)?;
849
850    Ok(group_storage_map_entries(map_values)?.remove(slot_name).unwrap_or_default())
851}
852
853fn group_storage_map_entries(
854    map_values: Vec<(String, Vec<u8>, Vec<u8>)>,
855) -> Result<HashMap<StorageSlotName, BTreeMap<StorageMapKey, Word>>, DatabaseError> {
856    let mut map_entries_by_slot: HashMap<StorageSlotName, BTreeMap<StorageMapKey, Word>> =
857        HashMap::new();
858    for (slot_name_str, key_bytes, value_bytes) in map_values {
859        let slot_name: StorageSlotName = slot_name_str.parse().map_err(|_| {
860            DatabaseError::DataCorrupted(format!("Invalid slot name: {slot_name_str}"))
861        })?;
862        let key = StorageMapKey::read_from_bytes(&key_bytes)?;
863        let value = Word::read_from_bytes(&value_bytes)?;
864        map_entries_by_slot.entry(slot_name).or_default().insert(key, value);
865    }
866
867    Ok(map_entries_by_slot)
868}
869
870// ACCOUNT MUTATION
871// ================================================================================================
872
873#[derive(Queryable, Selectable)]
874#[diesel(table_name = crate::db::schema::account_vault_assets)]
875#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
876pub struct AccountVaultUpdateRaw {
877    pub vault_key: Vec<u8>,
878    pub asset: Option<Vec<u8>>,
879    pub block_num: i64,
880}
881
882impl TryFrom<AccountVaultUpdateRaw> for AccountVaultValue {
883    type Error = DatabaseError;
884
885    fn try_from(raw: AccountVaultUpdateRaw) -> Result<Self, Self::Error> {
886        let vault_key = AssetVaultKey::try_from(Word::read_from_bytes(&raw.vault_key)?)?;
887        let asset = raw.asset.map(|bytes| Asset::read_from_bytes(&bytes)).transpose()?;
888        let block_num = BlockNumber::from_raw_sql(raw.block_num)?;
889
890        Ok(AccountVaultValue { block_num, vault_key, asset })
891    }
892}
893
894#[derive(Debug, Clone, PartialEq, Eq, Selectable, Queryable, QueryableByName)]
895#[diesel(table_name = schema::accounts)]
896#[diesel(check_for_backend(Sqlite))]
897pub struct AccountSummaryRaw {
898    account_id: Vec<u8>,         // AccountId,
899    account_commitment: Vec<u8>, //RpoDigest,
900    block_num: i64,              //BlockNumber,
901}
902
903impl TryInto<AccountSummary> for AccountSummaryRaw {
904    type Error = DatabaseError;
905    fn try_into(self) -> Result<AccountSummary, Self::Error> {
906        let account_id = AccountId::read_from_bytes(&self.account_id[..])?;
907        let account_commitment = Word::read_from_bytes(&self.account_commitment[..])?;
908        let block_num = BlockNumber::from_raw_sql(self.block_num)?;
909
910        Ok(AccountSummary {
911            account_id,
912            account_commitment,
913            block_num,
914        })
915    }
916}
917
918/// Insert an account vault asset row into the DB using the given [`SqliteConnection`].
919///
920/// Sets `is_latest=true` for the new row and updates any existing
921/// row with the same `(account_id, vault_key)` tuple to `is_latest=false`.
922///
923/// # Returns
924///
925/// The number of affected rows.
926pub(crate) fn insert_account_vault_asset(
927    conn: &mut SqliteConnection,
928    account_id: AccountId,
929    block_num: BlockNumber,
930    vault_key: AssetVaultKey,
931    asset: Option<Asset>,
932) -> Result<usize, DatabaseError> {
933    let record = AccountAssetRowInsert::new(&account_id, &vault_key, block_num, asset, true);
934
935    diesel::Connection::transaction(conn, |conn| {
936        // First, update any existing rows with the same (account_id, vault_key) to set
937        // is_latest=false
938        let vault_key: Word = vault_key.into();
939        let vault_key_bytes = vault_key.to_bytes();
940        let account_id_bytes = account_id.to_bytes();
941        let update_count = diesel::update(schema::account_vault_assets::table)
942            .filter(
943                schema::account_vault_assets::account_id
944                    .eq(account_id_bytes)
945                    .and(schema::account_vault_assets::vault_key.eq(vault_key_bytes))
946                    .and(schema::account_vault_assets::is_latest.eq(true)),
947            )
948            .set(schema::account_vault_assets::is_latest.eq(false))
949            .execute(conn)?;
950
951        // Insert the new latest row
952        let insert_count = diesel::insert_into(schema::account_vault_assets::table)
953            .values(record)
954            .execute(conn)?;
955
956        Ok(update_count + insert_count)
957    })
958}
959
960/// Insert an account storage map value into the DB using the given [`SqliteConnection`].
961///
962/// Sets `is_latest=true` for the new row and updates any existing
963/// row with the same `(account_id, slot_index, key)` tuple to `is_latest=false`.
964///
965/// # Returns
966///
967/// The number of affected rows.
968pub(crate) fn insert_account_storage_map_value(
969    conn: &mut SqliteConnection,
970    account_id: AccountId,
971    block_num: BlockNumber,
972    slot_name: StorageSlotName,
973    key: StorageMapKey,
974    value: Word,
975) -> Result<usize, DatabaseError> {
976    let account_id = account_id.to_bytes();
977    let key = key.to_bytes();
978    let value = value.to_bytes();
979    let slot_name = slot_name.to_raw_sql();
980    let block_num = block_num.to_raw_sql();
981
982    let update_count = diesel::update(schema::account_storage_map_values::table)
983        .filter(
984            schema::account_storage_map_values::account_id
985                .eq(&account_id)
986                .and(schema::account_storage_map_values::slot_name.eq(&slot_name))
987                .and(schema::account_storage_map_values::key.eq(&key))
988                .and(schema::account_storage_map_values::is_latest.eq(true)),
989        )
990        .set(schema::account_storage_map_values::is_latest.eq(false))
991        .execute(conn)?;
992
993    let record = AccountStorageMapRowInsert {
994        account_id,
995        key,
996        value,
997        slot_name,
998        block_num,
999        is_latest: true,
1000    };
1001    let insert_count = diesel::insert_into(schema::account_storage_map_values::table)
1002        .values(record)
1003        .execute(conn)?;
1004
1005    Ok(update_count + insert_count)
1006}
1007
1008type PendingStorageInserts = Vec<(AccountId, StorageSlotName, StorageMapKey, Word)>;
1009type PendingAssetInserts = Vec<(AccountId, AssetVaultKey, Option<Asset>)>;
1010
1011fn prepare_full_account_update(
1012    update: &BlockAccountUpdate,
1013    account: Account,
1014) -> Result<(AccountStateForInsert, PendingStorageInserts, PendingAssetInserts), DatabaseError> {
1015    let account_id = account.id();
1016
1017    // sanity check the commitment of account matches the final state commitment
1018    if account.to_commitment() != update.final_state_commitment() {
1019        return Err(DatabaseError::AccountCommitmentsMismatch {
1020            calculated: account.to_commitment(),
1021            expected: update.final_state_commitment(),
1022        });
1023    }
1024
1025    // collect storage-map inserts to apply after account upsert
1026    let mut storage = Vec::new();
1027    for slot in account.storage().slots() {
1028        if let StorageSlotContent::Map(storage_map) = slot.content() {
1029            for (key, value) in storage_map.entries() {
1030                storage.push((account_id, slot.name().clone(), *key, *value));
1031            }
1032        }
1033    }
1034
1035    // collect vault-asset inserts to apply after account upsert
1036    let mut assets = Vec::new();
1037    for asset in account.vault().assets() {
1038        // Only insert assets with non-zero values for fungible assets
1039        let should_insert = match asset {
1040            Asset::Fungible(fungible) => fungible.amount().as_u64() > 0,
1041            Asset::NonFungible(_) => true,
1042        };
1043        if should_insert {
1044            assets.push((account_id, asset.vault_key(), Some(asset)));
1045        }
1046    }
1047
1048    Ok((AccountStateForInsert::FullAccount(account), storage, assets))
1049}
1050
1051/// Prepare partial delta data for account upserts and follow-up storage and vault inserts.
1052fn prepare_partial_account_update(
1053    conn: &mut SqliteConnection,
1054    update: &BlockAccountUpdate,
1055    account_id: AccountId,
1056    delta: &miden_protocol::account::delta::AccountDelta,
1057) -> Result<(AccountStateForInsert, PendingStorageInserts, PendingAssetInserts), DatabaseError> {
1058    // Build the minimal account state needed for partial delta application. Only load the storage
1059    // map entries and vault balances that will receive updates. The next line fetches the header,
1060    // which will always change unless the delta is empty.
1061    let state_headers = select_minimal_account_state_headers(conn, account_id)?;
1062
1063    // --- Process asset updates. --------------------------------- Look up balances by vault key
1064    // (which includes the callback flag), not by faucet id.
1065    let vault_keys =
1066        Vec::from_iter(delta.vault().fungible().iter().map(|(vault_key, _)| *vault_key));
1067    let prev_balances = select_vault_balances_by_vault_keys(conn, account_id, &vault_keys)?;
1068
1069    // Encode `Some` as update and `None` as removal.
1070    let mut assets = Vec::new();
1071
1072    // Update fungible assets.
1073    for (vault_key, amount_delta) in delta.vault().fungible().iter() {
1074        let faucet_id = vault_key.faucet_id();
1075        let callback_flag = vault_key.callback_flag();
1076        let prev_amount = prev_balances.get(&vault_key.to_word()).copied().unwrap_or(0);
1077        let prev_asset = FungibleAsset::new(faucet_id, prev_amount)?.with_callbacks(callback_flag);
1078        let amount_abs = amount_delta.unsigned_abs();
1079        let delta = FungibleAsset::new(faucet_id, amount_abs)?.with_callbacks(callback_flag);
1080        let new_balance = if *amount_delta < 0 {
1081            prev_asset.sub(delta)?
1082        } else {
1083            prev_asset.add(delta)?
1084        };
1085        let update_or_remove = if new_balance.amount().as_u64() == 0 {
1086            None
1087        } else {
1088            Some(Asset::from(new_balance))
1089        };
1090        assets.push((account_id, new_balance.vault_key(), update_or_remove));
1091    }
1092
1093    // Update non-fungible assets.
1094    for (asset, delta_action) in delta.vault().non_fungible().iter() {
1095        let asset_update = match delta_action {
1096            NonFungibleDeltaAction::Add => Some(Asset::NonFungible(*asset)),
1097            NonFungibleDeltaAction::Remove => None,
1098        };
1099        assets.push((account_id, asset.vault_key(), asset_update));
1100    }
1101
1102    // --- Collect storage map updates. ---------------------------
1103
1104    let mut storage = Vec::new();
1105    for (slot_name, map_delta) in delta.storage().maps() {
1106        for (key, value) in map_delta.entries() {
1107            storage.push((account_id, slot_name.clone(), *key, *value));
1108        }
1109    }
1110
1111    // First collect entries that have associated changes.
1112    let slot_names = Vec::from_iter(delta.storage().maps().filter_map(|(slot_name, map_delta)| {
1113        if map_delta.is_empty() {
1114            None
1115        } else {
1116            Some(slot_name.clone())
1117        }
1118    }));
1119
1120    let map_entries = select_latest_storage_map_entries_for_slots(conn, &account_id, &slot_names)?;
1121
1122    // Apply the delta storage to the given storage header.
1123    let new_storage_header =
1124        apply_storage_delta(&state_headers.storage_header, delta.storage(), &map_entries)?;
1125
1126    // --- Update the vault root by constructing the asset vault from DB.
1127    let new_vault_root = {
1128        let assets = select_latest_vault_assets(conn, account_id)?;
1129        let mut vault = AssetVault::new(&assets)?;
1130        vault.apply_delta(delta.vault())?;
1131        vault.root()
1132    };
1133
1134    // --- Compute updated account state for the accounts row. --- Apply nonce delta.
1135    let new_nonce_value = state_headers
1136        .nonce
1137        .as_canonical_u64()
1138        .checked_add(delta.nonce_delta().as_canonical_u64())
1139        .ok_or_else(|| {
1140            DatabaseError::DataCorrupted(format!("Nonce overflow for account {account_id}"))
1141        })?;
1142    let new_nonce = Felt::new_unchecked(new_nonce_value);
1143
1144    // Create minimal account state data for the row insert.
1145    let account_state = PartialAccountState {
1146        nonce: new_nonce,
1147        code_commitment: state_headers.code_commitment,
1148        storage_header: new_storage_header,
1149        vault_root: new_vault_root,
1150    };
1151
1152    let account_header = miden_protocol::account::AccountHeader::new(
1153        account_id,
1154        account_state.nonce,
1155        account_state.vault_root,
1156        account_state.storage_header.to_commitment(),
1157        account_state.code_commitment,
1158    );
1159
1160    if account_header.to_commitment() != update.final_state_commitment() {
1161        return Err(DatabaseError::AccountCommitmentsMismatch {
1162            calculated: account_header.to_commitment(),
1163            expected: update.final_state_commitment(),
1164        });
1165    }
1166
1167    Ok((AccountStateForInsert::PartialState(account_state), storage, assets))
1168}
1169
1170/// Returns the subset of `account_ids` whose latest committed state is a network account.
1171///
1172/// Unknown ids and non-network accounts are silently omitted.
1173pub(crate) fn select_network_accounts_subset(
1174    conn: &mut SqliteConnection,
1175    account_ids: &[AccountId],
1176) -> Result<HashSet<AccountId>, DatabaseError> {
1177    QueryParamAccountIdLimit::check(account_ids.len())?;
1178    let id_bytes: Vec<Vec<u8>> =
1179        account_ids.iter().map(miden_crypto::utils::Serializable::to_bytes).collect();
1180
1181    let rows: Vec<Vec<u8>> =
1182        SelectDsl::select(schema::accounts::table, schema::accounts::account_id)
1183            .filter(
1184                schema::accounts::account_id
1185                    .eq_any(&id_bytes)
1186                    .and(
1187                        schema::accounts::network_account_type
1188                            .eq(NetworkAccountType::Network.to_raw_sql()),
1189                    )
1190                    .and(schema::accounts::is_latest.eq(true)),
1191            )
1192            .load::<Vec<u8>>(conn)
1193            .map_err(DatabaseError::Diesel)?;
1194
1195    rows.into_iter()
1196        .map(|bytes| {
1197            AccountId::read_from_bytes(&bytes).map_err(DatabaseError::DeserializationError)
1198        })
1199        .collect()
1200}
1201
1202/// Attention: Assumes the account details are NOT null! The schema explicitly allows this though!
1203#[miden_instrument(
1204    target = COMPONENT,
1205    skip_all,
1206    err,
1207)]
1208pub(crate) fn upsert_accounts(
1209    conn: &mut SqliteConnection,
1210    accounts: &[BlockAccountUpdate],
1211    block_num: BlockNumber,
1212) -> Result<usize, DatabaseError> {
1213    let mut count = 0;
1214    for update in accounts {
1215        let account_id = update.account_id();
1216        let account_id_bytes = account_id.to_bytes();
1217
1218        // Pull the latest row (if any) so we can carry forward `created_at_block` and the
1219        // `network_account_type` classification, both of which are fixed at account creation.
1220        let existing: Option<(i64, i32)> = QueryDsl::select(
1221            schema::accounts::table.filter(
1222                schema::accounts::account_id
1223                    .eq(&account_id_bytes)
1224                    .and(schema::accounts::is_latest.eq(true)),
1225            ),
1226            (schema::accounts::created_at_block, schema::accounts::network_account_type),
1227        )
1228        .first(conn)
1229        .optional()
1230        .map_err(DatabaseError::Diesel)?;
1231
1232        let created_at_block = match existing {
1233            Some((raw, _)) => BlockNumber::from_raw_sql(raw)?,
1234            None => block_num,
1235        };
1236
1237        // NOTE: we collect storage / asset inserts to apply them only after the account row is
1238        // written. The storage and vault tables have FKs pointing to accounts `(account_id,
1239        // block_num)`, so inserting them earlier would violate those constraints when inserting a
1240        // brand-new account.
1241        let (account_state, pending_storage_inserts, pending_asset_inserts) = match update.details()
1242        {
1243            AccountUpdateDetails::Private => (AccountStateForInsert::Private, vec![], vec![]),
1244
1245            // New account is always a full account, but also comes as an update
1246            AccountUpdateDetails::Delta(delta) if delta.is_full_state() => {
1247                let account = Account::try_from(delta)
1248                    .expect("Delta to full account always works for full state deltas");
1249                debug_assert_eq!(account_id, account.id());
1250
1251                prepare_full_account_update(update, account)?
1252            },
1253
1254            // Update of an existing account
1255            AccountUpdateDetails::Delta(delta) => {
1256                prepare_partial_account_update(conn, update, account_id, delta)?
1257            },
1258        };
1259
1260        // Inherit the classification when the account already exists; otherwise classify it once at
1261        // creation based on the new state.
1262        let network_account_type = match existing {
1263            Some((_, raw)) => NetworkAccountType::from_raw_sql(raw)?,
1264            None => match &account_state {
1265                AccountStateForInsert::FullAccount(account)
1266                    if NetworkAccount::new(account.clone()).is_ok() =>
1267                {
1268                    NetworkAccountType::Network
1269                },
1270                _ => NetworkAccountType::None,
1271            },
1272        };
1273
1274        // Insert account _code_ for full accounts (new account creation)
1275        if let AccountStateForInsert::FullAccount(ref account) = account_state {
1276            let code = account.code();
1277            let code_value = AccountCodeRowInsert {
1278                code_commitment: code.commitment().to_bytes(),
1279                code: code.to_bytes(),
1280            };
1281            diesel::insert_into(schema::account_codes::table)
1282                .values(&code_value)
1283                .on_conflict(schema::account_codes::code_commitment)
1284                .do_nothing()
1285                .execute(conn)?;
1286        }
1287
1288        // mark previous rows as non-latest and insert NEW account row
1289        diesel::update(schema::accounts::table)
1290            .filter(
1291                schema::accounts::account_id
1292                    .eq(&account_id_bytes)
1293                    .and(schema::accounts::is_latest.eq(true)),
1294            )
1295            .set(schema::accounts::is_latest.eq(false))
1296            .execute(conn)?;
1297
1298        let account_value = match &account_state {
1299            AccountStateForInsert::Private => AccountRowInsert::new_private(
1300                account_id,
1301                network_account_type,
1302                update.final_state_commitment(),
1303                block_num,
1304                created_at_block,
1305            ),
1306            AccountStateForInsert::FullAccount(account) => AccountRowInsert::new_from_account(
1307                account_id,
1308                network_account_type,
1309                update.final_state_commitment(),
1310                block_num,
1311                created_at_block,
1312                account,
1313            ),
1314            AccountStateForInsert::PartialState(state) => AccountRowInsert::new_from_partial(
1315                account_id,
1316                network_account_type,
1317                update.final_state_commitment(),
1318                block_num,
1319                created_at_block,
1320                state,
1321            ),
1322        };
1323
1324        diesel::insert_into(schema::accounts::table)
1325            .values(&account_value)
1326            .on_conflict((schema::accounts::account_id, schema::accounts::block_num))
1327            .do_update()
1328            .set(&account_value)
1329            .execute(conn)?;
1330
1331        // insert pending storage map entries TODO consider batching
1332        for (acc_id, slot_name, key, value) in pending_storage_inserts {
1333            insert_account_storage_map_value(conn, acc_id, block_num, slot_name, key, value)?;
1334        }
1335
1336        for (acc_id, vault_key, update) in pending_asset_inserts {
1337            insert_account_vault_asset(conn, acc_id, block_num, vault_key, update)?;
1338        }
1339
1340        count += 1;
1341    }
1342
1343    Ok(count)
1344}
1345
1346#[derive(Insertable, Debug, Clone)]
1347#[diesel(table_name = schema::account_codes)]
1348pub(crate) struct AccountCodeRowInsert {
1349    pub(crate) code_commitment: Vec<u8>,
1350    pub(crate) code: Vec<u8>,
1351}
1352
1353#[derive(Insertable, AsChangeset, Debug, Clone)]
1354#[diesel(table_name = schema::accounts)]
1355pub(crate) struct AccountRowInsert {
1356    pub(crate) account_id: Vec<u8>,
1357    pub(crate) network_account_type: i32,
1358    pub(crate) block_num: i64,
1359    pub(crate) account_commitment: Vec<u8>,
1360    pub(crate) code_commitment: Option<Vec<u8>>,
1361    pub(crate) nonce: Option<i64>,
1362    pub(crate) storage_header: Option<Vec<u8>>,
1363    pub(crate) vault_root: Option<Vec<u8>>,
1364    pub(crate) is_latest: bool,
1365    pub(crate) created_at_block: i64,
1366}
1367
1368impl AccountRowInsert {
1369    /// Creates an insert row for a private account (no public state).
1370    pub(crate) fn new_private(
1371        account_id: AccountId,
1372        network_account_type: NetworkAccountType,
1373        account_commitment: Word,
1374        block_num: BlockNumber,
1375        created_at_block: BlockNumber,
1376    ) -> Self {
1377        Self {
1378            account_id: account_id.to_bytes(),
1379            network_account_type: network_account_type.to_raw_sql(),
1380            account_commitment: account_commitment.to_bytes(),
1381            block_num: block_num.to_raw_sql(),
1382            nonce: None,
1383            code_commitment: None,
1384            storage_header: None,
1385            vault_root: None,
1386            is_latest: true,
1387            created_at_block: created_at_block.to_raw_sql(),
1388        }
1389    }
1390
1391    /// Creates an insert row from a full account (new account creation).
1392    fn new_from_account(
1393        account_id: AccountId,
1394        network_account_type: NetworkAccountType,
1395        account_commitment: Word,
1396        block_num: BlockNumber,
1397        created_at_block: BlockNumber,
1398        account: &Account,
1399    ) -> Self {
1400        Self {
1401            account_id: account_id.to_bytes(),
1402            network_account_type: network_account_type.to_raw_sql(),
1403            account_commitment: account_commitment.to_bytes(),
1404            block_num: block_num.to_raw_sql(),
1405            nonce: Some(nonce_to_raw_sql(account.nonce())),
1406            code_commitment: Some(account.code().commitment().to_bytes()),
1407            storage_header: Some(account.storage().to_header().to_bytes()),
1408            vault_root: Some(account.vault().root().to_bytes()),
1409            is_latest: true,
1410            created_at_block: created_at_block.to_raw_sql(),
1411        }
1412    }
1413
1414    /// Creates an insert row from a partial account state (delta update).
1415    fn new_from_partial(
1416        account_id: AccountId,
1417        network_account_type: NetworkAccountType,
1418        account_commitment: Word,
1419        block_num: BlockNumber,
1420        created_at_block: BlockNumber,
1421        state: &PartialAccountState,
1422    ) -> Self {
1423        Self {
1424            account_id: account_id.to_bytes(),
1425            network_account_type: network_account_type.to_raw_sql(),
1426            account_commitment: account_commitment.to_bytes(),
1427            block_num: block_num.to_raw_sql(),
1428            nonce: Some(nonce_to_raw_sql(state.nonce)),
1429            code_commitment: Some(state.code_commitment.to_bytes()),
1430            storage_header: Some(state.storage_header.to_bytes()),
1431            vault_root: Some(state.vault_root.to_bytes()),
1432            is_latest: true,
1433            created_at_block: created_at_block.to_raw_sql(),
1434        }
1435    }
1436}
1437
1438#[derive(Insertable, AsChangeset, Debug, Clone)]
1439#[diesel(table_name = schema::account_vault_assets)]
1440pub(crate) struct AccountAssetRowInsert {
1441    pub(crate) account_id: Vec<u8>,
1442    pub(crate) block_num: i64,
1443    pub(crate) vault_key: Vec<u8>,
1444    pub(crate) asset: Option<Vec<u8>>,
1445    pub(crate) is_latest: bool,
1446}
1447
1448impl AccountAssetRowInsert {
1449    pub(crate) fn new(
1450        account_id: &AccountId,
1451        vault_key: &AssetVaultKey,
1452        block_num: BlockNumber,
1453        asset: Option<Asset>,
1454        is_latest: bool,
1455    ) -> Self {
1456        let account_id = account_id.to_bytes();
1457        let vault_key: Word = (*vault_key).into();
1458        let vault_key = vault_key.to_bytes();
1459        let block_num = block_num.to_raw_sql();
1460        let asset = asset.map(|asset| asset.to_bytes());
1461        Self {
1462            account_id,
1463            block_num,
1464            vault_key,
1465            asset,
1466            is_latest,
1467        }
1468    }
1469}
1470
1471#[derive(Insertable, AsChangeset, Debug, Clone)]
1472#[diesel(table_name = schema::account_storage_map_values)]
1473pub(crate) struct AccountStorageMapRowInsert {
1474    pub(crate) account_id: Vec<u8>,
1475    pub(crate) block_num: i64,
1476    pub(crate) slot_name: String,
1477    pub(crate) key: Vec<u8>,
1478    pub(crate) value: Vec<u8>,
1479    pub(crate) is_latest: bool,
1480}
1481
1482// CLEANUP FUNCTIONS
1483// ================================================================================================
1484
1485/// Number of historical blocks to retain for vault assets, storage map values, and account codes.
1486/// Entries older than `chain_tip - HISTORICAL_BLOCK_RETENTION` will be deleted, except for entries
1487/// marked with `is_latest=true` which are always retained.
1488pub const HISTORICAL_BLOCK_RETENTION: u32 = 50;
1489
1490/// Clean up old entries for all accounts, deleting entries older than the retention window.
1491///
1492/// Deletes rows where `block_num < chain_tip - HISTORICAL_BLOCK_RETENTION` and `is_latest = false`
1493/// for vault assets and storage map values. Also deletes account codes that are no longer
1494/// referenced by any account row within the retention window.
1495///
1496/// # Returns
1497/// A tuple of `(vault_assets_deleted, storage_map_values_deleted, account_codes_deleted)`
1498#[miden_instrument(
1499    target = COMPONENT,
1500    skip_all,
1501    err,
1502    fields(
1503        cutoff_block,
1504    ),
1505)]
1506pub(crate) fn prune_history(
1507    conn: &mut SqliteConnection,
1508    chain_tip: BlockNumber,
1509) -> Result<(usize, usize, usize), DatabaseError> {
1510    let cutoff_block = i64::from(chain_tip.as_u32().saturating_sub(HISTORICAL_BLOCK_RETENTION));
1511    tracing::Span::current().record("cutoff_block", cutoff_block);
1512    let vault_deleted = prune_account_vault_assets(conn, cutoff_block)?;
1513    let storage_deleted = prune_account_storage_map_values(conn, cutoff_block)?;
1514    let codes_deleted = prune_account_codes(conn, cutoff_block)?;
1515
1516    Ok((vault_deleted, storage_deleted, codes_deleted))
1517}
1518
1519#[miden_instrument(
1520    target = COMPONENT,
1521    skip_all,
1522    err,
1523    fields(
1524        cutoff_block,
1525    ),
1526)]
1527fn prune_account_vault_assets(
1528    conn: &mut SqliteConnection,
1529    cutoff_block: i64,
1530) -> Result<usize, DatabaseError> {
1531    diesel::delete(
1532        schema::account_vault_assets::table.filter(
1533            schema::account_vault_assets::block_num
1534                .lt(cutoff_block)
1535                .and(schema::account_vault_assets::is_latest.eq(false)),
1536        ),
1537    )
1538    .execute(conn)
1539    .map_err(DatabaseError::Diesel)
1540}
1541
1542#[miden_instrument(
1543    target = COMPONENT,
1544    skip_all,
1545    err,
1546    fields(
1547        cutoff_block,
1548    ),
1549)]
1550fn prune_account_storage_map_values(
1551    conn: &mut SqliteConnection,
1552    cutoff_block: i64,
1553) -> Result<usize, DatabaseError> {
1554    diesel::delete(
1555        schema::account_storage_map_values::table.filter(
1556            schema::account_storage_map_values::block_num
1557                .lt(cutoff_block)
1558                .and(schema::account_storage_map_values::is_latest.eq(false)),
1559        ),
1560    )
1561    .execute(conn)
1562    .map_err(DatabaseError::Diesel)
1563}
1564
1565/// Deletes account codes that are no longer referenced by any account row within the retention
1566/// window.
1567///
1568/// An account code is safe to delete when no `accounts` row with `block_num >= cutoff_block`
1569/// references its `code_commitment`. This covers both active accounts (`is_latest=true`) and
1570/// recent historical rows that still fall within the retention window.
1571///
1572/// The `UNION ALL` shape and explicit index selections avoid SQLite choosing
1573/// `idx_accounts_code_commitment` for the whole predicate, which is expensive when the account
1574/// history table has millions of public rows.
1575#[miden_instrument(
1576    target = COMPONENT,
1577    skip_all,
1578    err,
1579    fields(
1580        cutoff_block,
1581    ),
1582)]
1583fn prune_account_codes(
1584    conn: &mut SqliteConnection,
1585    cutoff_block: i64,
1586) -> Result<usize, DatabaseError> {
1587    use diesel::sql_types::BigInt;
1588
1589    diesel::sql_query(
1590        "DELETE FROM account_codes \
1591         WHERE code_commitment NOT IN ( \
1592             SELECT DISTINCT code_commitment \
1593             FROM ( \
1594                 SELECT code_commitment \
1595                 FROM accounts INDEXED BY idx_accounts_prune_code \
1596                 WHERE code_commitment IS NOT NULL \
1597                   AND block_num >= ?1 \
1598                 UNION ALL \
1599                 SELECT code_commitment \
1600                 FROM accounts INDEXED BY idx_accounts_latest_code_commitment \
1601                 WHERE code_commitment IS NOT NULL \
1602                   AND is_latest = 1 \
1603             ) \
1604         )",
1605    )
1606    .bind::<BigInt, _>(cutoff_block)
1607    .execute(conn)
1608    .map_err(DatabaseError::Diesel)
1609}