Skip to main content

miden_node_store/db/models/queries/
accounts.rs

1use std::collections::{BTreeMap, HashMap, HashSet};
2use std::num::NonZeroUsize;
3use std::ops::RangeInclusive;
4
5use diesel::prelude::{Queryable, QueryableByName};
6use diesel::query_dsl::methods::SelectDsl;
7use diesel::sqlite::Sqlite;
8use diesel::{
9    AsChangeset,
10    BoolExpressionMethods,
11    ExpressionMethods,
12    Insertable,
13    JoinOnDsl,
14    NullableExpressionMethods,
15    OptionalExtension,
16    QueryDsl,
17    RunQueryDsl,
18    Selectable,
19    SelectableHelper,
20    SqliteConnection,
21};
22use miden_node_proto::domain::account::{AccountInfo, AccountSummary, AccountVaultDetails};
23use miden_node_utils::limiter::{
24    MAX_RESPONSE_PAYLOAD_BYTES,
25    QueryParamAccountIdLimit,
26    QueryParamLimiter,
27};
28use miden_node_utils::tracing::miden_instrument;
29use miden_protocol::Word;
30use miden_protocol::account::{
31    Account,
32    AccountCode,
33    AccountId,
34    AccountPatch,
35    AccountStorage,
36    AccountStorageHeader,
37    AccountUpdateDetails,
38    StorageMap,
39    StorageMapKey,
40    StorageMapPatchEntries,
41    StorageSlot,
42    StorageSlotContent,
43    StorageSlotName,
44    StorageSlotType,
45};
46use miden_protocol::asset::{Asset, AssetId, AssetVault};
47use miden_protocol::block::{BlockAccountUpdate, BlockNumber};
48use miden_protocol::utils::serde::{Deserializable, Serializable};
49use miden_standards::account::auth::NetworkAccount;
50
51use crate::COMPONENT;
52use crate::db::models::conv::{SqlTypeConvert, nonce_to_raw_sql, raw_sql_to_nonce};
53#[cfg(test)]
54use crate::db::models::vec_raw_try_into;
55use crate::db::{AccountVaultValue, schema};
56use crate::errors::DatabaseError;
57
58mod at_block;
59pub(crate) use at_block::select_account_header_with_storage_header_at_block;
60
61mod delta;
62use delta::{
63    AccountStateForInsert,
64    PartialAccountState,
65    apply_storage_patch,
66    select_latest_vault_assets,
67    select_minimal_account_state_headers,
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/// Query vault assets at a specific block by finding the most recent update for each `vault_key`.
560///
561/// Uses a single raw SQL query with a subquery join:
562/// ```sql
563/// SELECT a.asset FROM account_vault_assets a
564/// INNER JOIN (
565///     SELECT vault_key, MAX(block_num) as max_block
566///     FROM account_vault_assets
567///     WHERE account_id = ? AND block_num <= ?
568///     GROUP BY vault_key
569/// ) latest ON a.vault_key = latest.vault_key AND a.block_num = latest.max_block
570/// WHERE a.account_id = ?
571/// LIMIT ?
572/// ```
573///
574/// The read is bounded to [`AccountVaultDetails::MAX_RETURN_ENTRIES`] + 1 rows so an over-the-limit
575/// vault can be detected without materializing the whole set.
576pub(crate) fn select_account_vault_at_block(
577    conn: &mut SqliteConnection,
578    account_id: AccountId,
579    block_num: BlockNumber,
580) -> Result<Vec<Asset>, DatabaseError> {
581    use diesel::sql_types::{BigInt, Binary};
582
583    let account_id_bytes = account_id.to_bytes();
584    let block_num_sql = block_num.to_raw_sql();
585    let limit_sql =
586        i64::try_from(AccountVaultDetails::MAX_RETURN_ENTRIES + 1).expect("should fit within i64");
587
588    let entries: Vec<Option<Vec<u8>>> = diesel::sql_query(
589        r"
590        SELECT a.asset FROM account_vault_assets a
591        INNER JOIN (
592            SELECT vault_key, MAX(block_num) as max_block
593            FROM account_vault_assets
594            WHERE account_id = ? AND block_num <= ?
595            GROUP BY vault_key
596        ) latest ON a.vault_key = latest.vault_key AND a.block_num = latest.max_block
597        WHERE a.account_id = ?
598        LIMIT ?
599        ",
600    )
601    .bind::<Binary, _>(&account_id_bytes)
602    .bind::<BigInt, _>(block_num_sql)
603    .bind::<Binary, _>(&account_id_bytes)
604    .bind::<BigInt, _>(limit_sql)
605    .load::<AssetRow>(conn)?
606    .into_iter()
607    .map(|row| row.asset)
608    .collect();
609
610    // Convert to assets, filtering out deletions (None values)
611    let mut assets = Vec::new();
612    for asset_bytes in entries.into_iter().flatten() {
613        let asset = Asset::read_from_bytes(&asset_bytes)?;
614        assets.push(asset);
615    }
616
617    Ok(assets)
618}
619
620#[derive(QueryableByName)]
621struct AssetRow {
622    #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Binary>)]
623    asset: Option<Vec<u8>>,
624}
625
626/// Select all accounts from the DB using the given [`SqliteConnection`].
627///
628/// # Returns
629///
630/// A vector with accounts, or an error.
631///
632/// # Raw SQL
633///
634/// ```sql
635/// SELECT
636///     accounts.account_id,
637///     accounts.account_commitment,
638///     accounts.block_num
639/// FROM
640///     accounts
641/// WHERE
642///     is_latest = 1
643/// ORDER BY
644///     block_num ASC
645/// ```
646#[cfg(test)]
647pub(crate) fn select_all_accounts(
648    conn: &mut SqliteConnection,
649) -> Result<Vec<AccountInfo>, DatabaseError> {
650    let raw = SelectDsl::select(schema::accounts::table, AccountSummaryRaw::as_select())
651        .filter(schema::accounts::is_latest.eq(true))
652        .order_by(schema::accounts::block_num.asc())
653        .load::<AccountSummaryRaw>(conn)?;
654
655    let summaries: Vec<AccountSummary> = vec_raw_try_into(raw)?;
656
657    // Backfill account details from database
658    let account_infos = summaries
659        .into_iter()
660        .map(|summary| {
661            let account_id = summary.account_id;
662            let details = select_full_account(conn, account_id).ok();
663            AccountInfo { summary, details }
664        })
665        .collect();
666
667    Ok(account_infos)
668}
669
670#[derive(Debug, Clone, PartialEq, Eq)]
671pub struct StorageMapValue {
672    pub block_num: BlockNumber,
673    pub slot_name: StorageSlotName,
674    pub key: StorageMapKey,
675    pub value: Word,
676}
677
678#[derive(Debug, Clone, PartialEq, Eq)]
679pub struct StorageMapValuesPage {
680    /// Highest block number included in `rows`. If the page is empty, this will be `block_from`.
681    pub last_block_included: BlockNumber,
682    /// Storage map values
683    pub values: Vec<StorageMapValue>,
684}
685
686impl StorageMapValue {
687    pub fn from_raw_row(row: StorageMapValueRow) -> Result<Self, DatabaseError> {
688        let (block_num, slot_name, key, value) = row;
689        Ok(Self {
690            block_num: BlockNumber::from_raw_sql(block_num)?,
691            slot_name: StorageSlotName::from_raw_sql(slot_name)?,
692            key: StorageMapKey::read_from_bytes(&key)?,
693            value: Word::read_from_bytes(&value)?,
694        })
695    }
696}
697
698/// Select account storage map values from the DB using the given [`SqliteConnection`].
699///
700/// # Returns
701///
702/// A vector of tuples containing `(slot, key, value, is_latest)` for the given account.
703/// Each row contains one of:
704///
705/// - the historical value for a slot and key specifically on block `block_to`
706/// - the latest updated value for the slot and key combination, alongside the block number in which
707///   it was updated
708///
709/// # Raw SQL
710///
711/// ```sql
712/// SELECT
713///     block_num,
714///     slot,
715///     key,
716///     value
717/// FROM
718///     account_storage_map_values
719/// WHERE
720///     account_id = ?1
721///     AND block_num >= ?2
722///     AND block_num <= ?3
723/// ORDER BY
724///     block_num ASC
725/// LIMIT
726///     ?4
727/// ```
728/// Select account storage map values within a block range (inclusive).
729///
730/// ## Parameters
731///
732/// * `account_id`: Account ID to query
733/// * `block_range`: Range of block numbers (inclusive)
734///
735/// ## Response
736///
737/// * Response payload size: 0 <= size <= 2MB
738/// * Storage map values per response: 0 <= count <= (2MB / (2*Word + u32 + u8)) + 1
739pub(crate) fn select_account_storage_map_values_paged(
740    conn: &mut SqliteConnection,
741    account_id: AccountId,
742    block_range: RangeInclusive<BlockNumber>,
743    limit: usize,
744) -> Result<StorageMapValuesPage, DatabaseError> {
745    use schema::account_storage_map_values as t;
746
747    if !account_id.is_public() {
748        return Err(DatabaseError::AccountNotPublic(account_id));
749    }
750
751    if block_range.is_empty() {
752        return Err(DatabaseError::InvalidBlockRange {
753            from: *block_range.start(),
754            to: *block_range.end(),
755        });
756    }
757
758    let raw: Vec<StorageMapValueRow> =
759        SelectDsl::select(t::table, (t::block_num, t::slot_name, t::key, t::value))
760            .filter(
761                t::account_id
762                    .eq(account_id.to_bytes())
763                    .and(t::block_num.ge(block_range.start().to_raw_sql()))
764                    .and(t::block_num.le(block_range.end().to_raw_sql())),
765            )
766            .order(t::block_num.asc())
767            .limit(i64::try_from(limit + 1).expect("limit fits within i64"))
768            .load(conn)?;
769
770    // If we got more rows than the limit, the last block may be incomplete so we drop it entirely
771    // and derive last_block_included from the remaining rows.
772    let (last_block_included, values) = if let Some(&(last_block_num, ..)) = raw.last()
773        && raw.len() > limit
774    {
775        let values = raw
776            .into_iter()
777            .take_while(|(bn, ..)| *bn != last_block_num)
778            .map(StorageMapValue::from_raw_row)
779            .collect::<Result<Vec<_>, DatabaseError>>()?;
780
781        let last_block_included = values.last().map_or(*block_range.start(), |v| v.block_num);
782
783        (last_block_included, values)
784    } else {
785        (
786            *block_range.end(),
787            raw.into_iter()
788                .map(StorageMapValue::from_raw_row)
789                .collect::<Result<Vec<_>, _>>()?,
790        )
791    };
792
793    Ok(StorageMapValuesPage { last_block_included, values })
794}
795
796/// Select latest account storage by querying `accounts.storage_header` where `is_latest=true`
797/// and reconstructing full storage from the header plus map values from
798/// `account_storage_map_values`.
799///
800/// Attention: For large accounts it is prohibitively expensive!
801pub(crate) fn select_latest_account_storage(
802    conn: &mut SqliteConnection,
803    account_id: AccountId,
804) -> Result<AccountStorage, DatabaseError> {
805    let (storage_header, map_entries_by_slot) =
806        select_latest_account_storage_components(conn, account_id)?;
807    // Reconstruct StorageSlots from header slots + map entries
808    let slots =
809        Result::<Vec<_>, DatabaseError>::from_iter(storage_header.slots().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
826    Ok(AccountStorage::new(slots)?)
827}
828
829/// Fetch account storage header and all storage maps
830pub(crate) fn select_latest_account_storage_components(
831    conn: &mut SqliteConnection,
832    account_id: AccountId,
833) -> Result<StorageHeaderWithEntries, DatabaseError> {
834    let account_id_bytes = account_id.to_bytes();
835
836    // Query storage header blob for this account where is_latest = true
837    let storage_blob: Option<Vec<u8>> =
838        SelectDsl::select(schema::accounts::table, schema::accounts::storage_header)
839            .filter(schema::accounts::account_id.eq(&account_id_bytes))
840            .filter(schema::accounts::is_latest.eq(true))
841            .first(conn)
842            .optional()?
843            .flatten();
844
845    let header = match storage_blob {
846        Some(blob) => AccountStorageHeader::read_from_bytes(&blob)?,
847        None => AccountStorageHeader::new(Vec::new())?,
848    };
849
850    let entries = select_latest_storage_map_entries_all(conn, &account_id)?;
851    Ok((header, entries))
852}
853
854// TODO this is expensive and should only be called from tests
855fn select_latest_storage_map_entries_all(
856    conn: &mut SqliteConnection,
857    account_id: &AccountId,
858) -> Result<HashMap<StorageSlotName, BTreeMap<StorageMapKey, Word>>, DatabaseError> {
859    use schema::account_storage_map_values as t;
860
861    let map_values: Vec<(String, Vec<u8>, Vec<u8>)> =
862        SelectDsl::select(t::table, (t::slot_name, t::key, t::value))
863            .filter(t::account_id.eq(&account_id.to_bytes()))
864            .filter(t::is_latest.eq(true))
865            .load(conn)?;
866
867    group_storage_map_entries(map_values)
868}
869
870fn select_latest_storage_map_entries_for_slots(
871    conn: &mut SqliteConnection,
872    account_id: &AccountId,
873    slot_names: &[StorageSlotName],
874) -> Result<HashMap<StorageSlotName, BTreeMap<StorageMapKey, Word>>, DatabaseError> {
875    use schema::account_storage_map_values as t;
876
877    if slot_names.is_empty() {
878        return Ok(HashMap::new());
879    }
880
881    if let [slot_name] = slot_names {
882        let entries = select_latest_storage_map_entries_for_slot(conn, account_id, slot_name)?;
883        if entries.is_empty() {
884            return Ok(HashMap::new());
885        }
886
887        let mut map_entries = HashMap::new();
888        map_entries.insert(slot_name.clone(), entries);
889        return Ok(map_entries);
890    }
891
892    let slot_names = Vec::from_iter(slot_names.iter().cloned().map(StorageSlotName::to_raw_sql));
893    let map_values: Vec<(String, Vec<u8>, Vec<u8>)> =
894        SelectDsl::select(t::table, (t::slot_name, t::key, t::value))
895            .filter(t::account_id.eq(&account_id.to_bytes()))
896            .filter(t::is_latest.eq(true))
897            .filter(t::slot_name.eq_any(slot_names))
898            .load(conn)?;
899
900    group_storage_map_entries(map_values)
901}
902
903fn select_latest_storage_map_entries_for_slot(
904    conn: &mut SqliteConnection,
905    account_id: &AccountId,
906    slot_name: &StorageSlotName,
907) -> Result<BTreeMap<StorageMapKey, Word>, DatabaseError> {
908    use schema::account_storage_map_values as t;
909
910    let map_values: Vec<(String, Vec<u8>, Vec<u8>)> =
911        SelectDsl::select(t::table, (t::slot_name, t::key, t::value))
912            .filter(t::account_id.eq(&account_id.to_bytes()))
913            .filter(t::is_latest.eq(true))
914            .filter(t::slot_name.eq(slot_name.clone().to_raw_sql()))
915            .load(conn)?;
916
917    Ok(group_storage_map_entries(map_values)?.remove(slot_name).unwrap_or_default())
918}
919
920fn group_storage_map_entries(
921    map_values: Vec<(String, Vec<u8>, Vec<u8>)>,
922) -> Result<HashMap<StorageSlotName, BTreeMap<StorageMapKey, Word>>, DatabaseError> {
923    let mut map_entries_by_slot: HashMap<StorageSlotName, BTreeMap<StorageMapKey, Word>> =
924        HashMap::new();
925    for (slot_name_str, key_bytes, value_bytes) in map_values {
926        let slot_name: StorageSlotName = slot_name_str.parse().map_err(|_| {
927            DatabaseError::DataCorrupted(format!("Invalid slot name: {slot_name_str}"))
928        })?;
929        let key = StorageMapKey::read_from_bytes(&key_bytes)?;
930        let value = Word::read_from_bytes(&value_bytes)?;
931        map_entries_by_slot.entry(slot_name).or_default().insert(key, value);
932    }
933
934    Ok(map_entries_by_slot)
935}
936
937// ACCOUNT MUTATION
938// ================================================================================================
939
940#[derive(Queryable, Selectable)]
941#[diesel(table_name = crate::db::schema::account_vault_assets)]
942#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
943pub struct AccountVaultUpdateRaw {
944    pub vault_key: Vec<u8>,
945    pub asset: Option<Vec<u8>>,
946    pub block_num: i64,
947}
948
949impl TryFrom<AccountVaultUpdateRaw> for AccountVaultValue {
950    type Error = DatabaseError;
951
952    fn try_from(raw: AccountVaultUpdateRaw) -> Result<Self, Self::Error> {
953        let vault_key = AssetId::try_from(Word::read_from_bytes(&raw.vault_key)?)?;
954        let asset = raw.asset.map(|bytes| Asset::read_from_bytes(&bytes)).transpose()?;
955        let block_num = BlockNumber::from_raw_sql(raw.block_num)?;
956
957        Ok(AccountVaultValue { block_num, vault_key, asset })
958    }
959}
960
961#[derive(Debug, Clone, PartialEq, Eq, Selectable, Queryable, QueryableByName)]
962#[diesel(table_name = schema::accounts)]
963#[diesel(check_for_backend(Sqlite))]
964pub struct AccountSummaryRaw {
965    account_id: Vec<u8>,         // AccountId,
966    account_commitment: Vec<u8>, //RpoDigest,
967    block_num: i64,              //BlockNumber,
968}
969
970impl TryInto<AccountSummary> for AccountSummaryRaw {
971    type Error = DatabaseError;
972    fn try_into(self) -> Result<AccountSummary, Self::Error> {
973        let account_id = AccountId::read_from_bytes(&self.account_id[..])?;
974        let account_commitment = Word::read_from_bytes(&self.account_commitment[..])?;
975        let block_num = BlockNumber::from_raw_sql(self.block_num)?;
976
977        Ok(AccountSummary {
978            account_id,
979            account_commitment,
980            block_num,
981        })
982    }
983}
984
985/// Insert an account vault asset row into the DB using the given [`SqliteConnection`].
986///
987/// Sets `is_latest=true` for the new row and updates any existing
988/// row with the same `(account_id, vault_key)` tuple to `is_latest=false`.
989///
990/// # Returns
991///
992/// The number of affected rows.
993pub(crate) fn insert_account_vault_asset(
994    conn: &mut SqliteConnection,
995    account_id: AccountId,
996    block_num: BlockNumber,
997    vault_key: AssetId,
998    asset: Option<Asset>,
999) -> Result<usize, DatabaseError> {
1000    let record = AccountAssetRowInsert::new(&account_id, &vault_key, block_num, asset, true);
1001
1002    diesel::Connection::transaction(conn, |conn| {
1003        // First, update any existing rows with the same (account_id, vault_key) to set
1004        // is_latest=false
1005        let vault_key: Word = vault_key.into();
1006        let vault_key_bytes = vault_key.to_bytes();
1007        let account_id_bytes = account_id.to_bytes();
1008        let update_count = diesel::update(schema::account_vault_assets::table)
1009            .filter(
1010                schema::account_vault_assets::account_id
1011                    .eq(account_id_bytes)
1012                    .and(schema::account_vault_assets::vault_key.eq(vault_key_bytes))
1013                    .and(schema::account_vault_assets::is_latest.eq(true)),
1014            )
1015            .set(schema::account_vault_assets::is_latest.eq(false))
1016            .execute(conn)?;
1017
1018        // Insert the new latest row
1019        let insert_count = diesel::insert_into(schema::account_vault_assets::table)
1020            .values(record)
1021            .execute(conn)?;
1022
1023        Ok(update_count + insert_count)
1024    })
1025}
1026
1027/// Insert an account storage map value into the DB using the given [`SqliteConnection`].
1028///
1029/// Sets `is_latest=true` for the new row and updates any existing
1030/// row with the same `(account_id, slot_index, key)` tuple to `is_latest=false`.
1031///
1032/// # Returns
1033///
1034/// The number of affected rows.
1035pub(crate) fn insert_account_storage_map_value(
1036    conn: &mut SqliteConnection,
1037    account_id: AccountId,
1038    block_num: BlockNumber,
1039    slot_name: StorageSlotName,
1040    key: StorageMapKey,
1041    value: Word,
1042) -> Result<usize, DatabaseError> {
1043    let account_id = account_id.to_bytes();
1044    let key = key.to_bytes();
1045    let value = value.to_bytes();
1046    let slot_name = slot_name.to_raw_sql();
1047    let block_num = block_num.to_raw_sql();
1048
1049    let update_count = diesel::update(schema::account_storage_map_values::table)
1050        .filter(
1051            schema::account_storage_map_values::account_id
1052                .eq(&account_id)
1053                .and(schema::account_storage_map_values::slot_name.eq(&slot_name))
1054                .and(schema::account_storage_map_values::key.eq(&key))
1055                .and(schema::account_storage_map_values::is_latest.eq(true)),
1056        )
1057        .set(schema::account_storage_map_values::is_latest.eq(false))
1058        .execute(conn)?;
1059
1060    let record = AccountStorageMapRowInsert {
1061        account_id,
1062        key,
1063        value,
1064        slot_name,
1065        block_num,
1066        is_latest: true,
1067    };
1068    let insert_count = diesel::insert_into(schema::account_storage_map_values::table)
1069        .values(record)
1070        .execute(conn)?;
1071
1072    Ok(update_count + insert_count)
1073}
1074
1075type PendingStorageInserts = Vec<(AccountId, StorageSlotName, StorageMapKey, Word)>;
1076type PendingAssetInserts = Vec<(AccountId, AssetId, Option<Asset>)>;
1077
1078fn prepare_full_account_update(
1079    update: &BlockAccountUpdate,
1080    account: Account,
1081) -> Result<(AccountStateForInsert, PendingStorageInserts, PendingAssetInserts), DatabaseError> {
1082    let account_id = account.id();
1083
1084    // sanity check the commitment of account matches the final state commitment
1085    if account.to_commitment() != update.final_state_commitment() {
1086        return Err(DatabaseError::AccountCommitmentsMismatch {
1087            calculated: account.to_commitment(),
1088            expected: update.final_state_commitment(),
1089        });
1090    }
1091
1092    // collect storage-map inserts to apply after account upsert
1093    let mut storage = Vec::new();
1094    for slot in account.storage().slots() {
1095        if let StorageSlotContent::Map(storage_map) = slot.content() {
1096            for (key, value) in storage_map.entries() {
1097                storage.push((account_id, slot.name().clone(), *key, *value));
1098            }
1099        }
1100    }
1101
1102    // collect vault-asset inserts to apply after account upsert
1103    let mut assets = Vec::new();
1104    for asset in account.vault().assets() {
1105        // Only insert assets with non-zero values for fungible assets
1106        let should_insert = match asset {
1107            Asset::Fungible(fungible) => fungible.amount().as_u64() > 0,
1108            Asset::NonFungible(_) => true,
1109        };
1110        if should_insert {
1111            assets.push((account_id, asset.id(), Some(asset)));
1112        }
1113    }
1114
1115    Ok((AccountStateForInsert::FullAccount(account), storage, assets))
1116}
1117
1118/// Prepare partial patch data for account upserts and follow-up storage and vault inserts.
1119fn prepare_partial_account_update(
1120    conn: &mut SqliteConnection,
1121    update: &BlockAccountUpdate,
1122    account_id: AccountId,
1123    patch: &AccountPatch,
1124) -> Result<(AccountStateForInsert, PendingStorageInserts, PendingAssetInserts), DatabaseError> {
1125    // Build the minimal account state needed for partial patch application. Only load the storage
1126    // map entries that will receive updates. The next line fetches the header, which will always
1127    // change unless the patch is empty.
1128    let state_headers = select_minimal_account_state_headers(conn, account_id)?;
1129
1130    // --- Process asset updates. --------------------------------- The patch carries absolute final
1131    // values, so encode `Some` as update and `None` (an empty value word) as removal.
1132    let mut assets = Vec::new();
1133    for (vault_key, value) in patch.vault().iter() {
1134        let update_or_remove = if *value == Word::empty() {
1135            None
1136        } else {
1137            Some(Asset::from_id_and_value(*vault_key, *value)?)
1138        };
1139        assets.push((account_id, *vault_key, update_or_remove));
1140    }
1141
1142    // --- Collect storage map updates. ---------------------------
1143
1144    let mut storage = Vec::new();
1145    for (slot_name, map_patch) in patch.storage().maps() {
1146        for (key, value) in map_patch.entries().into_iter().flat_map(StorageMapPatchEntries::as_map)
1147        {
1148            storage.push((account_id, slot_name.clone(), *key, *value));
1149        }
1150    }
1151
1152    // First collect entries that have associated changes.
1153    let slot_names = Vec::from_iter(patch.storage().maps().filter_map(|(slot_name, map_patch)| {
1154        if map_patch.entries().is_none_or(StorageMapPatchEntries::is_empty) {
1155            None
1156        } else {
1157            Some(slot_name.clone())
1158        }
1159    }));
1160
1161    let map_entries = select_latest_storage_map_entries_for_slots(conn, &account_id, &slot_names)?;
1162
1163    // Apply the patch storage to the given storage header.
1164    let new_storage_header =
1165        apply_storage_patch(&state_headers.storage_header, patch.storage(), &map_entries)?;
1166
1167    // --- Update the vault root by constructing the asset vault from DB.
1168    let new_vault_root = {
1169        let assets = select_latest_vault_assets(conn, account_id)?;
1170        let mut vault = AssetVault::new(&assets)?;
1171        vault.apply_patch(patch.vault())?;
1172        vault.root()
1173    };
1174
1175    // --- Compute updated account state for the accounts row. --- Use the absolute final nonce.
1176    let new_nonce = patch.final_nonce().unwrap_or(state_headers.nonce);
1177
1178    // Create minimal account state data for the row insert.
1179    let account_state = PartialAccountState {
1180        nonce: new_nonce,
1181        code_commitment: state_headers.code_commitment,
1182        storage_header: new_storage_header,
1183        vault_root: new_vault_root,
1184    };
1185
1186    let account_header = miden_protocol::account::AccountHeader::new(
1187        account_id,
1188        account_state.nonce,
1189        account_state.vault_root,
1190        account_state.storage_header.to_commitment(),
1191        account_state.code_commitment,
1192    );
1193
1194    if account_header.to_commitment() != update.final_state_commitment() {
1195        return Err(DatabaseError::AccountCommitmentsMismatch {
1196            calculated: account_header.to_commitment(),
1197            expected: update.final_state_commitment(),
1198        });
1199    }
1200
1201    Ok((AccountStateForInsert::PartialState(account_state), storage, assets))
1202}
1203
1204/// Returns the subset of `account_ids` whose latest committed state is a network account.
1205///
1206/// Unknown ids and non-network accounts are silently omitted.
1207pub(crate) fn select_network_accounts_subset(
1208    conn: &mut SqliteConnection,
1209    account_ids: &[AccountId],
1210) -> Result<HashSet<AccountId>, DatabaseError> {
1211    QueryParamAccountIdLimit::check(account_ids.len())?;
1212    let id_bytes: Vec<Vec<u8>> =
1213        account_ids.iter().map(miden_crypto::utils::Serializable::to_bytes).collect();
1214
1215    let rows: Vec<Vec<u8>> =
1216        SelectDsl::select(schema::accounts::table, schema::accounts::account_id)
1217            .filter(
1218                schema::accounts::account_id
1219                    .eq_any(&id_bytes)
1220                    .and(
1221                        schema::accounts::network_account_type
1222                            .eq(NetworkAccountType::Network.to_raw_sql()),
1223                    )
1224                    .and(schema::accounts::is_latest.eq(true)),
1225            )
1226            .load::<Vec<u8>>(conn)
1227            .map_err(DatabaseError::Diesel)?;
1228
1229    rows.into_iter()
1230        .map(|bytes| {
1231            AccountId::read_from_bytes(&bytes).map_err(DatabaseError::DeserializationError)
1232        })
1233        .collect()
1234}
1235
1236/// Attention: Assumes the account details are NOT null! The schema explicitly allows this though!
1237#[miden_instrument(
1238    target = COMPONENT,
1239    skip_all,
1240    err,
1241)]
1242pub(crate) fn upsert_accounts(
1243    conn: &mut SqliteConnection,
1244    accounts: &[BlockAccountUpdate],
1245    block_num: BlockNumber,
1246) -> Result<usize, DatabaseError> {
1247    let mut count = 0;
1248    for update in accounts {
1249        let account_id = update.account_id();
1250        let account_id_bytes = account_id.to_bytes();
1251
1252        // Pull the latest row (if any) so we can carry forward `created_at_block` and the
1253        // `network_account_type` classification, both of which are fixed at account creation.
1254        let existing: Option<(i64, i32)> = QueryDsl::select(
1255            schema::accounts::table.filter(
1256                schema::accounts::account_id
1257                    .eq(&account_id_bytes)
1258                    .and(schema::accounts::is_latest.eq(true)),
1259            ),
1260            (schema::accounts::created_at_block, schema::accounts::network_account_type),
1261        )
1262        .first(conn)
1263        .optional()
1264        .map_err(DatabaseError::Diesel)?;
1265
1266        let created_at_block = match existing {
1267            Some((raw, _)) => BlockNumber::from_raw_sql(raw)?,
1268            None => block_num,
1269        };
1270
1271        // NOTE: we collect storage / asset inserts to apply them only after the account row is
1272        // written. The storage and vault tables have FKs pointing to accounts `(account_id,
1273        // block_num)`, so inserting them earlier would violate those constraints when inserting a
1274        // brand-new account.
1275        let (account_state, pending_storage_inserts, pending_asset_inserts) = match update.details()
1276        {
1277            AccountUpdateDetails::Private => (AccountStateForInsert::Private, vec![], vec![]),
1278
1279            // New account is always a full account, but also comes as an update
1280            AccountUpdateDetails::Public(patch) if patch.is_full_state() => {
1281                let account = Account::try_from(patch)
1282                    .expect("Patch to full account always works for full state patches");
1283                debug_assert_eq!(account_id, account.id());
1284
1285                prepare_full_account_update(update, account)?
1286            },
1287
1288            // Update of an existing account
1289            AccountUpdateDetails::Public(patch) => {
1290                prepare_partial_account_update(conn, update, account_id, patch)?
1291            },
1292        };
1293
1294        // Inherit the classification when the account already exists; otherwise classify it once at
1295        // creation based on the new state.
1296        let network_account_type = match existing {
1297            Some((_, raw)) => NetworkAccountType::from_raw_sql(raw)?,
1298            None => match &account_state {
1299                AccountStateForInsert::FullAccount(account)
1300                    if NetworkAccount::new(account.clone()).is_ok() =>
1301                {
1302                    NetworkAccountType::Network
1303                },
1304                _ => NetworkAccountType::None,
1305            },
1306        };
1307
1308        // Insert account _code_ for full accounts (new account creation)
1309        if let AccountStateForInsert::FullAccount(ref account) = account_state {
1310            let code = account.code();
1311            let code_value = AccountCodeRowInsert {
1312                code_commitment: code.commitment().to_bytes(),
1313                code: code.to_bytes(),
1314            };
1315            diesel::insert_into(schema::account_codes::table)
1316                .values(&code_value)
1317                .on_conflict(schema::account_codes::code_commitment)
1318                .do_nothing()
1319                .execute(conn)?;
1320        }
1321
1322        // mark previous rows as non-latest and insert NEW account row
1323        diesel::update(schema::accounts::table)
1324            .filter(
1325                schema::accounts::account_id
1326                    .eq(&account_id_bytes)
1327                    .and(schema::accounts::is_latest.eq(true)),
1328            )
1329            .set(schema::accounts::is_latest.eq(false))
1330            .execute(conn)?;
1331
1332        let account_value = match &account_state {
1333            AccountStateForInsert::Private => AccountRowInsert::new_private(
1334                account_id,
1335                network_account_type,
1336                update.final_state_commitment(),
1337                block_num,
1338                created_at_block,
1339            ),
1340            AccountStateForInsert::FullAccount(account) => AccountRowInsert::new_from_account(
1341                account_id,
1342                network_account_type,
1343                update.final_state_commitment(),
1344                block_num,
1345                created_at_block,
1346                account,
1347            ),
1348            AccountStateForInsert::PartialState(state) => AccountRowInsert::new_from_partial(
1349                account_id,
1350                network_account_type,
1351                update.final_state_commitment(),
1352                block_num,
1353                created_at_block,
1354                state,
1355            ),
1356        };
1357
1358        diesel::insert_into(schema::accounts::table)
1359            .values(&account_value)
1360            .on_conflict((schema::accounts::account_id, schema::accounts::block_num))
1361            .do_update()
1362            .set(&account_value)
1363            .execute(conn)?;
1364
1365        // insert pending storage map entries TODO consider batching
1366        for (acc_id, slot_name, key, value) in pending_storage_inserts {
1367            insert_account_storage_map_value(conn, acc_id, block_num, slot_name, key, value)?;
1368        }
1369
1370        for (acc_id, vault_key, update) in pending_asset_inserts {
1371            insert_account_vault_asset(conn, acc_id, block_num, vault_key, update)?;
1372        }
1373
1374        count += 1;
1375    }
1376
1377    Ok(count)
1378}
1379
1380#[derive(Insertable, Debug, Clone)]
1381#[diesel(table_name = schema::account_codes)]
1382pub(crate) struct AccountCodeRowInsert {
1383    pub(crate) code_commitment: Vec<u8>,
1384    pub(crate) code: Vec<u8>,
1385}
1386
1387#[derive(Insertable, AsChangeset, Debug, Clone)]
1388#[diesel(table_name = schema::accounts)]
1389pub(crate) struct AccountRowInsert {
1390    pub(crate) account_id: Vec<u8>,
1391    pub(crate) network_account_type: i32,
1392    pub(crate) block_num: i64,
1393    pub(crate) account_commitment: Vec<u8>,
1394    pub(crate) code_commitment: Option<Vec<u8>>,
1395    pub(crate) nonce: Option<i64>,
1396    pub(crate) storage_header: Option<Vec<u8>>,
1397    pub(crate) vault_root: Option<Vec<u8>>,
1398    pub(crate) is_latest: bool,
1399    pub(crate) created_at_block: i64,
1400}
1401
1402impl AccountRowInsert {
1403    /// Creates an insert row for a private account (no public state).
1404    pub(crate) fn new_private(
1405        account_id: AccountId,
1406        network_account_type: NetworkAccountType,
1407        account_commitment: Word,
1408        block_num: BlockNumber,
1409        created_at_block: BlockNumber,
1410    ) -> Self {
1411        Self {
1412            account_id: account_id.to_bytes(),
1413            network_account_type: network_account_type.to_raw_sql(),
1414            account_commitment: account_commitment.to_bytes(),
1415            block_num: block_num.to_raw_sql(),
1416            nonce: None,
1417            code_commitment: None,
1418            storage_header: None,
1419            vault_root: None,
1420            is_latest: true,
1421            created_at_block: created_at_block.to_raw_sql(),
1422        }
1423    }
1424
1425    /// Creates an insert row from a full account (new account creation).
1426    fn new_from_account(
1427        account_id: AccountId,
1428        network_account_type: NetworkAccountType,
1429        account_commitment: Word,
1430        block_num: BlockNumber,
1431        created_at_block: BlockNumber,
1432        account: &Account,
1433    ) -> Self {
1434        Self {
1435            account_id: account_id.to_bytes(),
1436            network_account_type: network_account_type.to_raw_sql(),
1437            account_commitment: account_commitment.to_bytes(),
1438            block_num: block_num.to_raw_sql(),
1439            nonce: Some(nonce_to_raw_sql(account.nonce())),
1440            code_commitment: Some(account.code().commitment().to_bytes()),
1441            storage_header: Some(account.storage().to_header().to_bytes()),
1442            vault_root: Some(account.vault().root().to_bytes()),
1443            is_latest: true,
1444            created_at_block: created_at_block.to_raw_sql(),
1445        }
1446    }
1447
1448    /// Creates an insert row from a partial account state (patch update).
1449    fn new_from_partial(
1450        account_id: AccountId,
1451        network_account_type: NetworkAccountType,
1452        account_commitment: Word,
1453        block_num: BlockNumber,
1454        created_at_block: BlockNumber,
1455        state: &PartialAccountState,
1456    ) -> Self {
1457        Self {
1458            account_id: account_id.to_bytes(),
1459            network_account_type: network_account_type.to_raw_sql(),
1460            account_commitment: account_commitment.to_bytes(),
1461            block_num: block_num.to_raw_sql(),
1462            nonce: Some(nonce_to_raw_sql(state.nonce)),
1463            code_commitment: Some(state.code_commitment.to_bytes()),
1464            storage_header: Some(state.storage_header.to_bytes()),
1465            vault_root: Some(state.vault_root.to_bytes()),
1466            is_latest: true,
1467            created_at_block: created_at_block.to_raw_sql(),
1468        }
1469    }
1470}
1471
1472#[derive(Insertable, AsChangeset, Debug, Clone)]
1473#[diesel(table_name = schema::account_vault_assets)]
1474pub(crate) struct AccountAssetRowInsert {
1475    pub(crate) account_id: Vec<u8>,
1476    pub(crate) block_num: i64,
1477    pub(crate) vault_key: Vec<u8>,
1478    pub(crate) asset: Option<Vec<u8>>,
1479    pub(crate) is_latest: bool,
1480}
1481
1482impl AccountAssetRowInsert {
1483    pub(crate) fn new(
1484        account_id: &AccountId,
1485        vault_key: &AssetId,
1486        block_num: BlockNumber,
1487        asset: Option<Asset>,
1488        is_latest: bool,
1489    ) -> Self {
1490        let account_id = account_id.to_bytes();
1491        let vault_key: Word = (*vault_key).into();
1492        let vault_key = vault_key.to_bytes();
1493        let block_num = block_num.to_raw_sql();
1494        let asset = asset.map(|asset| asset.to_bytes());
1495        Self {
1496            account_id,
1497            block_num,
1498            vault_key,
1499            asset,
1500            is_latest,
1501        }
1502    }
1503}
1504
1505#[derive(Insertable, AsChangeset, Debug, Clone)]
1506#[diesel(table_name = schema::account_storage_map_values)]
1507pub(crate) struct AccountStorageMapRowInsert {
1508    pub(crate) account_id: Vec<u8>,
1509    pub(crate) block_num: i64,
1510    pub(crate) slot_name: String,
1511    pub(crate) key: Vec<u8>,
1512    pub(crate) value: Vec<u8>,
1513    pub(crate) is_latest: bool,
1514}
1515
1516// CLEANUP FUNCTIONS
1517// ================================================================================================
1518
1519/// Number of historical blocks to retain for vault assets, storage map values, and account codes.
1520/// Entries older than `chain_tip - HISTORICAL_BLOCK_RETENTION` will be deleted, except for entries
1521/// marked with `is_latest=true` which are always retained.
1522pub const HISTORICAL_BLOCK_RETENTION: u32 = 50;
1523
1524/// Clean up old entries for all accounts, deleting entries older than the retention window.
1525///
1526/// Deletes rows where `block_num < chain_tip - HISTORICAL_BLOCK_RETENTION` and `is_latest = false`
1527/// for vault assets and storage map values. Also deletes account codes that are no longer
1528/// referenced by any account row within the retention window.
1529///
1530/// # Returns
1531/// A tuple of `(vault_assets_deleted, storage_map_values_deleted, account_codes_deleted)`
1532#[miden_instrument(
1533    target = COMPONENT,
1534    skip_all,
1535    err,
1536    fields(
1537        cutoff_block,
1538    ),
1539)]
1540pub(crate) fn prune_history(
1541    conn: &mut SqliteConnection,
1542    chain_tip: BlockNumber,
1543) -> Result<(usize, usize, usize), DatabaseError> {
1544    let cutoff_block = i64::from(chain_tip.as_u32().saturating_sub(HISTORICAL_BLOCK_RETENTION));
1545    tracing::Span::current().record("cutoff_block", cutoff_block);
1546    let vault_deleted = prune_account_vault_assets(conn, cutoff_block)?;
1547    let storage_deleted = prune_account_storage_map_values(conn, cutoff_block)?;
1548    let codes_deleted = prune_account_codes(conn, cutoff_block)?;
1549
1550    Ok((vault_deleted, storage_deleted, codes_deleted))
1551}
1552
1553#[miden_instrument(
1554    target = COMPONENT,
1555    skip_all,
1556    err,
1557    fields(
1558        cutoff_block,
1559    ),
1560)]
1561fn prune_account_vault_assets(
1562    conn: &mut SqliteConnection,
1563    cutoff_block: i64,
1564) -> Result<usize, DatabaseError> {
1565    diesel::delete(
1566        schema::account_vault_assets::table.filter(
1567            schema::account_vault_assets::block_num
1568                .lt(cutoff_block)
1569                .and(schema::account_vault_assets::is_latest.eq(false)),
1570        ),
1571    )
1572    .execute(conn)
1573    .map_err(DatabaseError::Diesel)
1574}
1575
1576#[miden_instrument(
1577    target = COMPONENT,
1578    skip_all,
1579    err,
1580    fields(
1581        cutoff_block,
1582    ),
1583)]
1584fn prune_account_storage_map_values(
1585    conn: &mut SqliteConnection,
1586    cutoff_block: i64,
1587) -> Result<usize, DatabaseError> {
1588    diesel::delete(
1589        schema::account_storage_map_values::table.filter(
1590            schema::account_storage_map_values::block_num
1591                .lt(cutoff_block)
1592                .and(schema::account_storage_map_values::is_latest.eq(false)),
1593        ),
1594    )
1595    .execute(conn)
1596    .map_err(DatabaseError::Diesel)
1597}
1598
1599/// Deletes account codes that are no longer referenced by any account row within the retention
1600/// window.
1601///
1602/// An account code is safe to delete when no `accounts` row with `block_num >= cutoff_block`
1603/// references its `code_commitment`. This covers both active accounts (`is_latest=true`) and
1604/// recent historical rows that still fall within the retention window.
1605///
1606/// The `UNION ALL` shape and explicit index selections avoid SQLite choosing
1607/// `idx_accounts_code_commitment` for the whole predicate, which is expensive when the account
1608/// history table has millions of public rows.
1609#[miden_instrument(
1610    target = COMPONENT,
1611    skip_all,
1612    err,
1613    fields(
1614        cutoff_block,
1615    ),
1616)]
1617fn prune_account_codes(
1618    conn: &mut SqliteConnection,
1619    cutoff_block: i64,
1620) -> Result<usize, DatabaseError> {
1621    use diesel::sql_types::BigInt;
1622
1623    diesel::sql_query(
1624        "DELETE FROM account_codes \
1625         WHERE code_commitment NOT IN ( \
1626             SELECT DISTINCT code_commitment \
1627             FROM ( \
1628                 SELECT code_commitment \
1629                 FROM accounts INDEXED BY idx_accounts_prune_code \
1630                 WHERE code_commitment IS NOT NULL \
1631                   AND block_num >= ?1 \
1632                 UNION ALL \
1633                 SELECT code_commitment \
1634                 FROM accounts INDEXED BY idx_accounts_latest_code_commitment \
1635                 WHERE code_commitment IS NOT NULL \
1636                   AND is_latest = 1 \
1637             ) \
1638         )",
1639    )
1640    .bind::<BigInt, _>(cutoff_block)
1641    .execute(conn)
1642    .map_err(DatabaseError::Diesel)
1643}