Skip to main content

miden_client_sqlite_store/account/
accounts.rs

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