Skip to main content

miden_client_sqlite_store/account/
accounts.rs

1//! Account-related database operations.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::string::ToString;
5use std::vec::Vec;
6
7use miden_client::account::{
8    Account,
9    AccountCode,
10    AccountHeader,
11    AccountId,
12    AccountPatch,
13    AccountStorage,
14    Address,
15    PartialAccount,
16    PartialStorage,
17    PartialStorageMap,
18    StorageMapKey,
19    StorageSlotName,
20    StorageSlotType,
21};
22use miden_client::asset::{Asset, AssetVault, AssetWitness};
23use miden_client::store::{
24    AccountRecord,
25    AccountRecordData,
26    AccountStatus,
27    AccountStorageFilter,
28    AccountUpdate,
29    ClientAccountType,
30    StoreError,
31};
32use miden_client::utils::{Deserializable, Serializable};
33use miden_client::{AccountError, Felt, Word};
34use miden_protocol::account::{AccountStorageHeader, StorageMapWitness, StorageSlotHeader};
35use miden_protocol::asset::{AssetId, PartialVault};
36use miden_protocol::crypto::merkle::MerkleError;
37use rusqlite::{Connection, OptionalExtension, Transaction, named_params, params};
38
39use crate::account::rows::{
40    query_account_addresses,
41    query_account_code,
42    query_historical_account_headers,
43    query_latest_account_headers,
44    query_storage_slots,
45    query_storage_values,
46    query_vault_assets,
47};
48use crate::forest::{ScopedAccountForest, SqliteForestBackend, allocate_forest_revision};
49use crate::sql_error::SqlResultExt;
50use crate::{
51    SqliteStore,
52    blob_array,
53    column_value_as_u64,
54    insert_sql,
55    int_array,
56    proto,
57    subst,
58    u64_to_value,
59    with_write_tx,
60};
61
62impl SqliteStore {
63    // READER METHODS
64    // --------------------------------------------------------------------------------------------
65
66    pub(crate) fn get_account_ids(conn: &mut Connection) -> Result<Vec<AccountId>, StoreError> {
67        const QUERY: &str = "SELECT id FROM latest_account_headers";
68
69        conn.prepare_cached(QUERY)
70            .into_store_error()?
71            .query_map([], |row| row.get(0))
72            .expect("no binding parameters used in query")
73            .map(|result| {
74                let id: Vec<u8> = result.into_store_error()?;
75                Ok(AccountId::read_from_bytes(&id)?)
76            })
77            .collect::<Result<Vec<AccountId>, StoreError>>()
78    }
79
80    pub(crate) fn get_account_headers(
81        conn: &mut Connection,
82    ) -> Result<Vec<(AccountHeader, AccountStatus)>, StoreError> {
83        Ok(query_latest_account_headers(conn, "1=1 ORDER BY id", params![])?
84            .into_iter()
85            .map(|(header, status, _)| (header, status))
86            .collect())
87    }
88
89    pub(crate) fn get_account_header(
90        conn: &Connection,
91        account_id: AccountId,
92    ) -> Result<Option<(AccountHeader, AccountStatus)>, StoreError> {
93        Ok(query_latest_account_headers(conn, "id = ?", params![account_id.to_bytes()])?
94            .pop()
95            .map(|(header, status, _)| (header, status)))
96    }
97
98    pub(crate) fn get_account_header_by_commitment(
99        conn: &mut Connection,
100        account_commitment: Word,
101    ) -> Result<Option<AccountHeader>, StoreError> {
102        Ok(query_historical_account_headers(
103            conn,
104            "account_commitment = ?",
105            params![account_commitment.to_bytes()],
106        )?
107        .pop()
108        .map(|(header, _)| header))
109    }
110
111    /// Retrieves a complete account record with full vault and storage data.
112    pub(crate) fn get_account(
113        conn: &mut Connection,
114        account_id: AccountId,
115    ) -> Result<Option<AccountRecord>, StoreError> {
116        let Some((header, status, client_account_type)) =
117            query_latest_account_headers(conn, "id = ?", params![account_id.to_bytes()])?.pop()
118        else {
119            return Ok(None);
120        };
121
122        let assets = query_vault_assets(conn, account_id)?;
123        let vault = AssetVault::new(&assets)?;
124
125        let slots = query_storage_slots(conn, account_id, &AccountStorageFilter::All)?
126            .into_values()
127            .collect();
128
129        let storage = AccountStorage::new(slots)?;
130
131        let Some(account_code) = query_account_code(conn, header.code_commitment())? else {
132            return Ok(None);
133        };
134
135        let account = Account::new_unchecked(
136            header.id(),
137            vault,
138            storage,
139            account_code,
140            header.nonce(),
141            status.seed().copied(),
142        );
143
144        let account_data = AccountRecordData::Full(account);
145        Ok(Some(AccountRecord::new(account_data, status, client_account_type)))
146    }
147
148    /// Retrieves a minimal partial account record with storage and vault witnesses.
149    pub(crate) fn get_minimal_partial_account(
150        conn: &mut Connection,
151        account_id: AccountId,
152    ) -> Result<Option<AccountRecord>, StoreError> {
153        let Some((header, status, client_account_type)) =
154            query_latest_account_headers(conn, "id = ?", params![account_id.to_bytes()])?.pop()
155        else {
156            return Ok(None);
157        };
158
159        // Partial vault retrieval
160        let partial_vault = PartialVault::new(header.vault_root());
161
162        // Partial storage retrieval
163        let mut storage_header = Vec::new();
164        let mut maps = vec![];
165
166        let storage_values = query_storage_values(conn, account_id)?;
167
168        // Storage maps are always minimal here (just roots, no entries). New accounts that need
169        // full storage data are handled by the DataStore layer, which fetches the full account via
170        // `get_account()` when nonce == 0.
171        for (slot_name, (slot_type, value)) in storage_values {
172            storage_header.push(StorageSlotHeader::new(slot_name.clone(), slot_type, value));
173            if slot_type == StorageSlotType::Map {
174                maps.push(PartialStorageMap::new(value));
175            }
176        }
177        storage_header.sort_by_key(StorageSlotHeader::id);
178        let storage_header =
179            AccountStorageHeader::new(storage_header).map_err(StoreError::AccountError)?;
180        let partial_storage =
181            PartialStorage::new(storage_header, maps).map_err(StoreError::AccountError)?;
182
183        let Some(account_code) = query_account_code(conn, header.code_commitment())? else {
184            return Ok(None);
185        };
186
187        let partial_account = PartialAccount::new(
188            header.id(),
189            header.nonce(),
190            account_code,
191            partial_storage,
192            partial_vault,
193            status.seed().copied(),
194        )?;
195        let account_record_data = AccountRecordData::Partial(partial_account);
196        Ok(Some(AccountRecord::new(account_record_data, status, client_account_type)))
197    }
198
199    pub fn get_foreign_account_code(
200        conn: &mut Connection,
201        account_ids: Vec<AccountId>,
202    ) -> Result<BTreeMap<AccountId, AccountCode>, StoreError> {
203        let account_id_list = blob_array(account_ids);
204        const QUERY: &str = "
205            SELECT account_id, code
206            FROM foreign_account_code JOIN account_code ON foreign_account_code.code_commitment = account_code.commitment
207            WHERE account_id IN rarray(?)";
208
209        conn.prepare_cached(QUERY)
210            .into_store_error()?
211            .query_map([account_id_list], |row| Ok((row.get("account_id")?, row.get("code")?)))
212            .into_store_error()?
213            .map(|result| {
214                let (id, code): (Vec<u8>, Vec<u8>) = result.into_store_error()?;
215                Ok((AccountId::read_from_bytes(&id)?, proto::decode_unchecked(&code)?))
216            })
217            .collect::<Result<BTreeMap<AccountId, AccountCode>, _>>()
218    }
219
220    /// Retrieves the full asset vault for a specific account.
221    pub fn get_account_vault(
222        conn: &Connection,
223        account_id: AccountId,
224    ) -> Result<AssetVault, StoreError> {
225        let assets = query_vault_assets(conn, account_id)?;
226        Ok(AssetVault::new(&assets)?)
227    }
228
229    /// Retrieves the full storage for a specific account.
230    pub fn get_account_storage(
231        conn: &Connection,
232        account_id: AccountId,
233        filter: &AccountStorageFilter,
234    ) -> Result<AccountStorage, StoreError> {
235        let slots = query_storage_slots(conn, account_id, filter)?.into_values().collect();
236        Ok(AccountStorage::new(slots)?)
237    }
238
239    /// Fetches a specific asset from the account's vault without the need of loading the entire
240    /// vault. The witness is retrieved from the [`AccountSmtForest`].
241    pub(crate) fn get_account_asset(
242        conn: &mut Connection,
243        account_id: AccountId,
244        asset_id: AssetId,
245    ) -> Result<Option<(Asset, AssetWitness)>, StoreError> {
246        // Begin the transaction first so the header and forest reads share one snapshot.
247        let db_tx = conn.transaction().into_store_error()?;
248        let header = Self::require_latest_account_header(&db_tx, account_id)?;
249        let smt_forest = ScopedAccountForest::new(SqliteForestBackend::new(&db_tx))?;
250
251        match smt_forest.get_asset_and_witness(account_id, header.vault_root(), asset_id) {
252            Ok((asset, witness)) => Ok(Some((asset, witness))),
253            Err(StoreError::VaultKeyNotTracked(..)) => Ok(None),
254            Err(err) => Err(err),
255        }
256    }
257
258    /// Retrieves a specific item from the account's storage map without loading the entire storage.
259    /// The witness is retrieved from the [`AccountSmtForest`].
260    pub(crate) fn get_account_map_item(
261        conn: &mut Connection,
262        account_id: AccountId,
263        slot_name: StorageSlotName,
264        key: StorageMapKey,
265    ) -> Result<(Word, StorageMapWitness), StoreError> {
266        // Begin the transaction first so the slot root and forest reads share one snapshot.
267        let db_tx = conn.transaction().into_store_error()?;
268        let header = Self::require_latest_account_header(&db_tx, account_id)?;
269
270        let mut storage_values = query_storage_values(&db_tx, account_id)?;
271        let (slot_type, map_root) = storage_values
272            .remove(&slot_name)
273            .ok_or(StoreError::AccountStorageRootNotFound(header.storage_commitment()))?;
274        if slot_type != StorageSlotType::Map {
275            return Err(StoreError::AccountError(AccountError::StorageSlotNotMap(slot_name)));
276        }
277
278        let smt_forest = ScopedAccountForest::new(SqliteForestBackend::new(&db_tx))?;
279
280        let witness =
281            smt_forest.get_storage_map_item_witness(account_id, &slot_name, map_root, key)?;
282        let item = witness.get(key).unwrap_or(miden_client::EMPTY_WORD);
283
284        Ok((item, witness))
285    }
286
287    /// Retrieves vault asset witnesses for the given vault keys, including emptiness proofs for
288    /// keys absent from the vault (which the executor needs when an asset is being added).
289    ///
290    /// The witnesses are opened against the account's vault tree in the forest, after verifying
291    /// that its root matches `vault_root` — the committed root the caller expects.
292    pub(crate) fn get_vault_asset_witnesses(
293        conn: &mut Connection,
294        account_id: AccountId,
295        vault_root: Word,
296        asset_ids: BTreeSet<AssetId>,
297    ) -> Result<Vec<AssetWitness>, StoreError> {
298        let db_tx = conn.transaction().into_store_error()?;
299        let smt_forest = ScopedAccountForest::new(SqliteForestBackend::new(&db_tx))?;
300        smt_forest.open_vault_asset_witnesses(account_id, vault_root, asset_ids)
301    }
302
303    pub(crate) fn get_account_addresses(
304        conn: &mut Connection,
305        account_id: AccountId,
306    ) -> Result<Vec<Address>, StoreError> {
307        query_account_addresses(conn, account_id)
308    }
309
310    /// Retrieves the account code for a specific account by ID.
311    pub(crate) fn get_account_code_by_id(
312        conn: &mut Connection,
313        account_id: AccountId,
314    ) -> Result<Option<AccountCode>, StoreError> {
315        let Some((header, ..)) =
316            query_latest_account_headers(conn, "id = ?", params![account_id.to_bytes()])?
317                .into_iter()
318                .next()
319        else {
320            return Ok(None);
321        };
322
323        query_account_code(conn, header.code_commitment())
324    }
325
326    // MUTATOR/WRITER METHODS
327    // --------------------------------------------------------------------------------------------
328
329    pub(crate) fn insert_account(
330        conn: &mut Connection,
331        account: &Account,
332        initial_address: &Address,
333        client_account_type: ClientAccountType,
334    ) -> Result<(), StoreError> {
335        with_write_tx(conn, |tx| {
336            let mut smt_forest = ScopedAccountForest::new(SqliteForestBackend::new(tx))?;
337            Self::insert_account_code(tx, account.code())?;
338
339            let account_id = account.id();
340            Self::insert_storage_slots(tx, account_id, account.storage().slots().iter())?;
341            Self::insert_assets(tx, account_id, account.vault().assets())?;
342            let watched = matches!(client_account_type, ClientAccountType::Watched);
343            Self::insert_new_account_header(tx, &account.into(), account.seed(), watched)?;
344            Self::insert_address_tx(tx, initial_address, account.id())?;
345
346            Self::reconcile_account_forest(
347                tx,
348                &mut smt_forest,
349                account_id,
350                account.vault(),
351                account.storage(),
352            )
353        })
354    }
355
356    pub(crate) fn update_account(
357        conn: &mut Connection,
358        new_account_state: &Account,
359    ) -> Result<(), StoreError> {
360        with_write_tx(conn, |tx| {
361            let mut smt_forest = ScopedAccountForest::new(SqliteForestBackend::new(tx))?;
362            Self::update_account_state(tx, &mut smt_forest, new_account_state)
363        })
364    }
365
366    pub(crate) fn upsert_foreign_account_code(
367        conn: &mut Connection,
368        account_id: AccountId,
369        code: &AccountCode,
370    ) -> Result<(), StoreError> {
371        with_write_tx(conn, |tx| {
372            Self::insert_account_code(tx, code)?;
373
374            const QUERY: &str =
375                insert_sql!(foreign_account_code { account_id, code_commitment } | REPLACE);
376
377            tx.execute(QUERY, params![account_id.to_bytes(), code.commitment().to_bytes()])
378                .into_store_error()?;
379
380            Ok(())
381        })
382    }
383
384    pub(crate) fn insert_address(
385        conn: &mut Connection,
386        address: &Address,
387        account_id: AccountId,
388    ) -> Result<(), StoreError> {
389        with_write_tx(conn, |tx| Self::insert_address_tx(tx, address, account_id))
390    }
391
392    pub(crate) fn insert_address_tx(
393        tx: &Transaction<'_>,
394        address: &Address,
395        account_id: AccountId,
396    ) -> Result<(), StoreError> {
397        const QUERY: &str = insert_sql!(addresses { address, account_id } | REPLACE);
398        let serialized_address = address.to_bytes();
399        tx.execute(QUERY, params![serialized_address, account_id.to_bytes(),])
400            .into_store_error()?;
401
402        Ok(())
403    }
404
405    /// Returns `true` if a row was deleted, `false` if the address wasn't tracked.
406    pub(crate) fn remove_address(
407        conn: &mut Connection,
408        address: &Address,
409    ) -> Result<bool, StoreError> {
410        with_write_tx(conn, |tx| {
411            const DELETE_QUERY: &str = "DELETE FROM addresses WHERE address = ?";
412            let count = tx.execute(DELETE_QUERY, params![address.to_bytes()]).into_store_error()?;
413
414            Ok(count > 0)
415        })
416    }
417
418    /// Inserts an [`AccountCode`].
419    pub(crate) fn insert_account_code(
420        tx: &Transaction<'_>,
421        account_code: &AccountCode,
422    ) -> Result<(), StoreError> {
423        const QUERY: &str = insert_sql!(account_code { commitment, code } | IGNORE);
424        tx.execute(
425            QUERY,
426            params![account_code.commitment().to_bytes(), proto::encode(account_code)],
427        )
428        .into_store_error()?;
429        Ok(())
430    }
431
432    /// Applies the account patch to the account state, updating the vault and storage maps.
433    ///
434    /// Archives old values from latest to historical and updates latest via INSERT OR REPLACE.
435    pub(crate) fn apply_account_patch(
436        tx: &Transaction<'_>,
437        smt_forest: &mut ScopedAccountForest<'_, '_>,
438        init_account_state: &AccountHeader,
439        final_account_state: &AccountHeader,
440        patch: &AccountPatch,
441    ) -> Result<(), StoreError> {
442        let account_id = final_account_state.id();
443
444        // Reject patches for accounts the store does not track (forest updates for unknown accounts
445        // would silently create partial state from empty trees), and stale or replayed patches
446        // whose initial state does not match the stored latest state (they would overwrite newer
447        // state and archive incorrect history).
448        let stored_header = Self::require_latest_account_header(tx, account_id)?;
449        if stored_header.to_commitment() != init_account_state.to_commitment() {
450            return Err(StoreError::DatabaseError(format!(
451                "apply_account_patch: stored state {} for account {} does not match the patch's \
452                 initial state {}",
453                stored_header.to_commitment(),
454                account_id,
455                init_account_state.to_commitment(),
456            )));
457        }
458
459        // The header refers to the account code by its commitment, so a code upgrade must store the
460        // new code before the header.
461        match patch.code().as_code() {
462            Some(code) => {
463                if code.commitment() != final_account_state.code_commitment() {
464                    return Err(StoreError::DatabaseError(format!(
465                        "apply_account_patch: patch code commitment {} for account {} does not \
466                         match the final code commitment {}",
467                        code.commitment(),
468                        account_id,
469                        final_account_state.code_commitment(),
470                    )));
471                }
472                Self::insert_account_code(tx, code)?;
473            },
474            None => {
475                if init_account_state.code_commitment() != final_account_state.code_commitment() {
476                    return Err(StoreError::DatabaseError(format!(
477                        "apply_account_patch: patch for account {} changes the code commitment \
478                         from {} to {} but does not contain the new code",
479                        account_id,
480                        init_account_state.code_commitment(),
481                        final_account_state.code_commitment(),
482                    )));
483                }
484            },
485        }
486
487        // Archive old header and insert the new one
488        Self::replace_account_header(tx, final_account_state, init_account_state, None)?;
489
490        Self::apply_account_vault_patch(tx, account_id, final_account_state, patch.vault())?;
491
492        // Build one forest update covering the vault and every changed map slot, and apply it at a
493        // freshly allocated revision.
494        let mut update = AccountUpdate::new();
495        update.vault_patch(account_id, patch.vault(), final_account_state.vault_root());
496        update.storage_patch(account_id, patch.storage());
497
498        let revision = allocate_forest_revision(tx).into_store_error()?;
499        smt_forest.apply(revision, update)?;
500
501        Self::write_storage_patch(
502            tx,
503            smt_forest,
504            account_id,
505            final_account_state.nonce().as_canonical_u64(),
506            patch.storage(),
507        )?;
508        Self::verify_storage_commitment(tx, account_id, final_account_state.storage_commitment())?;
509
510        Ok(())
511    }
512
513    /// Reconciles the account's forest lineages to exactly match the provided full state.
514    ///
515    /// Map slots that disappeared from the state are enumerated from the latest storage tables, so
516    /// this must run before those rows are replaced.
517    pub(crate) fn reconcile_account_forest(
518        tx: &Transaction<'_>,
519        smt_forest: &mut ScopedAccountForest<'_, '_>,
520        account_id: AccountId,
521        vault: &AssetVault,
522        storage: &AccountStorage,
523    ) -> Result<(), StoreError> {
524        let mut update = AccountUpdate::new();
525        update.full_state(account_id, vault.assets(), storage.slots().iter());
526
527        // Slots that still have stored rows but are absent from the new state must be emptied
528        // rather than keeping their old entries. Slots the new state does repopulate keep the
529        // entries recorded above; this only marks the lineage exhaustive.
530        for slot_name in Self::query_map_slot_names(tx, account_id)? {
531            update.clear_map(account_id, &slot_name);
532        }
533
534        Self::apply_forest_update(tx, smt_forest, update)
535    }
536
537    /// Reconciles the account's forest lineages to the state currently stored in the latest account
538    /// tables. Used after rows are restored from historical during undo.
539    ///
540    /// `extra_map_slots` lists map slots that may hold forest entries even though they have no rows
541    /// anymore (captured before the tables were rewritten); their lineages are reset to the empty
542    /// tree unless the restored state repopulates them.
543    fn reconcile_account_forest_from_tables(
544        tx: &Transaction<'_>,
545        smt_forest: &mut ScopedAccountForest<'_, '_>,
546        account_id: AccountId,
547        extra_map_slots: &[StorageSlotName],
548    ) -> Result<(), StoreError> {
549        let assets = query_vault_assets(tx, account_id)?;
550        let slots = query_storage_slots(tx, account_id, &AccountStorageFilter::All)?;
551
552        let mut update = AccountUpdate::new();
553        update.full_state(account_id, assets.into_iter(), slots.values());
554        for slot_name in extra_map_slots {
555            update.clear_map(account_id, slot_name);
556        }
557
558        Self::apply_forest_update(tx, smt_forest, update)
559    }
560
561    /// Verifies that the persisted top-level storage slots match the expected commitment.
562    ///
563    /// This runs after the storage patch is written so create, update, and removal semantics have a
564    /// single source of truth. A mismatch rolls back together with the rest of the transaction.
565    fn verify_storage_commitment(
566        tx: &Transaction<'_>,
567        account_id: AccountId,
568        expected: Word,
569    ) -> Result<(), StoreError> {
570        let mut slot_headers: Vec<StorageSlotHeader> = query_storage_values(tx, account_id)?
571            .into_iter()
572            .map(|(slot_name, (slot_type, value))| {
573                StorageSlotHeader::new(slot_name, slot_type, value)
574            })
575            .collect();
576        slot_headers.sort_by_key(StorageSlotHeader::id);
577
578        let actual = AccountStorageHeader::new(slot_headers)
579            .map_err(StoreError::AccountError)?
580            .to_commitment();
581        if actual != expected {
582            return Err(StoreError::MerkleStoreError(MerkleError::ConflictingRoots {
583                expected_root: expected,
584                actual_root: actual,
585            }));
586        }
587
588        Ok(())
589    }
590
591    /// Applies a recorded forest update at a freshly allocated revision.
592    fn apply_forest_update(
593        tx: &Transaction<'_>,
594        smt_forest: &mut ScopedAccountForest<'_, '_>,
595        update: AccountUpdate,
596    ) -> Result<(), StoreError> {
597        let revision = allocate_forest_revision(tx).into_store_error()?;
598        smt_forest.apply(revision, update)
599    }
600
601    /// Returns the stored latest header of an account, or [`StoreError::AccountDataNotFound`] if
602    /// the store does not track it.
603    fn require_latest_account_header(
604        tx: &Transaction<'_>,
605        account_id: AccountId,
606    ) -> Result<AccountHeader, StoreError> {
607        query_latest_account_headers(tx, "id = ?", params![account_id.to_bytes()])?
608            .into_iter()
609            .next()
610            .map(|(header, ..)| header)
611            .ok_or(StoreError::AccountDataNotFound(account_id))
612    }
613
614    /// Returns the names of the map slots that currently have entries stored for an account.
615    fn query_map_slot_names(
616        tx: &Transaction<'_>,
617        account_id: AccountId,
618    ) -> Result<Vec<StorageSlotName>, StoreError> {
619        let mut stmt = tx
620            .prepare(
621                "SELECT DISTINCT slot_name FROM latest_storage_map_entries WHERE account_id = ?",
622            )
623            .into_store_error()?;
624        let rows = stmt
625            .query_map(params![account_id.to_bytes()], |row| row.get::<_, String>(0))
626            .into_store_error()?;
627
628        rows.map(|row| {
629            StorageSlotName::new(row.into_store_error()?)
630                .map_err(|e| StoreError::ParsingError(e.to_string()))
631        })
632        .collect()
633    }
634
635    /// Undoes discarded account states by restoring old values from historical.
636    pub(crate) fn undo_account_state(
637        tx: &Transaction<'_>,
638        smt_forest: &mut ScopedAccountForest<'_, '_>,
639        discarded_states: &[(AccountId, Word)],
640    ) -> Result<(), StoreError> {
641        if discarded_states.is_empty() {
642            return Ok(());
643        }
644
645        let commitment_params =
646            blob_array(discarded_states.iter().map(|(_, commitment)| commitment));
647
648        // Resolve (account_id, nonce) pairs from both latest and historical headers, and group the
649        // nonces by account. The most recent discarded state is in latest, older ones are in
650        // historical.
651        let mut nonces_by_account: BTreeMap<Vec<u8>, BTreeSet<u64>> = BTreeMap::new();
652        for query in [
653            "SELECT id, nonce FROM latest_account_headers WHERE account_commitment IN rarray(?)",
654            "SELECT id, nonce FROM historical_account_headers WHERE account_commitment IN rarray(?)",
655        ] {
656            for row in tx
657                .prepare(query)
658                .into_store_error()?
659                .query_map(params![commitment_params.clone()], |row| {
660                    let id: Vec<u8> = row.get("id")?;
661                    let nonce: u64 = column_value_as_u64(row, "nonce")?;
662                    Ok((id, nonce))
663                })
664                .into_store_error()?
665            {
666                let (id, nonce) = row.into_store_error()?;
667                nonces_by_account.entry(id).or_default().insert(nonce);
668            }
669        }
670
671        // Undo one account at a time. Read the account's map slot names before the undo rewrites
672        // the latest tables, then reconcile its forest lineages to the restored state.
673        for (account_id_bytes, nonces) in &nonces_by_account {
674            let account_id = AccountId::read_from_bytes(account_id_bytes)?;
675            let stale_map_slots = Self::query_map_slot_names(tx, account_id)?;
676            Self::undo_account_nonces(tx, account_id_bytes, nonces)?;
677            Self::reconcile_account_forest_from_tables(
678                tx,
679                smt_forest,
680                account_id,
681                &stale_map_slots,
682            )?;
683        }
684
685        Ok(())
686    }
687
688    /// Undoes all nonces for a single account: restores old values, restores old header, and cleans
689    /// up consumed historical entries.
690    fn undo_account_nonces(
691        tx: &Transaction<'_>,
692        account_id_bytes: &[u8],
693        nonces: &BTreeSet<u64>,
694    ) -> Result<(), StoreError> {
695        // Undo each nonce in descending order. Each nonce's old value is the state before that
696        // nonce, so the most recent nonce must be undone first. Earlier nonces then overwrite it
697        // with the correct final value.
698        for &nonce in nonces.iter().rev() {
699            let nonce_val = u64_to_value(nonce);
700            Self::restore_old_values_for_nonce(tx, account_id_bytes, &nonce_val)?;
701        }
702
703        // Restore the old header from the earliest discarded nonce. The set always holds at least
704        // one nonce, because an entry is added to the map only when a nonce is inserted.
705        let min_nonce_val = u64_to_value(*nonces.first().expect("nonces is not empty"));
706
707        let old_header_exists: bool = tx
708            .query_row(
709                "SELECT COUNT(*) FROM historical_account_headers \
710                 WHERE id = ? AND replaced_at_nonce = ?",
711                params![account_id_bytes, &min_nonce_val],
712                |row| row.get::<_, i64>(0),
713            )
714            .into_store_error()?
715            > 0;
716
717        if old_header_exists {
718            // `watched` is not carried in historical_account_headers, so this restore resets it to
719            // the column default (FALSE). This is safe because undo only fires for discarded local
720            // transactions, and watched accounts have none.
721            tx.execute(
722                "INSERT OR REPLACE INTO latest_account_headers \
723                 (id, account_commitment, code_commitment, storage_commitment, \
724                  vault_root, nonce, account_seed, locked) \
725                 SELECT id, account_commitment, code_commitment, storage_commitment, \
726                        vault_root, nonce, account_seed, locked \
727                 FROM historical_account_headers \
728                 WHERE id = ? AND replaced_at_nonce = ?",
729                params![account_id_bytes, &min_nonce_val],
730            )
731            .into_store_error()?;
732        } else {
733            // No previous state — delete the account entirely
734            for table in [
735                "DELETE FROM latest_account_headers WHERE id = ?",
736                "DELETE FROM latest_account_storage WHERE account_id = ?",
737                "DELETE FROM latest_storage_map_entries WHERE account_id = ?",
738                "DELETE FROM latest_account_assets WHERE account_id = ?",
739            ] {
740                tx.execute(table, params![account_id_bytes]).into_store_error()?;
741            }
742        }
743
744        // Delete all consumed historical entries at the discarded nonces.
745        let nonce_params = int_array(nonces.iter().copied());
746        for table in [
747            "historical_account_storage",
748            "historical_storage_map_entries",
749            "historical_account_assets",
750        ] {
751            tx.execute(
752                &format!(
753                    "DELETE FROM {table} WHERE account_id = ? AND replaced_at_nonce IN rarray(?)"
754                ),
755                params![account_id_bytes, nonce_params.clone()],
756            )
757            .into_store_error()?;
758        }
759        tx.execute(
760            "DELETE FROM historical_account_headers \
761             WHERE id = ? AND replaced_at_nonce IN rarray(?)",
762            params![account_id_bytes, nonce_params],
763        )
764        .into_store_error()?;
765
766        Ok(())
767    }
768
769    /// Restores old values from historical entries for a given nonce. Non-NULL old values overwrite
770    /// latest, NULL old values (new entries) are deleted.
771    fn restore_old_values_for_nonce(
772        tx: &Transaction<'_>,
773        account_id_bytes: &[u8],
774        nonce_val: &rusqlite::types::Value,
775    ) -> Result<(), StoreError> {
776        // Restore storage slots with non-NULL old values
777        tx.execute(
778            "INSERT OR REPLACE INTO latest_account_storage \
779             (account_id, slot_name, slot_value, slot_type) \
780             SELECT account_id, slot_name, old_slot_value, slot_type \
781             FROM historical_account_storage \
782             WHERE account_id = ? AND replaced_at_nonce = ? AND old_slot_value IS NOT NULL",
783            params![account_id_bytes, nonce_val],
784        )
785        .into_store_error()?;
786
787        // Delete storage slots that were new (NULL old value)
788        tx.execute(
789            "DELETE FROM latest_account_storage \
790             WHERE account_id = ?1 AND slot_name IN (\
791                 SELECT slot_name FROM historical_account_storage \
792                 WHERE account_id = ?1 AND replaced_at_nonce = ?2 AND old_slot_value IS NULL\
793             )",
794            params![account_id_bytes, nonce_val],
795        )
796        .into_store_error()?;
797
798        // Restore map entries with non-NULL old values
799        tx.execute(
800            "INSERT OR REPLACE INTO latest_storage_map_entries \
801             (account_id, slot_name, key, value) \
802             SELECT account_id, slot_name, key, old_value \
803             FROM historical_storage_map_entries \
804             WHERE account_id = ? AND replaced_at_nonce = ? AND old_value IS NOT NULL",
805            params![account_id_bytes, nonce_val],
806        )
807        .into_store_error()?;
808
809        // Delete map entries that were new (NULL old value)
810        tx.execute(
811            "DELETE FROM latest_storage_map_entries \
812             WHERE account_id = ?1 AND EXISTS (\
813                 SELECT 1 FROM historical_storage_map_entries h \
814                 WHERE h.account_id = latest_storage_map_entries.account_id \
815                   AND h.slot_name = latest_storage_map_entries.slot_name \
816                   AND h.key = latest_storage_map_entries.key \
817                   AND h.replaced_at_nonce = ?2 AND h.old_value IS NULL\
818             )",
819            params![account_id_bytes, nonce_val],
820        )
821        .into_store_error()?;
822
823        // Restore assets with non-NULL old values
824        tx.execute(
825            "INSERT OR REPLACE INTO latest_account_assets \
826             (account_id, asset_id, asset) \
827             SELECT account_id, asset_id, old_asset \
828             FROM historical_account_assets \
829             WHERE account_id = ? AND replaced_at_nonce = ? AND old_asset IS NOT NULL",
830            params![account_id_bytes, nonce_val],
831        )
832        .into_store_error()?;
833
834        // Delete assets that were new (NULL old value)
835        tx.execute(
836            "DELETE FROM latest_account_assets \
837             WHERE account_id = ?1 AND asset_id IN (\
838             SELECT asset_id FROM historical_account_assets \
839                 WHERE account_id = ?1 AND replaced_at_nonce = ?2 AND old_asset IS NULL\
840             )",
841            params![account_id_bytes, nonce_val],
842        )
843        .into_store_error()?;
844
845        Ok(())
846    }
847
848    /// Replaces the account state with a completely new one from the network.
849    ///
850    /// Replaces the account state entirely: archives old state to historical, clears latest,
851    /// inserts new state to latest only. Preserves the `watched` flag.
852    pub(crate) fn update_account_state(
853        tx: &Transaction<'_>,
854        smt_forest: &mut ScopedAccountForest<'_, '_>,
855        new_account_state: &Account,
856    ) -> Result<(), StoreError> {
857        let account_id = new_account_state.id();
858        let account_id_bytes = account_id.to_bytes();
859
860        // Read old header before mutating the SMT snapshot or database rows. Sync filters stale
861        // full-account snapshots; if one still reaches storage, reject it before mutating.
862        let old_header = Self::require_latest_account_header(tx, account_id)?;
863
864        if new_account_state.nonce().as_canonical_u64() < old_header.nonce().as_canonical_u64() {
865            return Err(StoreError::DatabaseError(format!(
866                "update_account_state: new nonce {} is less than old nonce {} for account {}",
867                new_account_state.nonce().as_canonical_u64(),
868                old_header.nonce().as_canonical_u64(),
869                account_id,
870            )));
871        }
872
873        let nonce_val = u64_to_value(new_account_state.nonce().as_canonical_u64());
874
875        // Reconcile the forest to the new full state before the latest tables are replaced below.
876        Self::reconcile_account_forest(
877            tx,
878            smt_forest,
879            account_id,
880            new_account_state.vault(),
881            new_account_state.storage(),
882        )?;
883
884        // Archive all old entries from latest → historical
885        tx.execute(
886            "INSERT OR REPLACE INTO historical_account_storage \
887             (account_id, replaced_at_nonce, slot_name, old_slot_value, slot_type) \
888             SELECT account_id, ?, slot_name, slot_value, slot_type \
889             FROM latest_account_storage WHERE account_id = ?",
890            params![&nonce_val, &account_id_bytes],
891        )
892        .into_store_error()?;
893        tx.execute(
894            "INSERT OR REPLACE INTO historical_storage_map_entries \
895             (account_id, replaced_at_nonce, slot_name, key, old_value) \
896             SELECT account_id, ?, slot_name, key, value \
897             FROM latest_storage_map_entries WHERE account_id = ?",
898            params![&nonce_val, &account_id_bytes],
899        )
900        .into_store_error()?;
901        tx.execute(
902            "INSERT OR REPLACE INTO historical_account_assets \
903             (account_id, replaced_at_nonce, asset_id, old_asset) \
904             SELECT account_id, ?, asset_id, asset \
905             FROM latest_account_assets WHERE account_id = ?",
906            params![&nonce_val, &account_id_bytes],
907        )
908        .into_store_error()?;
909
910        // Delete all latest entries for this account
911        tx.execute(
912            "DELETE FROM latest_account_storage WHERE account_id = ?",
913            params![&account_id_bytes],
914        )
915        .into_store_error()?;
916        tx.execute(
917            "DELETE FROM latest_storage_map_entries WHERE account_id = ?",
918            params![&account_id_bytes],
919        )
920        .into_store_error()?;
921        tx.execute(
922            "DELETE FROM latest_account_assets WHERE account_id = ?",
923            params![&account_id_bytes],
924        )
925        .into_store_error()?;
926
927        // Insert all new entries into latest only
928        Self::insert_storage_slots(tx, account_id, new_account_state.storage().slots().iter())?;
929        Self::insert_assets(tx, account_id, new_account_state.vault().assets())?;
930
931        // Write NULL historical entries for genuinely new entries that didn't exist in the old
932        // state (INSERT OR IGNORE skips entries already archived above)
933        tx.execute(
934            "INSERT OR IGNORE INTO historical_account_storage \
935             (account_id, replaced_at_nonce, slot_name, old_slot_value, slot_type) \
936             SELECT account_id, ?, slot_name, NULL, slot_type \
937             FROM latest_account_storage WHERE account_id = ?",
938            params![&nonce_val, &account_id_bytes],
939        )
940        .into_store_error()?;
941        tx.execute(
942            "INSERT OR IGNORE INTO historical_storage_map_entries \
943             (account_id, replaced_at_nonce, slot_name, key, old_value) \
944             SELECT account_id, ?, slot_name, key, NULL \
945             FROM latest_storage_map_entries WHERE account_id = ?",
946            params![&nonce_val, &account_id_bytes],
947        )
948        .into_store_error()?;
949        tx.execute(
950            "INSERT OR IGNORE INTO historical_account_assets \
951             (account_id, replaced_at_nonce, asset_id, old_asset) \
952             SELECT account_id, ?, asset_id, NULL \
953             FROM latest_account_assets WHERE account_id = ?",
954            params![&nonce_val, &account_id_bytes],
955        )
956        .into_store_error()?;
957
958        // The new state can have upgraded code. The header refers to the code by its commitment, so
959        // store the code before the header.
960        Self::insert_account_code(tx, new_account_state.code())?;
961
962        // Archive the old header to historical and write the new one to latest. A state that is
963        // still undeployed keeps its seed
964        let new_seed = new_account_state.seed().filter(|_| new_account_state.is_new());
965        Self::replace_account_header(tx, &new_account_state.into(), &old_header, new_seed)?;
966
967        Ok(())
968    }
969
970    /// Applies an incremental patch to a public account's state during sync.
971    pub(crate) fn apply_sync_account_patch(
972        tx: &Transaction<'_>,
973        smt_forest: &mut ScopedAccountForest<'_, '_>,
974        new_header: &AccountHeader,
975        patch: &AccountPatch,
976    ) -> Result<(), StoreError> {
977        let account_id = new_header.id();
978
979        // Read current header from the store.
980        let init_header = Self::require_latest_account_header(tx, account_id)?;
981
982        if new_header.nonce().as_canonical_u64() <= init_header.nonce().as_canonical_u64() {
983            return Err(StoreError::DatabaseError(format!(
984                "apply_sync_account_patch: new nonce {} is not greater than local nonce {} for account {}",
985                new_header.nonce().as_canonical_u64(),
986                init_header.nonce().as_canonical_u64(),
987                account_id,
988            )));
989        }
990
991        // Transaction derefs to Connection, so we can pass it where Connection is expected.
992
993        Self::apply_account_patch(tx, smt_forest, &init_header, new_header, patch)
994    }
995
996    /// Locks the account if the mismatched digest doesn't belong to a previous account state (stale
997    /// data).
998    pub(crate) fn lock_account_on_unexpected_commitment(
999        tx: &Transaction<'_>,
1000        account_id: &AccountId,
1001        mismatched_digest: &Word,
1002    ) -> Result<(), StoreError> {
1003        // Mismatched digests may be due to stale network data. If the mismatched digest is tracked
1004        // in the db and corresponds to the mismatched account, it means we got a past update and
1005        // shouldn't lock the account.
1006        const LOCK_CONDITION: &str = "WHERE id = :account_id AND NOT EXISTS (SELECT 1 FROM historical_account_headers WHERE id = :account_id AND account_commitment = :digest)";
1007        let account_id_bytes = account_id.to_bytes();
1008        let digest_bytes = mismatched_digest.to_bytes();
1009        let params = named_params! {
1010            ":account_id": account_id_bytes,
1011            ":digest": digest_bytes
1012        };
1013
1014        let query = format!("UPDATE latest_account_headers SET locked = true {LOCK_CONDITION}");
1015        tx.execute(&query, params).into_store_error()?;
1016
1017        // Also lock historical rows so that undo_account_state preserves the lock.
1018        let query = format!("UPDATE historical_account_headers SET locked = true {LOCK_CONDITION}");
1019        tx.execute(&query, params).into_store_error()?;
1020
1021        Ok(())
1022    }
1023
1024    // HELPERS
1025    // --------------------------------------------------------------------------------------------
1026
1027    /// Writes a new row into `latest_account_headers`.
1028    ///
1029    /// Does not archive any previous state, use [`Self::replace_account_header`] when a row for
1030    /// this account already exists. If a row does exist it will be overwritten with the provided
1031    /// `watched` value and no historical row added.
1032    fn insert_new_account_header(
1033        tx: &Transaction<'_>,
1034        new_header: &AccountHeader,
1035        account_seed: Option<Word>,
1036        watched: bool,
1037    ) -> Result<(), StoreError> {
1038        let id = new_header.id().to_bytes();
1039        let code_commitment = new_header.code_commitment().to_bytes();
1040        let storage_commitment = new_header.storage_commitment().to_bytes();
1041        let vault_root = new_header.vault_root().to_bytes();
1042        let nonce = u64_to_value(new_header.nonce().as_canonical_u64());
1043        let commitment = new_header.to_commitment().to_bytes();
1044        let account_seed = account_seed.map(|seed| seed.to_bytes());
1045
1046        const LATEST_QUERY: &str = insert_sql!(
1047            latest_account_headers {
1048                id,
1049                code_commitment,
1050                storage_commitment,
1051                vault_root,
1052                nonce,
1053                account_seed,
1054                account_commitment,
1055                locked,
1056                watched
1057            } | REPLACE
1058        );
1059
1060        tx.execute(
1061            LATEST_QUERY,
1062            params![
1063                id,
1064                code_commitment,
1065                storage_commitment,
1066                vault_root,
1067                nonce,
1068                account_seed,
1069                commitment,
1070                false,
1071                watched,
1072            ],
1073        )
1074        .into_store_error()?;
1075
1076        Ok(())
1077    }
1078
1079    /// Replaces an account's latest header, archiving the previous one to historical.
1080    ///
1081    /// Preserves the `watched` flag from the existing latest row (mode is a per-account property,
1082    /// not per-state). The new latest row is written with `account_seed = new_seed` and `locked =
1083    /// false`; the previous seed and lock state move into the historical row. `new_seed` is only
1084    /// `Some` while the new state is still undeployed (nonce zero), since a deployed account no
1085    /// longer needs its seed.
1086    fn replace_account_header(
1087        tx: &Transaction<'_>,
1088        new_header: &AccountHeader,
1089        old_header: &AccountHeader,
1090        new_seed: Option<Word>,
1091    ) -> Result<(), StoreError> {
1092        if new_header.id() != old_header.id() {
1093            return Err(StoreError::DatabaseError(format!(
1094                "replace_account_header: account id mismatch (new: {}, old: {})",
1095                new_header.id(),
1096                old_header.id(),
1097            )));
1098        }
1099        if new_header.nonce().as_canonical_u64() < old_header.nonce().as_canonical_u64() {
1100            return Err(StoreError::DatabaseError(format!(
1101                "replace_account_header: new nonce {} is less than old nonce {} for account {}",
1102                new_header.nonce().as_canonical_u64(),
1103                old_header.nonce().as_canonical_u64(),
1104                new_header.id(),
1105            )));
1106        }
1107
1108        let id_bytes = new_header.id().to_bytes();
1109
1110        // `AccountHeader` doesn't carry the seed or per-account flags, so read them from the row
1111        // we're about to overwrite: `account_seed`/`locked` get archived into the historical row,
1112        // `watched` is carried into the new latest row.
1113        let (old_seed, old_locked, old_watched): (Option<Vec<u8>>, bool, bool) = tx
1114            .query_row(
1115                "SELECT account_seed, locked, watched FROM latest_account_headers WHERE id = ?",
1116                params![&id_bytes],
1117                |row| Ok((row.get("account_seed")?, row.get("locked")?, row.get("watched")?)),
1118            )
1119            .optional()
1120            .into_store_error()?
1121            .unwrap_or((None, false, false));
1122
1123        // Archive the old header to historical.
1124        let old_id = old_header.id().to_bytes();
1125        let old_code_commitment = old_header.code_commitment().to_bytes();
1126        let old_storage_commitment = old_header.storage_commitment().to_bytes();
1127        let old_vault_root = old_header.vault_root().to_bytes();
1128        let old_nonce = u64_to_value(old_header.nonce().as_canonical_u64());
1129        let old_commitment = old_header.to_commitment().to_bytes();
1130        let replaced_at_nonce = u64_to_value(new_header.nonce().as_canonical_u64());
1131
1132        const HISTORICAL_QUERY: &str = insert_sql!(
1133            historical_account_headers {
1134                id,
1135                code_commitment,
1136                storage_commitment,
1137                vault_root,
1138                nonce,
1139                account_seed,
1140                account_commitment,
1141                locked,
1142                replaced_at_nonce
1143            } | REPLACE
1144        );
1145
1146        tx.execute(
1147            HISTORICAL_QUERY,
1148            params![
1149                old_id,
1150                old_code_commitment,
1151                old_storage_commitment,
1152                old_vault_root,
1153                old_nonce,
1154                old_seed,
1155                old_commitment,
1156                old_locked,
1157                replaced_at_nonce,
1158            ],
1159        )
1160        .into_store_error()?;
1161
1162        // Write the new latest row.
1163        Self::insert_new_account_header(tx, new_header, new_seed, old_watched)
1164    }
1165
1166    /// Prunes historical account states for a single account up to the given nonce.
1167    ///
1168    /// Deletes all historical entries with `replaced_at_nonce <= up_to_nonce` (see DESIGN.md for
1169    /// why this threshold is safe), then removes any account code that was only referenced by the
1170    /// deleted headers.
1171    pub(crate) fn prune_account_history(
1172        conn: &mut Connection,
1173        account_id: AccountId,
1174        up_to_nonce: Felt,
1175    ) -> Result<usize, StoreError> {
1176        with_write_tx(conn, |tx| {
1177            let account_id_bytes = account_id.to_bytes();
1178            let boundary_val = u64_to_value(up_to_nonce.as_canonical_u64());
1179            let mut total_deleted: usize = 0;
1180
1181            // Collect code commitments from headers we are about to delete.
1182            let candidate_code_commitments: Vec<Vec<u8>> = {
1183                let mut stmt = tx
1184                    .prepare(
1185                        "SELECT DISTINCT code_commitment FROM historical_account_headers \
1186                     WHERE id = ? AND replaced_at_nonce <= ?",
1187                    )
1188                    .into_store_error()?;
1189                let rows = stmt
1190                    .query_map(params![&account_id_bytes, &boundary_val], |row| row.get(0))
1191                    .into_store_error()?;
1192                rows.collect::<Result<Vec<Vec<u8>>, _>>().into_store_error()?
1193            };
1194
1195            // Delete historical entries. The headers table names the account column `id`.
1196            for (table, account_column) in [
1197                ("historical_account_headers", "id"),
1198                ("historical_account_storage", "account_id"),
1199                ("historical_storage_map_entries", "account_id"),
1200                ("historical_account_assets", "account_id"),
1201            ] {
1202                let query = format!(
1203                    "DELETE FROM {table} WHERE {account_column} = ? AND replaced_at_nonce <= ?"
1204                );
1205                total_deleted += tx
1206                    .execute(&query, params![&account_id_bytes, &boundary_val])
1207                    .into_store_error()?;
1208            }
1209
1210            // Delete orphaned code: only check commitments from the deleted headers, and only if
1211            // they are not referenced by any remaining header or foreign code.
1212            for commitment in &candidate_code_commitments {
1213                let still_referenced: bool = tx
1214                    .query_row(
1215                        "SELECT EXISTS(
1216                        SELECT 1 FROM latest_account_headers WHERE code_commitment = ?1
1217                        UNION ALL
1218                        SELECT 1 FROM historical_account_headers WHERE code_commitment = ?1
1219                        UNION ALL
1220                        SELECT 1 FROM foreign_account_code WHERE code_commitment = ?1
1221                    )",
1222                        params![commitment],
1223                        |row| row.get(0),
1224                    )
1225                    .into_store_error()?;
1226
1227                if !still_referenced {
1228                    total_deleted += tx
1229                        .execute(
1230                            "DELETE FROM account_code WHERE commitment = ?",
1231                            params![commitment],
1232                        )
1233                        .into_store_error()?;
1234                }
1235            }
1236
1237            Ok(total_deleted)
1238        })
1239    }
1240}