From b0fa27f41d9c635cc6b73ae5b94a32303b7fcd54 Mon Sep 17 00:00:00 2001 From: Ignacio Amigo Date: Mon, 2 Mar 2026 17:03:51 -0300 Subject: [PATCH 1/4] feat: client service --- CHANGELOG.md | 3 + Cargo.lock | 11 + Cargo.toml | 2 + bin/integration-tests/Cargo.toml | 1 + bin/integration-tests/src/main.rs | 2 + .../src/tests/client_service.rs | 115 +++ bin/integration-tests/src/tests/mod.rs | 1 + crates/client-service/Cargo.toml | 35 + crates/client-service/src/config.rs | 42 + crates/client-service/src/lib.rs | 241 ++++++ crates/idxdb-store/src/README.md | 13 - crates/idxdb-store/src/account/js_bindings.rs | 68 +- crates/idxdb-store/src/account/mod.rs | 68 +- crates/idxdb-store/src/account/models.rs | 2 +- crates/idxdb-store/src/account/utils.rs | 167 +++- crates/idxdb-store/src/auth.rs | 8 +- crates/idxdb-store/src/js/accounts.js | 632 +++++++++++---- crates/idxdb-store/src/js/schema.js | 52 +- crates/idxdb-store/src/js/sync.js | 60 +- crates/idxdb-store/src/sync/js_bindings.rs | 25 +- crates/idxdb-store/src/sync/mod.rs | 2 +- crates/idxdb-store/src/transaction/mod.rs | 15 +- crates/idxdb-store/src/ts/accounts.ts | 766 ++++++++++++++---- crates/idxdb-store/src/ts/schema.ts | 198 +++-- crates/idxdb-store/src/ts/sync.ts | 193 +++-- crates/idxdb-store/src/yarn.lock | 310 +++---- crates/rust-client/src/note/import.rs | 2 +- crates/rust-client/src/note/note_screener.rs | 5 +- crates/rust-client/src/note_transport/mod.rs | 2 +- crates/rust-client/src/sync/mod.rs | 33 +- crates/rust-client/src/sync/state_sync.rs | 5 +- crates/rust-client/src/transaction/mod.rs | 4 +- crates/sqlite-store/src/account/accounts.rs | 567 +++++++------ crates/sqlite-store/src/account/helpers.rs | 148 ++-- crates/sqlite-store/src/account/storage.rs | 319 ++++++-- crates/sqlite-store/src/account/tests.rs | 673 ++++++++++++++- crates/sqlite-store/src/account/vault.rs | 218 +++-- crates/sqlite-store/src/smt_forest.rs | 15 +- crates/sqlite-store/src/store.sql | 154 ++-- crates/sqlite-store/src/transaction.rs | 8 +- .../web-client/src/models/account_storage.rs | 10 +- eslint.config.js | 1 + 42 files changed, 3897 insertions(+), 1299 deletions(-) create mode 100644 bin/integration-tests/src/tests/client_service.rs create mode 100644 crates/client-service/Cargo.toml create mode 100644 crates/client-service/src/config.rs create mode 100644 crates/client-service/src/lib.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c08804a83e..40d95dfe15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,14 @@ ### Enhancements +* Added `miden-client-service` crate that wraps `Client` to provide operation coordination (sync/transaction mutual exclusion), background sync, and async-friendly client access ([#1734](https://github.com/0xMiden/miden-client/pull/1734)). * Updated the `GrpcClient` to fetch the RPC limits from the node ([#1724](https://github.com/0xMiden/miden-client/pull/1724)) ([#1737](https://github.com/0xMiden/miden-client/pull/1737), [#1809](https://github.com/0xMiden/miden-client/pull/1809)). +* Added the `@miden-sdk/react` hooks library with a provider, hooks, and an example app for the web client ([#1711](https://github.com/0xMiden/miden-client/pull/1711)). * Added typed error parsing for node RPC endpoints, enabling programmatic error handling instead of string parsing ([#1734](https://github.com/0xMiden/miden-client/pull/1734)). * Added `--rpc-status` flag to `miden-client info` command to display RPC node status information including node version, genesis commitment, store status, and block producer status; also added `get_status_unversioned` to `NodeRpcClient` trait ([#1742](https://github.com/0xMiden/miden-client/pull/1742)). * Prevent a potential unwrap panic in `insert_storage_map_nodes_for_map` ([#1750](https://github.com/0xMiden/miden-client/pull/1750)). * Changed the `StateSync::sync_state()` to take a reference of the MMR ([#1764](https://github.com/0xMiden/miden-client/pull/1764)). +* Account storage restructured into latest/historical tables for efficient delta writes and simpler pruning ([#1775](https://github.com/0xMiden/miden-client/pull/1775)). * Remove unnecessary clones of `NoteInclusionProof` and `NoteMetadata` in note import and sync paths ([#1787](https://github.com/0xMiden/miden-client/pull/1787)). * [FEATURE][web] WebClient now automatically syncs state before account creation when the client has never been synced, preventing a slow full-chain scan on the next sync (#1704). * Added `NoteScreener` constructor via `Client::note_screener()` and improved note consumability checks with batch note screening support ([#1803](https://github.com/0xMiden/miden-client/pull/1803), [#1814](https://github.com/0xMiden/miden-client/pull/1814)). diff --git a/Cargo.lock b/Cargo.lock index 7a4dba014b..00360285fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2674,6 +2674,7 @@ dependencies = [ "async-trait", "clap 4.5.57", "miden-client", + "miden-client-service", "miden-client-sqlite-store", "num_cpus", "rand 0.9.2", @@ -2687,6 +2688,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "miden-client-service" +version = "0.14.0" +dependencies = [ + "miden-client", + "miden-protocol", + "tokio", + "tracing", +] + [[package]] name = "miden-client-sqlite-store" version = "0.14.0" diff --git a/Cargo.toml b/Cargo.toml index 7383e9b397..78cbd70eb7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "bin/integration-tests", "bin/miden-bench", "bin/miden-cli", + "crates/client-service", "crates/idxdb-store", "crates/rust-client", "crates/sqlite-store", @@ -32,6 +33,7 @@ opt-level = 1 # Workspace crates idxdb-store = { default-features = false, package = "miden-idxdb-store", path = "crates/idxdb-store", version = "0.14" } miden-client = { default-features = false, path = "crates/rust-client", version = "0.14" } +miden-client-service = { path = "crates/client-service", version = "0.14" } miden-client-sqlite-store = { default-features = false, path = "crates/sqlite-store", version = "0.14" } # Miden protocol dependencies diff --git a/bin/integration-tests/Cargo.toml b/bin/integration-tests/Cargo.toml index 9ffcf58b09..08a8a9cc0a 100644 --- a/bin/integration-tests/Cargo.toml +++ b/bin/integration-tests/Cargo.toml @@ -20,6 +20,7 @@ path = "tests/integration.rs" [dependencies] # Workspace dependencies miden-client = { features = ["std", "testing", "tonic"], workspace = true } +miden-client-service = { workspace = true } miden-client-sqlite-store = { workspace = true } # External dependencies diff --git a/bin/integration-tests/src/main.rs b/bin/integration-tests/src/main.rs index da0678116a..b1ee36df77 100644 --- a/bin/integration-tests/src/main.rs +++ b/bin/integration-tests/src/main.rs @@ -230,6 +230,7 @@ impl std::fmt::Debug for TestCase { #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] enum TestCategory { Client, + ClientService, CustomTransaction, Fpi, NetworkTransaction, @@ -243,6 +244,7 @@ impl AsRef for TestCategory { fn as_ref(&self) -> &str { match self { TestCategory::Client => "client", + TestCategory::ClientService => "client_service", TestCategory::CustomTransaction => "custom_transaction", TestCategory::Fpi => "fpi", TestCategory::NetworkTransaction => "network_transaction", diff --git a/bin/integration-tests/src/tests/client_service.rs b/bin/integration-tests/src/tests/client_service.rs new file mode 100644 index 0000000000..6148a3a6e3 --- /dev/null +++ b/bin/integration-tests/src/tests/client_service.rs @@ -0,0 +1,115 @@ +//! Integration tests for miden-client-service. + +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result}; +use miden_client::account::AccountStorageMode; +use miden_client::asset::FungibleAsset; +use miden_client::auth::RPO_FALCON_SCHEME_ID; +use miden_client::note::NoteType; +use miden_client::testing::common::*; +use miden_client::transaction::TransactionRequestBuilder; +use miden_client_service::{ClientService, ServiceConfig}; + +use crate::tests::config::ClientConfig; + +/// Comprehensive test that verifies ClientService sync and transaction submission. +/// Tests: +/// - Creating a ClientService +/// - Performing sync operations +/// - Submitting a transaction through the coordinated service +/// - Verifying the service continues to work after transaction +pub async fn test_client_service_sync_and_transaction(client_config: ClientConfig) -> Result<()> { + let (builder, keystore) = client_config.into_client_builder().await?; + let mut client = builder.build().await.context("failed to build client")?; + + // Wait for node and initial sync + wait_for_node(&mut client).await; + + // Set up a faucet account using the raw client (before wrapping in service) + let (faucet, _faucet_key) = insert_new_fungible_faucet( + &mut client, + AccountStorageMode::Private, + &keystore, + RPO_FALCON_SCHEME_ID, + ) + .await + .context("failed to create faucet")?; + + // Now wrap the client in ClientService + let config = ServiceConfig::new().without_background_sync(); + let service = Arc::new(ClientService::new(client, config)); + + // Perform initial sync through the service + let summary = service.sync_state().await.context("failed to sync")?; + assert!(summary.block_num.as_u32() > 0, "Should sync to a non-zero block"); + + // Build and submit a mint transaction + let target_account_id = miden_client::account::AccountId::try_from(ACCOUNT_ID_REGULAR) + .context("failed to create target account id")?; + let asset = + FungibleAsset::new(faucet.id(), MINT_AMOUNT).context("failed to create fungible asset")?; + + let tx_request = { + let mut client = service.client().await; + TransactionRequestBuilder::new() + .build_mint_fungible_asset(asset, target_account_id, NoteType::Public, client.rng()) + .context("failed to build mint transaction request")? + }; + + let tx_id = service + .submit_transaction(faucet.id(), tx_request) + .await + .context("failed to submit transaction")?; + + println!("Transaction submitted: {tx_id}"); + + // Sync again to verify service still works after transaction + let summary2 = service.sync_state().await.context("failed to sync after transaction")?; + assert!(summary2.block_num >= summary.block_num, "Should sync to same or later block"); + + println!("Final sync to block {}", summary2.block_num); + + Ok(()) +} + +/// Tests that background sync works correctly with periodic automatic syncing. +pub async fn test_client_service_background_sync(client_config: ClientConfig) -> Result<()> { + let (builder, _keystore) = client_config.into_client_builder().await?; + let mut client = builder.build().await.context("failed to build client")?; + + // Wait for node to be ready + wait_for_node(&mut client).await; + println!("Node is ready, creating service..."); + + // Create service with fast background sync for testing + let config = ServiceConfig::new().with_sync_interval(Some(Duration::from_millis(500))); + let service = Arc::new(ClientService::new(client, config)); + + // Do one manual sync to verify the service works + let initial_sync = service.sync_state().await.context("initial sync failed")?; + println!("Initial manual sync succeeded, block_num: {}", initial_sync.block_num); + + // Start background sync + let mut sync_handle = service.start_background_sync(); + assert!(sync_handle.is_active(), "Background sync handle should be active"); + + // Wait for a few sync cycles (500ms interval, wait 2s = ~3-4 cycles) + tokio::time::sleep(Duration::from_secs(2)).await; + + // Stop background sync + sync_handle.stop(); + assert!(!sync_handle.is_active(), "Background sync handle should be inactive after stop"); + + // Verify service still works after stopping background sync + let final_sync = service.sync_state().await.context("final sync failed")?; + assert!( + final_sync.block_num >= initial_sync.block_num, + "Should sync to same or later block" + ); + + println!("Background sync test completed, final block: {}", final_sync.block_num); + + Ok(()) +} diff --git a/bin/integration-tests/src/tests/mod.rs b/bin/integration-tests/src/tests/mod.rs index ab89dfbef4..e22f9eaa1c 100644 --- a/bin/integration-tests/src/tests/mod.rs +++ b/bin/integration-tests/src/tests/mod.rs @@ -1,4 +1,5 @@ pub mod client; +pub mod client_service; pub mod config; pub mod custom_transaction; pub mod fpi; diff --git a/crates/client-service/Cargo.toml b/crates/client-service/Cargo.toml new file mode 100644 index 0000000000..0607963b7c --- /dev/null +++ b/crates/client-service/Cargo.toml @@ -0,0 +1,35 @@ +[package] +authors.workspace = true +description = "Service wrapper for miden-client with operation coordination and background sync" +edition.workspace = true +keywords = ["client", "miden", "service"] +license.workspace = true +name = "miden-client-service" +readme = "README.md" +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[features] +default = ["std"] +std = ["miden-client/std", "tokio"] +testing = ["miden-client/testing"] + +[dependencies] +# Workspace crates +miden-client = { workspace = true } + +# Miden protocol dependencies +miden-protocol = { workspace = true } + +# External dependencies +tracing = { workspace = true } + +# Optional native dependencies +tokio = { features = ["rt", "sync", "time"], optional = true, workspace = true } + +[dev-dependencies] +tokio = { features = ["macros", "rt"], workspace = true } + +[lints] +workspace = true diff --git a/crates/client-service/src/config.rs b/crates/client-service/src/config.rs new file mode 100644 index 0000000000..290f22572e --- /dev/null +++ b/crates/client-service/src/config.rs @@ -0,0 +1,42 @@ +//! Configuration options for the client service. + +use std::time::Duration; + +/// Configuration options for [`ClientService`](crate::ClientService). +#[derive(Debug, Clone)] +pub struct ServiceConfig { + /// Interval between automatic background sync operations. + /// + /// Set to `None` to disable automatic background sync. + /// Default: 30 seconds. + pub sync_interval: Option, +} + +impl Default for ServiceConfig { + fn default() -> Self { + Self { + sync_interval: Some(Duration::from_secs(30)), + } + } +} + +impl ServiceConfig { + /// Creates a new configuration with default values. + pub fn new() -> Self { + Self::default() + } + + /// Sets the sync interval. + #[must_use] + pub fn with_sync_interval(mut self, interval: Option) -> Self { + self.sync_interval = interval; + self + } + + /// Disables automatic background sync. + #[must_use] + pub fn without_background_sync(mut self) -> Self { + self.sync_interval = None; + self + } +} diff --git a/crates/client-service/src/lib.rs b/crates/client-service/src/lib.rs new file mode 100644 index 0000000000..85abbcf9b7 --- /dev/null +++ b/crates/client-service/src/lib.rs @@ -0,0 +1,241 @@ +//! A service wrapper for the Miden client that provides operation coordination +//! and background synchronization. +//! +//! # Overview +//! +//! The [`ClientService`] wraps a [`Client`](miden_client::Client) to provide: +//! +//! - **Operation Coordination** - Mutual exclusion between sync and transaction operations +//! - **Background Sync** - Periodic automatic synchronization with the network +//! - **Async-Friendly Access** - Direct client access via [`MutexGuard`](tokio::sync::MutexGuard) +//! +//! # Example +//! +//! ```rust,ignore +//! use miden_client_service::{ClientService, ServiceConfig}; +//! use miden_client::Client; +//! +//! // Create the underlying client +//! let client = Client::builder() +//! .rpc(rpc_client) +//! .store(store) +//! .authenticator(keystore) +//! .build() +//! .await?; +//! +//! // Wrap it in a service +//! let service = Arc::new(ClientService::new(client, ServiceConfig::default())); +//! +//! // Start background sync +//! let sync_handle = service.start_background_sync(); +//! +//! // Use convenience methods +//! let summary = service.sync_state().await?; +//! let tx_id = service.submit_transaction(account_id, tx_request).await?; +//! +//! // Or access the client directly for any operation +//! let mut client = service.client().await; +//! let accounts = client.get_account_headers().await?; +//! ``` + +#![cfg_attr(not(feature = "std"), no_std)] + +extern crate alloc; + +use std::sync::Arc; + +use miden_client::auth::TransactionAuthenticator; +use miden_client::sync::SyncSummary; +use miden_client::transaction::{TransactionId, TransactionRequest}; +use miden_client::{Client, ClientError}; +use miden_protocol::account::AccountId; +use tokio::sync::{Mutex, MutexGuard, broadcast}; +use tracing::{debug, info, warn}; + +mod config; + +pub use config::ServiceConfig; + +/// A service wrapper for the Miden client that provides coordination and background sync. +/// +/// `ClientService` adds the following on top of the base `Client`: +/// +/// - **Operation serialization**: All operations are serialized through a single mutex, ensuring +/// sync and transaction operations never overlap. +/// +/// - **Background sync**: Optional periodic synchronization that runs in the background. +/// +/// The service is `Send + Sync` and can be safely shared across tasks. Access the +/// underlying client via [`client()`](Self::client) for any operation, or use the +/// convenience methods [`sync_state()`](Self::sync_state) and +/// [`submit_transaction()`](Self::submit_transaction). +pub struct ClientService +where + AUTH: TransactionAuthenticator + Send + Sync + 'static, +{ + /// The underlying client, wrapped in a mutex for interior mutability. + client: Mutex>, + /// Service configuration. + config: ServiceConfig, +} + +impl ClientService +where + AUTH: TransactionAuthenticator + Send + Sync + 'static, +{ + /// Creates a new service wrapping the given client. + pub fn new(client: Client, config: ServiceConfig) -> Self { + Self { client: Mutex::new(client), config } + } + + /// Creates a new service with default configuration. + pub fn with_default_config(client: Client) -> Self { + Self::new(client, ServiceConfig::default()) + } + + /// Returns a reference to the service configuration. + pub fn config(&self) -> &ServiceConfig { + &self.config + } + + /// Returns a guard providing access to the underlying client. + /// + /// The returned [`MutexGuard`] dereferences to `Client`, supporting both + /// shared (`&`) and mutable (`&mut`) access. The lock is held until the guard + /// is dropped. + /// + /// Use this for any client operation that isn't covered by the convenience methods. + /// + /// # Example + /// + /// ```rust,ignore + /// let mut client = service.client().await; + /// let accounts = client.get_account_headers().await?; + /// ``` + pub async fn client(&self) -> MutexGuard<'_, Client> { + self.client.lock().await + } + + /// Synchronizes the client state with the network. + /// + /// This acquires exclusive access to the client, fetches the latest state + /// from the network, and applies it to the local store. + pub async fn sync_state(&self) -> Result { + debug!("Starting coordinated sync"); + let mut client = self.client.lock().await; + + let state_sync_update = client.get_sync_update().await?; + let summary: SyncSummary = (&state_sync_update).into(); + client.apply_state_sync(state_sync_update).await?; + + info!(block_num = ?summary.block_num, "Sync completed"); + Ok(summary) + } + + /// Submits a new transaction. + /// + /// This acquires exclusive access to the client and submits the transaction. + pub async fn submit_transaction( + &self, + account_id: AccountId, + transaction_request: TransactionRequest, + ) -> Result { + debug!(?account_id, "Starting coordinated transaction"); + let mut client = self.client.lock().await; + let tx_id = client.submit_new_transaction(account_id, transaction_request).await?; + info!(?tx_id, "Transaction submitted"); + Ok(tx_id) + } + + /// Starts a background sync loop that periodically syncs with the network. + /// + /// Returns a handle that can be used to stop the background sync. + /// The sync uses the interval configured in [`ServiceConfig::sync_interval`]. + /// + /// If background sync is disabled in the config, returns a handle that does nothing. + /// + /// # Example + /// + /// ```rust,ignore + /// let service = Arc::new(ClientService::new(client, config)); + /// let handle = service.start_background_sync(); + /// // Later: handle.stop(); + /// ``` + pub fn start_background_sync(self: &Arc) -> BackgroundSyncHandle { + let Some(interval) = self.config.sync_interval else { + let (tx, _) = broadcast::channel(1); + return BackgroundSyncHandle::new(tx); + }; + + let (shutdown_tx, mut shutdown_rx) = broadcast::channel(1); + let service = Arc::clone(self); + + tokio::spawn(async move { + info!(?interval, "Starting background sync"); + + loop { + tokio::select! { + _ = shutdown_rx.recv() => { + info!("Background sync shutting down"); + break; + } + _ = tokio::time::sleep(interval) => { + match service.sync_state().await { + Ok(summary) => { + debug!(block_num = ?summary.block_num, "Background sync completed"); + } + Err(e) => { + warn!(error = %e, "Background sync failed"); + } + } + } + } + } + }); + + BackgroundSyncHandle::new(shutdown_tx) + } +} + +/// A handle to control background sync operations. +pub struct BackgroundSyncHandle { + shutdown_tx: Option>, +} + +impl BackgroundSyncHandle { + fn new(shutdown_tx: broadcast::Sender<()>) -> Self { + Self { shutdown_tx: Some(shutdown_tx) } + } + + /// Signals the background sync to stop. + /// + /// The sync will complete its current operation before stopping. + pub fn stop(&mut self) { + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()); + } + } + + /// Returns true if the handle can still control the background sync. + pub fn is_active(&self) -> bool { + self.shutdown_tx.as_ref().is_some_and(|tx| tx.receiver_count() > 0) + } +} + +impl Drop for BackgroundSyncHandle { + fn drop(&mut self) { + self.stop(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_send_sync() {} + + #[test] + fn service_is_send_sync() { + assert_send_sync::>(); + } +} diff --git a/crates/idxdb-store/src/README.md b/crates/idxdb-store/src/README.md index ee4534ebf3..07731e8374 100644 --- a/crates/idxdb-store/src/README.md +++ b/crates/idxdb-store/src/README.md @@ -14,16 +14,3 @@ files under `src/js`. This is because to use extern functions, we still need to To unify and make this setup straightforward, the top-most makefile from this project has a useful target: `make rust-client-ts-build`, which takes the .ts files and compiles them down to .js files. -## Testing - -Tests use [vitest](https://vitest.dev/) with [fake-indexeddb](https://github.com/dumbmatter/fakeIndexedDB) to run IndexedDB operations in Node.js. - -```bash -yarn test # run tests -yarn build # compile TS → JS -yarn lint # build + eslint -``` - -## IndexedDB schema migrations - -The schema is defined in `ts/schema.ts` using [Dexie.js](https://dexie.org/) version blocks. See the comment above `version(1)` in the `MidenDatabase` constructor for how to add migrations. Migration tests go in `ts/schema.test.ts`. diff --git a/crates/idxdb-store/src/account/js_bindings.rs b/crates/idxdb-store/src/account/js_bindings.rs index 8e4a0b0d1d..c000f18d96 100644 --- a/crates/idxdb-store/src/account/js_bindings.rs +++ b/crates/idxdb-store/src/account/js_bindings.rs @@ -8,6 +8,8 @@ use miden_client::utils::Serializable; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::js_sys; +use crate::sync::JsAccountUpdate; + // INDEXED DB BINDINGS // ================================================================================================ @@ -36,13 +38,13 @@ extern "C" { pub fn idxdb_get_account_code(db_id: &str, code_root: String) -> js_sys::Promise; #[wasm_bindgen(js_name = getAccountStorage)] - pub fn idxdb_get_account_storage(db_id: &str, storage_root: String) -> js_sys::Promise; + pub fn idxdb_get_account_storage(db_id: &str, account_id: String) -> js_sys::Promise; #[wasm_bindgen(js_name = getAccountStorageMaps)] - pub fn idxdb_get_account_storage_maps(db_id: &str, roots: Vec) -> js_sys::Promise; + pub fn idxdb_get_account_storage_maps(db_id: &str, account_id: String) -> js_sys::Promise; #[wasm_bindgen(js_name = getAccountVaultAssets)] - pub fn idxdb_get_account_vault_assets(db_id: &str, vault_root: String) -> js_sys::Promise; + pub fn idxdb_get_account_vault_assets(db_id: &str, account_id: String) -> js_sys::Promise; #[wasm_bindgen(js_name = getAccountAuthByPubKeyCommitment)] pub fn idxdb_get_account_auth_by_pub_key_commitment( @@ -66,17 +68,26 @@ extern "C" { #[wasm_bindgen(js_name = upsertAccountStorage)] pub fn idxdb_upsert_account_storage( db_id: &str, + account_id: String, + nonce: String, storage_slots: Vec, ) -> js_sys::Promise; #[wasm_bindgen(js_name = upsertStorageMapEntries)] pub fn idxdb_upsert_storage_map_entries( db_id: &str, + account_id: String, + nonce: String, entries: Vec, ) -> js_sys::Promise; #[wasm_bindgen(js_name = upsertVaultAssets)] - pub fn idxdb_upsert_vault_assets(db_id: &str, assets: Vec) -> js_sys::Promise; + pub fn idxdb_upsert_vault_assets( + db_id: &str, + account_id: String, + nonce: String, + assets: Vec, + ) -> js_sys::Promise; #[wasm_bindgen(js_name = upsertAccountRecord)] pub fn idxdb_upsert_account_record( @@ -113,6 +124,31 @@ extern "C" { pub fn idxdb_get_foreign_account_code(db_id: &str, account_ids: Vec) -> js_sys::Promise; + // TRANSACTIONAL WRITES + // -------------------------------------------------------------------------------------------- + + #[wasm_bindgen(js_name = applyTransactionDelta)] + pub fn idxdb_apply_transaction_delta( + db_id: &str, + account_id: String, + nonce: String, + updated_slots: Vec, + changed_map_entries: Vec, + changed_assets: Vec, + code_root: String, + storage_root: String, + vault_root: String, + committed: bool, + commitment: String, + account_seed: Option>, + ) -> js_sys::Promise; + + #[wasm_bindgen(js_name = applyFullAccountState)] + pub fn idxdb_apply_full_account_state( + db_id: &str, + account_state: JsAccountUpdate, + ) -> js_sys::Promise; + // UPDATES // -------------------------------------------------------------------------------------------- @@ -129,13 +165,10 @@ extern "C" { // VAULT ASSET // ================================================================================================ -/// An object that contains a serialized vault asset +/// An object that contains a serialized vault asset. #[wasm_bindgen(getter_with_clone, inspectable)] #[derive(Clone)] pub struct JsVaultAsset { - /// The merkle root of the vault's assets. - #[wasm_bindgen(js_name = "root")] - pub root: String, /// The vault key associated with the asset. #[wasm_bindgen(js_name = "vaultKey")] pub vault_key: String, @@ -148,9 +181,8 @@ pub struct JsVaultAsset { } impl JsVaultAsset { - pub fn from_asset(asset: &Asset, vault_root: Word) -> Self { + pub fn from_asset(asset: &Asset) -> Self { Self { - root: vault_root.to_hex(), vault_key: Word::from(asset.vault_key()).to_hex(), faucet_id_prefix: asset.faucet_id_prefix().to_hex(), asset: Word::from(asset).to_hex(), @@ -165,9 +197,6 @@ impl JsVaultAsset { #[wasm_bindgen(getter_with_clone, inspectable)] #[derive(Clone)] pub struct JsStorageSlot { - /// Commitment of the whole account storage - #[wasm_bindgen(js_name = "commitment")] - pub commitment: String, /// The name of the storage slot. #[wasm_bindgen(js_name = "slotName")] pub slot_name: String, @@ -180,9 +209,8 @@ pub struct JsStorageSlot { } impl JsStorageSlot { - pub fn from_slot(slot: &StorageSlot, storage_commitment: Word) -> Self { + pub fn from_slot(slot: &StorageSlot) -> Self { Self { - commitment: storage_commitment.to_hex(), slot_name: slot.name().to_string(), slot_value: slot.value().to_hex(), slot_type: slot.slot_type().to_bytes()[0], @@ -197,9 +225,9 @@ impl JsStorageSlot { #[wasm_bindgen(getter_with_clone, inspectable)] #[derive(Clone)] pub struct JsStorageMapEntry { - /// The root of the storage map entry. - #[wasm_bindgen(js_name = "root")] - pub root: String, + /// The slot name of the map this entry belongs to. + #[wasm_bindgen(js_name = "slotName")] + pub slot_name: String, /// The key of the storage map entry. #[wasm_bindgen(js_name = "key")] pub key: String, @@ -209,10 +237,10 @@ pub struct JsStorageMapEntry { } impl JsStorageMapEntry { - pub fn from_map(map: &StorageMap) -> Vec { + pub fn from_map(map: &StorageMap, slot_name: &str) -> Vec { map.entries() .map(|(key, value)| Self { - root: map.root().to_hex(), + slot_name: slot_name.to_string(), key: key.to_hex(), value: value.to_hex(), }) diff --git a/crates/idxdb-store/src/account/mod.rs b/crates/idxdb-store/src/account/mod.rs index d25401fe6c..99cc118c93 100644 --- a/crates/idxdb-store/src/account/mod.rs +++ b/crates/idxdb-store/src/account/mod.rs @@ -65,8 +65,8 @@ use models::{ pub(crate) mod utils; use utils::{ + apply_full_account_state, parse_account_record_idxdb_object, - update_account, upsert_account_asset_vault, upsert_account_code, upsert_account_record, @@ -168,10 +168,8 @@ impl WebStore { }; let account_code = self.get_account_code(account_header.code_commitment()).await?; - let account_storage = self - .get_storage(account_header.storage_commitment(), AccountStorageFilter::All) - .await?; - let assets = self.get_vault_assets(account_header.vault_root()).await?; + let account_storage = self.get_storage(account_id, AccountStorageFilter::All).await?; + let assets = self.get_vault_assets(account_id).await?; let account_vault = AssetVault::new(&assets)?; let account = Account::new( @@ -200,10 +198,8 @@ impl WebStore { }; let account_code = self.get_account_code(account_header.code_commitment()).await?; - let account_storage = self - .get_storage(account_header.storage_commitment(), AccountStorageFilter::All) - .await?; - let assets = self.get_vault_assets(account_header.vault_root()).await?; + let account_storage = self.get_storage(account_id, AccountStorageFilter::All).await?; + let assets = self.get_vault_assets(account_id).await?; let account_vault = AssetVault::new(&assets)?; let account = Account::new( @@ -234,12 +230,12 @@ impl WebStore { pub(super) async fn get_storage( &self, - commitment: Word, + account_id: AccountId, filter: AccountStorageFilter, ) -> Result { - let commitment_serialized = commitment.to_string(); + let account_id_str = account_id.to_string(); - let promise = idxdb_get_account_storage(self.db_id(), commitment_serialized); + let promise = idxdb_get_account_storage(self.db_id(), account_id_str.clone()); let account_storage_idxdb: Vec = await_js(promise, "failed to fetch account storage").await?; @@ -278,28 +274,20 @@ impl WebStore { }, }; - let mut roots = Vec::new(); - for slot in &filtered_slots { - let slot_type = StorageSlotType::try_from(slot.slot_type)?; - if slot_type == StorageSlotType::Map { - roots.push(slot.slot_value.clone()); - } - } - - let promise = idxdb_get_account_storage_maps(self.db_id(), roots); + let promise = idxdb_get_account_storage_maps(self.db_id(), account_id_str); let account_maps_idxdb: Vec = await_js(promise, "failed to fetch account storage maps").await?; let mut maps = BTreeMap::new(); for entry in account_maps_idxdb { - let map = maps.entry(entry.root).or_insert_with(StorageMap::new); + let map = maps.entry(entry.slot_name).or_insert_with(StorageMap::new); map.insert(Word::try_from(entry.key.as_str())?, Word::try_from(entry.value.as_str())?)?; } let slots: Vec = filtered_slots .into_iter() .map(|slot| { - let slot_name = StorageSlotName::new(slot.slot_name).map_err(|err| { + let slot_name = StorageSlotName::new(slot.slot_name.clone()).map_err(|err| { StoreError::DatabaseError(format!("invalid storage slot name in db: {err}")) })?; @@ -310,7 +298,7 @@ impl WebStore { StorageSlot::with_value(slot_name, Word::try_from(slot.slot_value.as_str())?) }, StorageSlotType::Map => { - let map = maps.remove(&slot.slot_value).unwrap_or_else(StorageMap::new); + let map = maps.remove(&slot.slot_name).unwrap_or_else(StorageMap::new); if map.root().to_hex() != slot.slot_value { return Err(StoreError::DatabaseError(format!( "incomplete storage map for slot {slot_name} (expected root {}, got {})", @@ -327,8 +315,11 @@ impl WebStore { Ok(AccountStorage::new(slots)?) } - pub(super) async fn get_vault_assets(&self, root: Word) -> Result, StoreError> { - let promise = idxdb_get_account_vault_assets(self.db_id(), root.to_hex()); + pub(super) async fn get_vault_assets( + &self, + account_id: AccountId, + ) -> Result, StoreError> { + let promise = idxdb_get_account_vault_assets(self.db_id(), account_id.to_string()); let vault_assets_idxdb: Vec = await_js(promise, "failed to fetch vault assets").await?; @@ -352,13 +343,14 @@ impl WebStore { StoreError::DatabaseError(format!("failed to insert account code: {js_error:?}",)) })?; - upsert_account_storage(self.db_id(), account.storage()) + let nonce = account.nonce().as_int(); + upsert_account_storage(self.db_id(), &account.id(), nonce, account.storage()) .await .map_err(|js_error| { StoreError::DatabaseError(format!("failed to insert account storage:{js_error:?}",)) })?; - upsert_account_asset_vault(self.db_id(), account.vault()) + upsert_account_asset_vault(self.db_id(), &account.id(), nonce, account.vault()) .await .map_err(|js_error| { StoreError::DatabaseError(format!("failed to insert account vault:{js_error:?}",)) @@ -392,7 +384,7 @@ impl WebStore { return Err(StoreError::AccountDataNotFound(new_account_state.id())); } - update_account(self.db_id(), new_account_state) + apply_full_account_state(self.db_id(), new_account_state) .await .map_err(|_| StoreError::DatabaseError("failed to update account".to_string())) } @@ -401,13 +393,12 @@ impl WebStore { &self, account_id: AccountId, ) -> Result { - let account_header = self - .get_account_header(account_id) + // Verify account exists + self.get_account_header(account_id) .await? - .ok_or(StoreError::AccountDataNotFound(account_id))? - .0; + .ok_or(StoreError::AccountDataNotFound(account_id))?; - let assets = self.get_vault_assets(account_header.vault_root()).await?; + let assets = self.get_vault_assets(account_id).await?; Ok(AssetVault::new(&assets)?) } @@ -416,13 +407,12 @@ impl WebStore { account_id: AccountId, filter: AccountStorageFilter, ) -> Result { - let account_header = self - .get_account_header(account_id) + // Verify account exists + self.get_account_header(account_id) .await? - .ok_or(StoreError::AccountDataNotFound(account_id))? - .0; + .ok_or(StoreError::AccountDataNotFound(account_id))?; - self.get_storage(account_header.storage_commitment(), filter).await + self.get_storage(account_id, filter).await } pub(crate) async fn upsert_foreign_account_code( diff --git a/crates/idxdb-store/src/account/models.rs b/crates/idxdb-store/src/account/models.rs index 3e826de46c..a517fc39a6 100644 --- a/crates/idxdb-store/src/account/models.rs +++ b/crates/idxdb-store/src/account/models.rs @@ -25,7 +25,7 @@ pub struct AccountStorageIdxdbObject { #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct StorageMapEntryIdxdbObject { - pub root: String, + pub slot_name: String, pub key: String, pub value: String, } diff --git a/crates/idxdb-store/src/account/utils.rs b/crates/idxdb-store/src/account/utils.rs index 51daae6ea1..97fda613ba 100644 --- a/crates/idxdb-store/src/account/utils.rs +++ b/crates/idxdb-store/src/account/utils.rs @@ -1,19 +1,21 @@ -use alloc::string::ToString; +use alloc::string::{String, ToString}; use alloc::vec::Vec; use miden_client::account::{ Account, AccountCode, + AccountDelta, AccountHeader, AccountId, AccountStorage, Address, StorageSlotContent, + StorageSlotType, }; -use miden_client::asset::AssetVault; +use miden_client::asset::{Asset, AssetVault, FungibleAsset}; use miden_client::store::{AccountStatus, StoreError}; use miden_client::utils::{Deserializable, Serializable}; -use miden_client::{Felt, Word}; +use miden_client::{EMPTY_WORD, Felt, Word}; use wasm_bindgen::JsValue; use wasm_bindgen_futures::JsFuture; @@ -21,6 +23,8 @@ use super::js_bindings::{ JsStorageMapEntry, JsStorageSlot, JsVaultAsset, + idxdb_apply_full_account_state, + idxdb_apply_transaction_delta, idxdb_upsert_account_code, idxdb_upsert_account_record, idxdb_upsert_account_storage, @@ -29,6 +33,7 @@ use super::js_bindings::{ }; use crate::account::js_bindings::idxdb_insert_account_address; use crate::account::models::{AccountRecordIdxdbObject, AddressIdxdbObject}; +use crate::sync::JsAccountUpdate; pub async fn upsert_account_code(db_id: &str, account_code: &AccountCode) -> Result<(), JsValue> { let root = account_code.commitment().to_string(); @@ -42,33 +47,45 @@ pub async fn upsert_account_code(db_id: &str, account_code: &AccountCode) -> Res pub async fn upsert_account_storage( db_id: &str, + account_id: &AccountId, + nonce: u64, account_storage: &AccountStorage, ) -> Result<(), JsValue> { let mut slots = vec![]; let mut maps = vec![]; for slot in account_storage.slots() { - slots.push(JsStorageSlot::from_slot(slot, account_storage.to_commitment())); + slots.push(JsStorageSlot::from_slot(slot)); if let StorageSlotContent::Map(map) = slot.content() { - maps.extend(JsStorageMapEntry::from_map(map)); + maps.extend(JsStorageMapEntry::from_map(map, slot.name().as_str())); } } - JsFuture::from(idxdb_upsert_account_storage(db_id, slots)).await?; - JsFuture::from(idxdb_upsert_storage_map_entries(db_id, maps)).await?; + let account_id_str = account_id.to_string(); + let nonce_str = nonce.to_string(); + JsFuture::from(idxdb_upsert_account_storage( + db_id, + account_id_str.clone(), + nonce_str.clone(), + slots, + )) + .await?; + JsFuture::from(idxdb_upsert_storage_map_entries(db_id, account_id_str, nonce_str, maps)) + .await?; Ok(()) } pub async fn upsert_account_asset_vault( db_id: &str, + account_id: &AccountId, + nonce: u64, asset_vault: &AssetVault, ) -> Result<(), JsValue> { - let js_assets: Vec = asset_vault - .assets() - .map(|asset| JsVaultAsset::from_asset(&asset, asset_vault.root())) - .collect(); + let js_assets: Vec = + asset_vault.assets().map(|asset| JsVaultAsset::from_asset(&asset)).collect(); - let promise = idxdb_upsert_vault_assets(db_id, js_assets); + let promise = + idxdb_upsert_vault_assets(db_id, account_id.to_string(), nonce.to_string(), js_assets); JsFuture::from(promise).await?; Ok(()) @@ -164,8 +181,126 @@ pub fn parse_account_address_idxdb_object( Ok((address, native_account_id)) } -pub async fn update_account(db_id: &str, new_account_state: &Account) -> Result<(), JsValue> { - upsert_account_storage(db_id, new_account_state.storage()).await?; - upsert_account_asset_vault(db_id, new_account_state.vault()).await?; - upsert_account_record(db_id, new_account_state).await +/// Applies a transaction's account delta atomically in a single Dexie transaction. +/// Combines storage delta + vault delta + account record upsert. +pub async fn apply_transaction_delta( + db_id: &str, + account: &Account, + delta: &AccountDelta, +) -> Result<(), JsValue> { + let account_id_str = account.id().to_string(); + let nonce_str = account.nonce().to_string(); + + let mut updated_slots = Vec::new(); + let mut changed_map_entries = Vec::new(); + + // Value slots: delta gives (StorageSlotName, Word) + for (slot_name, value) in delta.storage().values() { + updated_slots.push(JsStorageSlot { + slot_name: slot_name.to_string(), + slot_value: value.to_hex(), + slot_type: StorageSlotType::Value as u8, + }); + } + + // Map slots: delta gives (StorageSlotName, StorageMapDelta) + for (slot_name, map_delta) in delta.storage().maps() { + // Get the new root value from the final account state for the slot itself + if let Some(slot) = account.storage().get(slot_name) { + updated_slots.push(JsStorageSlot::from_slot(slot)); + } + + // Extract changed map entries from the delta + for (key, value) in map_delta.entries() { + let value_str = if *value == EMPTY_WORD { + // Removal sentinel — the JS side interprets "" as "remove from latest, + // write null tombstone to historical" + String::new() + } else { + value.to_hex() + }; + + changed_map_entries.push(JsStorageMapEntry { + slot_name: slot_name.to_string(), + key: key.inner().to_hex(), + value: value_str, + }); + } + } + + // Build vault delta + let mut changed_assets = Vec::new(); + + for (faucet_id, _amount_delta) in delta.vault().fungible().iter() { + let balance = account + .vault() + .get_balance(*faucet_id) + .expect("faucet_id from delta should be valid"); + + if balance > 0 { + let asset = FungibleAsset::new(*faucet_id, balance) + .expect("balance from vault should be valid"); + changed_assets.push(JsVaultAsset::from_asset(&Asset::Fungible(asset))); + } else { + let dummy = + FungibleAsset::new(*faucet_id, 1).expect("faucet_id from delta should be valid"); + changed_assets.push(JsVaultAsset { + vault_key: Word::from(dummy.vault_key()).to_hex(), + faucet_id_prefix: dummy.faucet_id_prefix().to_hex(), + asset: String::new(), + }); + } + } + + for (nft, action) in delta.vault().non_fungible().iter() { + use miden_client::asset::NonFungibleDeltaAction; + match action { + NonFungibleDeltaAction::Add => { + changed_assets.push(JsVaultAsset::from_asset(&Asset::NonFungible(*nft))); + }, + NonFungibleDeltaAction::Remove => { + changed_assets.push(JsVaultAsset { + vault_key: Word::from(nft.vault_key()).to_hex(), + faucet_id_prefix: nft.faucet_id_prefix().to_hex(), + asset: String::new(), + }); + }, + } + } + + // Account record fields + let code_root = account.code().commitment().to_string(); + let storage_root = account.storage().to_commitment().to_string(); + let vault_root = account.vault().root().to_string(); + let committed = account.is_public(); + let commitment = account.commitment().to_string(); + let account_seed = account.seed().map(|seed| seed.to_bytes()); + + JsFuture::from(idxdb_apply_transaction_delta( + db_id, + account_id_str, + nonce_str, + updated_slots, + changed_map_entries, + changed_assets, + code_root, + storage_root, + vault_root, + committed, + commitment, + account_seed, + )) + .await?; + + Ok(()) +} + +/// Writes the full account state atomically in a single Dexie transaction. +/// Combines storage upsert + map entries upsert + vault assets upsert + account record upsert. +pub async fn apply_full_account_state(db_id: &str, account: &Account) -> Result<(), JsValue> { + let account_state = JsAccountUpdate::from_account(account, account.seed()); + + JsFuture::from(idxdb_apply_full_account_state(db_id, account_state)).await?; + + Ok(()) } diff --git a/crates/idxdb-store/src/auth.rs b/crates/idxdb-store/src/auth.rs index 97ab16a1c3..82f2903248 100644 --- a/crates/idxdb-store/src/auth.rs +++ b/crates/idxdb-store/src/auth.rs @@ -61,14 +61,14 @@ extern "C" { account_id_hex: String, ) -> js_sys::Promise; - #[wasm_bindgen(js_name = removeAllMappingsForKey)] - pub fn idxdb_remove_all_mappings_for_key( + #[wasm_bindgen(js_name = getAccountIdByKeyCommitment)] + pub fn idxdb_get_account_id_by_key_commitment( db_id: &str, pub_key_commitment_hex: String, ) -> js_sys::Promise; - #[wasm_bindgen(js_name = getAccountIdByKeyCommitment)] - pub fn idxdb_get_account_id_by_key_commitment( + #[wasm_bindgen(js_name = removeAllMappingsForKey)] + pub fn idxdb_remove_all_mappings_for_key( db_id: &str, pub_key_commitment_hex: String, ) -> js_sys::Promise; diff --git a/crates/idxdb-store/src/js/accounts.js b/crates/idxdb-store/src/js/accounts.js index cb80c7d1d5..7f4c55d354 100644 --- a/crates/idxdb-store/src/js/accounts.js +++ b/crates/idxdb-store/src/js/accounts.js @@ -1,10 +1,13 @@ import { getDatabase, } from "./schema.js"; import { logWebStoreError, uint8ArrayToBase64 } from "./utils.js"; +function seedToBase64(seed) { + return seed ? uint8ArrayToBase64(seed) : undefined; +} export async function getAccountIds(dbId) { try { const db = getDatabase(dbId); - const tracked = await db.trackedAccounts.toArray(); - return tracked.map((entry) => entry.id); + const records = await db.latestAccountHeaders.toArray(); + return records.map((entry) => entry.id); } catch (error) { logWebStoreError(error, "Error while fetching account IDs"); @@ -14,34 +17,17 @@ export async function getAccountIds(dbId) { export async function getAllAccountHeaders(dbId) { try { const db = getDatabase(dbId); - const latestRecordsMap = new Map(); - await db.accounts.each((record) => { - const existingRecord = latestRecordsMap.get(record.id); - if (!existingRecord || - BigInt(record.nonce) > BigInt(existingRecord.nonce)) { - latestRecordsMap.set(record.id, record); - } - }); - const latestRecords = Array.from(latestRecordsMap.values()); - const resultObject = await Promise.all(latestRecords.map((record) => { - let accountSeedBase64 = undefined; - if (record.accountSeed) { - const seedAsBytes = new Uint8Array(record.accountSeed); - if (seedAsBytes.length > 0) { - accountSeedBase64 = uint8ArrayToBase64(seedAsBytes); - } - } - return { - id: record.id, - nonce: record.nonce, - vaultRoot: record.vaultRoot, - storageRoot: record.storageRoot || "", - codeRoot: record.codeRoot || "", - accountSeed: accountSeedBase64, - locked: record.locked, - committed: record.committed, - accountCommitment: record.accountCommitment || "", - }; + const records = await db.latestAccountHeaders.toArray(); + const resultObject = records.map((record) => ({ + id: record.id, + nonce: record.nonce, + vaultRoot: record.vaultRoot, + storageRoot: record.storageRoot || "", + codeRoot: record.codeRoot || "", + accountSeed: seedToBase64(record.accountSeed), + locked: record.locked, + committed: record.committed, + accountCommitment: record.accountCommitment || "", })); return resultObject; } @@ -52,39 +38,23 @@ export async function getAllAccountHeaders(dbId) { export async function getAccountHeader(dbId, accountId) { try { const db = getDatabase(dbId); - const allMatchingRecords = await db.accounts + const record = await db.latestAccountHeaders .where("id") .equals(accountId) - .toArray(); - if (allMatchingRecords.length === 0) { + .first(); + if (!record) { console.log("No account header record found for given ID."); return null; } - const sortedRecords = allMatchingRecords.sort((a, b) => { - const bigIntA = BigInt(a.nonce); - const bigIntB = BigInt(b.nonce); - return bigIntA > bigIntB ? -1 : bigIntA < bigIntB ? 1 : 0; - }); - const mostRecentRecord = sortedRecords[0]; - if (mostRecentRecord === undefined) { - return null; - } - let accountSeedBase64 = undefined; - if (mostRecentRecord.accountSeed) { - if (mostRecentRecord.accountSeed.length > 0) { - accountSeedBase64 = uint8ArrayToBase64(mostRecentRecord.accountSeed); - } - } - const AccountHeader = { - id: mostRecentRecord.id, - nonce: mostRecentRecord.nonce, - vaultRoot: mostRecentRecord.vaultRoot, - storageRoot: mostRecentRecord.storageRoot, - codeRoot: mostRecentRecord.codeRoot, - accountSeed: accountSeedBase64, - locked: mostRecentRecord.locked, + return { + id: record.id, + nonce: record.nonce, + vaultRoot: record.vaultRoot, + storageRoot: record.storageRoot, + codeRoot: record.codeRoot, + accountSeed: seedToBase64(record.accountSeed), + locked: record.locked, }; - return AccountHeader; } catch (error) { logWebStoreError(error, `Error while fetching account header for id: ${accountId}`); @@ -93,32 +63,22 @@ export async function getAccountHeader(dbId, accountId) { export async function getAccountHeaderByCommitment(dbId, accountCommitment) { try { const db = getDatabase(dbId); - const allMatchingRecords = await db.accounts + const record = await db.historicalAccountHeaders .where("accountCommitment") .equals(accountCommitment) - .toArray(); - if (allMatchingRecords.length == 0) { + .first(); + if (!record) { return undefined; } - const matchingRecord = allMatchingRecords[0]; - if (matchingRecord === undefined) { - console.log("No account header record found for given commitment."); - return null; - } - let accountSeedBase64 = undefined; - if (matchingRecord.accountSeed) { - accountSeedBase64 = uint8ArrayToBase64(matchingRecord.accountSeed); - } - const AccountHeader = { - id: matchingRecord.id, - nonce: matchingRecord.nonce, - vaultRoot: matchingRecord.vaultRoot, - storageRoot: matchingRecord.storageRoot, - codeRoot: matchingRecord.codeRoot, - accountSeed: accountSeedBase64, - locked: matchingRecord.locked, + return { + id: record.id, + nonce: record.nonce, + vaultRoot: record.vaultRoot, + storageRoot: record.storageRoot, + codeRoot: record.codeRoot, + accountSeed: seedToBase64(record.accountSeed), + locked: record.locked, }; - return AccountHeader; } catch (error) { logWebStoreError(error, `Error fetching account header for commitment ${accountCommitment}`); @@ -146,12 +106,12 @@ export async function getAccountCode(dbId, codeRoot) { logWebStoreError(error, `Error fetching account code for root ${codeRoot}`); } } -export async function getAccountStorage(dbId, storageCommitment) { +export async function getAccountStorage(dbId, accountId) { try { const db = getDatabase(dbId); - const allMatchingRecords = await db.accountStorages - .where("commitment") - .equals(storageCommitment) + const allMatchingRecords = await db.latestAccountStorages + .where("accountId") + .equals(accountId) .toArray(); const slots = allMatchingRecords.map((record) => { return { @@ -163,28 +123,28 @@ export async function getAccountStorage(dbId, storageCommitment) { return slots; } catch (error) { - logWebStoreError(error, `Error fetching account storage for commitment ${storageCommitment}`); + logWebStoreError(error, `Error fetching account storage for account ${accountId}`); } } -export async function getAccountStorageMaps(dbId, roots) { +export async function getAccountStorageMaps(dbId, accountId) { try { const db = getDatabase(dbId); - const allMatchingRecords = await db.storageMapEntries - .where("root") - .anyOf(roots) + const allMatchingRecords = await db.latestStorageMapEntries + .where("accountId") + .equals(accountId) .toArray(); return allMatchingRecords; } catch (error) { - logWebStoreError(error, `Error fetching account storage maps for roots ${roots.join(", ")}`); + logWebStoreError(error, `Error fetching account storage maps for account ${accountId}`); } } -export async function getAccountVaultAssets(dbId, vaultRoot) { +export async function getAccountVaultAssets(dbId, accountId) { try { const db = getDatabase(dbId); - const allMatchingRecords = await db.accountAssets - .where("root") - .equals(vaultRoot) + const allMatchingRecords = await db.latestAccountAssets + .where("accountId") + .equals(accountId) .toArray(); const assets = allMatchingRecords.map((record) => { return { @@ -194,7 +154,7 @@ export async function getAccountVaultAssets(dbId, vaultRoot) { return assets; } catch (error) { - logWebStoreError(error, `Error fetching account vault for root ${vaultRoot}`); + logWebStoreError(error, `Error fetching account vault for account ${accountId}`); } } export async function getAccountAuthByPubKeyCommitment(dbId, pubKeyCommitmentHex) { @@ -241,54 +201,271 @@ export async function upsertAccountCode(dbId, codeRoot, code) { logWebStoreError(error, `Error inserting code with root: ${codeRoot}`); } } -export async function upsertAccountStorage(dbId, storageSlots) { +export async function upsertAccountStorage(dbId, accountId, nonce, storageSlots) { try { const db = getDatabase(dbId); - let processedSlots = storageSlots.map((slot) => { - return { - commitment: slot.commitment, - slotName: slot.slotName, - slotValue: slot.slotValue, - slotType: slot.slotType, - }; - }); - await db.accountStorages.bulkPut(processedSlots); + await db.latestAccountStorages + .where("accountId") + .equals(accountId) + .delete(); + if (storageSlots.length === 0) + return; + const latestEntries = storageSlots.map((slot) => ({ + accountId, + slotName: slot.slotName, + slotValue: slot.slotValue, + slotType: slot.slotType, + })); + const historicalEntries = latestEntries.map((entry) => ({ + ...entry, + nonce, + })); + await db.latestAccountStorages.bulkPut(latestEntries); + await db.historicalAccountStorages.bulkPut(historicalEntries); } catch (error) { logWebStoreError(error, `Error inserting storage slots`); } } -export async function upsertStorageMapEntries(dbId, entries) { +export async function upsertStorageMapEntries(dbId, accountId, nonce, entries) { try { const db = getDatabase(dbId); - let processedEntries = entries.map((entry) => { - return { - root: entry.root, - key: entry.key, - value: entry.value, + // Read old latest entries before clearing, to detect removals + const oldEntries = await db.latestStorageMapEntries + .where("accountId") + .equals(accountId) + .toArray(); + await db.latestStorageMapEntries + .where("accountId") + .equals(accountId) + .delete(); + // Build a set of new keys for fast lookup + const newKeySet = new Set(entries.map((e) => `${e.slotName}\0${e.key}`)); + // Write tombstones to historical for entries that existed but are now absent + for (const old of oldEntries) { + if (!newKeySet.has(`${old.slotName}\0${old.key}`)) { + await db.historicalStorageMapEntries.put({ + accountId, + nonce, + slotName: old.slotName, + key: old.key, + value: null, + }); + } + } + if (entries.length === 0) + return; + const latestEntries = entries.map((entry) => ({ + accountId, + slotName: entry.slotName, + key: entry.key, + value: entry.value, + })); + const historicalEntries = latestEntries.map((entry) => ({ + ...entry, + nonce, + })); + await db.latestStorageMapEntries.bulkPut(latestEntries); + await db.historicalStorageMapEntries.bulkPut(historicalEntries); + } + catch (error) { + logWebStoreError(error, `Error inserting storage map entries`); + } +} +export async function upsertVaultAssets(dbId, accountId, nonce, assets) { + try { + const db = getDatabase(dbId); + // Read old latest entries before clearing, to detect removals + const oldAssets = await db.latestAccountAssets + .where("accountId") + .equals(accountId) + .toArray(); + await db.latestAccountAssets.where("accountId").equals(accountId).delete(); + // Build a set of new vault keys for fast lookup + const newKeySet = new Set(assets.map((a) => a.vaultKey)); + // Write tombstones to historical for assets that existed but are now absent + for (const old of oldAssets) { + if (!newKeySet.has(old.vaultKey)) { + await db.historicalAccountAssets.put({ + accountId, + nonce, + vaultKey: old.vaultKey, + faucetIdPrefix: old.faucetIdPrefix, + asset: null, + }); + } + } + if (assets.length === 0) + return; + const latestEntries = assets.map((asset) => ({ + accountId, + vaultKey: asset.vaultKey, + faucetIdPrefix: asset.faucetIdPrefix, + asset: asset.asset, + })); + const historicalEntries = latestEntries.map((entry) => ({ + ...entry, + nonce, + })); + await db.latestAccountAssets.bulkPut(latestEntries); + await db.historicalAccountAssets.bulkPut(historicalEntries); + } + catch (error) { + logWebStoreError(error, `Error inserting assets`); + } +} +export async function applyTransactionDelta(dbId, accountId, nonce, updatedSlots, changedMapEntries, changedAssets, codeRoot, storageRoot, vaultRoot, committed, commitment, accountSeed) { + try { + const db = getDatabase(dbId); + await db.dexie.transaction("rw", [ + db.latestAccountStorages, + db.historicalAccountStorages, + db.latestStorageMapEntries, + db.historicalStorageMapEntries, + db.latestAccountAssets, + db.historicalAccountAssets, + db.latestAccountHeaders, + db.historicalAccountHeaders, + ], async () => { + // Apply storage delta + for (const slot of updatedSlots) { + await db.latestAccountStorages.put({ + accountId, + slotName: slot.slotName, + slotValue: slot.slotValue, + slotType: slot.slotType, + }); + await db.historicalAccountStorages.put({ + accountId, + nonce, + slotName: slot.slotName, + slotValue: slot.slotValue, + slotType: slot.slotType, + }); + } + // Process map entries: value="" means removal + for (const entry of changedMapEntries) { + if (entry.value === "") { + // Removal: delete from latest, write tombstone to historical + await db.latestStorageMapEntries + .where("[accountId+slotName+key]") + .equals([accountId, entry.slotName, entry.key]) + .delete(); + await db.historicalStorageMapEntries.put({ + accountId, + nonce, + slotName: entry.slotName, + key: entry.key, + value: null, + }); + } + else { + // Update: put to both latest and historical + await db.latestStorageMapEntries.put({ + accountId, + slotName: entry.slotName, + key: entry.key, + value: entry.value, + }); + await db.historicalStorageMapEntries.put({ + accountId, + nonce, + slotName: entry.slotName, + key: entry.key, + value: entry.value, + }); + } + } + // Apply vault delta + for (const entry of changedAssets) { + if (entry.asset === "") { + // Removal: delete from latest, write tombstone to historical + await db.latestAccountAssets + .where("[accountId+vaultKey]") + .equals([accountId, entry.vaultKey]) + .delete(); + await db.historicalAccountAssets.put({ + accountId, + nonce, + vaultKey: entry.vaultKey, + faucetIdPrefix: entry.faucetIdPrefix, + asset: null, + }); + } + else { + // Update: put to both latest and historical + await db.latestAccountAssets.put({ + accountId, + vaultKey: entry.vaultKey, + faucetIdPrefix: entry.faucetIdPrefix, + asset: entry.asset, + }); + await db.historicalAccountAssets.put({ + accountId, + nonce, + vaultKey: entry.vaultKey, + faucetIdPrefix: entry.faucetIdPrefix, + asset: entry.asset, + }); + } + } + // Upsert account record + const data = { + id: accountId, + codeRoot, + storageRoot, + vaultRoot, + nonce, + committed, + accountSeed, + accountCommitment: commitment, + locked: false, }; + await db.historicalAccountHeaders.put(data); + await db.latestAccountHeaders.put(data); }); - await db.storageMapEntries.bulkPut(processedEntries); } catch (error) { - logWebStoreError(error, `Error inserting storage map entries`); + logWebStoreError(error, `Error applying transaction delta`); } } -export async function upsertVaultAssets(dbId, assets) { +export async function applyFullAccountState(dbId, accountState) { try { const db = getDatabase(dbId); - let processedAssets = assets.map((asset) => { - return { - root: asset.root, - vaultKey: asset.vaultKey, - faucetIdPrefix: asset.faucetIdPrefix, - asset: asset.asset, + const { accountId, nonce, storageSlots, storageMapEntries, assets, codeRoot, storageRoot, vaultRoot, committed, accountCommitment, accountSeed, } = accountState; + await db.dexie.transaction("rw", [ + db.latestAccountStorages, + db.historicalAccountStorages, + db.latestStorageMapEntries, + db.historicalStorageMapEntries, + db.latestAccountAssets, + db.historicalAccountAssets, + db.latestAccountHeaders, + db.historicalAccountHeaders, + ], async () => { + // Upsert account storage (calls existing helpers — Dexie nesting + // joins them to this outer transaction) + await upsertAccountStorage(dbId, accountId, nonce, storageSlots); + await upsertStorageMapEntries(dbId, accountId, nonce, storageMapEntries); + await upsertVaultAssets(dbId, accountId, nonce, assets); + // Upsert account record + const data = { + id: accountId, + codeRoot, + storageRoot, + vaultRoot, + nonce, + committed, + accountSeed, + accountCommitment, + locked: false, }; + await db.historicalAccountHeaders.put(data); + await db.latestAccountHeaders.put(data); }); - await db.accountAssets.bulkPut(processedAssets); } catch (error) { - logWebStoreError(error, `Error inserting assets`); + logWebStoreError(error, `Error applying full account state`); } } export async function upsertAccountRecord(dbId, accountId, codeRoot, storageRoot, vaultRoot, nonce, committed, commitment, accountSeed) { @@ -305,8 +482,8 @@ export async function upsertAccountRecord(dbId, accountId, codeRoot, storageRoot accountCommitment: commitment, locked: false, }; - await db.accounts.put(data); - await db.trackedAccounts.put({ id: accountId }); + await db.historicalAccountHeaders.put(data); + await db.latestAccountHeaders.put(data); } catch (error) { logWebStoreError(error, `Error inserting account: ${accountId}`); @@ -399,19 +576,200 @@ export async function getForeignAccountCode(dbId, accountIds) { export async function lockAccount(dbId, accountId) { try { const db = getDatabase(dbId); - await db.accounts.where("id").equals(accountId).modify({ locked: true }); + await db.latestAccountHeaders + .where("id") + .equals(accountId) + .modify({ locked: true }); + // Also lock historical rows so that undo/rebuild preserves the lock. + await db.historicalAccountHeaders + .where("id") + .equals(accountId) + .modify({ locked: true }); } catch (error) { logWebStoreError(error, `Error locking account: ${accountId}`); } } +/** + * Rebuilds latest storage slots from historical data. + * Groups by slotName, takes the entry with MAX(nonce) per slot. + * Slots cannot be removed, so no tombstone filtering needed. + */ +async function rebuildLatestStorageSlots(db, accountId) { + await db.latestAccountStorages.where("accountId").equals(accountId).delete(); + const allHist = await db.historicalAccountStorages + .where("accountId") + .equals(accountId) + .toArray(); + // Group by slotName, take MAX(nonce) per slot + const bySlot = new Map(); + for (const entry of allHist) { + const existing = bySlot.get(entry.slotName); + if (!existing || BigInt(entry.nonce) > BigInt(existing.nonce)) { + bySlot.set(entry.slotName, entry); + } + } + if (bySlot.size > 0) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const entries = [...bySlot.values()].map(({ nonce, ...rest }) => rest); + await db.latestAccountStorages.bulkPut(entries); + } +} +/** + * Rebuilds latest storage map entries from historical data. + * Groups by (slotName, key), takes the entry with MAX(nonce) per key. + * Filters out tombstones (value === null). + */ +async function rebuildLatestStorageMapEntries(db, accountId) { + await db.latestStorageMapEntries + .where("accountId") + .equals(accountId) + .delete(); + const allHist = await db.historicalStorageMapEntries + .where("accountId") + .equals(accountId) + .toArray(); + // Group by (slotName, key), take MAX(nonce) per key + const byKey = new Map(); + for (const entry of allHist) { + const compositeKey = `${entry.slotName}\0${entry.key}`; + const existing = byKey.get(compositeKey); + if (!existing || BigInt(entry.nonce) > BigInt(existing.nonce)) { + byKey.set(compositeKey, entry); + } + } + // Filter out tombstones and strip nonce + const entries = [...byKey.values()] + .filter((e) => e.value !== null) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .map(({ nonce, value, ...rest }) => ({ + ...rest, + value: value, + })); + if (entries.length > 0) { + await db.latestStorageMapEntries.bulkPut(entries); + } +} +/** + * Rebuilds latest vault assets from historical data. + * Groups by vaultKey, takes the entry with MAX(nonce) per key. + * Filters out tombstones (asset === null). + */ +async function rebuildLatestVaultAssets(db, accountId) { + await db.latestAccountAssets.where("accountId").equals(accountId).delete(); + const allHist = await db.historicalAccountAssets + .where("accountId") + .equals(accountId) + .toArray(); + // Group by vaultKey, take MAX(nonce) per key + const byKey = new Map(); + for (const entry of allHist) { + const existing = byKey.get(entry.vaultKey); + if (!existing || BigInt(entry.nonce) > BigInt(existing.nonce)) { + byKey.set(entry.vaultKey, entry); + } + } + // Filter out tombstones and strip nonce + const entries = [...byKey.values()] + .filter((e) => e.asset !== null) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .map(({ nonce, asset, ...rest }) => ({ + ...rest, + asset: asset, + })); + if (entries.length > 0) { + await db.latestAccountAssets.bulkPut(entries); + } +} export async function undoAccountStates(dbId, accountCommitments) { try { const db = getDatabase(dbId); - await db.accounts - .where("accountCommitment") - .anyOf(accountCommitments) - .delete(); + await db.dexie.transaction("rw", [ + db.latestAccountStorages, + db.historicalAccountStorages, + db.latestStorageMapEntries, + db.historicalStorageMapEntries, + db.latestAccountAssets, + db.historicalAccountAssets, + db.latestAccountHeaders, + db.historicalAccountHeaders, + ], async () => { + // Find affected records to get their account IDs and nonces before deleting + const affectedRecords = await db.historicalAccountHeaders + .where("accountCommitment") + .anyOf(accountCommitments) + .toArray(); + // Collect affected (accountId, nonce) pairs + const accountNonces = new Map(); + for (const record of affectedRecords) { + if (!accountNonces.has(record.id)) { + accountNonces.set(record.id, new Set()); + } + accountNonces.get(record.id).add(record.nonce); + } + // Delete matching records from historical account headers + await db.historicalAccountHeaders + .where("accountCommitment") + .anyOf(accountCommitments) + .delete(); + // Delete historical storage/map/assets for affected (accountId, nonce) pairs + for (const [accountId, nonces] of accountNonces) { + for (const nonce of nonces) { + await db.historicalAccountStorages + .where("[accountId+nonce]") + .equals([accountId, nonce]) + .delete(); + await db.historicalStorageMapEntries + .where("[accountId+nonce]") + .equals([accountId, nonce]) + .delete(); + await db.historicalAccountAssets + .where("[accountId+nonce]") + .equals([accountId, nonce]) + .delete(); + } + } + // Rebuild latest for each affected account + for (const accountId of accountNonces.keys()) { + const remaining = await db.historicalAccountHeaders + .where("id") + .equals(accountId) + .toArray(); + if (remaining.length === 0) { + // Account completely undone — clear all latest tables + await db.latestAccountHeaders + .where("id") + .equals(accountId) + .delete(); + await db.latestAccountStorages + .where("accountId") + .equals(accountId) + .delete(); + await db.latestStorageMapEntries + .where("accountId") + .equals(accountId) + .delete(); + await db.latestAccountAssets + .where("accountId") + .equals(accountId) + .delete(); + } + else { + // Find the record with the highest nonce + let maxRecord = remaining[0]; + for (const record of remaining) { + if (BigInt(record.nonce) > BigInt(maxRecord.nonce)) { + maxRecord = record; + } + } + // Rebuild latest from historical using MAX(nonce) per key + await db.latestAccountHeaders.put(maxRecord); + await rebuildLatestStorageSlots(db, accountId); + await rebuildLatestStorageMapEntries(db, accountId); + await rebuildLatestVaultAssets(db, accountId); + } + } + }); } catch (error) { logWebStoreError(error, `Error undoing account states: ${accountCommitments.join(",")}`); @@ -470,29 +828,29 @@ export async function getKeyCommitmentsByAccountId(dbId, accountIdHex) { return []; } } -export async function removeAllMappingsForKey(dbId, pubKeyCommitmentHex) { +export async function getAccountIdByKeyCommitment(dbId, pubKeyCommitmentHex) { try { const db = getDatabase(dbId); - await db.accountKeyMappings + const mapping = await db.accountKeyMappings .where("pubKeyCommitmentHex") .equals(pubKeyCommitmentHex) - .delete(); + .first(); + return mapping?.accountIdHex ?? null; } catch (error) { - logWebStoreError(error, `Error removing all mappings for key: ${pubKeyCommitmentHex}`); + logWebStoreError(error, `Error fetching account by public key commitment: ${pubKeyCommitmentHex}`); + return null; } } -export async function getAccountIdByKeyCommitment(dbId, pubKeyCommitmentHex) { +export async function removeAllMappingsForKey(dbId, pubKeyCommitmentHex) { try { const db = getDatabase(dbId); - const mapping = await db.accountKeyMappings + await db.accountKeyMappings .where("pubKeyCommitmentHex") .equals(pubKeyCommitmentHex) - .first(); - return mapping?.accountIdHex ?? null; + .delete(); } catch (error) { - logWebStoreError(error, `Error fetching account by public key commitment: ${pubKeyCommitmentHex}`); - return null; + logWebStoreError(error, `Error removing all mappings for key: ${pubKeyCommitmentHex}`); } } diff --git a/crates/idxdb-store/src/js/schema.js b/crates/idxdb-store/src/js/schema.js index c1fba0216f..25e3ebd72e 100644 --- a/crates/idxdb-store/src/js/schema.js +++ b/crates/idxdb-store/src/js/schema.js @@ -32,12 +32,16 @@ export async function openDatabase(network, clientVersion) { var Table; (function (Table) { Table["AccountCode"] = "accountCode"; - Table["AccountStorage"] = "accountStorage"; - Table["AccountAssets"] = "accountAssets"; - Table["StorageMapEntries"] = "storageMapEntries"; + Table["LatestAccountStorage"] = "latestAccountStorage"; + Table["HistoricalAccountStorage"] = "historicalAccountStorage"; + Table["LatestAccountAssets"] = "latestAccountAssets"; + Table["HistoricalAccountAssets"] = "historicalAccountAssets"; + Table["LatestStorageMapEntries"] = "latestStorageMapEntries"; + Table["HistoricalStorageMapEntries"] = "historicalStorageMapEntries"; Table["AccountAuth"] = "accountAuth"; Table["AccountKeyMapping"] = "accountKeyMapping"; - Table["Accounts"] = "accounts"; + Table["LatestAccountHeaders"] = "latestAccountHeaders"; + Table["HistoricalAccountHeaders"] = "historicalAccountHeaders"; Table["Addresses"] = "addresses"; Table["Transactions"] = "transactions"; Table["TransactionScripts"] = "transactionScripts"; @@ -50,7 +54,6 @@ var Table; Table["Tags"] = "tags"; Table["ForeignAccountCode"] = "foreignAccountCode"; Table["Settings"] = "settings"; - Table["TrackedAccounts"] = "trackedAccounts"; })(Table || (Table = {})); function indexes(...items) { return items.join(","); @@ -59,12 +62,16 @@ function indexes(...items) { * never be modified — all schema changes should go through new version blocks instead. */ const V1_STORES = { [Table.AccountCode]: indexes("root"), - [Table.AccountStorage]: indexes("[commitment+slotName]", "commitment"), - [Table.StorageMapEntries]: indexes("[root+key]", "root"), - [Table.AccountAssets]: indexes("[root+vaultKey]", "root", "faucetIdPrefix"), + [Table.LatestAccountStorage]: indexes("[accountId+slotName]", "accountId"), + [Table.HistoricalAccountStorage]: indexes("[accountId+nonce+slotName]", "accountId", "[accountId+nonce]"), + [Table.LatestStorageMapEntries]: indexes("[accountId+slotName+key]", "accountId", "[accountId+slotName]"), + [Table.HistoricalStorageMapEntries]: indexes("[accountId+nonce+slotName+key]", "accountId", "[accountId+nonce]"), + [Table.LatestAccountAssets]: indexes("[accountId+vaultKey]", "accountId", "faucetIdPrefix"), + [Table.HistoricalAccountAssets]: indexes("[accountId+nonce+vaultKey]", "accountId", "[accountId+nonce]"), [Table.AccountAuth]: indexes("pubKeyCommitmentHex"), [Table.AccountKeyMapping]: indexes("[accountIdHex+pubKeyCommitmentHex]", "accountIdHex", "pubKeyCommitmentHex"), - [Table.Accounts]: indexes("&accountCommitment", "id", "[id+nonce]", "codeRoot", "storageRoot", "vaultRoot"), + [Table.LatestAccountHeaders]: indexes("&id", "accountCommitment"), + [Table.HistoricalAccountHeaders]: indexes("&accountCommitment", "id", "[id+nonce]"), [Table.Addresses]: indexes("address", "id"), [Table.Transactions]: indexes("id", "statusVariant"), [Table.TransactionScripts]: indexes("scriptRoot"), @@ -77,17 +84,20 @@ const V1_STORES = { [Table.Tags]: indexes("id++", "tag", "sourceNoteId", "sourceAccountId"), [Table.ForeignAccountCode]: indexes("accountId"), [Table.Settings]: indexes("key"), - [Table.TrackedAccounts]: indexes("&id"), }; export class MidenDatabase { dexie; accountCodes; - accountStorages; - storageMapEntries; - accountAssets; + latestAccountStorages; + historicalAccountStorages; + latestStorageMapEntries; + historicalStorageMapEntries; + latestAccountAssets; + historicalAccountAssets; accountAuths; accountKeyMappings; - accounts; + latestAccountHeaders; + historicalAccountHeaders; addresses; transactions; transactionScripts; @@ -100,7 +110,6 @@ export class MidenDatabase { tags; foreignAccountCode; settings; - trackedAccounts; constructor(network) { this.dexie = new Dexie(network); // --- Schema versioning --- @@ -148,12 +157,16 @@ export class MidenDatabase { // 3. Add version(2+) blocks below for all schema changes going forward. this.dexie.version(1).stores(V1_STORES); this.accountCodes = this.dexie.table(Table.AccountCode); - this.accountStorages = this.dexie.table(Table.AccountStorage); - this.storageMapEntries = this.dexie.table(Table.StorageMapEntries); - this.accountAssets = this.dexie.table(Table.AccountAssets); + this.latestAccountStorages = this.dexie.table(Table.LatestAccountStorage); + this.historicalAccountStorages = this.dexie.table(Table.HistoricalAccountStorage); + this.latestStorageMapEntries = this.dexie.table(Table.LatestStorageMapEntries); + this.historicalStorageMapEntries = this.dexie.table(Table.HistoricalStorageMapEntries); + this.latestAccountAssets = this.dexie.table(Table.LatestAccountAssets); + this.historicalAccountAssets = this.dexie.table(Table.HistoricalAccountAssets); this.accountAuths = this.dexie.table(Table.AccountAuth); this.accountKeyMappings = this.dexie.table(Table.AccountKeyMapping); - this.accounts = this.dexie.table(Table.Accounts); + this.latestAccountHeaders = this.dexie.table(Table.LatestAccountHeaders); + this.historicalAccountHeaders = this.dexie.table(Table.HistoricalAccountHeaders); this.addresses = this.dexie.table(Table.Addresses); this.transactions = this.dexie.table(Table.Transactions); this.transactionScripts = this.dexie.table(Table.TransactionScripts); @@ -166,7 +179,6 @@ export class MidenDatabase { this.tags = this.dexie.table(Table.Tags); this.foreignAccountCode = this.dexie.table(Table.ForeignAccountCode); this.settings = this.dexie.table(Table.Settings); - this.trackedAccounts = this.dexie.table(Table.TrackedAccounts); this.dexie.on("populate", () => { this.stateSync .put({ id: 1, blockNum: 0 }) diff --git a/crates/idxdb-store/src/js/sync.js b/crates/idxdb-store/src/js/sync.js index a2e2aa53ed..4eeb035dc5 100644 --- a/crates/idxdb-store/src/js/sync.js +++ b/crates/idxdb-store/src/js/sync.js @@ -75,44 +75,50 @@ export async function applyStateSync(dbId, stateUpdate) { const { blockNum, flattenedNewBlockHeaders, flattenedPartialBlockChainPeaks, newBlockNums, blockHasRelevantNotes, serializedNodeIds, serializedNodes, committedNoteIds, serializedInputNotes, serializedOutputNotes, accountUpdates, transactionUpdates, } = stateUpdate; const newBlockHeaders = reconstructFlattenedVec(flattenedNewBlockHeaders); const partialBlockchainPeaks = reconstructFlattenedVec(flattenedPartialBlockChainPeaks); - let inputNotesWriteOp = Promise.all(serializedInputNotes.map((note) => { - return upsertInputNote(dbId, note.noteId, note.noteAssets, note.serialNumber, note.inputs, note.noteScriptRoot, note.noteScript, note.nullifier, note.createdAt, note.stateDiscriminant, note.state); - })); - let outputNotesWriteOp = Promise.all(serializedOutputNotes.map((note) => { - return upsertOutputNote(dbId, note.noteId, note.noteAssets, note.recipientDigest, note.metadata, note.nullifier, note.expectedHeight, note.stateDiscriminant, note.state); - })); - let transactionWriteOp = Promise.all(transactionUpdates.map((transactionRecord) => { - let promises = [ - upsertTransactionRecord(dbId, transactionRecord.id, transactionRecord.details, transactionRecord.blockNum, transactionRecord.statusVariant, transactionRecord.status, transactionRecord.scriptRoot), - ]; - if (transactionRecord.scriptRoot && transactionRecord.txScript) { - promises.push(insertTransactionScript(dbId, transactionRecord.scriptRoot, transactionRecord.txScript)); - } - return Promise.all(promises); - })); - let accountUpdatesWriteOp = Promise.all(accountUpdates.flatMap((accountUpdate) => { - return [ - upsertAccountStorage(dbId, accountUpdate.storageSlots), - upsertStorageMapEntries(dbId, accountUpdate.storageMapEntries), - upsertVaultAssets(dbId, accountUpdate.assets), - upsertAccountRecord(dbId, accountUpdate.accountId, accountUpdate.codeRoot, accountUpdate.storageRoot, accountUpdate.assetVaultRoot, accountUpdate.nonce, accountUpdate.committed, accountUpdate.accountCommitment, accountUpdate.accountSeed), - ]; - })); const tablesToAccess = [ db.stateSync, db.inputNotes, db.outputNotes, + db.notesScripts, db.transactions, + db.transactionScripts, db.blockHeaders, db.partialBlockchainNodes, db.tags, + db.latestAccountHeaders, + db.historicalAccountHeaders, + db.latestAccountStorages, + db.historicalAccountStorages, + db.latestStorageMapEntries, + db.historicalStorageMapEntries, + db.latestAccountAssets, + db.historicalAccountAssets, ]; return await db.dexie.transaction("rw", tablesToAccess, async (tx) => { await Promise.all([ - inputNotesWriteOp, - outputNotesWriteOp, - transactionWriteOp, - accountUpdatesWriteOp, + Promise.all(serializedInputNotes.map((note) => { + return upsertInputNote(dbId, note.noteId, note.noteAssets, note.serialNumber, note.inputs, note.noteScriptRoot, note.noteScript, note.nullifier, note.createdAt, note.stateDiscriminant, note.state); + })), + Promise.all(serializedOutputNotes.map((note) => { + return upsertOutputNote(dbId, note.noteId, note.noteAssets, note.recipientDigest, note.metadata, note.nullifier, note.expectedHeight, note.stateDiscriminant, note.state); + })), + Promise.all(transactionUpdates.map((transactionRecord) => { + let promises = [ + upsertTransactionRecord(dbId, transactionRecord.id, transactionRecord.details, transactionRecord.blockNum, transactionRecord.statusVariant, transactionRecord.status, transactionRecord.scriptRoot), + ]; + if (transactionRecord.scriptRoot && transactionRecord.txScript) { + promises.push(insertTransactionScript(dbId, transactionRecord.scriptRoot, transactionRecord.txScript)); + } + return Promise.all(promises); + })), + Promise.all(accountUpdates.flatMap((accountUpdate) => { + return [ + upsertAccountStorage(dbId, accountUpdate.accountId, accountUpdate.nonce, accountUpdate.storageSlots), + upsertStorageMapEntries(dbId, accountUpdate.accountId, accountUpdate.nonce, accountUpdate.storageMapEntries), + upsertVaultAssets(dbId, accountUpdate.accountId, accountUpdate.nonce, accountUpdate.assets), + upsertAccountRecord(dbId, accountUpdate.accountId, accountUpdate.codeRoot, accountUpdate.storageRoot, accountUpdate.vaultRoot, accountUpdate.nonce, accountUpdate.committed, accountUpdate.accountCommitment, accountUpdate.accountSeed), + ]; + })), updateSyncHeight(tx, blockNum), updatePartialBlockchainNodes(tx, serializedNodeIds, serializedNodes), updateCommittedNoteTags(tx, committedNoteIds), diff --git a/crates/idxdb-store/src/sync/js_bindings.rs b/crates/idxdb-store/src/sync/js_bindings.rs index 0e00ae5319..a29c12bdd4 100644 --- a/crates/idxdb-store/src/sync/js_bindings.rs +++ b/crates/idxdb-store/src/sync/js_bindings.rs @@ -129,8 +129,8 @@ pub struct JsAccountUpdate { pub storage_map_entries: Vec, /// The merkle root of the account's asset vault. - #[wasm_bindgen(js_name = "assetVaultRoot")] - pub asset_vault_root: String, + #[wasm_bindgen(js_name = "vaultRoot")] + pub vault_root: String, /// The account's asset vault. #[wasm_bindgen(js_name = "assets")] @@ -166,35 +166,22 @@ impl JsAccountUpdate { let asset_vault = account.vault(); Self { storage_root: account.storage().to_commitment().to_string(), - storage_slots: account - .storage() - .slots() - .iter() - .map(|slot| JsStorageSlot { - commitment: account.storage().to_commitment().to_hex(), - slot_name: slot.name().to_string(), - slot_value: slot.value().to_hex(), - slot_type: slot.slot_type().to_bytes()[0], - }) - .collect(), + storage_slots: account.storage().slots().iter().map(JsStorageSlot::from_slot).collect(), storage_map_entries: account .storage() .slots() .iter() .filter_map(|slot| { if let StorageSlotContent::Map(map) = slot.content() { - Some(JsStorageMapEntry::from_map(map)) + Some(JsStorageMapEntry::from_map(map, slot.name().as_str())) } else { None } }) .flatten() .collect(), - asset_vault_root: asset_vault.root().to_string(), - assets: asset_vault - .assets() - .map(|asset| JsVaultAsset::from_asset(&asset, asset_vault.root())) - .collect(), + vault_root: asset_vault.root().to_string(), + assets: asset_vault.assets().map(|asset| JsVaultAsset::from_asset(&asset)).collect(), account_id: account.id().to_string(), code_root: account.code().commitment().to_string(), committed: account.is_public(), diff --git a/crates/idxdb-store/src/sync/mod.rs b/crates/idxdb-store/src/sync/mod.rs index bd0ffbb76e..13d8832627 100644 --- a/crates/idxdb-store/src/sync/mod.rs +++ b/crates/idxdb-store/src/sync/mod.rs @@ -17,8 +17,8 @@ use super::transaction::utils::serialize_transaction_record; use crate::promise::{await_js, await_js_value}; mod js_bindings; +pub use js_bindings::JsAccountUpdate; use js_bindings::{ - JsAccountUpdate, JsStateSyncUpdate, idxdb_add_note_tag, idxdb_apply_state_sync, diff --git a/crates/idxdb-store/src/transaction/mod.rs b/crates/idxdb-store/src/transaction/mod.rs index 57fd701024..4f1757f62c 100644 --- a/crates/idxdb-store/src/transaction/mod.rs +++ b/crates/idxdb-store/src/transaction/mod.rs @@ -15,7 +15,7 @@ use miden_client::transaction::{ use miden_client::utils::Deserializable; use super::WebStore; -use super::account::utils::update_account; +use super::account::utils::{apply_full_account_state, apply_transaction_delta}; use super::note::utils::apply_note_updates_tx; use crate::promise::await_js; @@ -96,7 +96,6 @@ impl WebStore { .await?; // Account Data - // TODO: This should be refactored to avoid fetching the whole account state. let delta = tx_update.executed_transaction().account_delta(); let mut account: Account = self .get_account(delta.id()) @@ -108,14 +107,18 @@ impl WebStore { if delta.is_full_state() { account = delta.try_into().expect("casting account from full state delta should not fail"); + // Full-state path: writes all entries and tombstones for removed ones + apply_full_account_state(self.db_id(), &account).await.map_err(|err| { + StoreError::DatabaseError(format!("failed to apply full account state: {err:?}")) + })?; } else { account.apply_delta(delta)?; + // Delta path: write only changed entries (single Dexie transaction) + apply_transaction_delta(self.db_id(), &account, delta).await.map_err(|err| { + StoreError::DatabaseError(format!("failed to apply transaction delta: {err:?}")) + })?; } - update_account(self.db_id(), &account).await.map_err(|err| { - StoreError::DatabaseError(format!("failed to update account: {err:?}")) - })?; - // Updates for notes apply_note_updates_tx(self.db_id(), tx_update.note_updates()).await?; diff --git a/crates/idxdb-store/src/ts/accounts.ts b/crates/idxdb-store/src/ts/accounts.ts index a96e2f2270..8ffa795a96 100644 --- a/crates/idxdb-store/src/ts/accounts.ts +++ b/crates/idxdb-store/src/ts/accounts.ts @@ -1,21 +1,24 @@ import { getDatabase, IAccount, - IAccountAsset, - IAccountStorage, - ITrackedAccount, - IStorageMapEntry, + IHistoricalAccountAsset, + IHistoricalStorageMapEntry, JsStorageMapEntry, JsStorageSlot, JsVaultAsset, + MidenDatabase, } from "./schema.js"; import { logWebStoreError, uint8ArrayToBase64 } from "./utils.js"; +function seedToBase64(seed: Uint8Array | undefined): string | undefined { + return seed ? uint8ArrayToBase64(seed) : undefined; +} + export async function getAccountIds(dbId: string) { try { const db = getDatabase(dbId); - const tracked = await db.trackedAccounts.toArray(); - return tracked.map((entry) => entry.id); + const records = await db.latestAccountHeaders.toArray(); + return records.map((entry) => entry.id); } catch (error) { logWebStoreError(error, "Error while fetching account IDs"); } @@ -26,43 +29,19 @@ export async function getAccountIds(dbId: string) { export async function getAllAccountHeaders(dbId: string) { try { const db = getDatabase(dbId); - const latestRecordsMap: Map = new Map(); - - await db.accounts.each((record) => { - const existingRecord = latestRecordsMap.get(record.id); - if ( - !existingRecord || - BigInt(record.nonce) > BigInt(existingRecord.nonce) - ) { - latestRecordsMap.set(record.id, record); - } - }); - - const latestRecords = Array.from(latestRecordsMap.values()); - - const resultObject = await Promise.all( - latestRecords.map((record) => { - let accountSeedBase64: string | undefined = undefined; - if (record.accountSeed) { - const seedAsBytes = new Uint8Array(record.accountSeed); - if (seedAsBytes.length > 0) { - accountSeedBase64 = uint8ArrayToBase64(seedAsBytes); - } - } - - return { - id: record.id, - nonce: record.nonce, - vaultRoot: record.vaultRoot, - storageRoot: record.storageRoot || "", - codeRoot: record.codeRoot || "", - accountSeed: accountSeedBase64, - locked: record.locked, - committed: record.committed, - accountCommitment: record.accountCommitment || "", - }; - }) - ); + const records = await db.latestAccountHeaders.toArray(); + + const resultObject = records.map((record) => ({ + id: record.id, + nonce: record.nonce, + vaultRoot: record.vaultRoot, + storageRoot: record.storageRoot || "", + codeRoot: record.codeRoot || "", + accountSeed: seedToBase64(record.accountSeed), + locked: record.locked, + committed: record.committed, + accountCommitment: record.accountCommitment || "", + })); return resultObject; } catch (error) { @@ -73,45 +52,25 @@ export async function getAllAccountHeaders(dbId: string) { export async function getAccountHeader(dbId: string, accountId: string) { try { const db = getDatabase(dbId); - const allMatchingRecords = await db.accounts + const record = await db.latestAccountHeaders .where("id") .equals(accountId) - .toArray(); + .first(); - if (allMatchingRecords.length === 0) { + if (!record) { console.log("No account header record found for given ID."); return null; } - const sortedRecords = allMatchingRecords.sort((a, b) => { - const bigIntA = BigInt(a.nonce); - const bigIntB = BigInt(b.nonce); - return bigIntA > bigIntB ? -1 : bigIntA < bigIntB ? 1 : 0; - }); - - const mostRecentRecord: IAccount | undefined = sortedRecords[0]; - - if (mostRecentRecord === undefined) { - return null; - } - - let accountSeedBase64: string | undefined = undefined; - - if (mostRecentRecord.accountSeed) { - if (mostRecentRecord.accountSeed.length > 0) { - accountSeedBase64 = uint8ArrayToBase64(mostRecentRecord.accountSeed); - } - } - const AccountHeader = { - id: mostRecentRecord.id, - nonce: mostRecentRecord.nonce, - vaultRoot: mostRecentRecord.vaultRoot, - storageRoot: mostRecentRecord.storageRoot, - codeRoot: mostRecentRecord.codeRoot, - accountSeed: accountSeedBase64, - locked: mostRecentRecord.locked, + return { + id: record.id, + nonce: record.nonce, + vaultRoot: record.vaultRoot, + storageRoot: record.storageRoot, + codeRoot: record.codeRoot, + accountSeed: seedToBase64(record.accountSeed), + locked: record.locked, }; - return AccountHeader; } catch (error) { logWebStoreError( error, @@ -126,36 +85,24 @@ export async function getAccountHeaderByCommitment( ) { try { const db = getDatabase(dbId); - const allMatchingRecords = await db.accounts + const record = await db.historicalAccountHeaders .where("accountCommitment") .equals(accountCommitment) - .toArray(); + .first(); - if (allMatchingRecords.length == 0) { + if (!record) { return undefined; } - const matchingRecord: IAccount | undefined = allMatchingRecords[0]; - - if (matchingRecord === undefined) { - console.log("No account header record found for given commitment."); - return null; - } - - let accountSeedBase64: string | undefined = undefined; - if (matchingRecord.accountSeed) { - accountSeedBase64 = uint8ArrayToBase64(matchingRecord.accountSeed); - } - const AccountHeader = { - id: matchingRecord.id, - nonce: matchingRecord.nonce, - vaultRoot: matchingRecord.vaultRoot, - storageRoot: matchingRecord.storageRoot, - codeRoot: matchingRecord.codeRoot, - accountSeed: accountSeedBase64, - locked: matchingRecord.locked, + return { + id: record.id, + nonce: record.nonce, + vaultRoot: record.vaultRoot, + storageRoot: record.storageRoot, + codeRoot: record.codeRoot, + accountSeed: seedToBase64(record.accountSeed), + locked: record.locked, }; - return AccountHeader; } catch (error) { logWebStoreError( error, @@ -189,15 +136,12 @@ export async function getAccountCode(dbId: string, codeRoot: string) { } } -export async function getAccountStorage( - dbId: string, - storageCommitment: string -) { +export async function getAccountStorage(dbId: string, accountId: string) { try { const db = getDatabase(dbId); - const allMatchingRecords = await db.accountStorages - .where("commitment") - .equals(storageCommitment) + const allMatchingRecords = await db.latestAccountStorages + .where("accountId") + .equals(accountId) .toArray(); const slots = allMatchingRecords.map((record) => { @@ -211,34 +155,34 @@ export async function getAccountStorage( } catch (error) { logWebStoreError( error, - `Error fetching account storage for commitment ${storageCommitment}` + `Error fetching account storage for account ${accountId}` ); } } -export async function getAccountStorageMaps(dbId: string, roots: string[]) { +export async function getAccountStorageMaps(dbId: string, accountId: string) { try { const db = getDatabase(dbId); - const allMatchingRecords = await db.storageMapEntries - .where("root") - .anyOf(roots) + const allMatchingRecords = await db.latestStorageMapEntries + .where("accountId") + .equals(accountId) .toArray(); return allMatchingRecords; } catch (error) { logWebStoreError( error, - `Error fetching account storage maps for roots ${roots.join(", ")}` + `Error fetching account storage maps for account ${accountId}` ); } } -export async function getAccountVaultAssets(dbId: string, vaultRoot: string) { +export async function getAccountVaultAssets(dbId: string, accountId: string) { try { const db = getDatabase(dbId); - const allMatchingRecords = await db.accountAssets - .where("root") - .equals(vaultRoot) + const allMatchingRecords = await db.latestAccountAssets + .where("accountId") + .equals(accountId) .toArray(); const assets = allMatchingRecords.map((record) => { @@ -251,7 +195,7 @@ export async function getAccountVaultAssets(dbId: string, vaultRoot: string) { } catch (error: unknown) { logWebStoreError( error, - `Error fetching account vault for root ${vaultRoot}` + `Error fetching account vault for account ${accountId}` ); } } @@ -319,20 +263,33 @@ export async function upsertAccountCode( export async function upsertAccountStorage( dbId: string, + accountId: string, + nonce: string, storageSlots: JsStorageSlot[] ) { try { const db = getDatabase(dbId); - let processedSlots = storageSlots.map((slot) => { - return { - commitment: slot.commitment, - slotName: slot.slotName, - slotValue: slot.slotValue, - slotType: slot.slotType, - } as IAccountStorage; - }); + await db.latestAccountStorages + .where("accountId") + .equals(accountId) + .delete(); - await db.accountStorages.bulkPut(processedSlots); + if (storageSlots.length === 0) return; + + const latestEntries = storageSlots.map((slot) => ({ + accountId, + slotName: slot.slotName, + slotValue: slot.slotValue, + slotType: slot.slotType, + })); + + const historicalEntries = latestEntries.map((entry) => ({ + ...entry, + nonce, + })); + + await db.latestAccountStorages.bulkPut(latestEntries); + await db.historicalAccountStorages.bulkPut(historicalEntries); } catch (error) { logWebStoreError(error, `Error inserting storage slots`); } @@ -340,42 +297,326 @@ export async function upsertAccountStorage( export async function upsertStorageMapEntries( dbId: string, + accountId: string, + nonce: string, entries: JsStorageMapEntry[] ) { try { const db = getDatabase(dbId); - let processedEntries = entries.map((entry) => { - return { - root: entry.root, - key: entry.key, - value: entry.value, - } as IStorageMapEntry; - }); - await db.storageMapEntries.bulkPut(processedEntries); + // Read old latest entries before clearing, to detect removals + const oldEntries = await db.latestStorageMapEntries + .where("accountId") + .equals(accountId) + .toArray(); + + await db.latestStorageMapEntries + .where("accountId") + .equals(accountId) + .delete(); + + // Build a set of new keys for fast lookup + const newKeySet = new Set(entries.map((e) => `${e.slotName}\0${e.key}`)); + + // Write tombstones to historical for entries that existed but are now absent + for (const old of oldEntries) { + if (!newKeySet.has(`${old.slotName}\0${old.key}`)) { + await db.historicalStorageMapEntries.put({ + accountId, + nonce, + slotName: old.slotName, + key: old.key, + value: null, + } as IHistoricalStorageMapEntry); + } + } + + if (entries.length === 0) return; + + const latestEntries = entries.map((entry) => ({ + accountId, + slotName: entry.slotName, + key: entry.key, + value: entry.value, + })); + + const historicalEntries = latestEntries.map((entry) => ({ + ...entry, + nonce, + })); + + await db.latestStorageMapEntries.bulkPut(latestEntries); + await db.historicalStorageMapEntries.bulkPut(historicalEntries); } catch (error) { logWebStoreError(error, `Error inserting storage map entries`); } } -export async function upsertVaultAssets(dbId: string, assets: JsVaultAsset[]) { +export async function upsertVaultAssets( + dbId: string, + accountId: string, + nonce: string, + assets: JsVaultAsset[] +) { try { const db = getDatabase(dbId); - let processedAssets = assets.map((asset) => { - return { - root: asset.root, - vaultKey: asset.vaultKey, - faucetIdPrefix: asset.faucetIdPrefix, - asset: asset.asset, - } as IAccountAsset; - }); - await db.accountAssets.bulkPut(processedAssets); + // Read old latest entries before clearing, to detect removals + const oldAssets = await db.latestAccountAssets + .where("accountId") + .equals(accountId) + .toArray(); + + await db.latestAccountAssets.where("accountId").equals(accountId).delete(); + + // Build a set of new vault keys for fast lookup + const newKeySet = new Set(assets.map((a) => a.vaultKey)); + + // Write tombstones to historical for assets that existed but are now absent + for (const old of oldAssets) { + if (!newKeySet.has(old.vaultKey)) { + await db.historicalAccountAssets.put({ + accountId, + nonce, + vaultKey: old.vaultKey, + faucetIdPrefix: old.faucetIdPrefix, + asset: null, + } as IHistoricalAccountAsset); + } + } + + if (assets.length === 0) return; + + const latestEntries = assets.map((asset) => ({ + accountId, + vaultKey: asset.vaultKey, + faucetIdPrefix: asset.faucetIdPrefix, + asset: asset.asset, + })); + + const historicalEntries = latestEntries.map((entry) => ({ + ...entry, + nonce, + })); + + await db.latestAccountAssets.bulkPut(latestEntries); + await db.historicalAccountAssets.bulkPut(historicalEntries); } catch (error: unknown) { logWebStoreError(error, `Error inserting assets`); } } +export async function applyTransactionDelta( + dbId: string, + accountId: string, + nonce: string, + updatedSlots: JsStorageSlot[], + changedMapEntries: JsStorageMapEntry[], + changedAssets: JsVaultAsset[], + codeRoot: string, + storageRoot: string, + vaultRoot: string, + committed: boolean, + commitment: string, + accountSeed: Uint8Array | undefined +) { + try { + const db = getDatabase(dbId); + + await db.dexie.transaction( + "rw", + [ + db.latestAccountStorages, + db.historicalAccountStorages, + db.latestStorageMapEntries, + db.historicalStorageMapEntries, + db.latestAccountAssets, + db.historicalAccountAssets, + db.latestAccountHeaders, + db.historicalAccountHeaders, + ], + async () => { + // Apply storage delta + for (const slot of updatedSlots) { + await db.latestAccountStorages.put({ + accountId, + slotName: slot.slotName, + slotValue: slot.slotValue, + slotType: slot.slotType, + }); + await db.historicalAccountStorages.put({ + accountId, + nonce, + slotName: slot.slotName, + slotValue: slot.slotValue, + slotType: slot.slotType, + }); + } + + // Process map entries: value="" means removal + for (const entry of changedMapEntries) { + if (entry.value === "") { + // Removal: delete from latest, write tombstone to historical + await db.latestStorageMapEntries + .where("[accountId+slotName+key]") + .equals([accountId, entry.slotName, entry.key]) + .delete(); + await db.historicalStorageMapEntries.put({ + accountId, + nonce, + slotName: entry.slotName, + key: entry.key, + value: null, + } as IHistoricalStorageMapEntry); + } else { + // Update: put to both latest and historical + await db.latestStorageMapEntries.put({ + accountId, + slotName: entry.slotName, + key: entry.key, + value: entry.value, + }); + await db.historicalStorageMapEntries.put({ + accountId, + nonce, + slotName: entry.slotName, + key: entry.key, + value: entry.value, + }); + } + } + + // Apply vault delta + for (const entry of changedAssets) { + if (entry.asset === "") { + // Removal: delete from latest, write tombstone to historical + await db.latestAccountAssets + .where("[accountId+vaultKey]") + .equals([accountId, entry.vaultKey]) + .delete(); + await db.historicalAccountAssets.put({ + accountId, + nonce, + vaultKey: entry.vaultKey, + faucetIdPrefix: entry.faucetIdPrefix, + asset: null, + } as IHistoricalAccountAsset); + } else { + // Update: put to both latest and historical + await db.latestAccountAssets.put({ + accountId, + vaultKey: entry.vaultKey, + faucetIdPrefix: entry.faucetIdPrefix, + asset: entry.asset, + }); + await db.historicalAccountAssets.put({ + accountId, + nonce, + vaultKey: entry.vaultKey, + faucetIdPrefix: entry.faucetIdPrefix, + asset: entry.asset, + }); + } + } + + // Upsert account record + const data = { + id: accountId, + codeRoot, + storageRoot, + vaultRoot, + nonce, + committed, + accountSeed, + accountCommitment: commitment, + locked: false, + }; + await db.historicalAccountHeaders.put(data as IAccount); + await db.latestAccountHeaders.put(data as IAccount); + } + ); + } catch (error) { + logWebStoreError(error, `Error applying transaction delta`); + } +} + +export async function applyFullAccountState( + dbId: string, + accountState: { + accountId: string; + nonce: string; + storageSlots: JsStorageSlot[]; + storageMapEntries: JsStorageMapEntry[]; + assets: JsVaultAsset[]; + codeRoot: string; + storageRoot: string; + vaultRoot: string; + committed: boolean; + accountCommitment: string; + accountSeed: Uint8Array | undefined; + } +) { + try { + const db = getDatabase(dbId); + const { + accountId, + nonce, + storageSlots, + storageMapEntries, + assets, + codeRoot, + storageRoot, + vaultRoot, + committed, + accountCommitment, + accountSeed, + } = accountState; + + await db.dexie.transaction( + "rw", + [ + db.latestAccountStorages, + db.historicalAccountStorages, + db.latestStorageMapEntries, + db.historicalStorageMapEntries, + db.latestAccountAssets, + db.historicalAccountAssets, + db.latestAccountHeaders, + db.historicalAccountHeaders, + ], + async () => { + // Upsert account storage (calls existing helpers — Dexie nesting + // joins them to this outer transaction) + await upsertAccountStorage(dbId, accountId, nonce, storageSlots); + await upsertStorageMapEntries( + dbId, + accountId, + nonce, + storageMapEntries + ); + await upsertVaultAssets(dbId, accountId, nonce, assets); + + // Upsert account record + const data = { + id: accountId, + codeRoot, + storageRoot, + vaultRoot, + nonce, + committed, + accountSeed, + accountCommitment, + locked: false, + }; + await db.historicalAccountHeaders.put(data as IAccount); + await db.latestAccountHeaders.put(data as IAccount); + } + ); + } catch (error) { + logWebStoreError(error, `Error applying full account state`); + } +} + export async function upsertAccountRecord( dbId: string, accountId: string, @@ -401,8 +642,8 @@ export async function upsertAccountRecord( locked: false, }; - await db.accounts.put(data as IAccount); - await db.trackedAccounts.put({ id: accountId } as ITrackedAccount); + await db.historicalAccountHeaders.put(data as IAccount); + await db.latestAccountHeaders.put(data as IAccount); } catch (error) { logWebStoreError(error, `Error inserting account: ${accountId}`); } @@ -536,22 +777,225 @@ export async function getForeignAccountCode( export async function lockAccount(dbId: string, accountId: string) { try { const db = getDatabase(dbId); - await db.accounts.where("id").equals(accountId).modify({ locked: true }); + await db.latestAccountHeaders + .where("id") + .equals(accountId) + .modify({ locked: true }); + // Also lock historical rows so that undo/rebuild preserves the lock. + await db.historicalAccountHeaders + .where("id") + .equals(accountId) + .modify({ locked: true }); } catch (error) { logWebStoreError(error, `Error locking account: ${accountId}`); } } +/** + * Rebuilds latest storage slots from historical data. + * Groups by slotName, takes the entry with MAX(nonce) per slot. + * Slots cannot be removed, so no tombstone filtering needed. + */ +async function rebuildLatestStorageSlots(db: MidenDatabase, accountId: string) { + await db.latestAccountStorages.where("accountId").equals(accountId).delete(); + const allHist = await db.historicalAccountStorages + .where("accountId") + .equals(accountId) + .toArray(); + + // Group by slotName, take MAX(nonce) per slot + const bySlot = new Map(); + for (const entry of allHist) { + const existing = bySlot.get(entry.slotName); + if (!existing || BigInt(entry.nonce) > BigInt(existing.nonce)) { + bySlot.set(entry.slotName, entry); + } + } + + if (bySlot.size > 0) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const entries = [...bySlot.values()].map(({ nonce, ...rest }) => rest); + await db.latestAccountStorages.bulkPut(entries); + } +} + +/** + * Rebuilds latest storage map entries from historical data. + * Groups by (slotName, key), takes the entry with MAX(nonce) per key. + * Filters out tombstones (value === null). + */ +async function rebuildLatestStorageMapEntries( + db: MidenDatabase, + accountId: string +) { + await db.latestStorageMapEntries + .where("accountId") + .equals(accountId) + .delete(); + const allHist = await db.historicalStorageMapEntries + .where("accountId") + .equals(accountId) + .toArray(); + + // Group by (slotName, key), take MAX(nonce) per key + const byKey = new Map(); + for (const entry of allHist) { + const compositeKey = `${entry.slotName}\0${entry.key}`; + const existing = byKey.get(compositeKey); + if (!existing || BigInt(entry.nonce) > BigInt(existing.nonce)) { + byKey.set(compositeKey, entry); + } + } + + // Filter out tombstones and strip nonce + const entries = [...byKey.values()] + .filter((e) => e.value !== null) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .map(({ nonce, value, ...rest }) => ({ + ...rest, + value: value as string, + })); + if (entries.length > 0) { + await db.latestStorageMapEntries.bulkPut(entries); + } +} + +/** + * Rebuilds latest vault assets from historical data. + * Groups by vaultKey, takes the entry with MAX(nonce) per key. + * Filters out tombstones (asset === null). + */ +async function rebuildLatestVaultAssets(db: MidenDatabase, accountId: string) { + await db.latestAccountAssets.where("accountId").equals(accountId).delete(); + const allHist = await db.historicalAccountAssets + .where("accountId") + .equals(accountId) + .toArray(); + + // Group by vaultKey, take MAX(nonce) per key + const byKey = new Map(); + for (const entry of allHist) { + const existing = byKey.get(entry.vaultKey); + if (!existing || BigInt(entry.nonce) > BigInt(existing.nonce)) { + byKey.set(entry.vaultKey, entry); + } + } + + // Filter out tombstones and strip nonce + const entries = [...byKey.values()] + .filter((e) => e.asset !== null) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .map(({ nonce, asset, ...rest }) => ({ + ...rest, + asset: asset as string, + })); + if (entries.length > 0) { + await db.latestAccountAssets.bulkPut(entries); + } +} + export async function undoAccountStates( dbId: string, accountCommitments: string[] ) { try { const db = getDatabase(dbId); - await db.accounts - .where("accountCommitment") - .anyOf(accountCommitments) - .delete(); + + await db.dexie.transaction( + "rw", + [ + db.latestAccountStorages, + db.historicalAccountStorages, + db.latestStorageMapEntries, + db.historicalStorageMapEntries, + db.latestAccountAssets, + db.historicalAccountAssets, + db.latestAccountHeaders, + db.historicalAccountHeaders, + ], + async () => { + // Find affected records to get their account IDs and nonces before deleting + const affectedRecords = await db.historicalAccountHeaders + .where("accountCommitment") + .anyOf(accountCommitments) + .toArray(); + + // Collect affected (accountId, nonce) pairs + const accountNonces = new Map>(); + for (const record of affectedRecords) { + if (!accountNonces.has(record.id)) { + accountNonces.set(record.id, new Set()); + } + accountNonces.get(record.id)!.add(record.nonce); + } + + // Delete matching records from historical account headers + await db.historicalAccountHeaders + .where("accountCommitment") + .anyOf(accountCommitments) + .delete(); + + // Delete historical storage/map/assets for affected (accountId, nonce) pairs + for (const [accountId, nonces] of accountNonces) { + for (const nonce of nonces) { + await db.historicalAccountStorages + .where("[accountId+nonce]") + .equals([accountId, nonce]) + .delete(); + await db.historicalStorageMapEntries + .where("[accountId+nonce]") + .equals([accountId, nonce]) + .delete(); + await db.historicalAccountAssets + .where("[accountId+nonce]") + .equals([accountId, nonce]) + .delete(); + } + } + + // Rebuild latest for each affected account + for (const accountId of accountNonces.keys()) { + const remaining = await db.historicalAccountHeaders + .where("id") + .equals(accountId) + .toArray(); + + if (remaining.length === 0) { + // Account completely undone — clear all latest tables + await db.latestAccountHeaders + .where("id") + .equals(accountId) + .delete(); + await db.latestAccountStorages + .where("accountId") + .equals(accountId) + .delete(); + await db.latestStorageMapEntries + .where("accountId") + .equals(accountId) + .delete(); + await db.latestAccountAssets + .where("accountId") + .equals(accountId) + .delete(); + } else { + // Find the record with the highest nonce + let maxRecord = remaining[0]; + for (const record of remaining) { + if (BigInt(record.nonce) > BigInt(maxRecord.nonce)) { + maxRecord = record; + } + } + + // Rebuild latest from historical using MAX(nonce) per key + await db.latestAccountHeaders.put(maxRecord); + await rebuildLatestStorageSlots(db, accountId); + await rebuildLatestStorageMapEntries(db, accountId); + await rebuildLatestVaultAssets(db, accountId); + } + } + } + ); } catch (error) { logWebStoreError( error, @@ -639,40 +1083,40 @@ export async function getKeyCommitmentsByAccountId( } } -export async function removeAllMappingsForKey( +export async function getAccountIdByKeyCommitment( dbId: string, pubKeyCommitmentHex: string -) { +): Promise { try { const db = getDatabase(dbId); - await db.accountKeyMappings + const mapping = await db.accountKeyMappings .where("pubKeyCommitmentHex") .equals(pubKeyCommitmentHex) - .delete(); + .first(); + return mapping?.accountIdHex ?? null; } catch (error) { logWebStoreError( error, - `Error removing all mappings for key: ${pubKeyCommitmentHex}` + `Error fetching account by public key commitment: ${pubKeyCommitmentHex}` ); + return null; } } -export async function getAccountIdByKeyCommitment( +export async function removeAllMappingsForKey( dbId: string, pubKeyCommitmentHex: string -): Promise { +) { try { const db = getDatabase(dbId); - const mapping = await db.accountKeyMappings + await db.accountKeyMappings .where("pubKeyCommitmentHex") .equals(pubKeyCommitmentHex) - .first(); - return mapping?.accountIdHex ?? null; + .delete(); } catch (error) { logWebStoreError( error, - `Error fetching account by public key commitment: ${pubKeyCommitmentHex}` + `Error removing all mappings for key: ${pubKeyCommitmentHex}` ); - return null; } } diff --git a/crates/idxdb-store/src/ts/schema.ts b/crates/idxdb-store/src/ts/schema.ts index 98cabd2ea5..d040607407 100644 --- a/crates/idxdb-store/src/ts/schema.ts +++ b/crates/idxdb-store/src/ts/schema.ts @@ -41,12 +41,16 @@ export async function openDatabase( enum Table { AccountCode = "accountCode", - AccountStorage = "accountStorage", - AccountAssets = "accountAssets", - StorageMapEntries = "storageMapEntries", + LatestAccountStorage = "latestAccountStorage", + HistoricalAccountStorage = "historicalAccountStorage", + LatestAccountAssets = "latestAccountAssets", + HistoricalAccountAssets = "historicalAccountAssets", + LatestStorageMapEntries = "latestStorageMapEntries", + HistoricalStorageMapEntries = "historicalStorageMapEntries", AccountAuth = "accountAuth", AccountKeyMapping = "accountKeyMapping", - Accounts = "accounts", + LatestAccountHeaders = "latestAccountHeaders", + HistoricalAccountHeaders = "historicalAccountHeaders", Addresses = "addresses", Transactions = "transactions", TransactionScripts = "transactionScripts", @@ -59,7 +63,6 @@ enum Table { Tags = "tags", ForeignAccountCode = "foreignAccountCode", Settings = "settings", - TrackedAccounts = "trackedAccounts", } export interface IAccountCode { @@ -67,26 +70,51 @@ export interface IAccountCode { code: Uint8Array; } -export interface IAccountStorage { - commitment: string; +export interface ILatestAccountStorage { + accountId: string; slotName: string; slotValue: string; slotType: number; } -export interface IStorageMapEntry { - root: string; +export interface IHistoricalAccountStorage { + accountId: string; + nonce: string; + slotName: string; + slotValue: string; + slotType: number; +} + +export interface ILatestStorageMapEntry { + accountId: string; + slotName: string; key: string; value: string; } -export interface IAccountAsset { - root: string; +export interface IHistoricalStorageMapEntry { + accountId: string; + nonce: string; + slotName: string; + key: string; + value: string | null; +} + +export interface ILatestAccountAsset { + accountId: string; vaultKey: string; faucetIdPrefix: string; asset: string; } +export interface IHistoricalAccountAsset { + accountId: string; + nonce: string; + vaultKey: string; + faucetIdPrefix: string; + asset: string | null; +} + export interface IAccountAuth { pubKeyCommitmentHex: string; secretKeyHex: string; @@ -190,26 +218,20 @@ export interface ISetting { value: Uint8Array; } -export interface ITrackedAccount { - id: string; -} - export interface JsVaultAsset { - root: string; vaultKey: string; faucetIdPrefix: string; asset: string; } export interface JsStorageSlot { - commitment: string; slotName: string; slotValue: string; slotType: number; } export interface JsStorageMapEntry { - root: string; + slotName: string; key: string; value: string; } @@ -218,49 +240,47 @@ function indexes(...items: string[]): string { return items.join(","); } -export type MidenDexie = Dexie & { - accountCodes: Dexie.Table; - accountStorages: Dexie.Table; - accountAssets: Dexie.Table; - storageMapEntries: Dexie.Table; - accountAuths: Dexie.Table; - accountKeyMappings: Dexie.Table; - accounts: Dexie.Table; - addresses: Dexie.Table; - transactions: Dexie.Table; - transactionScripts: Dexie.Table; - inputNotes: Dexie.Table; - outputNotes: Dexie.Table; - notesScripts: Dexie.Table; - stateSync: Dexie.Table; - blockHeaders: Dexie.Table; - partialBlockchainNodes: Dexie.Table; - tags: Dexie.Table; - foreignAccountCode: Dexie.Table; - settings: Dexie.Table; - trackedAccounts: Dexie.Table; -}; - /** V1 baseline schema. Extracted as a constant because once migrations are enabled, this must * never be modified — all schema changes should go through new version blocks instead. */ const V1_STORES: Record = { [Table.AccountCode]: indexes("root"), - [Table.AccountStorage]: indexes("[commitment+slotName]", "commitment"), - [Table.StorageMapEntries]: indexes("[root+key]", "root"), - [Table.AccountAssets]: indexes("[root+vaultKey]", "root", "faucetIdPrefix"), + [Table.LatestAccountStorage]: indexes("[accountId+slotName]", "accountId"), + [Table.HistoricalAccountStorage]: indexes( + "[accountId+nonce+slotName]", + "accountId", + "[accountId+nonce]" + ), + [Table.LatestStorageMapEntries]: indexes( + "[accountId+slotName+key]", + "accountId", + "[accountId+slotName]" + ), + [Table.HistoricalStorageMapEntries]: indexes( + "[accountId+nonce+slotName+key]", + "accountId", + "[accountId+nonce]" + ), + [Table.LatestAccountAssets]: indexes( + "[accountId+vaultKey]", + "accountId", + "faucetIdPrefix" + ), + [Table.HistoricalAccountAssets]: indexes( + "[accountId+nonce+vaultKey]", + "accountId", + "[accountId+nonce]" + ), [Table.AccountAuth]: indexes("pubKeyCommitmentHex"), [Table.AccountKeyMapping]: indexes( "[accountIdHex+pubKeyCommitmentHex]", "accountIdHex", "pubKeyCommitmentHex" ), - [Table.Accounts]: indexes( + [Table.LatestAccountHeaders]: indexes("&id", "accountCommitment"), + [Table.HistoricalAccountHeaders]: indexes( "&accountCommitment", "id", - "[id+nonce]", - "codeRoot", - "storageRoot", - "vaultRoot" + "[id+nonce]" ), [Table.Addresses]: indexes("address", "id"), [Table.Transactions]: indexes("id", "statusVariant"), @@ -279,18 +299,47 @@ const V1_STORES: Record = { [Table.Tags]: indexes("id++", "tag", "sourceNoteId", "sourceAccountId"), [Table.ForeignAccountCode]: indexes("accountId"), [Table.Settings]: indexes("key"), - [Table.TrackedAccounts]: indexes("&id"), +}; + +export type MidenDexie = Dexie & { + accountCodes: Dexie.Table; + latestAccountStorages: Dexie.Table; + historicalAccountStorages: Dexie.Table; + latestStorageMapEntries: Dexie.Table; + historicalStorageMapEntries: Dexie.Table; + latestAccountAssets: Dexie.Table; + historicalAccountAssets: Dexie.Table; + accountAuths: Dexie.Table; + accountKeyMappings: Dexie.Table; + latestAccountHeaders: Dexie.Table; + historicalAccountHeaders: Dexie.Table; + addresses: Dexie.Table; + transactions: Dexie.Table; + transactionScripts: Dexie.Table; + inputNotes: Dexie.Table; + outputNotes: Dexie.Table; + notesScripts: Dexie.Table; + stateSync: Dexie.Table; + blockHeaders: Dexie.Table; + partialBlockchainNodes: Dexie.Table; + tags: Dexie.Table; + foreignAccountCode: Dexie.Table; + settings: Dexie.Table; }; export class MidenDatabase { dexie: MidenDexie; accountCodes: Dexie.Table; - accountStorages: Dexie.Table; - storageMapEntries: Dexie.Table; - accountAssets: Dexie.Table; + latestAccountStorages: Dexie.Table; + historicalAccountStorages: Dexie.Table; + latestStorageMapEntries: Dexie.Table; + historicalStorageMapEntries: Dexie.Table; + latestAccountAssets: Dexie.Table; + historicalAccountAssets: Dexie.Table; accountAuths: Dexie.Table; accountKeyMappings: Dexie.Table; - accounts: Dexie.Table; + latestAccountHeaders: Dexie.Table; + historicalAccountHeaders: Dexie.Table; addresses: Dexie.Table; transactions: Dexie.Table; transactionScripts: Dexie.Table; @@ -303,7 +352,6 @@ export class MidenDatabase { tags: Dexie.Table; foreignAccountCode: Dexie.Table; settings: Dexie.Table; - trackedAccounts: Dexie.Table; constructor(network: string) { this.dexie = new Dexie(network) as MidenDexie; @@ -356,22 +404,41 @@ export class MidenDatabase { this.accountCodes = this.dexie.table( Table.AccountCode ); - this.accountStorages = this.dexie.table( - Table.AccountStorage - ); - this.storageMapEntries = this.dexie.table( - Table.StorageMapEntries - ); - this.accountAssets = this.dexie.table( - Table.AccountAssets + this.latestAccountStorages = this.dexie.table< + ILatestAccountStorage, + string + >(Table.LatestAccountStorage); + this.historicalAccountStorages = this.dexie.table< + IHistoricalAccountStorage, + string + >(Table.HistoricalAccountStorage); + this.latestStorageMapEntries = this.dexie.table< + ILatestStorageMapEntry, + string + >(Table.LatestStorageMapEntries); + this.historicalStorageMapEntries = this.dexie.table< + IHistoricalStorageMapEntry, + string + >(Table.HistoricalStorageMapEntries); + this.latestAccountAssets = this.dexie.table( + Table.LatestAccountAssets ); + this.historicalAccountAssets = this.dexie.table< + IHistoricalAccountAsset, + string + >(Table.HistoricalAccountAssets); this.accountAuths = this.dexie.table( Table.AccountAuth ); this.accountKeyMappings = this.dexie.table( Table.AccountKeyMapping ); - this.accounts = this.dexie.table(Table.Accounts); + this.latestAccountHeaders = this.dexie.table( + Table.LatestAccountHeaders + ); + this.historicalAccountHeaders = this.dexie.table( + Table.HistoricalAccountHeaders + ); this.addresses = this.dexie.table(Table.Addresses); this.transactions = this.dexie.table( Table.Transactions @@ -397,9 +464,6 @@ export class MidenDatabase { Table.ForeignAccountCode ); this.settings = this.dexie.table(Table.Settings); - this.trackedAccounts = this.dexie.table( - Table.TrackedAccounts - ); this.dexie.on("populate", () => { this.stateSync diff --git a/crates/idxdb-store/src/ts/sync.ts b/crates/idxdb-store/src/ts/sync.ts index 56f6357409..c3e90008d5 100644 --- a/crates/idxdb-store/src/ts/sync.ts +++ b/crates/idxdb-store/src/ts/sync.ts @@ -146,7 +146,7 @@ interface JsAccountUpdate { storageRoot: string; storageSlots: JsStorageSlot[]; storageMapEntries: JsStorageMapEntry[]; - assetVaultRoot: string; + vaultRoot: string; assets: JsVaultAsset[]; accountId: string; codeRoot: string; @@ -196,105 +196,122 @@ export async function applyStateSync( flattenedPartialBlockChainPeaks ); - let inputNotesWriteOp = Promise.all( - serializedInputNotes.map((note) => { - return upsertInputNote( - dbId, - note.noteId, - note.noteAssets, - note.serialNumber, - note.inputs, - note.noteScriptRoot, - note.noteScript, - note.nullifier, - note.createdAt, - note.stateDiscriminant, - note.state - ); - }) - ); - - let outputNotesWriteOp = Promise.all( - serializedOutputNotes.map((note) => { - return upsertOutputNote( - dbId, - note.noteId, - note.noteAssets, - note.recipientDigest, - note.metadata, - note.nullifier, - note.expectedHeight, - note.stateDiscriminant, - note.state - ); - }) - ); - - let transactionWriteOp = Promise.all( - transactionUpdates.map((transactionRecord) => { - let promises = [ - upsertTransactionRecord( - dbId, - transactionRecord.id, - transactionRecord.details, - transactionRecord.blockNum, - transactionRecord.statusVariant, - transactionRecord.status, - transactionRecord.scriptRoot - ), - ]; - - if (transactionRecord.scriptRoot && transactionRecord.txScript) { - promises.push( - insertTransactionScript( - dbId, - transactionRecord.scriptRoot, - transactionRecord.txScript - ) - ); - } - - return Promise.all(promises); - }) - ); - - let accountUpdatesWriteOp = Promise.all( - accountUpdates.flatMap((accountUpdate) => { - return [ - upsertAccountStorage(dbId, accountUpdate.storageSlots), - upsertStorageMapEntries(dbId, accountUpdate.storageMapEntries), - upsertVaultAssets(dbId, accountUpdate.assets), - upsertAccountRecord( - dbId, - accountUpdate.accountId, - accountUpdate.codeRoot, - accountUpdate.storageRoot, - accountUpdate.assetVaultRoot, - accountUpdate.nonce, - accountUpdate.committed, - accountUpdate.accountCommitment, - accountUpdate.accountSeed - ), - ]; - }) - ); - const tablesToAccess = [ db.stateSync, db.inputNotes, db.outputNotes, + db.notesScripts, db.transactions, + db.transactionScripts, db.blockHeaders, db.partialBlockchainNodes, db.tags, + db.latestAccountHeaders, + db.historicalAccountHeaders, + db.latestAccountStorages, + db.historicalAccountStorages, + db.latestStorageMapEntries, + db.historicalStorageMapEntries, + db.latestAccountAssets, + db.historicalAccountAssets, ]; return await db.dexie.transaction("rw", tablesToAccess, async (tx) => { await Promise.all([ - inputNotesWriteOp, - outputNotesWriteOp, - transactionWriteOp, - accountUpdatesWriteOp, + Promise.all( + serializedInputNotes.map((note) => { + return upsertInputNote( + dbId, + note.noteId, + note.noteAssets, + note.serialNumber, + note.inputs, + note.noteScriptRoot, + note.noteScript, + note.nullifier, + note.createdAt, + note.stateDiscriminant, + note.state + ); + }) + ), + Promise.all( + serializedOutputNotes.map((note) => { + return upsertOutputNote( + dbId, + note.noteId, + note.noteAssets, + note.recipientDigest, + note.metadata, + note.nullifier, + note.expectedHeight, + note.stateDiscriminant, + note.state + ); + }) + ), + Promise.all( + transactionUpdates.map((transactionRecord) => { + let promises = [ + upsertTransactionRecord( + dbId, + transactionRecord.id, + transactionRecord.details, + transactionRecord.blockNum, + transactionRecord.statusVariant, + transactionRecord.status, + transactionRecord.scriptRoot + ), + ]; + + if (transactionRecord.scriptRoot && transactionRecord.txScript) { + promises.push( + insertTransactionScript( + dbId, + transactionRecord.scriptRoot, + transactionRecord.txScript + ) + ); + } + + return Promise.all(promises); + }) + ), + Promise.all( + accountUpdates.flatMap((accountUpdate) => { + return [ + upsertAccountStorage( + dbId, + accountUpdate.accountId, + accountUpdate.nonce, + accountUpdate.storageSlots + ), + upsertStorageMapEntries( + dbId, + accountUpdate.accountId, + accountUpdate.nonce, + accountUpdate.storageMapEntries + ), + upsertVaultAssets( + dbId, + accountUpdate.accountId, + accountUpdate.nonce, + accountUpdate.assets + ), + upsertAccountRecord( + dbId, + accountUpdate.accountId, + accountUpdate.codeRoot, + accountUpdate.storageRoot, + accountUpdate.vaultRoot, + accountUpdate.nonce, + accountUpdate.committed, + accountUpdate.accountCommitment, + accountUpdate.accountSeed + ), + ]; + }) + ), updateSyncHeight(tx, blockNum), updatePartialBlockchainNodes(tx, serializedNodeIds, serializedNodes), updateCommittedNoteTags(tx, committedNoteIds), diff --git a/crates/idxdb-store/src/yarn.lock b/crates/idxdb-store/src/yarn.lock index 939f6cf09d..82987bae21 100644 --- a/crates/idxdb-store/src/yarn.lock +++ b/crates/idxdb-store/src/yarn.lock @@ -252,130 +252,130 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" -"@rollup/rollup-android-arm-eabi@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz#add5e608d4e7be55bc3ca3d962490b8b1890e088" - integrity sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg== - -"@rollup/rollup-android-arm64@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz#10bd0382b73592beee6e9800a69401a29da625c4" - integrity sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w== - -"@rollup/rollup-darwin-arm64@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz#1e99ab04c0b8c619dd7bbde725ba2b87b55bfd81" - integrity sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg== - -"@rollup/rollup-darwin-x64@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz#69e741aeb2839d2e8f0da2ce7a33d8bd23632423" - integrity sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w== - -"@rollup/rollup-freebsd-arm64@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz#3736c232a999c7bef7131355d83ebdf9651a0839" - integrity sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug== - -"@rollup/rollup-freebsd-x64@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz#227dcb8f466684070169942bd3998901c9bfc065" - integrity sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q== - -"@rollup/rollup-linux-arm-gnueabihf@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz#ba004b30df31b724f99ce66e7128248bea17cb0c" - integrity sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw== - -"@rollup/rollup-linux-arm-musleabihf@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz#6929f3e07be6b6da5991f63c6b68b3e473d0a65a" - integrity sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw== - -"@rollup/rollup-linux-arm64-gnu@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz#06e89fd4a25d21fe5575d60b6f913c0e65297bfa" - integrity sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g== - -"@rollup/rollup-linux-arm64-musl@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz#fddabf395b90990d5194038e6cd8c00156ed8ac0" - integrity sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q== - -"@rollup/rollup-linux-loong64-gnu@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz#04c10bb764bbf09a3c1bd90432e92f58d6603c36" - integrity sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA== - -"@rollup/rollup-linux-loong64-musl@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz#f2450361790de80581d8687ea19142d8a4de5c0f" - integrity sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw== - -"@rollup/rollup-linux-ppc64-gnu@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz#0474f4667259e407eee1a6d38e29041b708f6a30" - integrity sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w== - -"@rollup/rollup-linux-ppc64-musl@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz#9f32074819eeb1ddbe51f50ea9dcd61a6745ec33" - integrity sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw== - -"@rollup/rollup-linux-riscv64-gnu@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz#3fdb9d4b1e29fb6b6a6da9f15654d42eb77b99b2" - integrity sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A== - -"@rollup/rollup-linux-riscv64-musl@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz#1de780d64e6be0e3e8762035c22e0d8ea68df8ed" - integrity sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw== - -"@rollup/rollup-linux-s390x-gnu@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz#1da022ffd2d9e9f0fd8344ea49e113001fbcac64" - integrity sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg== - -"@rollup/rollup-linux-x64-gnu@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz#78c16eef9520bd10e1ea7a112593bb58e2842622" - integrity sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg== - -"@rollup/rollup-linux-x64-musl@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz#a7598591b4d9af96cb3167b50a5bf1e02dfea06c" - integrity sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw== - -"@rollup/rollup-openbsd-x64@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz#c51d48c07cd6c466560e5bed934aec688ce02614" - integrity sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw== - -"@rollup/rollup-openharmony-arm64@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz#f09921d0b2a0b60afbf3586d2a7a7f208ba6df17" - integrity sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ== - -"@rollup/rollup-win32-arm64-msvc@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz#08d491717135376e4a99529821c94ecd433d5b36" - integrity sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ== - -"@rollup/rollup-win32-ia32-msvc@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz#b0c12aac1104a8b8f26a5e0098e5facbb3e3964a" - integrity sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew== - -"@rollup/rollup-win32-x64-gnu@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz#b9cccef26f5e6fdc013bf3c0911a3c77428509d0" - integrity sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ== - -"@rollup/rollup-win32-x64-msvc@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz#a03348e7b559c792b6277cc58874b89ef46e1e72" - integrity sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA== +"@rollup/rollup-android-arm-eabi@4.58.0": + version "4.58.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.58.0.tgz#4cda52aeff1d38539ff823b371dfe85064c1252e" + integrity sha512-mr0tmS/4FoVk1cnaeN244A/wjvGDNItZKR8hRhnmCzygyRXYtKF5jVDSIILR1U97CTzAYmbgIj/Dukg62ggG5w== + +"@rollup/rollup-android-arm64@4.58.0": + version "4.58.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.58.0.tgz#ceffd87180d55701fb362c8fda9a380d64976c9e" + integrity sha512-+s++dbp+/RTte62mQD9wLSbiMTV+xr/PeRJEc/sFZFSBRlHPNPVaf5FXlzAL77Mr8FtSfQqCN+I598M8U41ccQ== + +"@rollup/rollup-darwin-arm64@4.58.0": + version "4.58.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.58.0.tgz#fe0e54a3b2affd1a951fec7c35b9013d1d0ec5f6" + integrity sha512-MFWBwTcYs0jZbINQBXHfSrpSQJq3IUOakcKPzfeSznONop14Pxuqa0Kg19GD0rNBMPQI2tFtu3UzapZpH0Uc1Q== + +"@rollup/rollup-darwin-x64@4.58.0": + version "4.58.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.58.0.tgz#0509fab5a7f3e77cbd4b26f011e75cdc69e2205d" + integrity sha512-yiKJY7pj9c9JwzuKYLFaDZw5gma3fI9bkPEIyofvVfsPqjCWPglSHdpdwXpKGvDeYDms3Qal8qGMEHZ1M/4Udg== + +"@rollup/rollup-freebsd-arm64@4.58.0": + version "4.58.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.58.0.tgz#dbe79b0ef383eae698623d0c1d6b959c2b4584ac" + integrity sha512-x97kCoBh5MOevpn/CNK9W1x8BEzO238541BGWBc315uOlN0AD/ifZ1msg+ZQB05Ux+VF6EcYqpiagfLJ8U3LvQ== + +"@rollup/rollup-freebsd-x64@4.58.0": + version "4.58.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.58.0.tgz#b65254c46414c59698316671d69d6ea6ee462737" + integrity sha512-Aa8jPoZ6IQAG2eIrcXPpjRcMjROMFxCt1UYPZZtCxRV68WkuSigYtQ/7Zwrcr2IvtNJo7T2JfDXyMLxq5L4Jlg== + +"@rollup/rollup-linux-arm-gnueabihf@4.58.0": + version "4.58.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.58.0.tgz#5a85c07b0a7083f7db33a9c9acb8415aa87e4efb" + integrity sha512-Ob8YgT5kD/lSIYW2Rcngs5kNB/44Q2RzBSPz9brf2WEtcGR7/f/E9HeHn1wYaAwKBni+bdXEwgHvUd0x12lQSA== + +"@rollup/rollup-linux-arm-musleabihf@4.58.0": + version "4.58.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.58.0.tgz#b29ff496f689529603bd3761e6d46325aba4d542" + integrity sha512-K+RI5oP1ceqoadvNt1FecL17Qtw/n9BgRSzxif3rTL2QlIu88ccvY+Y9nnHe/cmT5zbH9+bpiJuG1mGHRVwF4Q== + +"@rollup/rollup-linux-arm64-gnu@4.58.0": + version "4.58.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.58.0.tgz#f188f24ab4ed307391bd12d9ce6021414ab33858" + integrity sha512-T+17JAsCKUjmbopcKepJjHWHXSjeW7O5PL7lEFaeQmiVyw4kkc5/lyYKzrv6ElWRX/MrEWfPiJWqbTvfIvjM1Q== + +"@rollup/rollup-linux-arm64-musl@4.58.0": + version "4.58.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.58.0.tgz#8c0f105952ab6eaf3b7c335d67866a12f6e9701d" + integrity sha512-cCePktb9+6R9itIJdeCFF9txPU7pQeEHB5AbHu/MKsfH/k70ZtOeq1k4YAtBv9Z7mmKI5/wOLYjQ+B9QdxR6LA== + +"@rollup/rollup-linux-loong64-gnu@4.58.0": + version "4.58.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.58.0.tgz#5637c6293d20c2a42e32bc176fe91f25df29a589" + integrity sha512-iekUaLkfliAsDl4/xSdoCJ1gnnIXvoNz85C8U8+ZxknM5pBStfZjeXgB8lXobDQvvPRCN8FPmmuTtH+z95HTmg== + +"@rollup/rollup-linux-loong64-musl@4.58.0": + version "4.58.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.58.0.tgz#232cc97d391a8e2a31f012d31e527a10d2b7624f" + integrity sha512-68ofRgJNl/jYJbxFjCKE7IwhbfxOl1muPN4KbIqAIe32lm22KmU7E8OPvyy68HTNkI2iV/c8y2kSPSm2mW/Q9Q== + +"@rollup/rollup-linux-ppc64-gnu@4.58.0": + version "4.58.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.58.0.tgz#dd67db4a17c577eca6903d8f2e6427729318946f" + integrity sha512-dpz8vT0i+JqUKuSNPCP5SYyIV2Lh0sNL1+FhM7eLC457d5B9/BC3kDPp5BBftMmTNsBarcPcoz5UGSsnCiw4XQ== + +"@rollup/rollup-linux-ppc64-musl@4.58.0": + version "4.58.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.58.0.tgz#2faf62360f3b98d252a03c9b83ba38013048edf4" + integrity sha512-4gdkkf9UJ7tafnweBCR/mk4jf3Jfl0cKX9Np80t5i78kjIH0ZdezUv/JDI2VtruE5lunfACqftJ8dIMGN4oHew== + +"@rollup/rollup-linux-riscv64-gnu@4.58.0": + version "4.58.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.58.0.tgz#5281964be59302d441abcd8ad2440beb92999be3" + integrity sha512-YFS4vPnOkDTD/JriUeeZurFYoJhPf9GQQEF/v4lltp3mVcBmnsAdjEWhr2cjUCZzZNzxCG0HZOvJU44UGHSdzw== + +"@rollup/rollup-linux-riscv64-musl@4.58.0": + version "4.58.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.58.0.tgz#aff5c40462868bb3ec5dc3950b5c57f34b64e36e" + integrity sha512-x2xgZlFne+QVNKV8b4wwaCS8pwq3y14zedZ5DqLzjdRITvreBk//4Knbcvm7+lWmms9V9qFp60MtUd0/t/PXPw== + +"@rollup/rollup-linux-s390x-gnu@4.58.0": + version "4.58.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.58.0.tgz#4203a0d06289b6e684e4d2b643a0d16e66bb4435" + integrity sha512-jIhrujyn4UnWF8S+DHSkAkDEO3hLX0cjzxJZPLF80xFyzyUIYgSMRcYQ3+uqEoyDD2beGq7Dj7edi8OnJcS/hg== + +"@rollup/rollup-linux-x64-gnu@4.58.0": + version "4.58.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.58.0.tgz#1ec45245ea6b8699d14386e91ba78ea0107d982f" + integrity sha512-+410Srdoh78MKSJxTQ+hZ/Mx+ajd6RjjPwBPNd0R3J9FtL6ZA0GqiiyNjCO9In0IzZkCNrpGymSfn+kgyPQocg== + +"@rollup/rollup-linux-x64-musl@4.58.0": + version "4.58.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.58.0.tgz#42ca10181712a325a5392d423e49804813a390ce" + integrity sha512-ZjMyby5SICi227y1MTR3VYBpFTdZs823Rs/hpakufleBoufoOIB6jtm9FEoxn/cgO7l6PM2rCEl5Kre5vX0QrQ== + +"@rollup/rollup-openbsd-x64@4.58.0": + version "4.58.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.58.0.tgz#02cfab92d507419194f7ee9b4a76ad9d101ca4a1" + integrity sha512-ds4iwfYkSQ0k1nb8LTcyXw//ToHOnNTJtceySpL3fa7tc/AsE+UpUFphW126A6fKBGJD5dhRvg8zw1rvoGFxmw== + +"@rollup/rollup-openharmony-arm64@4.58.0": + version "4.58.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.58.0.tgz#34d1ffb17a72819611cf277474e2e6b35e6beaa7" + integrity sha512-fd/zpJniln4ICdPkjWFhZYeY/bpnaN9pGa6ko+5WD38I0tTqk9lXMgXZg09MNdhpARngmxiCg0B0XUamNw/5BQ== + +"@rollup/rollup-win32-arm64-msvc@4.58.0": + version "4.58.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.58.0.tgz#c9cbe2d5a5a741222301b473f29069781e8a0f77" + integrity sha512-YpG8dUOip7DCz3nr/JUfPbIUo+2d/dy++5bFzgi4ugOGBIox+qMbbqt/JoORwvI/C9Kn2tz6+Bieoqd5+B1CjA== + +"@rollup/rollup-win32-ia32-msvc@4.58.0": + version "4.58.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.58.0.tgz#b4b393de58f38f87fa66ca1371d865366756a24c" + integrity sha512-b9DI8jpFQVh4hIXFr0/+N/TzLdpBIoPzjt0Rt4xJbW3mzguV3mduR9cNgiuFcuL/TeORejJhCWiAXe3E/6PxWA== + +"@rollup/rollup-win32-x64-gnu@4.58.0": + version "4.58.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.58.0.tgz#583097cf2e8403c14df49da1a93ce94fde2bca22" + integrity sha512-CSrVpmoRJFN06LL9xhkitkwUcTZtIotYAF5p6XOR2zW0Zz5mzb3IPpcoPhB02frzMHFNo1reQ9xSF5fFm3hUsQ== + +"@rollup/rollup-win32-x64-msvc@4.58.0": + version "4.58.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.58.0.tgz#de01bb79f597fdba7c205e4d7d7f85760f8b2645" + integrity sha512-QFsBgQNTnh5K0t/sBsjJLq24YVqEIVkGpfN2VHsnN90soZyhaiA9UUHufcctVNL4ypJY0wrwad0wslx2KJQ1/w== "@types/chai@^5.2.2": version "5.2.3" @@ -1231,37 +1231,37 @@ reusify@^1.0.4: integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw== rollup@^4.43.0: - version "4.57.1" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.57.1.tgz#947f70baca32db2b9c594267fe9150aa316e5a88" - integrity sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A== + version "4.58.0" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.58.0.tgz#cdbbb79d48ff94f192d9974988c1728f9dd1994c" + integrity sha512-wbT0mBmWbIvvq8NeEYWWvevvxnOyhKChir47S66WCxw1SXqhw7ssIYejnQEVt7XYQpsj2y8F9PM+Cr3SNEa0gw== dependencies: "@types/estree" "1.0.8" optionalDependencies: - "@rollup/rollup-android-arm-eabi" "4.57.1" - "@rollup/rollup-android-arm64" "4.57.1" - "@rollup/rollup-darwin-arm64" "4.57.1" - "@rollup/rollup-darwin-x64" "4.57.1" - "@rollup/rollup-freebsd-arm64" "4.57.1" - "@rollup/rollup-freebsd-x64" "4.57.1" - "@rollup/rollup-linux-arm-gnueabihf" "4.57.1" - "@rollup/rollup-linux-arm-musleabihf" "4.57.1" - "@rollup/rollup-linux-arm64-gnu" "4.57.1" - "@rollup/rollup-linux-arm64-musl" "4.57.1" - "@rollup/rollup-linux-loong64-gnu" "4.57.1" - "@rollup/rollup-linux-loong64-musl" "4.57.1" - "@rollup/rollup-linux-ppc64-gnu" "4.57.1" - "@rollup/rollup-linux-ppc64-musl" "4.57.1" - "@rollup/rollup-linux-riscv64-gnu" "4.57.1" - "@rollup/rollup-linux-riscv64-musl" "4.57.1" - "@rollup/rollup-linux-s390x-gnu" "4.57.1" - "@rollup/rollup-linux-x64-gnu" "4.57.1" - "@rollup/rollup-linux-x64-musl" "4.57.1" - "@rollup/rollup-openbsd-x64" "4.57.1" - "@rollup/rollup-openharmony-arm64" "4.57.1" - "@rollup/rollup-win32-arm64-msvc" "4.57.1" - "@rollup/rollup-win32-ia32-msvc" "4.57.1" - "@rollup/rollup-win32-x64-gnu" "4.57.1" - "@rollup/rollup-win32-x64-msvc" "4.57.1" + "@rollup/rollup-android-arm-eabi" "4.58.0" + "@rollup/rollup-android-arm64" "4.58.0" + "@rollup/rollup-darwin-arm64" "4.58.0" + "@rollup/rollup-darwin-x64" "4.58.0" + "@rollup/rollup-freebsd-arm64" "4.58.0" + "@rollup/rollup-freebsd-x64" "4.58.0" + "@rollup/rollup-linux-arm-gnueabihf" "4.58.0" + "@rollup/rollup-linux-arm-musleabihf" "4.58.0" + "@rollup/rollup-linux-arm64-gnu" "4.58.0" + "@rollup/rollup-linux-arm64-musl" "4.58.0" + "@rollup/rollup-linux-loong64-gnu" "4.58.0" + "@rollup/rollup-linux-loong64-musl" "4.58.0" + "@rollup/rollup-linux-ppc64-gnu" "4.58.0" + "@rollup/rollup-linux-ppc64-musl" "4.58.0" + "@rollup/rollup-linux-riscv64-gnu" "4.58.0" + "@rollup/rollup-linux-riscv64-musl" "4.58.0" + "@rollup/rollup-linux-s390x-gnu" "4.58.0" + "@rollup/rollup-linux-x64-gnu" "4.58.0" + "@rollup/rollup-linux-x64-musl" "4.58.0" + "@rollup/rollup-openbsd-x64" "4.58.0" + "@rollup/rollup-openharmony-arm64" "4.58.0" + "@rollup/rollup-win32-arm64-msvc" "4.58.0" + "@rollup/rollup-win32-ia32-msvc" "4.58.0" + "@rollup/rollup-win32-x64-gnu" "4.58.0" + "@rollup/rollup-win32-x64-msvc" "4.58.0" fsevents "~2.3.2" run-parallel@^1.1.9: @@ -1277,9 +1277,9 @@ semver@^7.6.0: integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== semver@^7.6.3: - version "7.7.4" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" - integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== + version "7.7.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" + integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== shebang-command@^2.0.0: version "2.0.0" diff --git a/crates/rust-client/src/note/import.rs b/crates/rust-client/src/note/import.rs index 273ac44f83..cb03b6e1a4 100644 --- a/crates/rust-client/src/note/import.rs +++ b/crates/rust-client/src/note/import.rs @@ -34,7 +34,7 @@ use crate::{Client, ClientError}; /// Note importing methods. impl Client where - AUTH: TransactionAuthenticator + Sync + 'static, + AUTH: TransactionAuthenticator + Send + Sync + 'static, { // INPUT NOTE CREATION // -------------------------------------------------------------------------------------------- diff --git a/crates/rust-client/src/note/note_screener.rs b/crates/rust-client/src/note/note_screener.rs index 306f2e480e..1034b557cf 100644 --- a/crates/rust-client/src/note/note_screener.rs +++ b/crates/rust-client/src/note/note_screener.rs @@ -188,10 +188,11 @@ where // DEFAULT CALLBACK IMPLEMENTATIONS // ================================================================================================ -#[async_trait(?Send)] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] impl OnNoteReceived for NoteScreener where - AUTH: TransactionAuthenticator + Sync, + AUTH: TransactionAuthenticator + Send + Sync, { /// Default implementation of the [`OnNoteReceived`] callback. It queries the store for the /// committed note to check if it's relevant. If the note wasn't being tracked but it came in diff --git a/crates/rust-client/src/note_transport/mod.rs b/crates/rust-client/src/note_transport/mod.rs index f2ee091623..6ced1a59a4 100644 --- a/crates/rust-client/src/note_transport/mod.rs +++ b/crates/rust-client/src/note_transport/mod.rs @@ -61,7 +61,7 @@ impl Client { impl Client where - AUTH: TransactionAuthenticator + Sync + 'static, + AUTH: TransactionAuthenticator + Send + Sync + 'static, { /// Fetch notes for tracked note tags. /// diff --git a/crates/rust-client/src/sync/mod.rs b/crates/rust-client/src/sync/mod.rs index 0ac105b2f8..8e069cb40c 100644 --- a/crates/rust-client/src/sync/mod.rs +++ b/crates/rust-client/src/sync/mod.rs @@ -31,7 +31,7 @@ //! # use miden_client::{Client, ClientError}; //! # use miden_protocol::{block::BlockHeader, Felt, Word, StarkField}; //! # use miden_protocol::crypto::rand::FeltRng; -//! # async fn run_sync(client: &mut Client) -> Result<(), ClientError> { +//! # async fn run_sync(client: &mut Client) -> Result<(), ClientError> { //! // Attempt to synchronize the client's state with the Miden network. //! // The requested data is based on the client's state: it gets updates for accounts, relevant //! // notes, etc. For more information on the data that gets requested, see the doc comments for @@ -88,7 +88,7 @@ pub use state_sync_update::{ /// Client synchronization methods. impl Client where - AUTH: TransactionAuthenticator + Sync + 'static, + AUTH: TransactionAuthenticator + Send + Sync + 'static, { // SYNC STATE // -------------------------------------------------------------------------------------------- @@ -185,6 +185,33 @@ where }) } + /// Gets the state sync update from the network without applying it. + /// + /// This method performs the same network requests as [`sync_state()`](Self::sync_state) but + /// returns the raw [`StateSyncUpdate`] instead of applying it to the store. This is useful + /// when you want to inspect or modify the update before applying it, or when implementing + /// custom sync logic. + /// + /// Use [`apply_state_sync()`](Self::apply_state_sync) to apply the returned update. + /// + /// # Example + /// + /// ```rust,ignore + /// let update = client.get_sync_update().await?; + /// println!("Sync would update {} accounts", update.accounts.updated_accounts.len()); + /// client.apply_state_sync(update).await?; + /// ``` + pub async fn get_sync_update(&self) -> Result { + let note_screener = self.note_screener(); + let state_sync = + StateSync::new(self.rpc_api.clone(), Arc::new(note_screener), self.tx_discard_delta); + + let mut current_partial_mmr = self.store.get_current_partial_mmr().await?; + let input = self.build_sync_input().await?; + + state_sync.sync_state(&mut current_partial_mmr, input).await + } + /// Applies the state sync update to the store and prunes the irrelevant block headers. /// /// See [`crate::Store::apply_state_sync()`] for what the update implies. @@ -214,7 +241,7 @@ where // ================================================================================================ /// Contains stats about the sync operation. -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Clone)] pub struct SyncSummary { /// Block number up to which the client has been synced. pub block_num: BlockNumber, diff --git a/crates/rust-client/src/sync/state_sync.rs b/crates/rust-client/src/sync/state_sync.rs index 3a4abfa1e5..c5c1fbaf7d 100644 --- a/crates/rust-client/src/sync/state_sync.rs +++ b/crates/rust-client/src/sync/state_sync.rs @@ -65,8 +65,9 @@ pub enum NoteUpdateAction { Discard, } -#[async_trait(?Send)] -pub trait OnNoteReceived { +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +pub trait OnNoteReceived: Send + Sync { /// Callback that gets executed when a new note is received as part of the sync response. /// /// It receives: diff --git a/crates/rust-client/src/transaction/mod.rs b/crates/rust-client/src/transaction/mod.rs index eb5e0e3e5d..32d197792c 100644 --- a/crates/rust-client/src/transaction/mod.rs +++ b/crates/rust-client/src/transaction/mod.rs @@ -35,7 +35,7 @@ //! /// containing 100 tokens of `faucet_id`'s fungible asset. //! async fn create_and_submit_transaction< //! R: rand::Rng, -//! AUTH: TransactionAuthenticator + Sync + 'static, +//! AUTH: TransactionAuthenticator + Send + Sync + 'static, //! >( //! client: &mut Client, //! sender_id: AccountId, @@ -152,7 +152,7 @@ pub use result::TransactionResult; /// Transaction management methods impl Client where - AUTH: TransactionAuthenticator + Sync + 'static, + AUTH: TransactionAuthenticator + Send + Sync + 'static, { // TRANSACTION DATA RETRIEVAL // -------------------------------------------------------------------------------------------- diff --git a/crates/sqlite-store/src/account/accounts.rs b/crates/sqlite-store/src/account/accounts.rs index eddc714247..01cd14f401 100644 --- a/crates/sqlite-store/src/account/accounts.rs +++ b/crates/sqlite-store/src/account/accounts.rs @@ -18,7 +18,6 @@ use miden_client::account::{ PartialAccount, PartialStorage, PartialStorageMap, - StorageMap, StorageSlotName, StorageSlotType, }; @@ -40,11 +39,10 @@ use rusqlite::types::Value; use rusqlite::{Connection, Transaction, named_params, params}; use crate::account::helpers::{ - SerializedHeaderData, - parse_accounts, query_account_addresses, query_account_code, - query_account_headers, + query_historical_account_headers, + query_latest_account_headers, query_storage_slots, query_storage_values, query_vault_assets, @@ -59,7 +57,7 @@ impl SqliteStore { // -------------------------------------------------------------------------------------------- pub(crate) fn get_account_ids(conn: &mut Connection) -> Result, StoreError> { - const QUERY: &str = "SELECT id FROM tracked_accounts"; + const QUERY: &str = "SELECT id FROM latest_account_headers"; conn.prepare_cached(QUERY) .into_store_error()? @@ -75,62 +73,14 @@ impl SqliteStore { pub(crate) fn get_account_headers( conn: &mut Connection, ) -> Result, StoreError> { - const QUERY: &str = " - SELECT - a.id, - a.nonce, - a.vault_root, - a.storage_commitment, - a.code_commitment, - a.account_seed, - a.locked - FROM accounts AS a - JOIN ( - SELECT id, MAX(nonce) AS nonce - FROM accounts - GROUP BY id - ) AS latest - ON a.id = latest.id - AND a.nonce = latest.nonce - ORDER BY a.id; - "; - - conn.prepare_cached(QUERY) - .into_store_error()? - .query_map(params![], |row| { - let id: String = row.get(0)?; - let nonce: u64 = column_value_as_u64(row, 1)?; - let vault_root: String = row.get(2)?; - let storage_commitment: String = row.get(3)?; - let code_commitment: String = row.get(4)?; - let account_seed: Option> = row.get(5)?; - let locked: bool = row.get(6)?; - - Ok(SerializedHeaderData { - id, - nonce, - vault_root, - storage_commitment, - code_commitment, - account_seed, - locked, - }) - }) - .into_store_error()? - .map(|result| parse_accounts(result.into_store_error()?)) - .collect::, StoreError>>() + query_latest_account_headers(conn, "1=1 ORDER BY id", params![]) } pub(crate) fn get_account_header( conn: &mut Connection, account_id: AccountId, ) -> Result, StoreError> { - Ok(query_account_headers( - conn, - "id = ? ORDER BY nonce DESC LIMIT 1", - params![account_id.to_hex()], - )? - .pop()) + Ok(query_latest_account_headers(conn, "id = ?", params![account_id.to_hex()])?.pop()) } pub(crate) fn get_account_header_by_commitment( @@ -138,11 +88,13 @@ impl SqliteStore { account_commitment: Word, ) -> Result, StoreError> { let account_commitment_str: String = account_commitment.to_string(); - Ok( - query_account_headers(conn, "account_commitment = ?", params![account_commitment_str])? - .pop() - .map(|(header, _)| header), - ) + Ok(query_historical_account_headers( + conn, + "account_commitment = ?", + params![account_commitment_str], + )? + .pop() + .map(|(header, _)| header)) } /// Retrieves a complete account record with full vault and storage data. @@ -154,16 +106,12 @@ impl SqliteStore { return Ok(None); }; - let assets = query_vault_assets(conn, "root = ?", params![header.vault_root().to_hex()])?; + let assets = query_vault_assets(conn, account_id)?; let vault = AssetVault::new(&assets)?; - let slots = query_storage_slots( - conn, - "commitment = ?", - params![header.storage_commitment().to_hex()], - )? - .into_values() - .collect(); + let slots = query_storage_slots(conn, account_id, &AccountStorageFilter::All)? + .into_values() + .collect(); let storage = AccountStorage::new(slots)?; @@ -200,11 +148,7 @@ impl SqliteStore { let mut storage_header = Vec::new(); let mut maps = vec![]; - let storage_values = query_storage_values( - conn, - "commitment = ?", - params![header.storage_commitment().to_hex()], - )?; + let storage_values = query_storage_values(conn, account_id)?; // Storage maps are always minimal here (just roots, no entries). // New accounts that need full storage data are handled by the DataStore layer, @@ -274,12 +218,7 @@ impl SqliteStore { conn: &Connection, account_id: AccountId, ) -> Result { - let assets = query_vault_assets( - conn, - "root = (SELECT vault_root FROM accounts WHERE id = ? ORDER BY nonce DESC LIMIT 1)", - params![account_id.to_hex()], - )?; - + let assets = query_vault_assets(conn, account_id)?; Ok(AssetVault::new(&assets)?) } @@ -289,23 +228,7 @@ impl SqliteStore { account_id: AccountId, filter: &AccountStorageFilter, ) -> Result { - let (where_clause, params) = match filter { - AccountStorageFilter::All => ( - "commitment = (SELECT storage_commitment FROM accounts WHERE id = ? ORDER BY nonce DESC LIMIT 1)", - params![account_id.to_hex()], - ), - AccountStorageFilter::Root(root) => ( - "commitment = (SELECT storage_commitment FROM accounts WHERE id = ? ORDER BY nonce DESC LIMIT 1) AND slot_value = ?", - params![account_id.to_hex(), root.to_hex()], - ), - AccountStorageFilter::SlotName(slot_name) => ( - "commitment = (SELECT storage_commitment FROM accounts WHERE id = ? ORDER BY nonce DESC LIMIT 1) AND slot_name = ?", - params![account_id.to_hex(), slot_name.to_string()], - ), - }; - - let slots = query_storage_slots(conn, where_clause, params)?.into_values().collect(); - + let slots = query_storage_slots(conn, account_id, filter)?.into_values().collect(); Ok(AccountStorage::new(slots)?) } @@ -342,11 +265,7 @@ impl SqliteStore { .ok_or(StoreError::AccountDataNotFound(account_id))? .0; - let mut storage_values = query_storage_values( - conn, - "commitment = ? AND slot_name = ?", - params![header.storage_commitment().to_hex(), slot_name.to_string()], - )?; + let mut storage_values = query_storage_values(conn, account_id)?; let (slot_type, map_root) = storage_values .remove(&slot_name) .ok_or(StoreError::AccountStorageRootNotFound(header.storage_commitment()))?; @@ -373,13 +292,11 @@ impl SqliteStore { conn: &mut Connection, account_id: AccountId, ) -> Result, StoreError> { - let Some((header, _)) = query_account_headers( - conn, - "id = ? ORDER BY nonce DESC LIMIT 1", - params![account_id.to_hex()], - )? - .into_iter() - .next() else { + let Some((header, _)) = + query_latest_account_headers(conn, "id = ?", params![account_id.to_hex()])? + .into_iter() + .next() + else { return Ok(None); }; @@ -399,13 +316,12 @@ impl SqliteStore { Self::insert_account_code(&tx, account.code())?; - Self::insert_storage_slots( - &tx, - account.storage().to_commitment(), - account.storage().slots().iter(), - )?; + let account_id = account.id(); + let nonce = account.nonce().as_int(); + + Self::insert_storage_slots(&tx, account_id, nonce, account.storage().slots().iter())?; - Self::insert_assets(&tx, account.vault().root(), account.vault().assets())?; + Self::insert_assets(&tx, account_id, nonce, account.vault().assets())?; Self::insert_account_header(&tx, &account.into(), account.seed())?; Self::insert_address(&tx, initial_address, account.id())?; @@ -423,7 +339,7 @@ impl SqliteStore { smt_forest: &Arc>, new_account_state: &Account, ) -> Result<(), StoreError> { - const QUERY: &str = "SELECT id FROM accounts WHERE id = ?"; + const QUERY: &str = "SELECT id FROM latest_account_headers WHERE id = ?"; if conn .prepare(QUERY) .into_store_error()? @@ -513,27 +429,26 @@ impl SqliteStore { /// Applies the account delta to the account state, updating the vault and storage maps. /// - /// The apply delta operation strats by copying over the initial account state (vault and - /// storage) and then applying the delta on top of it. The storage and vault elements are - /// overwritten in the new state. In the cases where the delta depends on previous state (e.g. - /// adding or subtracting fungible assets), the previous state needs to be provided via the - /// `updated_fungible_assets` and `updated_storage_maps` parameters. + /// Writes only changed entries to historical (at the new nonce) and updates latest via + /// INSERT OR REPLACE. pub(crate) fn apply_account_delta( tx: &Transaction<'_>, smt_forest: &mut AccountSmtForest, init_account_state: &AccountHeader, final_account_state: &AccountHeader, updated_fungible_assets: BTreeMap, - updated_storage_maps: BTreeMap, + old_map_roots: &BTreeMap, delta: &AccountDelta, ) -> Result<(), StoreError> { - // Copy over the storage and vault from the previous state. Non-relevant data will not be - // modified. - Self::copy_account_state(tx, init_account_state, final_account_state)?; + let account_id = final_account_state.id(); + + // Insert the new account header + Self::insert_account_header(tx, final_account_state, None)?; Self::apply_account_vault_delta( tx, smt_forest, + account_id, init_account_state, final_account_state, updated_fungible_assets, @@ -541,12 +456,14 @@ impl SqliteStore { )?; let updated_storage_slots = - Self::apply_account_storage_delta(smt_forest, updated_storage_maps, delta)?; + Self::apply_account_storage_delta(smt_forest, old_map_roots, delta)?; - Self::insert_storage_slots( + Self::write_storage_delta( tx, - final_account_state.storage_commitment(), - updated_storage_slots.values(), + account_id, + final_account_state.nonce().as_int(), + &updated_storage_slots, + delta, )?; Ok(()) @@ -555,11 +472,8 @@ impl SqliteStore { /// Removes account states with the specified hashes from the database and pops their /// SMT roots from the forest to free up memory. /// - /// This is used to rollback account changes when a transaction is discarded, - /// effectively undoing the account state changes that were applied by the transaction. - /// - /// Note: This is not part of the Store trait and is only used internally by the `SQLite` store - /// implementation to handle transaction rollbacks. + /// This also removes the corresponding historical entries and rebuilds the latest tables + /// for affected accounts. pub(crate) fn undo_account_state( tx: &Transaction<'_>, smt_forest: &mut AccountSmtForest, @@ -576,9 +490,54 @@ impl SqliteStore { // Query all SMT roots before deletion so we can pop them from the forest let smt_roots = Self::get_smt_roots_by_account_commitment(tx, &account_hash_params)?; - const DELETE_QUERY: &str = "DELETE FROM accounts WHERE account_commitment IN rarray(?)"; + // Resolve (account_id, nonce) pairs for the accounts being undone + const RESOLVE_QUERY: &str = "SELECT id, nonce FROM historical_account_headers WHERE account_commitment IN rarray(?)"; + let id_nonce_pairs: Vec<(String, u64)> = tx + .prepare(RESOLVE_QUERY) + .into_store_error()? + .query_map(params![account_hash_params.clone()], |row| { + let id: String = row.get(0)?; + let nonce: u64 = column_value_as_u64(row, 1)?; + Ok((id, nonce)) + }) + .into_store_error()? + .filter_map(Result::ok) + .collect(); + + // Delete historical entries for these (account_id, nonce) pairs + for (account_id_hex, nonce) in &id_nonce_pairs { + let nonce_val = u64_to_value(*nonce); + tx.execute( + "DELETE FROM historical_account_storage WHERE account_id = ? AND nonce = ?", + params![account_id_hex, nonce_val], + ) + .into_store_error()?; + tx.execute( + "DELETE FROM historical_storage_map_entries WHERE account_id = ? AND nonce = ?", + params![account_id_hex, nonce_val], + ) + .into_store_error()?; + tx.execute( + "DELETE FROM historical_account_assets WHERE account_id = ? AND nonce = ?", + params![account_id_hex, nonce_val], + ) + .into_store_error()?; + } + + // Delete from historical_account_headers table + const DELETE_QUERY: &str = + "DELETE FROM historical_account_headers WHERE account_commitment IN rarray(?)"; tx.execute(DELETE_QUERY, params![account_hash_params]).into_store_error()?; + // Rebuild latest tables for affected accounts + let mut unique_ids: Vec = id_nonce_pairs.iter().map(|(id, _)| id.clone()).collect(); + unique_ids.sort(); + unique_ids.dedup(); + + for account_id_hex in &unique_ids { + Self::rebuild_latest_for_account(tx, account_id_hex)?; + } + // Pop the roots from the forest to release memory for nodes that are no longer reachable smt_forest.pop_roots(smt_roots); @@ -589,40 +548,73 @@ impl SqliteStore { /// /// This function replaces the current account state with a completely new one. It: /// - Inserts the new account header - /// - Stores all storage slots and their maps - /// - Stores all vault assets + /// - Deletes all latest entries and replaces them with the new state + /// - Writes all entries to historical at the new nonce + /// - Writes tombstones to historical for entries that existed before but are absent from the + /// new state, so that `rebuild_latest_for_account` won't resurrect them after a later undo /// - Updates the SMT forest with the new state /// - Pops old SMT roots from the forest to free memory - /// - /// This is typically used for replacing a full account state, as opposed to applying - /// incremental deltas via `apply_account_delta`. - /// - /// # Arguments - /// * `tx` - Database transaction - /// * `smt_forest` - SMT forest for updating and pruning - /// * `new_account_state` - The new complete account state to persist - /// - /// # Returns - /// `Ok(())` if the update was successful, or an error if any operation fails. pub(crate) fn update_account_state( tx: &Transaction<'_>, smt_forest: &mut AccountSmtForest, new_account_state: &Account, ) -> Result<(), StoreError> { + let account_id = new_account_state.id(); + let account_id_hex = account_id.to_hex(); + let nonce = new_account_state.nonce().as_int(); + let nonce_val = u64_to_value(nonce); + // Get old SMT roots before updating so we can prune them after - let old_roots = Self::get_smt_roots_by_account_id(tx, new_account_state.id())?; + let old_roots = Self::get_smt_roots_by_account_id(tx, account_id)?; smt_forest.insert_account_state(new_account_state.vault(), new_account_state.storage())?; + + // Write tombstones for all current entries before deleting latest. + // insert_storage_slots/insert_assets will overwrite entries that still exist + // (INSERT OR REPLACE), leaving only genuinely removed entries as tombstones. + tx.execute( + "INSERT OR REPLACE INTO historical_storage_map_entries \ + (account_id, nonce, slot_name, key, value) \ + SELECT account_id, ?, slot_name, key, NULL \ + FROM latest_storage_map_entries WHERE account_id = ?", + params![&nonce_val, &account_id_hex], + ) + .into_store_error()?; + tx.execute( + "INSERT OR REPLACE INTO historical_account_assets \ + (account_id, nonce, vault_key, faucet_id_prefix, asset) \ + SELECT account_id, ?, vault_key, faucet_id_prefix, NULL \ + FROM latest_account_assets WHERE account_id = ?", + params![&nonce_val, &account_id_hex], + ) + .into_store_error()?; + + // Delete all latest entries for this account + tx.execute( + "DELETE FROM latest_account_storage WHERE account_id = ?", + params![&account_id_hex], + ) + .into_store_error()?; + tx.execute( + "DELETE FROM latest_storage_map_entries WHERE account_id = ?", + params![&account_id_hex], + ) + .into_store_error()?; + tx.execute( + "DELETE FROM latest_account_assets WHERE account_id = ?", + params![&account_id_hex], + ) + .into_store_error()?; + + // Insert all new entries into latest + historical (overwrites tombstones for + // entries that still exist) Self::insert_storage_slots( tx, - new_account_state.storage().to_commitment(), + account_id, + nonce, new_account_state.storage().slots().iter(), )?; - Self::insert_assets( - tx, - new_account_state.vault().root(), - new_account_state.vault().assets(), - )?; + Self::insert_assets(tx, account_id, nonce, new_account_state.vault().assets())?; Self::insert_account_header(tx, &new_account_state.into(), None)?; // Pop old roots to free memory for nodes no longer reachable @@ -641,82 +633,120 @@ impl SqliteStore { // Mismatched digests may be due to stale network data. If the mismatched digest is // tracked in the db and corresponds to the mismatched account, it means we // got a past update and shouldn't lock the account. - const QUERY: &str = "UPDATE accounts SET locked = true WHERE id = :account_id AND NOT EXISTS (SELECT 1 FROM accounts WHERE id = :account_id AND account_commitment = :digest)"; - tx.execute( - QUERY, - named_params! { - ":account_id": account_id.to_hex(), - ":digest": mismatched_digest.to_string() - }, - ) - .into_store_error()?; + 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)"; + let account_id_hex = account_id.to_hex(); + let digest_str = mismatched_digest.to_string(); + let params = named_params! { + ":account_id": account_id_hex, + ":digest": digest_str + }; + + let query = format!("UPDATE latest_account_headers SET locked = true {LOCK_CONDITION}"); + tx.execute(&query, params).into_store_error()?; + + // Also lock historical rows so that rebuild_latest_for_account preserves the lock. + let query = format!("UPDATE historical_account_headers SET locked = true {LOCK_CONDITION}"); + tx.execute(&query, params).into_store_error()?; + Ok(()) } // HELPERS // -------------------------------------------------------------------------------------------- - /// Inserts the new final account header and copies over the previous account state. - fn copy_account_state( + /// Rebuilds the latest tables for a single account from its historical data. + /// If the account has no remaining states, clears its latest entries. + fn rebuild_latest_for_account( tx: &Transaction<'_>, - init_account_header: &AccountHeader, - final_account_header: &AccountHeader, + account_id_hex: &str, ) -> Result<(), StoreError> { - Self::insert_account_header(tx, final_account_header, None)?; - - if init_account_header.vault_root() != final_account_header.vault_root() { - const VAULT_QUERY: &str = " - INSERT OR IGNORE INTO account_assets ( - root, - vault_key, - faucet_id_prefix, - asset - ) - SELECT - ?, --new root - vault_key, - faucet_id_prefix, - asset - FROM account_assets - WHERE root = (SELECT vault_root FROM accounts WHERE account_commitment = ?) - "; - tx.execute( - VAULT_QUERY, - params![ - final_account_header.vault_root().to_hex(), - init_account_header.commitment().to_hex() - ], + let remaining: Option = tx + .query_row( + "SELECT MAX(nonce) FROM historical_account_headers WHERE id = ?", + params![account_id_hex], + |row| row.get(0), ) .into_store_error()?; - } - - if init_account_header.storage_commitment() != final_account_header.storage_commitment() { - const STORAGE_QUERY: &str = " - INSERT OR IGNORE INTO account_storage ( - commitment, - slot_name, - slot_value, - slot_type - ) - SELECT - ?, -- new commitment - slot_name, - slot_value, - slot_type - FROM account_storage - WHERE commitment = (SELECT storage_commitment FROM accounts WHERE account_commitment = ?) - "; - tx.execute( - STORAGE_QUERY, - params![ - final_account_header.storage_commitment().to_hex(), - init_account_header.commitment().to_hex() - ], - ) + // Clear all latest entries first + tx.execute("DELETE FROM latest_account_headers WHERE id = ?", params![account_id_hex]) .into_store_error()?; + tx.execute( + "DELETE FROM latest_account_storage WHERE account_id = ?", + params![account_id_hex], + ) + .into_store_error()?; + tx.execute( + "DELETE FROM latest_storage_map_entries WHERE account_id = ?", + params![account_id_hex], + ) + .into_store_error()?; + tx.execute( + "DELETE FROM latest_account_assets WHERE account_id = ?", + params![account_id_hex], + ) + .into_store_error()?; + + if remaining.is_none() { + return Ok(()); } + // Rebuild latest_account_headers from historical + tx.execute( + "INSERT INTO latest_account_headers (id, account_commitment, code_commitment, storage_commitment, vault_root, nonce, account_seed, locked) + SELECT h.id, h.account_commitment, h.code_commitment, h.storage_commitment, h.vault_root, h.nonce, h.account_seed, h.locked + FROM historical_account_headers h + WHERE h.id = ?1 AND h.nonce = (SELECT MAX(nonce) FROM historical_account_headers WHERE id = ?1)", + params![account_id_hex], + ) + .into_store_error()?; + + // Rebuild from historical using MAX(nonce) per key + tx.execute( + "INSERT INTO latest_account_storage (account_id, slot_name, slot_value, slot_type) + SELECT h.account_id, h.slot_name, h.slot_value, h.slot_type + FROM historical_account_storage h + INNER JOIN ( + SELECT account_id, slot_name, MAX(nonce) AS max_nonce + FROM historical_account_storage WHERE account_id = ?1 + GROUP BY account_id, slot_name + ) latest ON h.account_id = latest.account_id + AND h.slot_name = latest.slot_name AND h.nonce = latest.max_nonce", + params![account_id_hex], + ) + .into_store_error()?; + + tx.execute( + "INSERT INTO latest_storage_map_entries (account_id, slot_name, key, value) + SELECT h.account_id, h.slot_name, h.key, h.value + FROM historical_storage_map_entries h + INNER JOIN ( + SELECT account_id, slot_name, key, MAX(nonce) AS max_nonce + FROM historical_storage_map_entries WHERE account_id = ?1 + GROUP BY account_id, slot_name, key + ) latest ON h.account_id = latest.account_id + AND h.slot_name = latest.slot_name AND h.key = latest.key + AND h.nonce = latest.max_nonce + WHERE h.value IS NOT NULL", + params![account_id_hex], + ) + .into_store_error()?; + + tx.execute( + "INSERT INTO latest_account_assets (account_id, vault_key, faucet_id_prefix, asset) + SELECT h.account_id, h.vault_key, h.faucet_id_prefix, h.asset + FROM historical_account_assets h + INNER JOIN ( + SELECT account_id, vault_key, MAX(nonce) AS max_nonce + FROM historical_account_assets WHERE account_id = ?1 + GROUP BY account_id, vault_key + ) latest ON h.account_id = latest.account_id + AND h.vault_key = latest.vault_key AND h.nonce = latest.max_nonce + WHERE h.asset IS NOT NULL", + params![account_id_hex], + ) + .into_store_error()?; + Ok(()) } @@ -731,16 +761,14 @@ impl SqliteStore { ) -> Result, StoreError> { const LATEST_ACCOUNT_QUERY: &str = r" SELECT vault_root, storage_commitment - FROM accounts + FROM latest_account_headers WHERE id = ?1 - ORDER BY nonce DESC - LIMIT 1 "; const STORAGE_MAP_ROOTS_QUERY: &str = r" SELECT slot_value - FROM account_storage - WHERE commitment = ?1 + FROM latest_account_storage + WHERE account_id = ?1 AND slot_type = ?2 AND slot_value IS NOT NULL "; @@ -748,7 +776,7 @@ impl SqliteStore { let map_slot_type = StorageSlotType::Map as u8; // 1) Fetch latest vault root + storage commitment. - let (vault_root, storage_commitment): (String, String) = tx + let (vault_root, _storage_commitment): (String, String) = tx .query_row(LATEST_ACCOUNT_QUERY, params![account_id.to_hex()], |row| { Ok((row.get(0)?, row.get(1)?)) }) @@ -761,10 +789,10 @@ impl SqliteStore { roots.push(root); } - // 2) Fetch storage map roots for the latest storage commitment. + // 2) Fetch storage map roots from latest_account_storage. let mut stmt = tx.prepare(STORAGE_MAP_ROOTS_QUERY).into_store_error()?; let iter = stmt - .query_map(params![storage_commitment, map_slot_type], |row| row.get::<_, String>(0)) + .query_map(params![account_id.to_hex(), map_slot_type], |row| row.get::<_, String>(0)) .into_store_error()?; roots.extend(iter.filter_map(Result::ok).filter_map(|r| Word::try_from(r.as_str()).ok())); @@ -777,27 +805,50 @@ impl SqliteStore { tx: &Transaction<'_>, account_hash_params: &Rc>, ) -> Result, StoreError> { - const ROOTS_QUERY: &str = " - SELECT vault_root FROM accounts WHERE account_commitment IN rarray(?1) - UNION ALL - SELECT slot_value FROM account_storage - WHERE commitment IN ( - SELECT storage_commitment FROM accounts WHERE account_commitment IN rarray(?1) - ) AND slot_type = ?2"; + // Get vault roots from the accounts being undone + const VAULT_ROOTS_QUERY: &str = "SELECT vault_root FROM historical_account_headers WHERE account_commitment IN rarray(?1)"; + + // Get storage map roots from the exact historical states being undone, not from + // latest (which may have already been updated). This ensures we pop the correct + // intermediate map roots from the SMT forest. + const MAP_ROOTS_QUERY: &str = " + SELECT s.slot_value + FROM historical_account_storage s + INNER JOIN historical_account_headers h + ON s.account_id = h.id AND s.nonce = h.nonce + WHERE h.account_commitment IN rarray(?1) + AND s.slot_type = ?2 + AND s.slot_value IS NOT NULL"; let map_slot_type = StorageSlotType::Map as u8; - let mut stmt = tx.prepare(ROOTS_QUERY).into_store_error()?; - let roots = stmt + + let mut roots = Vec::new(); + + let mut vault_stmt = tx.prepare(VAULT_ROOTS_QUERY).into_store_error()?; + let vault_iter = vault_stmt + .query_map(params![account_hash_params], |row| row.get::<_, String>(0)) + .into_store_error()?; + roots.extend( + vault_iter + .filter_map(Result::ok) + .filter_map(|r| Word::try_from(r.as_str()).ok()), + ); + + let mut map_stmt = tx.prepare(MAP_ROOTS_QUERY).into_store_error()?; + let map_iter = map_stmt .query_map(params![account_hash_params, map_slot_type], |row| row.get::<_, String>(0)) - .into_store_error()? - .filter_map(Result::ok) - .filter_map(|r| Word::try_from(r.as_str()).ok()) - .collect(); + .into_store_error()?; + roots.extend( + map_iter.filter_map(Result::ok).filter_map(|r| Word::try_from(r.as_str()).ok()), + ); Ok(roots) } /// Inserts a new account record into the database. + /// + /// Writes to both `historical_account_headers` (append-only log of all state transitions) + /// and `latest_account_headers` (current state, one row per account via REPLACE). fn insert_account_header( tx: &Transaction<'_>, account: &AccountHeader, @@ -812,8 +863,8 @@ impl SqliteStore { let account_seed = account_seed.map(|seed| seed.to_bytes()); - const QUERY: &str = insert_sql!( - accounts { + const HISTORICAL_QUERY: &str = insert_sql!( + historical_account_headers { id, code_commitment, storage_commitment, @@ -826,7 +877,7 @@ impl SqliteStore { ); tx.execute( - QUERY, + HISTORICAL_QUERY, params![ id, code_commitment, @@ -840,16 +891,34 @@ impl SqliteStore { ) .into_store_error()?; - Self::insert_tracked_account_id_tx(tx, account.id())?; - Ok(()) - } + const LATEST_QUERY: &str = insert_sql!( + latest_account_headers { + id, + code_commitment, + storage_commitment, + vault_root, + nonce, + account_seed, + account_commitment, + locked + } | REPLACE + ); + + tx.execute( + LATEST_QUERY, + params![ + id, + code_commitment, + storage_commitment, + vault_root, + nonce, + account_seed, + commitment, + false, + ], + ) + .into_store_error()?; - fn insert_tracked_account_id_tx( - tx: &Transaction<'_>, - account_id: AccountId, - ) -> Result<(), StoreError> { - const QUERY: &str = insert_sql!(tracked_accounts { id } | IGNORE); - tx.execute(QUERY, params![account_id.to_hex()]).into_store_error()?; Ok(()) } diff --git a/crates/sqlite-store/src/account/helpers.rs b/crates/sqlite-store/src/account/helpers.rs index d61476a558..8f95fd9c6c 100644 --- a/crates/sqlite-store/src/account/helpers.rs +++ b/crates/sqlite-store/src/account/helpers.rs @@ -1,7 +1,6 @@ //! Helper functions for account operations. use std::collections::BTreeMap; -use std::rc::Rc; use miden_client::account::{ AccountCode, @@ -14,10 +13,9 @@ use miden_client::account::{ StorageSlotType, }; use miden_client::asset::Asset; -use miden_client::store::{AccountStatus, StoreError}; +use miden_client::store::{AccountStatus, AccountStorageFilter, StoreError}; use miden_client::{Deserializable, Word}; -use rusqlite::types::Value; -use rusqlite::{Connection, Params, params}; +use rusqlite::{Connection, Params, params, params_from_iter}; use crate::column_value_as_u64; use crate::sql_error::SqlResultExt; @@ -65,14 +63,32 @@ pub(crate) fn parse_accounts( )) } -pub(crate) fn query_account_headers( +pub(crate) fn query_latest_account_headers( conn: &Connection, where_clause: &str, params: impl Params, ) -> Result, StoreError> { - const SELECT_QUERY: &str = "SELECT id, nonce, vault_root, storage_commitment, code_commitment, account_seed, locked \ - FROM accounts"; - let query = format!("{SELECT_QUERY} WHERE {where_clause}"); + query_account_headers_from_table(conn, "latest_account_headers", where_clause, params) +} + +pub(crate) fn query_historical_account_headers( + conn: &Connection, + where_clause: &str, + params: impl Params, +) -> Result, StoreError> { + query_account_headers_from_table(conn, "historical_account_headers", where_clause, params) +} + +fn query_account_headers_from_table( + conn: &Connection, + table: &str, + where_clause: &str, + params: impl Params, +) -> Result, StoreError> { + let query = format!( + "SELECT id, nonce, vault_root, storage_commitment, code_commitment, account_seed, locked \ + FROM {table} WHERE {where_clause}" + ); conn.prepare(&query) .into_store_error()? .query_map(params, |row| { @@ -99,12 +115,12 @@ pub(crate) fn query_account_headers( .collect::, StoreError>>() } +// TODO: this function will probably be refactored to receive more complex where clauses and +// return multiple mast forests pub(crate) fn query_account_code( conn: &Connection, commitment: Word, ) -> Result, StoreError> { - // TODO: this function will probably be refactored to receive more complex where clauses and - // return multiple mast forests const CODE_QUERY: &str = "SELECT code FROM account_code WHERE commitment = ?"; conn.prepare_cached(CODE_QUERY) @@ -145,15 +161,13 @@ pub(crate) fn query_account_addresses( pub(crate) fn query_vault_assets( conn: &Connection, - where_clause: &str, - params: impl Params, + account_id: AccountId, ) -> Result, StoreError> { - const VAULT_QUERY: &str = "SELECT asset FROM account_assets"; + const VAULT_QUERY: &str = "SELECT asset FROM latest_account_assets WHERE account_id = ?"; - let query = format!("{VAULT_QUERY} WHERE {where_clause}"); - conn.prepare(&query) + conn.prepare(VAULT_QUERY) .into_store_error()? - .query_map(params, |row| { + .query_map(params![account_id.to_hex()], |row| { let asset: String = row.get(0)?; Ok(asset) }) @@ -168,16 +182,30 @@ pub(crate) fn query_vault_assets( pub(crate) fn query_storage_slots( conn: &Connection, - where_clause: &str, - params: impl Params, + account_id: AccountId, + filter: &AccountStorageFilter, ) -> Result, StoreError> { - const STORAGE_QUERY: &str = "SELECT slot_name, slot_value, slot_type FROM account_storage"; + let account_id_hex = account_id.to_hex(); - let query = format!("{STORAGE_QUERY} WHERE {where_clause}"); - let storage_values = conn - .prepare(&query) - .into_store_error()? - .query_map(params, |row| { + // Build storage values query with filter pushed to SQL + let base_query = + "SELECT slot_name, slot_value, slot_type FROM latest_account_storage WHERE account_id = ?1"; + let mut values_params: Vec = vec![account_id_hex]; + let query = match filter { + AccountStorageFilter::All => base_query.to_string(), + AccountStorageFilter::SlotName(name) => { + values_params.push(name.to_string()); + format!("{base_query} AND slot_name = ?2") + }, + AccountStorageFilter::Root(root) => { + values_params.push(root.to_hex()); + format!("{base_query} AND slot_value = ?2") + }, + }; + + let mut stmt = conn.prepare(&query).into_store_error()?; + let storage_values = stmt + .query_map(params_from_iter(values_params.iter()), |row| { let slot_name: String = row.get(0)?; let value: String = row.get(1)?; let slot_type: u8 = row.get(2)?; @@ -194,11 +222,18 @@ pub(crate) fn query_storage_slots( }) .collect::, StoreError>>()?; - let possible_roots: Vec = - storage_values.iter().map(|(_, value, _)| Value::from(value.to_hex())).collect(); + // For SlotName filter, also restrict map entries query to avoid loading unneeded maps + let map_filter = match filter { + AccountStorageFilter::SlotName(name) => Some(name.to_string()), + _ => None, + }; - let mut storage_maps = - query_storage_maps(conn, "root IN rarray(?)", [Rc::new(possible_roots)])?; + let has_map_slots = storage_values.iter().any(|(_, _, t)| *t == StorageSlotType::Map); + let mut storage_maps = if has_map_slots { + query_storage_maps(conn, account_id, map_filter.as_deref())? + } else { + BTreeMap::new() + }; Ok(storage_values .into_iter() @@ -207,8 +242,8 @@ pub(crate) fn query_storage_slots( let slot = match slot_type { StorageSlotType::Value => StorageSlot::with_value(slot_name, value), StorageSlotType::Map => StorageSlot::with_map( - slot_name, - storage_maps.remove(&value).unwrap_or(StorageMap::new()), + slot_name.clone(), + storage_maps.remove(&slot_name).unwrap_or(StorageMap::new()), ), }; (key, slot) @@ -218,32 +253,42 @@ pub(crate) fn query_storage_slots( pub(crate) fn query_storage_maps( conn: &Connection, - where_clause: &str, - params: impl Params, -) -> Result, StoreError> { - const STORAGE_MAP_SELECT: &str = "SELECT root, key, value FROM storage_map_entries"; - let query = format!("{STORAGE_MAP_SELECT} WHERE {where_clause}"); + account_id: AccountId, + slot_name_filter: Option<&str>, +) -> Result, StoreError> { + let account_id_hex = account_id.to_hex(); + let base_query = + "SELECT slot_name, key, value FROM latest_storage_map_entries WHERE account_id = ?1"; + let mut map_params: Vec = vec![account_id_hex]; + let query = match slot_name_filter { + Some(name) => { + map_params.push(name.to_string()); + format!("{base_query} AND slot_name = ?2") + }, + None => base_query.to_string(), + }; - let map_entries = conn - .prepare(&query) - .into_store_error()? - .query_map(params, |row| { - let root: String = row.get(0)?; + let mut stmt = conn.prepare(&query).into_store_error()?; + let map_entries = stmt + .query_map(params_from_iter(map_params.iter()), |row| { + let slot_name: String = row.get(0)?; let key: String = row.get(1)?; let value: String = row.get(2)?; - Ok((root, key, value)) + Ok((slot_name, key, value)) }) .into_store_error()? .map(|result| { - let (root, key, value) = result.into_store_error()?; - Ok((Word::try_from(root)?, Word::try_from(key)?, Word::try_from(value)?)) + let (slot_name, key, value) = result.into_store_error()?; + let slot_name = StorageSlotName::new(slot_name) + .map_err(|err| StoreError::ParsingError(err.to_string()))?; + Ok((slot_name, Word::try_from(key)?, Word::try_from(value)?)) }) - .collect::, StoreError>>()?; + .collect::, StoreError>>()?; let mut maps = BTreeMap::new(); - for (root, key, value) in map_entries { - let map = maps.entry(root).or_insert_with(StorageMap::new); + for (slot_name, key, value) in map_entries { + let map = maps.entry(slot_name).or_insert_with(StorageMap::new); map.insert(key, value)?; } @@ -252,15 +297,14 @@ pub(crate) fn query_storage_maps( pub(crate) fn query_storage_values( conn: &Connection, - where_clause: &str, - params: impl Params, + account_id: AccountId, ) -> Result, StoreError> { - const STORAGE_QUERY: &str = "SELECT slot_name, slot_value, slot_type FROM account_storage"; + const STORAGE_QUERY: &str = + "SELECT slot_name, slot_value, slot_type FROM latest_account_storage WHERE account_id = ?"; - let query = format!("{STORAGE_QUERY} WHERE {where_clause}"); - conn.prepare(&query) + conn.prepare(STORAGE_QUERY) .into_store_error()? - .query_map(params, |row| { + .query_map(params![account_id.to_hex()], |row| { let slot_name: String = row.get(0)?; let value: String = row.get(1)?; let slot_type: u8 = row.get(2)?; diff --git a/crates/sqlite-store/src/account/storage.rs b/crates/sqlite-store/src/account/storage.rs index eb90860611..e0eddd759b 100644 --- a/crates/sqlite-store/src/account/storage.rs +++ b/crates/sqlite-store/src/account/storage.rs @@ -5,99 +5,273 @@ use std::rc::Rc; use std::string::ToString; use std::vec::Vec; -use miden_client::Word; use miden_client::account::{ AccountDelta, - AccountHeader, + AccountId, StorageMap, StorageSlot, StorageSlotContent, StorageSlotName, + StorageSlotType, }; use miden_client::store::StoreError; -use miden_protocol::crypto::merkle::MerkleError; +use miden_client::{EMPTY_WORD, Word}; use rusqlite::types::Value; -use rusqlite::{Connection, Transaction, params}; +use rusqlite::{Transaction, params}; -use crate::account::helpers::query_storage_slots; use crate::smt_forest::AccountSmtForest; use crate::sql_error::SqlResultExt; -use crate::{SqliteStore, insert_sql, subst}; +use crate::{SqliteStore, insert_sql, subst, u64_to_value}; impl SqliteStore { // READER METHODS // -------------------------------------------------------------------------------------------- - /// Fetches the relevant storage maps inside the account's storage that will be updated by the - /// account delta. - pub(crate) fn get_account_storage_maps_for_delta( - conn: &Connection, - header: &AccountHeader, + /// Fetches the current root values for storage maps that will be updated by the account delta. + /// + /// Only queries the slot values (roots) from the latest storage table, avoiding the need to + /// load full storage map entries into memory. The `AccountSmtForest` handles the actual + /// Merkle tree operations. + pub(crate) fn get_storage_map_roots_for_delta( + conn: &rusqlite::Connection, + account_id: AccountId, delta: &AccountDelta, - ) -> Result, StoreError> { - let updated_map_names = delta + ) -> Result, StoreError> { + let map_slot_names: Vec = delta .storage() .maps() .map(|(slot_name, _)| Value::Text(slot_name.to_string())) - .collect::>(); - - query_storage_slots( - conn, - "commitment = ? AND slot_name IN rarray(?)", - params![header.storage_commitment().to_hex(), Rc::new(updated_map_names)], - )? - .into_iter() - .map(|(slot_name, slot)| { - let StorageSlotContent::Map(map) = slot.into_parts().1 else { - return Err(StoreError::AccountError( - miden_client::AccountError::StorageSlotNotMap(slot_name), - )); - }; - - Ok((slot_name, map)) - }) - .collect() + .collect(); + + if map_slot_names.is_empty() { + return Ok(BTreeMap::new()); + } + + const QUERY: &str = "SELECT slot_name, slot_value FROM latest_account_storage \ + WHERE account_id = ? AND slot_name IN rarray(?)"; + + conn.prepare(QUERY) + .into_store_error()? + .query_map(params![account_id.to_hex(), Rc::new(map_slot_names)], |row| { + let name: String = row.get(0)?; + let value: String = row.get(1)?; + Ok((name, value)) + }) + .into_store_error()? + .map(|result| { + let (name, value) = result.into_store_error()?; + let slot_name = StorageSlotName::new(name) + .map_err(|err| StoreError::ParsingError(err.to_string()))?; + Ok((slot_name, Word::try_from(value)?)) + }) + .collect() } // MUTATOR/WRITER METHODS // -------------------------------------------------------------------------------------------- - /// Inserts storage slots into the database for a given storage commitment. + /// Inserts storage slots into both latest and historical tables for a given + /// (`account_id`, `nonce`). pub(crate) fn insert_storage_slots<'a>( tx: &Transaction<'_>, - commitment: Word, + account_id: AccountId, + nonce: u64, account_storage: impl Iterator, ) -> Result<(), StoreError> { - const SLOT_QUERY: &str = insert_sql!( - account_storage { - commitment, + const LATEST_SLOT_QUERY: &str = insert_sql!( + latest_account_storage { + account_id, slot_name, slot_value, slot_type } | REPLACE ); - const MAP_ENTRY_QUERY: &str = - insert_sql!(storage_map_entries { root, key, value } | REPLACE); + const HISTORICAL_SLOT_QUERY: &str = insert_sql!( + historical_account_storage { + account_id, + nonce, + slot_name, + slot_value, + slot_type + } | REPLACE + ); + const LATEST_MAP_ENTRY_QUERY: &str = + insert_sql!(latest_storage_map_entries { account_id, slot_name, key, value } | REPLACE); + const HISTORICAL_MAP_ENTRY_QUERY: &str = insert_sql!( + historical_storage_map_entries { account_id, nonce, slot_name, key, value } | REPLACE + ); - let mut slot_stmt = tx.prepare_cached(SLOT_QUERY).into_store_error()?; - let mut map_entry_stmt = tx.prepare_cached(MAP_ENTRY_QUERY).into_store_error()?; - let commitment_hex = commitment.to_hex(); + let mut latest_slot_stmt = tx.prepare_cached(LATEST_SLOT_QUERY).into_store_error()?; + let mut hist_slot_stmt = tx.prepare_cached(HISTORICAL_SLOT_QUERY).into_store_error()?; + let mut latest_map_stmt = tx.prepare_cached(LATEST_MAP_ENTRY_QUERY).into_store_error()?; + let mut hist_map_stmt = tx.prepare_cached(HISTORICAL_MAP_ENTRY_QUERY).into_store_error()?; + let account_id_hex = account_id.to_hex(); + let nonce_val = u64_to_value(nonce); for slot in account_storage { - slot_stmt + let slot_name_str = slot.name().to_string(); + let slot_value_hex = slot.value().to_hex(); + let slot_type_val = slot.slot_type() as u8; + + latest_slot_stmt + .execute(params![&account_id_hex, &slot_name_str, &slot_value_hex, slot_type_val]) + .into_store_error()?; + + hist_slot_stmt .execute(params![ - &commitment_hex, - slot.name().to_string(), - slot.value().to_hex(), - slot.slot_type() as u8 + &account_id_hex, + &nonce_val, + &slot_name_str, + &slot_value_hex, + slot_type_val, ]) .into_store_error()?; if let StorageSlotContent::Map(map) = slot.content() { - let root_hex = map.root().to_hex(); for (key, value) in map.entries() { - map_entry_stmt - .execute(params![&root_hex, key.to_hex(), value.to_hex()]) + latest_map_stmt + .execute(params![ + &account_id_hex, + &slot_name_str, + key.to_hex(), + value.to_hex(), + ]) + .into_store_error()?; + + hist_map_stmt + .execute(params![ + &account_id_hex, + &nonce_val, + &slot_name_str, + key.to_hex(), + value.to_hex(), + ]) + .into_store_error()?; + } + } + } + + Ok(()) + } + + /// Writes only the changed storage slots to historical and updates latest via INSERT OR + /// REPLACE. + /// + /// For both latest and historical tables: writes slot values and only the delta's changed + /// map entries. Removed map entries (`EMPTY_WORD`) are deleted from latest and stored as NULL + /// in historical. + pub(crate) fn write_storage_delta( + tx: &Transaction<'_>, + account_id: AccountId, + nonce: u64, + updated_slots: &BTreeMap, + delta: &AccountDelta, + ) -> Result<(), StoreError> { + const LATEST_SLOT_QUERY: &str = insert_sql!( + latest_account_storage { + account_id, + slot_name, + slot_value, + slot_type + } | REPLACE + ); + const HISTORICAL_SLOT_QUERY: &str = insert_sql!( + historical_account_storage { + account_id, + nonce, + slot_name, + slot_value, + slot_type + } | REPLACE + ); + const LATEST_MAP_ENTRY_QUERY: &str = + insert_sql!(latest_storage_map_entries { account_id, slot_name, key, value } | REPLACE); + const HISTORICAL_MAP_ENTRY_QUERY: &str = insert_sql!( + historical_storage_map_entries { account_id, nonce, slot_name, key, value } | REPLACE + ); + const DELETE_LATEST_MAP_ENTRY: &str = "DELETE FROM latest_storage_map_entries WHERE account_id = ? AND slot_name = ? AND key = ?"; + + let mut latest_slot_stmt = tx.prepare_cached(LATEST_SLOT_QUERY).into_store_error()?; + let mut hist_slot_stmt = tx.prepare_cached(HISTORICAL_SLOT_QUERY).into_store_error()?; + let mut latest_map_stmt = tx.prepare_cached(LATEST_MAP_ENTRY_QUERY).into_store_error()?; + let mut hist_map_stmt = tx.prepare_cached(HISTORICAL_MAP_ENTRY_QUERY).into_store_error()?; + let account_id_hex = account_id.to_hex(); + let nonce_val = u64_to_value(nonce); + + // Collect the delta's changed map entries for efficient lookup + let delta_map_entries: BTreeMap<&StorageSlotName, Vec<(Word, Word)>> = delta + .storage() + .maps() + .map(|(slot_name, map_delta)| { + let entries: Vec<(Word, Word)> = map_delta + .entries() + .iter() + .map(|(key, value)| ((*key).into(), *value)) + .collect(); + (slot_name, entries) + }) + .collect(); + + for (slot_name, (value, slot_type)) in updated_slots { + let slot_name_str = slot_name.to_string(); + let slot_value_hex = value.to_hex(); + let slot_type_val = *slot_type as u8; + + // Update latest slot + latest_slot_stmt + .execute(params![&account_id_hex, &slot_name_str, &slot_value_hex, slot_type_val]) + .into_store_error()?; + + // Write slot to historical + hist_slot_stmt + .execute(params![ + &account_id_hex, + &nonce_val, + &slot_name_str, + &slot_value_hex, + slot_type_val, + ]) + .into_store_error()?; + + if let Some(changed_entries) = delta_map_entries.get(slot_name) { + for (key, value) in changed_entries { + // Latest: write only the delta entries (REPLACE for updates, DELETE + // for removals) + if *value == EMPTY_WORD { + tx.execute( + DELETE_LATEST_MAP_ENTRY, + params![&account_id_hex, &slot_name_str, key.to_hex()], + ) + .into_store_error()?; + } else { + latest_map_stmt + .execute(params![ + &account_id_hex, + &slot_name_str, + key.to_hex(), + value.to_hex(), + ]) + .into_store_error()?; + } + + // Historical: write ONLY the delta's changed entries. + // Use NULL as tombstone for removed entries (EMPTY_WORD) so that + // rebuild_latest_for_account can filter them out, matching the + // behavior of StorageMap::entries() which excludes zero-valued + // entries. + let value_param: Option = if *value == EMPTY_WORD { + None + } else { + Some(value.to_hex()) + }; + hist_map_stmt + .execute(params![ + &account_id_hex, + &nonce_val, + &slot_name_str, + key.to_hex(), + value_param, + ]) .into_store_error()?; } } @@ -106,51 +280,34 @@ impl SqliteStore { Ok(()) } - /// Applies storage delta changes to the account state, updating storage slots and maps. + /// Applies storage delta changes to the account state, computing new roots via the SMT forest. /// - /// All updated storage map entries are validated against the SMT forest to ensure consistency. - /// If the computed root doesn't match the expected root, an error is returned. + /// Value-type slot updates are taken directly from the delta. For map-type slots, the old + /// root is used to update the SMT forest with the delta entries, producing the new root. + /// Full storage maps are never loaded into memory — the `AccountSmtForest` handles all + /// Merkle tree operations. pub(crate) fn apply_account_storage_delta( smt_forest: &mut AccountSmtForest, - mut updated_storage_maps: BTreeMap, + old_map_roots: &BTreeMap, delta: &AccountDelta, - ) -> Result, StoreError> { - // Apply storage delta. This map will contain all updated storage slots, both values and - // maps. It gets initialized with value type updates which contain the new value and - // don't depend on previous state. - let mut updated_storage_slots: BTreeMap = delta + ) -> Result, StoreError> { + let mut updated_slots: BTreeMap = delta .storage() .values() - .map(|(slot_name, slot)| { - (slot_name.clone(), StorageSlot::with_value(slot_name.clone(), *slot)) - }) + .map(|(slot_name, value)| (slot_name.clone(), (*value, StorageSlotType::Value))) .collect(); - // For storage map deltas, we only updated the keys in the delta, this is why we need the - // previously retrieved storage maps. + let default_map_root = StorageMap::default().root(); + for (slot_name, map_delta) in delta.storage().maps() { - let mut map = updated_storage_maps.remove(slot_name).unwrap_or_default(); - let map_root = map.root(); + let old_root = old_map_roots.get(slot_name).copied().unwrap_or(default_map_root); let entries: Vec<(Word, Word)> = map_delta.entries().iter().map(|(key, value)| ((*key).into(), *value)).collect(); - for (key, value) in &entries { - map.insert(*key, *value)?; - } - - let expected_root = map.root(); - let new_root = smt_forest.update_storage_map_nodes(map_root, entries.into_iter())?; - if new_root != expected_root { - return Err(StoreError::MerkleStoreError(MerkleError::ConflictingRoots { - expected_root, - actual_root: new_root, - })); - } - - updated_storage_slots - .insert(slot_name.clone(), StorageSlot::with_map(slot_name.clone(), map)); + let new_root = smt_forest.update_storage_map_nodes(old_root, entries.into_iter())?; + updated_slots.insert(slot_name.clone(), (new_root, StorageSlotType::Map)); } - Ok(updated_storage_slots) + Ok(updated_slots) } } diff --git a/crates/sqlite-store/src/account/tests.rs b/crates/sqlite-store/src/account/tests.rs index da0a2b8a39..c35634efa6 100644 --- a/crates/sqlite-store/src/account/tests.rs +++ b/crates/sqlite-store/src/account/tests.rs @@ -29,12 +29,13 @@ use miden_client::asset::{ use miden_client::auth::{AuthFalcon512Rpo, PublicKeyCommitment}; use miden_client::store::Store; use miden_client::testing::common::ACCOUNT_ID_REGULAR; -use miden_client::{EMPTY_WORD, ONE, ZERO}; +use miden_client::{EMPTY_WORD, Felt, ONE, ZERO}; use miden_protocol::testing::account_id::{ ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET, }; use miden_protocol::testing::constants::NON_FUNGIBLE_ASSET_DATA; +use rusqlite::params; use crate::SqliteStore; use crate::sql_error::SqlResultExt; @@ -154,7 +155,7 @@ async fn apply_account_delta_additions() -> anyhow::Result<()> { &account.into(), &final_state, BTreeMap::default(), - BTreeMap::default(), + &BTreeMap::new(), &delta, )?; @@ -234,13 +235,10 @@ async fn apply_account_delta_removals() -> anyhow::Result<()> { let smt_forest = store.smt_forest.clone(); store .interact_with_connection(move |conn| { - let fungible_assets = SqliteStore::get_account_fungible_assets_for_delta( - conn, - &(&account).into(), - &delta, - )?; - let storage_maps = - SqliteStore::get_account_storage_maps_for_delta(conn, &(&account).into(), &delta)?; + let fungible_assets = + SqliteStore::get_account_fungible_assets_for_delta(conn, account.id(), &delta)?; + let old_map_roots = + SqliteStore::get_storage_map_roots_for_delta(conn, account.id(), &delta)?; let tx = conn.transaction().into_store_error()?; let mut smt_forest = smt_forest.write().expect("smt_forest write lock not poisoned"); @@ -250,7 +248,7 @@ async fn apply_account_delta_removals() -> anyhow::Result<()> { &account.into(), &final_state, fungible_assets, - storage_maps, + &old_map_roots, &delta, )?; @@ -590,3 +588,658 @@ async fn account_reader_addresses_access() -> anyhow::Result<()> { Ok(()) } + +// TEST HELPERS +// ================================================================================================ + +/// Row counts across the account-related tables. +struct StorageMetrics { + latest_account_headers: usize, + historical_account_headers: usize, + latest_account_storage: usize, + latest_storage_map_entries: usize, + latest_account_assets: usize, + historical_account_storage: usize, + historical_storage_map_entries: usize, + historical_account_assets: usize, +} + +impl std::fmt::Display for StorageMetrics { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "latest_headers={:<3} hist_headers={:<3} latest_storage={:<3} latest_map={:<3} \ + latest_assets={:<3} hist_storage={:<3} hist_map={:<3} hist_assets={:<3}", + self.latest_account_headers, + self.historical_account_headers, + self.latest_account_storage, + self.latest_storage_map_entries, + self.latest_account_assets, + self.historical_account_storage, + self.historical_storage_map_entries, + self.historical_account_assets, + ) + } +} + +async fn get_storage_metrics(store: &SqliteStore) -> StorageMetrics { + store + .interact_with_connection(|conn| { + let count = |table| { + conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0)) + .into_store_error() + }; + Ok(StorageMetrics { + latest_account_headers: count("latest_account_headers")?, + historical_account_headers: count("historical_account_headers")?, + latest_account_storage: count("latest_account_storage")?, + latest_storage_map_entries: count("latest_storage_map_entries")?, + latest_account_assets: count("latest_account_assets")?, + historical_account_storage: count("historical_account_storage")?, + historical_storage_map_entries: count("historical_storage_map_entries")?, + historical_account_assets: count("historical_account_assets")?, + }) + }) + .await + .unwrap() +} + +/// Creates an account with a storage map of `map_size` entries, inserts it into the store, +/// and returns the account. Uses `Store::insert_account` (public API). +async fn setup_account_with_map( + store: &SqliteStore, + map_size: u64, + map_slot_name: &StorageSlotName, +) -> anyhow::Result { + let mut map = StorageMap::new(); + for i in 1..=map_size { + map.insert( + [Felt::new(i), ZERO, ZERO, ZERO].into(), + [Felt::new(i * 100), ZERO, ZERO, ZERO].into(), + )?; + } + + let component = AccountComponent::new( + basic_wallet_library(), + vec![StorageSlot::with_map(map_slot_name.clone(), map)], + )? + .with_supports_all_types(); + + let account = AccountBuilder::new([0; 32]) + .account_type(AccountType::RegularAccountImmutableCode) + .with_auth_component(AuthFalcon512Rpo::new(PublicKeyCommitment::from(EMPTY_WORD))) + .with_component(component) + .build()?; + + store.insert_account(&account, Address::new(account.id())).await?; + Ok(account) +} + +/// Applies a delta that changes a single map entry (key=1) and persists it. +async fn apply_single_entry_update( + store: &SqliteStore, + account: &mut Account, + map_slot_name: &StorageSlotName, + nonce: u64, +) -> anyhow::Result<()> { + let mut storage_delta = AccountStorageDelta::new(); + storage_delta.set_map_item( + map_slot_name.clone(), + [Felt::new(1), ZERO, ZERO, ZERO].into(), + [Felt::new(nonce * 1000), ZERO, ZERO, ZERO].into(), + )?; + + let delta = AccountDelta::new( + account.id(), + storage_delta, + AccountVaultDelta::from_iters([], []), + Felt::new(nonce), + )?; + + let prev_header: AccountHeader = (&*account).into(); + account.apply_delta(&delta)?; + let final_header: AccountHeader = (&*account).into(); + + let smt_forest = store.smt_forest.clone(); + let delta_clone = delta.clone(); + let account_id = account.id(); + store + .interact_with_connection(move |conn| { + let old_map_roots = + SqliteStore::get_storage_map_roots_for_delta(conn, account_id, &delta_clone)?; + let tx = conn.transaction().into_store_error()?; + let mut smt_forest = smt_forest.write().expect("smt_forest write lock not poisoned"); + + SqliteStore::apply_account_delta( + &tx, + &mut smt_forest, + &prev_header, + &final_header, + BTreeMap::default(), + &old_map_roots, + &delta, + )?; + + tx.commit().into_store_error()?; + Ok(()) + }) + .await?; + + Ok(()) +} + +// UNDO & COMMITMENT LOOKUP TESTS (issue #1768) +// ================================================================================================ + +/// Verifies that `undo_account_state` correctly reverts the latest tables to the previous state. +/// Exercises `undo_account_state` + `rebuild_latest_for_account`. +/// +/// The delta includes both storage and vault changes so that the vault root changes between +/// nonce 0 and nonce 1. This is required because `undo_account_state` pops SMT roots from the +/// forest, and the vault root must differ to avoid removing the initial state's root. +#[tokio::test] +async fn undo_account_state_restores_previous_latest() -> anyhow::Result<()> { + let store = create_test_store().await; + let map_slot_name = StorageSlotName::new("test::undo::map").expect("valid slot name"); + + // Insert account with 5 map entries (nonce 0) + let mut account = setup_account_with_map(&store, 5, &map_slot_name).await?; + let initial_commitment = account.commitment(); + + // Apply a delta (nonce 1) that changes a map entry AND adds a fungible asset. + // The vault change ensures the vault root differs between nonce 0 and 1, + // which is needed for pop_roots to work correctly. + let mut storage_delta = AccountStorageDelta::new(); + storage_delta.set_map_item( + map_slot_name.clone(), + [Felt::new(1), ZERO, ZERO, ZERO].into(), + [Felt::new(1000), ZERO, ZERO, ZERO].into(), + )?; + let vault_delta = AccountVaultDelta::from_iters( + vec![ + FungibleAsset::new(AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET)?, 100)? + .into(), + ], + [], + ); + let delta = AccountDelta::new(account.id(), storage_delta, vault_delta, ONE)?; + + let prev_header: AccountHeader = (&account).into(); + account.apply_delta(&delta)?; + let final_header: AccountHeader = (&account).into(); + let post_delta_commitment = account.commitment(); + + let smt_forest = store.smt_forest.clone(); + let account_id = account.id(); + let delta_clone = delta.clone(); + store + .interact_with_connection(move |conn| { + let old_map_roots = + SqliteStore::get_storage_map_roots_for_delta(conn, account_id, &delta_clone)?; + let tx = conn.transaction().into_store_error()?; + let mut smt_forest = smt_forest.write().expect("smt_forest write lock not poisoned"); + SqliteStore::apply_account_delta( + &tx, + &mut smt_forest, + &prev_header, + &final_header, + BTreeMap::default(), + &old_map_roots, + &delta, + )?; + tx.commit().into_store_error()?; + Ok(()) + }) + .await?; + + // Pre-undo: 2 historical headers (nonce 0 + nonce 1), 1 latest + let m = get_storage_metrics(&store).await; + assert_eq!(m.historical_account_headers, 2); + assert_eq!(m.latest_account_headers, 1); + assert_eq!(m.latest_account_assets, 1); + + // Undo the nonce-1 state + let smt_forest = store.smt_forest.clone(); + store + .interact_with_connection(move |conn| { + let tx = conn.transaction().into_store_error()?; + let mut smt_forest = smt_forest.write().expect("smt_forest write lock not poisoned"); + SqliteStore::undo_account_state(&tx, &mut smt_forest, &[post_delta_commitment])?; + tx.commit().into_store_error()?; + Ok(()) + }) + .await?; + + // After undo: only nonce-0 state remains in historical, latest rebuilt from it + let m = get_storage_metrics(&store).await; + assert_eq!(m.historical_account_headers, 1); + assert_eq!(m.latest_account_headers, 1); + assert_eq!(m.latest_storage_map_entries, 5); + assert_eq!(m.historical_storage_map_entries, 5); + assert_eq!(m.latest_account_assets, 0, "Vault should be empty after undo to nonce 0"); + + // Latest header should reflect nonce 0 with the initial commitment + let (header, _status) = store + .interact_with_connection(move |conn| SqliteStore::get_account_header(conn, account_id)) + .await? + .expect("account should still exist after undo"); + assert_eq!(header.nonce().as_int(), 0); + assert_eq!(header.commitment(), initial_commitment); + + Ok(()) +} + +/// Verifies that undoing the only state (nonce 0) of an account removes it entirely from both +/// latest and historical tables. This exercises the `rebuild_latest_for_account` early-return +/// path when `MAX(nonce)` is None. +/// +/// The account is created with assets so the vault root is non-trivial — the SMT forest +/// only ref-counts non-empty roots, so `pop_roots` after undo would underflow on an empty vault. +#[tokio::test] +async fn undo_account_state_deletes_account_entirely() -> anyhow::Result<()> { + let store = create_test_store().await; + let map_slot_name = StorageSlotName::new("test::undo_del::map").expect("valid slot name"); + + // Build account with a map AND an asset so the vault root is non-trivial + let mut map = StorageMap::new(); + for i in 1..=3u64 { + map.insert( + [Felt::new(i), ZERO, ZERO, ZERO].into(), + [Felt::new(i * 100), ZERO, ZERO, ZERO].into(), + )?; + } + let component = AccountComponent::new( + basic_wallet_library(), + vec![StorageSlot::with_map(map_slot_name.clone(), map)], + )? + .with_supports_all_types(); + + let account = AccountBuilder::new([0; 32]) + .account_type(AccountType::RegularAccountImmutableCode) + .with_auth_component(AuthFalcon512Rpo::new(PublicKeyCommitment::from(EMPTY_WORD))) + .with_component(component) + .with_assets(vec![ + FungibleAsset::new(AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET)?, 100)? + .into(), + ]) + .build_existing()?; + + let account_id = account.id(); + let commitment = account.commitment(); + store.insert_account(&account, Address::new(account_id)).await?; + + // Pre-undo: 1 latest header, 1 historical header, storage/map/asset entries exist + let m = get_storage_metrics(&store).await; + assert_eq!(m.latest_account_headers, 1); + assert_eq!(m.historical_account_headers, 1); + assert!(m.latest_storage_map_entries > 0); + assert_eq!(m.latest_account_assets, 1); + + // Undo the only state + let smt_forest = store.smt_forest.clone(); + store + .interact_with_connection(move |conn| { + let tx = conn.transaction().into_store_error()?; + let mut smt_forest = smt_forest.write().expect("smt_forest write lock not poisoned"); + SqliteStore::undo_account_state(&tx, &mut smt_forest, &[commitment])?; + tx.commit().into_store_error()?; + Ok(()) + }) + .await?; + + // After undo: all tables should be empty for this account + let m = get_storage_metrics(&store).await; + assert_eq!(m.latest_account_headers, 0); + assert_eq!(m.historical_account_headers, 0); + assert_eq!(m.latest_account_storage, 0); + assert_eq!(m.latest_storage_map_entries, 0); + assert_eq!(m.historical_account_storage, 0); + assert_eq!(m.historical_storage_map_entries, 0); + assert_eq!(m.latest_account_assets, 0); + assert_eq!(m.historical_account_assets, 0); + + // get_account should return None + let result = store + .interact_with_connection(move |conn| SqliteStore::get_account_header(conn, account_id)) + .await?; + assert!(result.is_none()); + + Ok(()) +} + +/// Verifies that `lock_account_on_unexpected_commitment` sets `locked = true` in both the +/// latest and historical tables so that the lock survives undo/rebuild. +#[tokio::test] +async fn lock_account_affects_latest_and_historical() -> anyhow::Result<()> { + let store = create_test_store().await; + let map_slot_name = StorageSlotName::new("test::lock::map").expect("valid slot name"); + + // Insert account (nonce 0) + let mut account = setup_account_with_map(&store, 3, &map_slot_name).await?; + let account_id = account.id(); + + // Apply a delta (nonce 1) with vault change + let mut storage_delta = AccountStorageDelta::new(); + storage_delta.set_map_item( + map_slot_name.clone(), + [Felt::new(1), ZERO, ZERO, ZERO].into(), + [Felt::new(2000), ZERO, ZERO, ZERO].into(), + )?; + let vault_delta = AccountVaultDelta::from_iters( + vec![ + FungibleAsset::new(AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET)?, 100)? + .into(), + ], + [], + ); + let delta = AccountDelta::new(account.id(), storage_delta, vault_delta, ONE)?; + let prev_header: AccountHeader = (&account).into(); + account.apply_delta(&delta)?; + let final_header: AccountHeader = (&account).into(); + + let smt_forest = store.smt_forest.clone(); + let delta_clone = delta.clone(); + store + .interact_with_connection(move |conn| { + let old_map_roots = + SqliteStore::get_storage_map_roots_for_delta(conn, account_id, &delta_clone)?; + let tx = conn.transaction().into_store_error()?; + let mut smt_forest = smt_forest.write().expect("smt_forest write lock not poisoned"); + SqliteStore::apply_account_delta( + &tx, + &mut smt_forest, + &prev_header, + &final_header, + BTreeMap::default(), + &old_map_roots, + &delta, + )?; + tx.commit().into_store_error()?; + Ok(()) + }) + .await?; + + // Pre-lock: 2 historical headers (nonce 0 + nonce 1), both unlocked + let m = get_storage_metrics(&store).await; + assert_eq!(m.historical_account_headers, 2); + + // Lock the account with a fake mismatched digest (not matching any historical commitment) + let fake_digest = [Felt::new(999), Felt::new(888), Felt::new(777), Felt::new(666)].into(); + store + .interact_with_connection(move |conn| { + let tx = conn.transaction().into_store_error()?; + SqliteStore::lock_account_on_unexpected_commitment(&tx, &account_id, &fake_digest)?; + tx.commit().into_store_error()?; + Ok(()) + }) + .await?; + + // Latest should be locked + let (_header, status) = store + .interact_with_connection(move |conn| SqliteStore::get_account_header(conn, account_id)) + .await? + .expect("account should exist"); + assert!(status.is_locked(), "Latest header should be locked"); + + // Historical entries should also be locked (so rebuild preserves the lock) + let historical_locked: Vec = store + .interact_with_connection(move |conn| { + let mut stmt = conn + .prepare( + "SELECT locked FROM historical_account_headers WHERE id = ? ORDER BY nonce", + ) + .into_store_error()?; + let rows = stmt + .query_map(params![account_id.to_hex()], |row| row.get(0)) + .into_store_error()? + .collect::, _>>() + .into_store_error()?; + Ok(rows) + }) + .await?; + assert_eq!(historical_locked.len(), 2, "Should have 2 historical entries"); + assert!(historical_locked[0], "Historical nonce-0 should be locked"); + assert!(historical_locked[1], "Historical nonce-1 should be locked"); + + Ok(()) +} + +/// Verifies that undoing a delta after `update_account_state` does not resurrect entries that +/// were removed by the update. This exercises the tombstone-writing logic in +/// `update_account_state`. +/// +/// Flow: +/// 1. Insert account with map entries {A, B, C} and an asset X at nonce 0 +/// 2. Apply delta at nonce 1: add asset Y (changes vault root) +/// 3. `update_account_state` with in-memory state at nonce 2: {A, B} and {X} (C and Y removed) +/// 4. Apply delta at nonce 3: change entry A, add asset Z +/// 5. Undo nonce 3 +/// 6. Assert C and Y are not in latest tables +#[tokio::test] +#[allow(clippy::too_many_lines)] +async fn undo_after_update_account_state_does_not_resurrect_removed_entries() -> anyhow::Result<()> +{ + let store = create_test_store().await; + let map_slot_name = + StorageSlotName::new("miden::testing::sqlite_store::map").expect("valid slot name"); + + let faucet_id = AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET)?; + let nf_faucet_id = AccountId::try_from(ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET)?; + + // Build initial map with 3 entries: A (key=1), B (key=2), C (key=3) + let key_a = [Felt::new(1), ZERO, ZERO, ZERO].into(); + let key_c = [Felt::new(3), ZERO, ZERO, ZERO].into(); + + let mut initial_map = StorageMap::new(); + initial_map.insert(key_a, [Felt::new(100), ZERO, ZERO, ZERO].into())?; + initial_map.insert( + [Felt::new(2), ZERO, ZERO, ZERO].into(), + [Felt::new(200), ZERO, ZERO, ZERO].into(), + )?; + initial_map.insert(key_c, [Felt::new(300), ZERO, ZERO, ZERO].into())?; + + let component = AccountComponent::new( + basic_wallet_library(), + vec![StorageSlot::with_map(map_slot_name.clone(), initial_map)], + )? + .with_supports_all_types(); + + // Build with build() at nonce 0 — no initial assets + let account = AccountBuilder::new([0; 32]) + .account_type(AccountType::RegularAccountImmutableCode) + .with_auth_component(AuthFalcon512Rpo::new(PublicKeyCommitment::from(EMPTY_WORD))) + .with_component(component) + .build()?; + + let account_id = account.id(); + store.insert_account(&account, Address::new(account_id)).await?; + + // Step 1+2: Apply delta at nonce 1 adding assets X and Y + let asset_x = FungibleAsset::new(faucet_id, 100)?; + let asset_y = NonFungibleAsset::new(&NonFungibleAssetDetails::new( + nf_faucet_id.prefix(), + NON_FUNGIBLE_ASSET_DATA.into(), + )?)?; + + let vault_delta_1 = AccountVaultDelta::from_iters(vec![asset_x.into(), asset_y.into()], []); + let delta_1 = AccountDelta::new(account_id, AccountStorageDelta::new(), vault_delta_1, ONE)?; + + let prev_header_0: AccountHeader = (&account).into(); + let mut account_nonce1 = account.clone(); + account_nonce1.apply_delta(&delta_1)?; + let final_header_1: AccountHeader = (&account_nonce1).into(); + + let smt_forest = store.smt_forest.clone(); + store + .interact_with_connection(move |conn| { + let tx = conn.transaction().into_store_error()?; + let mut smt_forest = smt_forest.write().expect("smt_forest write lock not poisoned"); + SqliteStore::apply_account_delta( + &tx, + &mut smt_forest, + &prev_header_0, + &final_header_1, + BTreeMap::default(), + &BTreeMap::new(), + &delta_1, + )?; + tx.commit().into_store_error()?; + Ok(()) + }) + .await?; + + // Now: map entries {A, B, C} and assets {X, Y} at nonce 1 + let m = get_storage_metrics(&store).await; + assert_eq!(m.latest_storage_map_entries, 3, "Should have 3 map entries"); + assert_eq!(m.latest_account_assets, 2, "Should have 2 assets (X + Y)"); + + // Step 3: Build in-memory state with only {A, B} and {X} (C and Y removed) + let mut storage_delta_remove = AccountStorageDelta::new(); + storage_delta_remove.set_map_item(map_slot_name.clone(), key_c, EMPTY_WORD)?; + let vault_delta_remove = AccountVaultDelta::from_iters([], vec![asset_y.into()]); + let delta_remove = + AccountDelta::new(account_id, storage_delta_remove, vault_delta_remove, ONE)?; + + let mut account_updated = account_nonce1.clone(); + account_updated.apply_delta(&delta_remove)?; + let updated_nonce = account_updated.nonce().as_int(); + + // Call update_account_state with the updated state + let smt_forest = store.smt_forest.clone(); + let account_updated_clone = account_updated.clone(); + store + .interact_with_connection(move |conn| { + let tx = conn.transaction().into_store_error()?; + let mut smt_forest = smt_forest.write().expect("smt_forest write lock not poisoned"); + SqliteStore::update_account_state(&tx, &mut smt_forest, &account_updated_clone)?; + tx.commit().into_store_error()?; + Ok(()) + }) + .await?; + + // After update: 2 map entries (A, B), 1 asset (X) + let m = get_storage_metrics(&store).await; + assert_eq!(m.latest_storage_map_entries, 2, "Should have 2 map entries after update"); + assert_eq!(m.latest_account_assets, 1, "Should have 1 asset after update"); + + // Step 4: Apply a delta that changes entry A and adds asset Z + let mut storage_delta_next = AccountStorageDelta::new(); + storage_delta_next.set_map_item( + map_slot_name.clone(), + key_a, + [Felt::new(999), ZERO, ZERO, ZERO].into(), + )?; + + let asset_z = NonFungibleAsset::new(&NonFungibleAssetDetails::new( + nf_faucet_id.prefix(), + vec![5, 6, 7, 8], + )?)?; + let vault_delta_next = AccountVaultDelta::from_iters(vec![asset_z.into()], []); + + let delta_next = AccountDelta::new(account_id, storage_delta_next, vault_delta_next, ONE)?; + + let prev_header: AccountHeader = (&account_updated).into(); + let mut account_next = account_updated.clone(); + account_next.apply_delta(&delta_next)?; + let final_header: AccountHeader = (&account_next).into(); + let commitment_next = account_next.commitment(); + + let smt_forest = store.smt_forest.clone(); + let delta_next_clone = delta_next.clone(); + store + .interact_with_connection(move |conn| { + let old_map_roots = + SqliteStore::get_storage_map_roots_for_delta(conn, account_id, &delta_next_clone)?; + let tx = conn.transaction().into_store_error()?; + let mut smt_forest = smt_forest.write().expect("smt_forest write lock not poisoned"); + SqliteStore::apply_account_delta( + &tx, + &mut smt_forest, + &prev_header, + &final_header, + BTreeMap::default(), + &old_map_roots, + &delta_next, + )?; + tx.commit().into_store_error()?; + Ok(()) + }) + .await?; + + // After delta: 2 map entries (A modified, B unchanged), 2 assets (X + Z) + let m = get_storage_metrics(&store).await; + assert_eq!(m.latest_storage_map_entries, 2, "Should have 2 map entries after delta"); + assert_eq!(m.latest_account_assets, 2, "Should have 2 assets after delta (X + Z)"); + + // Step 5: Undo the last delta + let smt_forest = store.smt_forest.clone(); + store + .interact_with_connection(move |conn| { + let tx = conn.transaction().into_store_error()?; + let mut smt_forest = smt_forest.write().expect("smt_forest write lock not poisoned"); + SqliteStore::undo_account_state(&tx, &mut smt_forest, &[commitment_next])?; + tx.commit().into_store_error()?; + Ok(()) + }) + .await?; + + // Step 6: Verify C and Y are NOT resurrected + let m = get_storage_metrics(&store).await; + assert_eq!( + m.latest_storage_map_entries, 2, + "C should NOT be resurrected — only A and B should be in latest" + ); + assert_eq!( + m.latest_account_assets, 1, + "Y should NOT be resurrected — only X should be in latest" + ); + + // Also verify the header reverted to the post-update nonce + let (header, _) = store + .interact_with_connection(move |conn| SqliteStore::get_account_header(conn, account_id)) + .await? + .expect("account should exist"); + assert_eq!(header.nonce().as_int(), updated_nonce); + + Ok(()) +} + +/// Verifies that `get_account_header_by_commitment` retrieves historical states by commitment. +#[tokio::test] +async fn get_account_header_by_commitment_returns_historical() -> anyhow::Result<()> { + let store = create_test_store().await; + let map_slot_name = StorageSlotName::new("test::commitment::map").expect("valid slot name"); + + // Insert account (nonce 0) + let mut account = setup_account_with_map(&store, 3, &map_slot_name).await?; + let initial_commitment = account.commitment(); + + // Apply a delta (nonce 1) + apply_single_entry_update(&store, &mut account, &map_slot_name, 1).await?; + let post_delta_commitment = account.commitment(); + assert_ne!(initial_commitment, post_delta_commitment); + + // Look up the initial commitment — should find the nonce-0 state in historical + let lookup = initial_commitment; + let header = store + .interact_with_connection(move |conn| { + SqliteStore::get_account_header_by_commitment(conn, lookup) + }) + .await? + .expect("Initial commitment should exist in historical"); + assert_eq!(header.nonce().as_int(), 0); + assert_eq!(header.commitment(), initial_commitment); + + // Look up the post-delta commitment — should find the nonce-1 state in historical + let lookup = post_delta_commitment; + let header = store + .interact_with_connection(move |conn| { + SqliteStore::get_account_header_by_commitment(conn, lookup) + }) + .await? + .expect("Post-delta commitment should exist in historical"); + assert_eq!(header.nonce().as_int(), 1); + assert_eq!(header.commitment(), post_delta_commitment); + + Ok(()) +} diff --git a/crates/sqlite-store/src/account/vault.rs b/crates/sqlite-store/src/account/vault.rs index 0462735a77..54810612a4 100644 --- a/crates/sqlite-store/src/account/vault.rs +++ b/crates/sqlite-store/src/account/vault.rs @@ -5,7 +5,7 @@ use std::rc::Rc; use std::vec::Vec; use miden_client::Word; -use miden_client::account::{AccountDelta, AccountHeader, AccountIdPrefix}; +use miden_client::account::{AccountDelta, AccountHeader, AccountId, AccountIdPrefix}; use miden_client::asset::{Asset, FungibleAsset, NonFungibleDeltaAction}; use miden_client::store::StoreError; use miden_protocol::asset::AssetVaultKey; @@ -13,10 +13,9 @@ use miden_protocol::crypto::merkle::MerkleError; use rusqlite::types::Value; use rusqlite::{Connection, Transaction, params}; -use crate::account::helpers::query_vault_assets; use crate::smt_forest::AccountSmtForest; use crate::sql_error::SqlResultExt; -use crate::{SqliteStore, insert_sql, subst}; +use crate::{SqliteStore, insert_sql, subst, u64_to_value}; impl SqliteStore { // READER METHODS @@ -26,7 +25,7 @@ impl SqliteStore { /// delta. pub(crate) fn get_account_fungible_assets_for_delta( conn: &Connection, - header: &AccountHeader, + account_id: AccountId, delta: &AccountDelta, ) -> Result, StoreError> { let fungible_faucet_prefixes = delta @@ -36,39 +35,81 @@ impl SqliteStore { .map(|(faucet_id, _)| Value::Text(faucet_id.prefix().to_hex())) .collect::>(); - Ok(query_vault_assets( - conn, - "root = ? AND faucet_id_prefix IN rarray(?)", - params![header.vault_root().to_hex(), Rc::new(fungible_faucet_prefixes)] - )? - .into_iter() - // SAFETY: all retrieved assets should be fungible - .map(|asset| (asset.faucet_id_prefix(), asset.unwrap_fungible())) - .collect()) + const QUERY: &str = "SELECT asset FROM latest_account_assets WHERE account_id = ? AND faucet_id_prefix IN rarray(?)"; + + Ok(conn + .prepare(QUERY) + .into_store_error()? + .query_map(params![account_id.to_hex(), Rc::new(fungible_faucet_prefixes)], |row| { + let asset: String = row.get(0)?; + Ok(asset) + }) + .into_store_error()? + .map(|result| { + let asset_str: String = result.into_store_error()?; + let word = Word::try_from(asset_str)?; + Ok(Asset::try_from(word)?) + }) + .collect::, StoreError>>()? + .into_iter() + // SAFETY: all retrieved assets should be fungible + .map(|asset| (asset.faucet_id_prefix(), asset.unwrap_fungible())) + .collect()) } // MUTATOR/WRITER METHODS // -------------------------------------------------------------------------------------------- - /// Inserts assets into the vault for a specific vault root. + /// Inserts assets into both latest and historical tables for a specific + /// (`account_id`, `nonce`). pub(crate) fn insert_assets( tx: &Transaction<'_>, - root: Word, + account_id: AccountId, + nonce: u64, assets: impl Iterator, ) -> Result<(), StoreError> { - const QUERY: &str = - insert_sql!(account_assets { root, vault_key, faucet_id_prefix, asset } | REPLACE); - let mut stmt = tx.prepare_cached(QUERY).into_store_error()?; - let root_hex = root.to_hex(); + const LATEST_QUERY: &str = insert_sql!( + latest_account_assets { + account_id, + vault_key, + faucet_id_prefix, + asset + } | REPLACE + ); + const HISTORICAL_QUERY: &str = insert_sql!( + historical_account_assets { + account_id, + nonce, + vault_key, + faucet_id_prefix, + asset + } | REPLACE + ); + + let mut latest_stmt = tx.prepare_cached(LATEST_QUERY).into_store_error()?; + let mut hist_stmt = tx.prepare_cached(HISTORICAL_QUERY).into_store_error()?; + let account_id_hex = account_id.to_hex(); + let nonce_val = u64_to_value(nonce); + for asset in assets { let vault_key_word: Word = asset.vault_key().into(); - stmt.execute(params![ - &root_hex, - vault_key_word.to_hex(), - asset.faucet_id_prefix().to_hex(), - Word::from(asset).to_hex(), - ]) - .into_store_error()?; + let vault_key_hex = vault_key_word.to_hex(); + let faucet_prefix_hex = asset.faucet_id_prefix().to_hex(); + let asset_hex = Word::from(asset).to_hex(); + + latest_stmt + .execute(params![&account_id_hex, &vault_key_hex, &faucet_prefix_hex, &asset_hex]) + .into_store_error()?; + + hist_stmt + .execute(params![ + &account_id_hex, + &nonce_val, + &vault_key_hex, + &faucet_prefix_hex, + &asset_hex, + ]) + .into_store_error()?; } Ok(()) @@ -77,16 +118,21 @@ impl SqliteStore { /// Applies vault delta changes to the account state, updating fungible and non-fungible assets. /// /// The function updates the SMT forest with all asset changes and verifies that the resulting - /// vault root matches the expected final state. It deletes removed assets and inserts updated - /// ones into the database. + /// vault root matches the expected final state. It deletes removed assets from latest and + /// writes tombstones to historical, then inserts updated assets. pub(crate) fn apply_account_vault_delta( tx: &Transaction<'_>, smt_forest: &mut AccountSmtForest, + account_id: AccountId, init_account_state: &AccountHeader, final_account_state: &AccountHeader, mut updated_fungible_assets: BTreeMap, delta: &AccountDelta, ) -> Result<(), StoreError> { + let nonce = final_account_state.nonce().as_int(); + let account_id_hex = account_id.to_hex(); + let nonce_val = u64_to_value(nonce); + // Apply vault delta. This map will contain all updated assets (indexed by vault key), both // fungible and non-fungible. let mut updated_assets: BTreeMap = BTreeMap::new(); @@ -135,28 +181,13 @@ impl SqliteStore { removed_vault_keys .extend(removed_nonfungible_assets.iter().map(|(asset, _)| asset.vault_key())); - const DELETE_QUERY: &str = - "DELETE FROM account_assets WHERE root = ? AND vault_key IN rarray(?)"; - - tx.execute( - DELETE_QUERY, - params![ - final_account_state.vault_root().to_hex(), - Rc::new( - removed_vault_keys - .iter() - .map(|k| Value::from(Word::from(*k).to_hex())) - .collect::>(), - ), - ], - ) - .into_store_error()?; - let updated_assets_values: Vec = updated_assets.values().copied().collect(); - Self::insert_assets( + Self::persist_vault_delta( tx, - final_account_state.vault_root(), - updated_assets_values.iter().copied(), + &account_id_hex, + &nonce_val, + &removed_vault_keys, + &updated_assets_values, )?; let new_vault_root = smt_forest.update_asset_nodes( @@ -173,4 +204,95 @@ impl SqliteStore { Ok(()) } + + /// Persists vault delta changes to the database: deletes removed assets from latest, + /// writes tombstones to historical, and inserts updated assets into both tables. + fn persist_vault_delta( + tx: &Transaction<'_>, + account_id_hex: &str, + nonce_val: &rusqlite::types::Value, + removed_vault_keys: &[AssetVaultKey], + updated_assets: &[Asset], + ) -> Result<(), StoreError> { + // Delete removed assets from latest + const DELETE_LATEST_QUERY: &str = + "DELETE FROM latest_account_assets WHERE account_id = ? AND vault_key IN rarray(?)"; + + tx.execute( + DELETE_LATEST_QUERY, + params![ + account_id_hex, + Rc::new( + removed_vault_keys + .iter() + .map(|k| Value::from(Word::from(*k).to_hex())) + .collect::>(), + ), + ], + ) + .into_store_error()?; + + // Write tombstones to historical for removed assets + const HISTORICAL_TOMBSTONE_QUERY: &str = "INSERT OR REPLACE INTO historical_account_assets \ + (account_id, nonce, vault_key, faucet_id_prefix, asset) VALUES (?, ?, ?, ?, NULL)"; + let mut tombstone_stmt = + tx.prepare_cached(HISTORICAL_TOMBSTONE_QUERY).into_store_error()?; + for vault_key in removed_vault_keys { + let vault_key_word: Word = (*vault_key).into(); + let faucet_prefix_hex = vault_key.faucet_id_prefix().to_hex(); + tombstone_stmt + .execute(params![ + account_id_hex, + nonce_val, + vault_key_word.to_hex(), + faucet_prefix_hex + ]) + .into_store_error()?; + } + + // Insert updated assets into latest and historical + const LATEST_INSERT: &str = insert_sql!( + latest_account_assets { + account_id, + vault_key, + faucet_id_prefix, + asset + } | REPLACE + ); + const HISTORICAL_INSERT: &str = insert_sql!( + historical_account_assets { + account_id, + nonce, + vault_key, + faucet_id_prefix, + asset + } | REPLACE + ); + + let mut latest_stmt = tx.prepare_cached(LATEST_INSERT).into_store_error()?; + let mut hist_stmt = tx.prepare_cached(HISTORICAL_INSERT).into_store_error()?; + + for asset in updated_assets { + let vault_key_word: Word = asset.vault_key().into(); + let vault_key_hex = vault_key_word.to_hex(); + let faucet_prefix_hex = asset.faucet_id_prefix().to_hex(); + let asset_hex = Word::from(*asset).to_hex(); + + latest_stmt + .execute(params![account_id_hex, &vault_key_hex, &faucet_prefix_hex, &asset_hex]) + .into_store_error()?; + + hist_stmt + .execute(params![ + account_id_hex, + nonce_val, + &vault_key_hex, + &faucet_prefix_hex, + &asset_hex, + ]) + .into_store_error()?; + } + + Ok(()) + } } diff --git a/crates/sqlite-store/src/smt_forest.rs b/crates/sqlite-store/src/smt_forest.rs index ea1ba67f57..4d29d9bcaf 100644 --- a/crates/sqlite-store/src/smt_forest.rs +++ b/crates/sqlite-store/src/smt_forest.rs @@ -106,6 +106,9 @@ impl AccountSmtForest { let empty_root = *EmptySubtreeRoots::entry(SMT_DEPTH, 0); let entries: Vec<(Word, Word)> = smt.entries().map(|(k, v)| (*k, *v)).collect(); + if entries.is_empty() { + return Ok(()); + } let new_root = self.forest.batch_insert(empty_root, entries).map_err(StoreError::from)?; debug_assert_eq!(new_root, smt.root()); Ok(()) @@ -138,13 +141,23 @@ impl AccountSmtForest { let empty_root = *EmptySubtreeRoots::entry(SMT_DEPTH, 0); let entries: Vec<(Word, Word)> = map.entries().map(|(k, v)| (StorageMap::hash_key(*k), *v)).collect(); + if entries.is_empty() { + return Ok(()); + } self.forest.batch_insert(empty_root, entries).map_err(StoreError::from)?; Ok(()) } /// Removes the specified SMT roots from the forest, releasing memory used by nodes /// that are no longer reachable from any remaining root. + /// + /// Filters out the empty tree root because it should never be popped: the empty + /// hash nodes are pre-populated infrastructure in the `SmtStore`, and popping the + /// empty root would walk and destroy them, corrupting the store for all future + /// operations. + // TODO(#1834): remove this filter once the miden-crypto fix lands. pub fn pop_roots(&mut self, roots: impl IntoIterator) { - self.forest.pop_smts(roots); + let empty_tree_root = *EmptySubtreeRoots::entry(SMT_DEPTH, 0); + self.forest.pop_smts(roots.into_iter().filter(|r| *r != empty_tree_root)); } } diff --git a/crates/sqlite-store/src/store.sql b/crates/sqlite-store/src/store.sql index e4a899bad1..7bbe417d9d 100644 --- a/crates/sqlite-store/src/store.sql +++ b/crates/sqlite-store/src/store.sql @@ -24,66 +24,107 @@ CREATE TABLE account_code ( PRIMARY KEY (commitment) ); --- Create account_storage table -CREATE TABLE account_storage ( - commitment TEXT NOT NULL, -- commitment to the account storage - slot_name TEXT NOT NULL, -- name of the storage slot - slot_value TEXT NULL, -- top-level value of the slot (e.g., if the slot is a map it contains the root) - slot_type INTEGER NOT NULL, -- type of the slot (0 = Value, 1 = Map) - PRIMARY KEY (commitment, slot_name) +-- ── Account headers ────────────────────────────────────────────────────── + +-- Latest account header: one row per account (current state). +CREATE TABLE latest_account_headers ( + id UNSIGNED BIG INT NOT NULL, -- account ID + account_commitment TEXT NOT NULL UNIQUE, -- account state commitment + code_commitment TEXT NOT NULL, -- commitment to the account code + storage_commitment TEXT NOT NULL, -- commitment to the account storage + vault_root TEXT NOT NULL, -- root of the account vault Merkle tree + nonce BIGINT NOT NULL, -- account nonce + account_seed BLOB NULL, -- seed used to generate the ID; NULL for non-new accounts + locked BOOLEAN NOT NULL, -- whether the account is locked + PRIMARY KEY (id), + FOREIGN KEY (code_commitment) REFERENCES account_code(commitment) +); + +-- Historical account headers: all state transitions (one row per nonce). +CREATE TABLE historical_account_headers ( + id UNSIGNED BIG INT NOT NULL, -- account ID + account_commitment TEXT NOT NULL UNIQUE, -- account state commitment + code_commitment TEXT NOT NULL, -- commitment to the account code + storage_commitment TEXT NOT NULL, -- commitment to the account storage + vault_root TEXT NOT NULL, -- root of the account vault Merkle tree + nonce BIGINT NOT NULL, -- account nonce + account_seed BLOB NULL, -- seed used to generate the ID; NULL for non-new accounts + locked BOOLEAN NOT NULL, -- whether the account is locked + PRIMARY KEY (account_commitment), + FOREIGN KEY (code_commitment) REFERENCES account_code(commitment), + + CONSTRAINT check_seed_nonzero CHECK (NOT (nonce = 0 AND account_seed IS NULL)) +); +CREATE INDEX idx_historical_account_headers_id_nonce ON historical_account_headers(id, nonce DESC); + +-- ── Account storage (latest + historical) ──────────────────────────────── + +CREATE TABLE latest_account_storage ( + account_id TEXT NOT NULL, -- account ID + slot_name TEXT NOT NULL, -- name of the storage slot + slot_value TEXT NULL, -- top-level value of the slot (for maps, contains the root) + slot_type INTEGER NOT NULL, -- type of the slot (0 = Value, 1 = Map) + PRIMARY KEY (account_id, slot_name) ) WITHOUT ROWID; -CREATE INDEX idx_account_storage_commitment ON account_storage(commitment); +CREATE TABLE historical_account_storage ( + account_id TEXT NOT NULL, -- account ID + nonce BIGINT NOT NULL, -- nonce at which this slot was written + slot_name TEXT NOT NULL, -- name of the storage slot + slot_value TEXT NULL, -- top-level value of the slot (for maps, contains the root) + slot_type INTEGER NOT NULL, -- type of the slot (0 = Value, 1 = Map) + PRIMARY KEY (account_id, nonce, slot_name) +) WITHOUT ROWID; + +-- ── Storage map entries (latest + historical) ──────────────────────────── + +CREATE TABLE latest_storage_map_entries ( + account_id TEXT NOT NULL, -- account ID + slot_name TEXT NOT NULL, -- name of the storage slot this entry belongs to + key TEXT NOT NULL, -- map entry key + value TEXT NOT NULL, -- map entry value + PRIMARY KEY (account_id, slot_name, key) +) WITHOUT ROWID; --- Create storage_map_entries table -CREATE TABLE storage_map_entries ( - root TEXT NOT NULL, -- root of the storage map - key TEXT NOT NULL, -- key of the storage map entry - value TEXT NOT NULL, -- value of the storage map entry - PRIMARY KEY (root, key) +CREATE TABLE historical_storage_map_entries ( + account_id TEXT NOT NULL, -- account ID + nonce BIGINT NOT NULL, -- nonce at which this entry was written + slot_name TEXT NOT NULL, -- name of the storage slot this entry belongs to + key TEXT NOT NULL, -- map entry key + value TEXT NULL, -- map entry value; NULL marks a removed entry (tombstone) + PRIMARY KEY (account_id, nonce, slot_name, key) ) WITHOUT ROWID; -CREATE INDEX idx_storage_map_entries_root ON storage_map_entries(root); +-- ── Account assets (latest + historical) ───────────────────────────────── --- Create account_assets table -CREATE TABLE account_assets ( - root TEXT NOT NULL, -- root of the account_vault Merkle tree - vault_key TEXT NOT NULL, -- asset's vault key - faucet_id_prefix TEXT NOT NULL, -- prefix of the faucet ID, used to identify the faucet - asset TEXT NULL, -- value that represents the asset in the vault - PRIMARY KEY (root, vault_key) +CREATE TABLE latest_account_assets ( + account_id TEXT NOT NULL, -- account ID + vault_key TEXT NOT NULL, -- asset's vault key + faucet_id_prefix TEXT NOT NULL, -- prefix of the faucet ID, used to filter by faucet + asset TEXT NOT NULL, -- serialized asset value + PRIMARY KEY (account_id, vault_key) ) WITHOUT ROWID; -CREATE INDEX idx_account_assets_root ON account_assets(root); -CREATE INDEX idx_account_assets_root_faucet_prefix ON account_assets(root, faucet_id_prefix); +CREATE TABLE historical_account_assets ( + account_id TEXT NOT NULL, -- account ID + nonce BIGINT NOT NULL, -- nonce at which this asset was written + vault_key TEXT NOT NULL, -- asset's vault key + faucet_id_prefix TEXT NOT NULL, -- prefix of the faucet ID, used to filter by faucet + asset TEXT NULL, -- serialized asset value; NULL marks a removed asset (tombstone) + PRIMARY KEY (account_id, nonce, vault_key) +) WITHOUT ROWID; + +-- ── Foreign account code ───────────────────────────────────────────────── --- Create foreign_account_code table CREATE TABLE foreign_account_code( - account_id TEXT NOT NULL, -- ID of the account - code_commitment TEXT NOT NULL, -- Commitment to the account_code + account_id TEXT NOT NULL, + code_commitment TEXT NOT NULL, PRIMARY KEY (account_id), FOREIGN KEY (code_commitment) REFERENCES account_code(commitment) ); --- Create accounts table -CREATE TABLE accounts ( - id UNSIGNED BIG INT NOT NULL, -- Account ID. - account_commitment TEXT NOT NULL UNIQUE, -- Account state commitment - code_commitment TEXT NOT NULL, -- Commitment to the account code - storage_commitment TEXT NOT NULL, -- Commitment to the account storage - vault_root TEXT NOT NULL, -- Root of the account_vault Merkle tree. - nonce BIGINT NOT NULL, -- Account nonce. - account_seed BLOB NULL, -- Account seed used to generate the ID. Expected to be NULL for non-new accounts - locked BOOLEAN NOT NULL, -- True if the account is locked, false if not. - PRIMARY KEY (account_commitment), - FOREIGN KEY (code_commitment) REFERENCES account_code(commitment), - - CONSTRAINT check_seed_nonzero CHECK (NOT (nonce = 0 AND account_seed IS NULL)) -); -CREATE INDEX idx_accounts_id_nonce ON accounts(id, nonce DESC); -CREATE INDEX idx_accounts_id ON accounts(id); +-- ── Transactions ───────────────────────────────────────────────────────── --- Create transactions table CREATE TABLE transactions ( id TEXT NOT NULL, -- Transaction ID (commitment of various components) details BLOB NOT NULL, -- Serialized transaction details @@ -104,7 +145,8 @@ CREATE TABLE transaction_scripts ( PRIMARY KEY (script_root) ) WITHOUT ROWID; --- Create input notes table +-- ── Notes ──────────────────────────────────────────────────────────────── + CREATE TABLE input_notes ( note_id TEXT NOT NULL, -- the note id assets BLOB NOT NULL, -- the serialized list of assets @@ -122,7 +164,6 @@ CREATE TABLE input_notes ( CREATE INDEX idx_input_notes_state ON input_notes(state_discriminant); CREATE INDEX idx_input_notes_nullifier ON input_notes(nullifier); --- Create output notes table CREATE TABLE output_notes ( note_id TEXT NOT NULL, -- the note id recipient_digest TEXT NOT NULL, -- the note recipient @@ -140,7 +181,6 @@ CREATE TABLE output_notes ( CREATE INDEX idx_output_notes_state ON output_notes(state_discriminant); CREATE INDEX idx_output_notes_nullifier ON output_notes(nullifier); --- Create note's scripts table, used for both input and output notes CREATE TABLE notes_scripts ( script_root TEXT NOT NULL, -- Note script root serialized_note_script BLOB, -- NoteScript, serialized @@ -148,13 +188,13 @@ CREATE TABLE notes_scripts ( PRIMARY KEY (script_root) ); --- Create state sync table +-- ── State sync & tags ──────────────────────────────────────────────────── + CREATE TABLE state_sync ( block_num UNSIGNED BIG INT NOT NULL, -- the block number of the most recent state sync PRIMARY KEY (block_num) ); --- Create tags table CREATE TABLE tags ( tag BLOB NOT NULL, -- the serialized tag source BLOB NOT NULL -- the serialized tag source @@ -167,7 +207,8 @@ WHERE ( SELECT COUNT(*) FROM state_sync ) = 0; --- Create block headers table +-- ── Block headers & partial blockchain ─────────────────────────────────── + CREATE TABLE block_headers ( block_num UNSIGNED BIG INT NOT NULL, -- block number header BLOB NOT NULL, -- serialized block header @@ -177,14 +218,14 @@ CREATE TABLE block_headers ( ); CREATE INDEX IF NOT EXISTS idx_block_headers_has_notes ON block_headers(block_num) WHERE has_client_notes = 1; --- Create partial blockchain nodes CREATE TABLE partial_blockchain_nodes ( id UNSIGNED BIG INT NOT NULL, -- in-order index of the internal MMR node node BLOB NOT NULL, -- internal node value (commitment) PRIMARY KEY (id) ) WITHOUT ROWID; --- Create addresses table +-- ── Addresses ──────────────────────────────────────────────────────────── + CREATE TABLE addresses ( address BLOB NOT NULL, -- the address account_id UNSIGNED BIG INT NOT NULL, -- associated Account ID. @@ -193,10 +234,3 @@ CREATE TABLE addresses ( ) WITHOUT ROWID; CREATE INDEX idx_addresses_account_id ON addresses(account_id); - --- Create tracked_accounts table to easily read account IDs --- TODO: this should maybe use the settings table in the future? - -CREATE TABLE tracked_accounts ( - id TEXT NOT NULL PRIMARY KEY -); diff --git a/crates/sqlite-store/src/transaction.rs b/crates/sqlite-store/src/transaction.rs index fd4bc17002..59b87086d9 100644 --- a/crates/sqlite-store/src/transaction.rs +++ b/crates/sqlite-store/src/transaction.rs @@ -114,13 +114,13 @@ impl SqliteStore { let updated_fungible_assets = Self::get_account_fungible_assets_for_delta( conn, - &executed_transaction.initial_account().into(), + executed_transaction.account_id(), executed_transaction.account_delta(), )?; - let updated_storage_maps = Self::get_account_storage_maps_for_delta( + let old_map_roots = Self::get_storage_map_roots_for_delta( conn, - &executed_transaction.initial_account().into(), + executed_transaction.account_id(), executed_transaction.account_delta(), )?; @@ -165,7 +165,7 @@ impl SqliteStore { &executed_transaction.initial_account().into(), executed_transaction.final_account(), updated_fungible_assets, - updated_storage_maps, + &old_map_roots, executed_transaction.account_delta(), )?; drop(smt_forest); diff --git a/crates/web-client/src/models/account_storage.rs b/crates/web-client/src/models/account_storage.rs index 362afbe8bb..aec8a1d955 100644 --- a/crates/web-client/src/models/account_storage.rs +++ b/crates/web-client/src/models/account_storage.rs @@ -60,15 +60,7 @@ impl AccountStorage { return None; }; - Some( - map.entries() - .map(|(key, value)| JsStorageMapEntry { - root: map.root().to_hex(), - key: key.to_hex(), - value: value.to_hex(), - }) - .collect(), - ) + Some(JsStorageMapEntry::from_map(map, slot_name)) } } diff --git a/eslint.config.js b/eslint.config.js index 87e0236b07..810ea79673 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,6 +3,7 @@ module.exports = [ // Ignore patterns ignores: [ "crates/web-client/dist/**/*", + "crates/react-sdk/dist/**/*", "target/**/*", "**/target/**/*", "miden-node/**/*", From 15ac427ab034fd6be89e34559a2bf20ec34ba08f Mon Sep 17 00:00:00 2001 From: Ignacio Amigo Date: Thu, 5 Mar 2026 15:39:50 -0300 Subject: [PATCH 2/4] chore: CI --- bin/miden-cli/src/commands/exec.rs | 2 +- bin/miden-cli/src/commands/export.rs | 4 ++-- bin/miden-cli/src/commands/import.rs | 2 +- bin/miden-cli/src/commands/new_account.rs | 8 ++++---- bin/miden-cli/src/commands/new_transactions.rs | 10 +++++----- bin/miden-cli/src/commands/notes.rs | 12 ++++++------ bin/miden-cli/src/commands/sync.rs | 2 +- bin/miden-cli/src/commands/transactions.rs | 4 ++-- bin/miden-cli/src/info.rs | 4 ++-- bin/miden-cli/src/lib.rs | 2 +- crates/rust-client/src/sync/state_sync.rs | 2 +- 11 files changed, 26 insertions(+), 26 deletions(-) diff --git a/bin/miden-cli/src/commands/exec.rs b/bin/miden-cli/src/commands/exec.rs index 6d55beef01..4a128f436c 100644 --- a/bin/miden-cli/src/commands/exec.rs +++ b/bin/miden-cli/src/commands/exec.rs @@ -50,7 +50,7 @@ pub struct ExecCmd { } impl ExecCmd { - pub async fn execute( + pub async fn execute( &self, mut client: Client, ) -> Result<(), CliError> { diff --git a/bin/miden-cli/src/commands/export.rs b/bin/miden-cli/src/commands/export.rs index e9322c2518..a7349d9514 100644 --- a/bin/miden-cli/src/commands/export.rs +++ b/bin/miden-cli/src/commands/export.rs @@ -55,7 +55,7 @@ impl From<&ExportType> for NoteExportType { } impl ExportCmd { - pub async fn execute( + pub async fn execute( &self, mut client: Client, keystore: FilesystemKeyStore, @@ -116,7 +116,7 @@ async fn export_account( // EXPORT NOTE // ================================================================================================ -async fn export_note( +async fn export_note( client: &mut Client, note_id: &str, filename: Option, diff --git a/bin/miden-cli/src/commands/import.rs b/bin/miden-cli/src/commands/import.rs index dc95d14574..565d6486d8 100644 --- a/bin/miden-cli/src/commands/import.rs +++ b/bin/miden-cli/src/commands/import.rs @@ -25,7 +25,7 @@ pub struct ImportCmd { } impl ImportCmd { - pub async fn execute( + pub async fn execute( &self, mut client: Client, keystore: FilesystemKeyStore, diff --git a/bin/miden-cli/src/commands/new_account.rs b/bin/miden-cli/src/commands/new_account.rs index acd0d45c95..8ee6603ffb 100644 --- a/bin/miden-cli/src/commands/new_account.rs +++ b/bin/miden-cli/src/commands/new_account.rs @@ -99,7 +99,7 @@ pub struct NewWalletCmd { } impl NewWalletCmd { - pub async fn execute( + pub async fn execute( &self, mut client: Client, keystore: CliKeyStore, @@ -194,7 +194,7 @@ pub struct NewAccountCmd { } impl NewAccountCmd { - pub async fn execute( + pub async fn execute( &self, mut client: Client, keystore: CliKeyStore, @@ -346,7 +346,7 @@ fn separate_auth_components( /// and build the account. /// /// If no auth component is detected in the packages, a Falcon-based auth component will be added. -async fn create_client_account( +async fn create_client_account( client: &mut Client, keystore: &CliKeyStore, account_type: AccountType, @@ -424,7 +424,7 @@ async fn create_client_account( } /// Submits a deploy transaction to the node for the specified account. -async fn deploy_account( +async fn deploy_account( client: &mut Client, account: &Account, ) -> Result<(), CliError> { diff --git a/bin/miden-cli/src/commands/new_transactions.rs b/bin/miden-cli/src/commands/new_transactions.rs index 863a1420e5..14c1ac6969 100644 --- a/bin/miden-cli/src/commands/new_transactions.rs +++ b/bin/miden-cli/src/commands/new_transactions.rs @@ -72,7 +72,7 @@ pub struct MintCmd { } impl MintCmd { - pub async fn execute( + pub async fn execute( &self, mut client: Client, ) -> Result<(), CliError> { @@ -143,7 +143,7 @@ pub struct SendCmd { } impl SendCmd { - pub async fn execute( + pub async fn execute( &self, mut client: Client, ) -> Result<(), CliError> { @@ -221,7 +221,7 @@ pub struct SwapCmd { } impl SwapCmd { - pub async fn execute( + pub async fn execute( &self, mut client: Client, ) -> Result<(), CliError> { @@ -299,7 +299,7 @@ pub struct ConsumeNotesCmd { } impl ConsumeNotesCmd { - pub async fn execute( + pub async fn execute( &self, mut client: Client, ) -> Result<(), CliError> { @@ -370,7 +370,7 @@ impl ConsumeNotesCmd { // EXECUTE TRANSACTION // ================================================================================================ -async fn execute_transaction( +async fn execute_transaction( client: &mut Client, account_id: AccountId, transaction_request: TransactionRequest, diff --git a/bin/miden-cli/src/commands/notes.rs b/bin/miden-cli/src/commands/notes.rs index 2653fde989..478a69b542 100644 --- a/bin/miden-cli/src/commands/notes.rs +++ b/bin/miden-cli/src/commands/notes.rs @@ -72,7 +72,7 @@ pub struct NotesCmd { } impl NotesCmd { - pub async fn execute( + pub async fn execute( &self, mut client: Client, ) -> Result<(), CliError> { @@ -121,7 +121,7 @@ struct CliNoteSummary { // LIST NOTES // ================================================================================================ -async fn list_notes( +async fn list_notes( client: Client, filter: ClientNoteFilter, ) -> Result<(), CliError> { @@ -147,7 +147,7 @@ async fn list_notes( // SHOW NOTE // ================================================================================================ #[allow(clippy::too_many_lines)] -async fn show_note( +async fn show_note( client: Client, note_id: String, with_code: bool, @@ -316,7 +316,7 @@ async fn show_note( // LIST CONSUMABLE INPUT NOTES // ================================================================================================ -async fn list_consumable_notes( +async fn list_consumable_notes( client: Client, account_id: Option<&String>, ) -> Result<(), CliError> { @@ -333,7 +333,7 @@ async fn list_consumable_notes( // ================================================================================================ /// Send a (stored) note -async fn send( +async fn send( client: &mut Client, note_id: &str, address: &str, @@ -359,7 +359,7 @@ async fn send( /// Fetched notes are stored in the store. async fn fetch(client: &mut Client) -> Result<(), CliError> where - AUTH: Keystore + Sync + 'static, + AUTH: Keystore + Send + Sync + 'static, { client.fetch_private_notes().await?; diff --git a/bin/miden-cli/src/commands/sync.rs b/bin/miden-cli/src/commands/sync.rs index 599b0ed70f..ac60616088 100644 --- a/bin/miden-cli/src/commands/sync.rs +++ b/bin/miden-cli/src/commands/sync.rs @@ -9,7 +9,7 @@ use crate::errors::CliError; pub struct SyncCmd {} impl SyncCmd { - pub async fn execute( + pub async fn execute( &self, mut client: Client, ) -> Result<(), CliError> { diff --git a/bin/miden-cli/src/commands/transactions.rs b/bin/miden-cli/src/commands/transactions.rs index 4a196a0220..6d75169798 100644 --- a/bin/miden-cli/src/commands/transactions.rs +++ b/bin/miden-cli/src/commands/transactions.rs @@ -15,7 +15,7 @@ pub struct TransactionCmd { } impl TransactionCmd { - pub async fn execute( + pub async fn execute( &self, client: Client, ) -> Result<(), CliError> { @@ -26,7 +26,7 @@ impl TransactionCmd { // LIST TRANSACTIONS // ================================================================================================ -async fn list_transactions( +async fn list_transactions( client: Client, ) -> Result<(), CliError> { let transactions = client.get_transactions(TransactionFilter::All).await?; diff --git a/bin/miden-cli/src/info.rs b/bin/miden-cli/src/info.rs index 9cc1140f82..9ce0fd1a4b 100644 --- a/bin/miden-cli/src/info.rs +++ b/bin/miden-cli/src/info.rs @@ -11,7 +11,7 @@ use super::config::CliConfig; use crate::commands::account::DEFAULT_ACCOUNT_ID_KEY; use crate::errors::CliError; -pub async fn print_client_info( +pub async fn print_client_info( client: &Client, show_rpc_status: bool, ) -> Result<(), CliError> { @@ -42,7 +42,7 @@ pub async fn print_client_info( // HELPERS // ================================================================================================ -async fn print_client_stats( +async fn print_client_stats( client: &Client, ) -> Result<(), CliError> { println!("Block number: {}", client.get_sync_height().await?); diff --git a/bin/miden-cli/src/lib.rs b/bin/miden-cli/src/lib.rs index f044cd3890..8b2d063423 100644 --- a/bin/miden-cli/src/lib.rs +++ b/bin/miden-cli/src/lib.rs @@ -496,7 +496,7 @@ pub fn create_dynamic_table(headers: &[&str]) -> Table { /// unable to find any note where `note_id_prefix` is a prefix of its ID. /// - Returns [`IdPrefixFetchError::MultipleMatches`](miden_client::IdPrefixFetchError::MultipleMatches) /// if there were more than one note found where `note_id_prefix` is a prefix of its ID. -pub(crate) async fn get_output_note_with_id_prefix( +pub(crate) async fn get_output_note_with_id_prefix( client: &miden_client::Client, note_id_prefix: &str, ) -> Result { diff --git a/crates/rust-client/src/sync/state_sync.rs b/crates/rust-client/src/sync/state_sync.rs index c5c1fbaf7d..b8531b2e4f 100644 --- a/crates/rust-client/src/sync/state_sync.rs +++ b/crates/rust-client/src/sync/state_sync.rs @@ -554,7 +554,7 @@ mod tests { /// Mock note screener that discards all notes, for minimal test setup. struct MockScreener; - #[async_trait(?Send)] + #[async_trait] impl OnNoteReceived for MockScreener { async fn on_note_received( &self, From 853cea9ab3109adc0bd64fcc3d67d75ff0925b32 Mon Sep 17 00:00:00 2001 From: Ignacio Amigo Date: Mon, 13 Apr 2026 23:11:21 -0300 Subject: [PATCH 3/4] chore: merge --- Cargo.lock | 2 +- crates/client-service/src/events.rs | 166 +++++++++ crates/client-service/src/handlers.rs | 183 ++++++++++ crates/client-service/src/lib.rs | 337 ++++++++++++++---- crates/client-service/src/test_utils.rs | 19 + crates/idxdb-store/src/yarn.lock | 306 ++++++++-------- crates/rust-client/src/note/note_screener.rs | 3 +- crates/rust-client/src/sync/state_sync.rs | 3 +- .../testing/miden-client-tests/src/tests.rs | 6 +- 9 files changed, 799 insertions(+), 226 deletions(-) create mode 100644 crates/client-service/src/events.rs create mode 100644 crates/client-service/src/handlers.rs create mode 100644 crates/client-service/src/test_utils.rs diff --git a/Cargo.lock b/Cargo.lock index 0e21519ba3..08d38c914e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3018,7 +3018,7 @@ dependencies = [ [[package]] name = "miden-client-service" -version = "0.14.0" +version = "0.15.0" dependencies = [ "miden-client", "miden-protocol", diff --git a/crates/client-service/src/events.rs b/crates/client-service/src/events.rs new file mode 100644 index 0000000000..36b2f70c0f --- /dev/null +++ b/crates/client-service/src/events.rs @@ -0,0 +1,166 @@ +//! Event types emitted by the client service. +//! +//! After each sync, the [`ClientService`](crate::ClientService) decomposes the +//! [`SyncSummary`] into individual [`ClientEvent`] variants and publishes them +//! on a broadcast channel. Consumers can react to these events in three ways: +//! +//! ## 1. Raw Event Stream +//! +//! ```rust,ignore +//! let mut events = service.subscribe(); +//! tokio::spawn(async move { +//! while let Ok(event) = events.recv().await { +//! match event { +//! ClientEvent::NoteCommitted { note_id } => println!("committed: {note_id}"), +//! ClientEvent::TransactionCommitted { transaction_id } => println!("confirmed: {transaction_id}"), +//! _ => {} +//! } +//! } +//! }); +//! ``` +//! +//! ## 2. Persistent Handlers +//! +//! ```rust,ignore +//! service.on(EventFilter::AnyNoteReceived, |event, service| async move { +//! let client = service.client().await; +//! // query client state... +//! }); +//! ``` +//! +//! ## 3. One-Shot Awaiters +//! +//! ```rust,ignore +//! let tx_id = service.submit_transaction(account_id, request).await?; +//! service.once(EventFilter::TransactionCommitted(tx_id), Some(Duration::from_secs(60))).await?; +//! ``` + +use miden_client::sync::SyncSummary; +use miden_client::transaction::TransactionId; +use miden_protocol::account::AccountId; +use miden_protocol::note::NoteId; + +/// Events emitted by the [`ClientService`](crate::ClientService). +/// +/// Granular events (notes, transactions, accounts) are emitted before +/// [`SyncCompleted`](Self::SyncCompleted) so subscribers see individual +/// changes first. +#[derive(Debug, Clone)] +pub enum ClientEvent { + /// A new public note was received during sync. + NoteReceived { note_id: NoteId }, + /// A tracked note was committed on-chain. + NoteCommitted { note_id: NoteId }, + /// A note was consumed. + NoteConsumed { note_id: NoteId }, + /// A transaction was committed on-chain. + TransactionCommitted { transaction_id: TransactionId }, + /// An on-chain account was updated. + AccountUpdated { account_id: AccountId }, + /// A private account was locked. + AccountLocked { account_id: AccountId }, + /// Sync completed. Always emitted last in a sync cycle. + SyncCompleted { summary: SyncSummary }, +} + +impl ClientEvent { + /// Returns the [`NoteId`] if this is a note-related event. + pub fn note_id(&self) -> Option { + match self { + Self::NoteReceived { note_id } + | Self::NoteCommitted { note_id } + | Self::NoteConsumed { note_id } => Some(*note_id), + _ => None, + } + } + + /// Returns the [`TransactionId`] if this is a transaction-related event. + pub fn transaction_id(&self) -> Option { + match self { + Self::TransactionCommitted { transaction_id } => Some(*transaction_id), + _ => None, + } + } + + /// Returns the [`AccountId`] if this is an account-related event. + pub fn account_id(&self) -> Option { + match self { + Self::AccountUpdated { account_id } | Self::AccountLocked { account_id } => { + Some(*account_id) + }, + _ => None, + } + } + + /// Returns the [`SyncSummary`] if this is a [`SyncCompleted`](Self::SyncCompleted) event. + pub fn summary(&self) -> Option<&SyncSummary> { + match self { + Self::SyncCompleted { summary } => Some(summary), + _ => None, + } + } +} + +/// Decomposes a [`SyncSummary`] into individual [`ClientEvent`] variants. +/// +/// Granular events are emitted first, with [`ClientEvent::SyncCompleted`] last. +pub(crate) fn events_from_sync(summary: &SyncSummary) -> Vec { + let capacity = summary.new_public_notes.len() + + summary.committed_notes.len() + + summary.consumed_notes.len() + + summary.committed_transactions.len() + + summary.updated_accounts.len() + + summary.locked_accounts.len() + + 1; + let mut events = Vec::with_capacity(capacity); + + for ¬e_id in &summary.new_public_notes { + events.push(ClientEvent::NoteReceived { note_id }); + } + for ¬e_id in &summary.committed_notes { + events.push(ClientEvent::NoteCommitted { note_id }); + } + for ¬e_id in &summary.consumed_notes { + events.push(ClientEvent::NoteConsumed { note_id }); + } + for &transaction_id in &summary.committed_transactions { + events.push(ClientEvent::TransactionCommitted { transaction_id }); + } + for &account_id in &summary.updated_accounts { + events.push(ClientEvent::AccountUpdated { account_id }); + } + for &account_id in &summary.locked_accounts { + events.push(ClientEvent::AccountLocked { account_id }); + } + + events.push(ClientEvent::SyncCompleted { summary: summary.clone() }); + + events +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::{empty_summary, test_note_id}; + + #[test] + fn empty_summary_produces_only_sync_completed() { + let events = events_from_sync(&empty_summary()); + assert_eq!(events.len(), 1); + assert!(matches!(events[0], ClientEvent::SyncCompleted { .. })); + } + + #[test] + fn sync_completed_is_last() { + let mut summary = empty_summary(); + let note_id = test_note_id(); + summary.new_public_notes = vec![note_id]; + summary.committed_notes = vec![note_id]; + + let events = events_from_sync(&summary); + assert_eq!(events.len(), 3); + assert!(matches!(events[0], ClientEvent::NoteReceived { .. })); + assert!(matches!(events[1], ClientEvent::NoteCommitted { .. })); + assert!(matches!(events[2], ClientEvent::SyncCompleted { .. })); + } +} diff --git a/crates/client-service/src/handlers.rs b/crates/client-service/src/handlers.rs new file mode 100644 index 0000000000..6c476ac3eb --- /dev/null +++ b/crates/client-service/src/handlers.rs @@ -0,0 +1,183 @@ +//! Event handler registration and dispatch. + +use std::future::Future; +use std::pin::Pin; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, RwLock}; + +use miden_client::transaction::TransactionId; +use miden_protocol::account::AccountId; +use miden_protocol::note::NoteId; + +use crate::events::ClientEvent; + +type BoxFuture = Pin + Send>>; + +#[derive(Clone)] +pub(crate) struct StoredHandler(Arc BoxFuture + Send + Sync>); + +impl StoredHandler { + pub(crate) fn new(f: F) -> Self + where + F: Fn(ClientEvent) -> Fut + Send + Sync + 'static, + Fut: Future + Send + 'static, + { + Self(Arc::new(move |event| Box::pin(f(event)))) + } + + fn call(&self, event: ClientEvent) -> BoxFuture { + (self.0)(event) + } +} + +/// Opaque identifier for a registered handler. Used with +/// [`ClientService::remove_handler`](crate::ClientService::remove_handler). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct HandlerId(u64); + +/// Describes which events a handler or awaiter is interested in. +/// +/// Used with [`ClientService::on`](crate::ClientService::on) and +/// [`ClientService::once`](crate::ClientService::once). +#[derive(Debug, Clone)] +pub enum EventFilter { + /// Match a specific note being received. + NoteReceived(NoteId), + /// Match any note received. + AnyNoteReceived, + /// Match a specific note being committed. + NoteCommitted(NoteId), + /// Match any note committed. + AnyNoteCommitted, + /// Match any note consumed. + AnyNoteConsumed, + /// Match a specific transaction committed. + TransactionCommitted(TransactionId), + /// Match any transaction committed. + AnyTransactionCommitted, + /// Match a specific account updated. + AccountUpdated(AccountId), + /// Match any account updated. + AnyAccountUpdated, + /// Match any sync completed. + AnySyncCompleted, +} + +impl EventFilter { + pub(crate) fn matches(&self, event: &ClientEvent) -> bool { + match (self, event) { + (Self::NoteReceived(id), ClientEvent::NoteReceived { note_id }) + | (Self::NoteCommitted(id), ClientEvent::NoteCommitted { note_id }) => id == note_id, + (Self::AccountUpdated(id), ClientEvent::AccountUpdated { account_id }) => { + id == account_id + }, + ( + Self::TransactionCommitted(id), + ClientEvent::TransactionCommitted { transaction_id }, + ) => id == transaction_id, + (Self::AnyNoteReceived, ClientEvent::NoteReceived { .. }) + | (Self::AnyNoteCommitted, ClientEvent::NoteCommitted { .. }) + | (Self::AnyNoteConsumed, ClientEvent::NoteConsumed { .. }) + | (Self::AnyTransactionCommitted, ClientEvent::TransactionCommitted { .. }) + | (Self::AnyAccountUpdated, ClientEvent::AccountUpdated { .. }) + | (Self::AnySyncCompleted, ClientEvent::SyncCompleted { .. }) => true, + _ => false, + } + } +} + +struct HandlerEntry { + id: HandlerId, + filter: EventFilter, + handler: StoredHandler, +} + +pub(crate) struct HandlerRegistry { + handlers: RwLock>, + next_id: AtomicU64, +} + +impl HandlerRegistry { + pub fn new() -> Self { + Self { + handlers: RwLock::new(Vec::new()), + next_id: AtomicU64::new(1), + } + } + + pub fn register(&self, filter: EventFilter, handler: StoredHandler) -> HandlerId { + let id = HandlerId(self.next_id.fetch_add(1, Ordering::Relaxed)); + self.handlers.write().expect("handler registry poisoned").push(HandlerEntry { + id, + filter, + handler, + }); + id + } + + pub fn unregister(&self, id: HandlerId) -> bool { + let mut handlers = self.handlers.write().expect("handler registry poisoned"); + let len_before = handlers.len(); + handlers.retain(|entry| entry.id != id); + handlers.len() < len_before + } + + /// Dispatches events to all matching handlers. Each invocation is spawned + /// as a separate tokio task with no ordering guarantees. + pub fn dispatch(&self, events: &[ClientEvent]) { + let handlers = self.handlers.read().expect("handler registry poisoned"); + + for event in events { + for entry in handlers.iter() { + if entry.filter.matches(event) { + let handler = entry.handler.clone(); + let event = event.clone(); + tokio::spawn(async move { handler.call(event).await }); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::{empty_summary, test_note_id}; + + #[test] + fn filter_matches_specific_note() { + let note_id = test_note_id(); + + assert!( + EventFilter::NoteCommitted(note_id).matches(&ClientEvent::NoteCommitted { note_id }) + ); + assert!( + !EventFilter::NoteCommitted(note_id).matches(&ClientEvent::NoteReceived { note_id }) + ); + assert!( + !EventFilter::NoteCommitted(note_id).matches(&ClientEvent::NoteConsumed { note_id }) + ); + } + + #[test] + fn filter_matches_any_variant() { + let note_id = test_note_id(); + + assert!(EventFilter::AnyNoteReceived.matches(&ClientEvent::NoteReceived { note_id })); + assert!(!EventFilter::AnyNoteReceived.matches(&ClientEvent::NoteCommitted { note_id })); + assert!( + EventFilter::AnySyncCompleted + .matches(&ClientEvent::SyncCompleted { summary: empty_summary() }) + ); + } + + #[test] + fn register_and_unregister() { + let registry = HandlerRegistry::new(); + let handler = StoredHandler::new(|_| async {}); + let id = registry.register(EventFilter::NoteCommitted(test_note_id()), handler); + + assert!(registry.unregister(id)); + assert!(!registry.unregister(id)); + } +} diff --git a/crates/client-service/src/lib.rs b/crates/client-service/src/lib.rs index 85abbcf9b7..1f79ff5c98 100644 --- a/crates/client-service/src/lib.rs +++ b/crates/client-service/src/lib.rs @@ -1,5 +1,6 @@ -//! A service wrapper for the Miden client that provides operation coordination -//! and background synchronization. +//! A service wrapper for the Miden client that provides operation coordination, +//! background synchronization, and an event system for reacting to on-chain +//! state changes. //! //! # Overview //! @@ -7,42 +8,40 @@ //! //! - **Operation Coordination** - Mutual exclusion between sync and transaction operations //! - **Background Sync** - Periodic automatic synchronization with the network +//! - **Event System** - Subscribe to state changes, register handlers, or await specific events //! - **Async-Friendly Access** - Direct client access via [`MutexGuard`](tokio::sync::MutexGuard) //! //! # Example //! //! ```rust,ignore -//! use miden_client_service::{ClientService, ServiceConfig}; -//! use miden_client::Client; +//! use std::sync::Arc; +//! use std::time::Duration; +//! use miden_client_service::{ClientService, ServiceConfig, EventFilter}; //! -//! // Create the underlying client -//! let client = Client::builder() -//! .rpc(rpc_client) -//! .store(store) -//! .authenticator(keystore) -//! .build() -//! .await?; -//! -//! // Wrap it in a service //! let service = Arc::new(ClientService::new(client, ServiceConfig::default())); +//! let _sync = service.start_background_sync(); //! -//! // Start background sync -//! let sync_handle = service.start_background_sync(); -//! -//! // Use convenience methods -//! let summary = service.sync_state().await?; +//! // Submit a transaction and wait for it to be committed //! let tx_id = service.submit_transaction(account_id, tx_request).await?; +//! service.once(EventFilter::TransactionCommitted(tx_id), Some(Duration::from_secs(60))).await?; //! -//! // Or access the client directly for any operation -//! let mut client = service.client().await; -//! let accounts = client.get_account_headers().await?; +//! // Register a persistent handler (has access to the service) +//! service.on(EventFilter::AnyNoteReceived, |event, service| async move { +//! let client = service.client().await; +//! // query client state... +//! }); +//! +//! // Or subscribe to the raw event stream +//! let mut events = service.subscribe(); //! ``` #![cfg_attr(not(feature = "std"), no_std)] extern crate alloc; +use std::future::Future; use std::sync::Arc; +use std::time::Duration; use miden_client::auth::TransactionAuthenticator; use miden_client::sync::SyncSummary; @@ -52,31 +51,29 @@ use miden_protocol::account::AccountId; use tokio::sync::{Mutex, MutexGuard, broadcast}; use tracing::{debug, info, warn}; -mod config; +pub(crate) mod events; +mod handlers; +mod config; pub use config::ServiceConfig; +pub use events::ClientEvent; +pub use handlers::{EventFilter, HandlerId}; -/// A service wrapper for the Miden client that provides coordination and background sync. -/// -/// `ClientService` adds the following on top of the base `Client`: -/// -/// - **Operation serialization**: All operations are serialized through a single mutex, ensuring -/// sync and transaction operations never overlap. -/// -/// - **Background sync**: Optional periodic synchronization that runs in the background. +#[cfg(test)] +pub(crate) mod test_utils; + +/// A service wrapper for the Miden client that provides coordination, background +/// sync, and an event system. /// -/// The service is `Send + Sync` and can be safely shared across tasks. Access the -/// underlying client via [`client()`](Self::client) for any operation, or use the -/// convenience methods [`sync_state()`](Self::sync_state) and -/// [`submit_transaction()`](Self::submit_transaction). +/// The service is `Send + Sync` and designed to be used behind an `Arc`. pub struct ClientService where AUTH: TransactionAuthenticator + Send + Sync + 'static, { - /// The underlying client, wrapped in a mutex for interior mutability. client: Mutex>, - /// Service configuration. config: ServiceConfig, + event_tx: broadcast::Sender, + handlers: handlers::HandlerRegistry, } impl ClientService @@ -85,7 +82,13 @@ where { /// Creates a new service wrapping the given client. pub fn new(client: Client, config: ServiceConfig) -> Self { - Self { client: Mutex::new(client), config } + let (event_tx, _) = broadcast::channel(256); + Self { + client: Mutex::new(client), + config, + event_tx, + handlers: handlers::HandlerRegistry::new(), + } } /// Creates a new service with default configuration. @@ -99,27 +102,14 @@ where } /// Returns a guard providing access to the underlying client. - /// - /// The returned [`MutexGuard`] dereferences to `Client`, supporting both - /// shared (`&`) and mutable (`&mut`) access. The lock is held until the guard - /// is dropped. - /// - /// Use this for any client operation that isn't covered by the convenience methods. - /// - /// # Example - /// - /// ```rust,ignore - /// let mut client = service.client().await; - /// let accounts = client.get_account_headers().await?; - /// ``` pub async fn client(&self) -> MutexGuard<'_, Client> { self.client.lock().await } /// Synchronizes the client state with the network. /// - /// This acquires exclusive access to the client, fetches the latest state - /// from the network, and applies it to the local store. + /// Acquires exclusive access, fetches the latest state, applies it to the + /// local store, and emits [`ClientEvent`]s for each change observed. pub async fn sync_state(&self) -> Result { debug!("Starting coordinated sync"); let mut client = self.client.lock().await; @@ -129,12 +119,13 @@ where client.apply_state_sync(state_sync_update).await?; info!(block_num = ?summary.block_num, "Sync completed"); + + self.emit_events(events::events_from_sync(&summary)); + Ok(summary) } /// Submits a new transaction. - /// - /// This acquires exclusive access to the client and submits the transaction. pub async fn submit_transaction( &self, account_id: AccountId, @@ -150,17 +141,7 @@ where /// Starts a background sync loop that periodically syncs with the network. /// /// Returns a handle that can be used to stop the background sync. - /// The sync uses the interval configured in [`ServiceConfig::sync_interval`]. - /// - /// If background sync is disabled in the config, returns a handle that does nothing. - /// - /// # Example - /// - /// ```rust,ignore - /// let service = Arc::new(ClientService::new(client, config)); - /// let handle = service.start_background_sync(); - /// // Later: handle.stop(); - /// ``` + /// If background sync is disabled in the config, returns an inactive handle. pub fn start_background_sync(self: &Arc) -> BackgroundSyncHandle { let Some(interval) = self.config.sync_interval else { let (tx, _) = broadcast::channel(1); @@ -172,7 +153,6 @@ where tokio::spawn(async move { info!(?interval, "Starting background sync"); - loop { tokio::select! { _ = shutdown_rx.recv() => { @@ -195,8 +175,97 @@ where BackgroundSyncHandle::new(shutdown_tx) } + + // ------------------------------------------------------------------- + // Event system + // ------------------------------------------------------------------- + + /// Returns a receiver for the raw event stream. + pub fn subscribe(&self) -> broadcast::Receiver { + self.event_tx.subscribe() + } + + /// Registers a persistent event handler. + /// + /// The handler fires whenever an event matching `filter` is observed. + /// It receives the [`ClientEvent`] and an `Arc` so it can + /// query client state. Each invocation is spawned as a separate task. + /// + /// Returns a [`HandlerId`] that can be used with + /// [`remove_handler`](Self::remove_handler). + pub fn on(self: &Arc, filter: EventFilter, handler: F) -> HandlerId + where + F: Fn(ClientEvent, Arc>) -> Fut + Send + Sync + 'static, + Fut: Future + Send + 'static, + { + let service = Arc::clone(self); + let stored = handlers::StoredHandler::new(move |event| { + let service = Arc::clone(&service); + handler(event, service) + }); + self.handlers.register(filter, stored) + } + + /// Removes a previously registered handler. + pub fn remove_handler(&self, id: HandlerId) -> bool { + self.handlers.unregister(id) + } + + /// Waits for the first event matching `filter`. + /// + /// Returns the matched [`ClientEvent`]. If `timeout` is `Some`, returns + /// an error if no matching event arrives within the duration. + pub async fn once( + &self, + filter: EventFilter, + timeout: Option, + ) -> Result { + let mut rx = self.event_tx.subscribe(); + + let fut = async { + loop { + match rx.recv().await { + Ok(event) if filter.matches(&event) => return event, + Ok(_) => {}, + Err(broadcast::error::RecvError::Lagged(n)) => { + warn!(skipped = n, "Event receiver lagged"); + }, + Err(broadcast::error::RecvError::Closed) => { + std::future::pending::<()>().await; + unreachable!() + }, + } + } + }; + + match timeout { + Some(duration) => { + tokio::time::timeout(duration, fut).await.map_err(|_| EventTimeoutError) + }, + None => Ok(fut.await), + } + } + + fn emit_events(&self, events: Vec) { + self.handlers.dispatch(&events); + for event in events { + let _ = self.event_tx.send(event); + } + } } +/// Error returned when a [`once`](ClientService::once) call times out. +#[derive(Debug, Clone)] +pub struct EventTimeoutError; + +impl core::fmt::Display for EventTimeoutError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "timed out waiting for event") + } +} + +impl std::error::Error for EventTimeoutError {} + /// A handle to control background sync operations. pub struct BackgroundSyncHandle { shutdown_tx: Option>, @@ -208,8 +277,6 @@ impl BackgroundSyncHandle { } /// Signals the background sync to stop. - /// - /// The sync will complete its current operation before stopping. pub fn stop(&mut self) { if let Some(tx) = self.shutdown_tx.take() { let _ = tx.send(()); @@ -231,6 +298,7 @@ impl Drop for BackgroundSyncHandle { #[cfg(test)] mod tests { use super::*; + use crate::test_utils::{empty_summary, test_note_id}; fn assert_send_sync() {} @@ -238,4 +306,137 @@ mod tests { fn service_is_send_sync() { assert_send_sync::>(); } + + #[tokio::test] + async fn subscribe_receives_events() { + let (event_tx, _) = broadcast::channel(256); + let mut rx = event_tx.subscribe(); + + let note_id = test_note_id(); + let _ = event_tx.send(ClientEvent::NoteCommitted { note_id }); + + let event = rx.recv().await.unwrap(); + assert!(matches!(event, ClientEvent::NoteCommitted { note_id: id } if id == note_id)); + } + + #[tokio::test] + async fn once_resolves_on_match() { + let (event_tx, _) = broadcast::channel(256); + let mut rx = event_tx.subscribe(); + let note_id = test_note_id(); + + let tx = event_tx.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(10)).await; + let _ = tx.send(ClientEvent::NoteCommitted { note_id }); + }); + + let filter = EventFilter::NoteCommitted(note_id); + let event = tokio::time::timeout(Duration::from_secs(1), async { + loop { + match rx.recv().await { + Ok(event) if filter.matches(&event) => return event, + Ok(_) | Err(broadcast::error::RecvError::Lagged(_)) => {}, + Err(broadcast::error::RecvError::Closed) => unreachable!(), + } + } + }) + .await + .unwrap(); + + assert_eq!(event.note_id().unwrap(), note_id); + } + + #[tokio::test] + async fn once_times_out() { + let (_event_tx, mut rx) = broadcast::channel::(256); + + let result = tokio::time::timeout(Duration::from_millis(50), async { + loop { + match rx.recv().await { + Ok(_) | Err(broadcast::error::RecvError::Lagged(_)) => {}, + Err(broadcast::error::RecvError::Closed) => { + std::future::pending::<()>().await; + unreachable!() + }, + } + } + }) + .await; + + assert!(result.is_err()); + } + + #[tokio::test] + async fn handler_fires_for_matching_event() { + let registry = handlers::HandlerRegistry::new(); + let note_id = test_note_id(); + + let (notify_tx, notify_rx) = tokio::sync::oneshot::channel(); + let notify_tx = Arc::new(std::sync::Mutex::new(Some(notify_tx))); + + let handler = handlers::StoredHandler::new(move |_event| { + let tx = notify_tx.clone(); + async move { + if let Some(tx) = tx.lock().unwrap().take() { + let _ = tx.send(()); + } + } + }); + + registry.register(EventFilter::NoteCommitted(note_id), handler); + registry.dispatch(&[ClientEvent::NoteCommitted { note_id }]); + + tokio::time::timeout(Duration::from_secs(1), notify_rx) + .await + .expect("timed out") + .expect("channel dropped"); + } + + #[tokio::test] + async fn handler_skips_non_matching_event() { + let registry = handlers::HandlerRegistry::new(); + let note_id = test_note_id(); + + let (notify_tx, notify_rx) = tokio::sync::oneshot::channel(); + let notify_tx = Arc::new(std::sync::Mutex::new(Some(notify_tx))); + + let handler = handlers::StoredHandler::new(move |_event| { + let tx = notify_tx.clone(); + async move { + if let Some(tx) = tx.lock().unwrap().take() { + let _ = tx.send(()); + } + } + }); + + registry.register(EventFilter::NoteCommitted(note_id), handler); + registry.dispatch(&[ClientEvent::NoteReceived { note_id }]); + + let result = tokio::time::timeout(Duration::from_millis(50), notify_rx).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn events_from_sync_integration() { + let note_id = test_note_id(); + let mut summary = empty_summary(); + summary.new_public_notes = vec![note_id]; + + let events = events::events_from_sync(&summary); + assert_eq!(events.len(), 2); + assert_eq!(events[0].note_id().unwrap(), note_id); + assert!(events[1].summary().is_some()); + } + + #[test] + fn client_event_accessors() { + let note_id = test_note_id(); + let event = ClientEvent::NoteReceived { note_id }; + + assert_eq!(event.note_id(), Some(note_id)); + assert_eq!(event.transaction_id(), None); + assert_eq!(event.account_id(), None); + assert!(event.summary().is_none()); + } } diff --git a/crates/client-service/src/test_utils.rs b/crates/client-service/src/test_utils.rs new file mode 100644 index 0000000000..01c52ed615 --- /dev/null +++ b/crates/client-service/src/test_utils.rs @@ -0,0 +1,19 @@ +use miden_client::sync::SyncSummary; +use miden_protocol::block::BlockNumber; +use miden_protocol::note::NoteId; + +pub(crate) fn test_note_id() -> NoteId { + NoteId::from_raw(miden_protocol::EMPTY_WORD) +} + +pub(crate) fn empty_summary() -> SyncSummary { + SyncSummary { + block_num: BlockNumber::from(0u32), + new_public_notes: vec![], + committed_notes: vec![], + consumed_notes: vec![], + updated_accounts: vec![], + locked_accounts: vec![], + committed_transactions: vec![], + } +} diff --git a/crates/idxdb-store/src/yarn.lock b/crates/idxdb-store/src/yarn.lock index b7111a7e1d..2f1a36f3ab 100644 --- a/crates/idxdb-store/src/yarn.lock +++ b/crates/idxdb-store/src/yarn.lock @@ -252,130 +252,130 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" -"@rollup/rollup-android-arm-eabi@4.58.0": - version "4.58.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.58.0.tgz#4cda52aeff1d38539ff823b371dfe85064c1252e" - integrity sha512-mr0tmS/4FoVk1cnaeN244A/wjvGDNItZKR8hRhnmCzygyRXYtKF5jVDSIILR1U97CTzAYmbgIj/Dukg62ggG5w== - -"@rollup/rollup-android-arm64@4.58.0": - version "4.58.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.58.0.tgz#ceffd87180d55701fb362c8fda9a380d64976c9e" - integrity sha512-+s++dbp+/RTte62mQD9wLSbiMTV+xr/PeRJEc/sFZFSBRlHPNPVaf5FXlzAL77Mr8FtSfQqCN+I598M8U41ccQ== - -"@rollup/rollup-darwin-arm64@4.58.0": - version "4.58.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.58.0.tgz#fe0e54a3b2affd1a951fec7c35b9013d1d0ec5f6" - integrity sha512-MFWBwTcYs0jZbINQBXHfSrpSQJq3IUOakcKPzfeSznONop14Pxuqa0Kg19GD0rNBMPQI2tFtu3UzapZpH0Uc1Q== - -"@rollup/rollup-darwin-x64@4.58.0": - version "4.58.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.58.0.tgz#0509fab5a7f3e77cbd4b26f011e75cdc69e2205d" - integrity sha512-yiKJY7pj9c9JwzuKYLFaDZw5gma3fI9bkPEIyofvVfsPqjCWPglSHdpdwXpKGvDeYDms3Qal8qGMEHZ1M/4Udg== - -"@rollup/rollup-freebsd-arm64@4.58.0": - version "4.58.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.58.0.tgz#dbe79b0ef383eae698623d0c1d6b959c2b4584ac" - integrity sha512-x97kCoBh5MOevpn/CNK9W1x8BEzO238541BGWBc315uOlN0AD/ifZ1msg+ZQB05Ux+VF6EcYqpiagfLJ8U3LvQ== - -"@rollup/rollup-freebsd-x64@4.58.0": - version "4.58.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.58.0.tgz#b65254c46414c59698316671d69d6ea6ee462737" - integrity sha512-Aa8jPoZ6IQAG2eIrcXPpjRcMjROMFxCt1UYPZZtCxRV68WkuSigYtQ/7Zwrcr2IvtNJo7T2JfDXyMLxq5L4Jlg== - -"@rollup/rollup-linux-arm-gnueabihf@4.58.0": - version "4.58.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.58.0.tgz#5a85c07b0a7083f7db33a9c9acb8415aa87e4efb" - integrity sha512-Ob8YgT5kD/lSIYW2Rcngs5kNB/44Q2RzBSPz9brf2WEtcGR7/f/E9HeHn1wYaAwKBni+bdXEwgHvUd0x12lQSA== - -"@rollup/rollup-linux-arm-musleabihf@4.58.0": - version "4.58.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.58.0.tgz#b29ff496f689529603bd3761e6d46325aba4d542" - integrity sha512-K+RI5oP1ceqoadvNt1FecL17Qtw/n9BgRSzxif3rTL2QlIu88ccvY+Y9nnHe/cmT5zbH9+bpiJuG1mGHRVwF4Q== - -"@rollup/rollup-linux-arm64-gnu@4.58.0": - version "4.58.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.58.0.tgz#f188f24ab4ed307391bd12d9ce6021414ab33858" - integrity sha512-T+17JAsCKUjmbopcKepJjHWHXSjeW7O5PL7lEFaeQmiVyw4kkc5/lyYKzrv6ElWRX/MrEWfPiJWqbTvfIvjM1Q== - -"@rollup/rollup-linux-arm64-musl@4.58.0": - version "4.58.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.58.0.tgz#8c0f105952ab6eaf3b7c335d67866a12f6e9701d" - integrity sha512-cCePktb9+6R9itIJdeCFF9txPU7pQeEHB5AbHu/MKsfH/k70ZtOeq1k4YAtBv9Z7mmKI5/wOLYjQ+B9QdxR6LA== - -"@rollup/rollup-linux-loong64-gnu@4.58.0": - version "4.58.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.58.0.tgz#5637c6293d20c2a42e32bc176fe91f25df29a589" - integrity sha512-iekUaLkfliAsDl4/xSdoCJ1gnnIXvoNz85C8U8+ZxknM5pBStfZjeXgB8lXobDQvvPRCN8FPmmuTtH+z95HTmg== - -"@rollup/rollup-linux-loong64-musl@4.58.0": - version "4.58.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.58.0.tgz#232cc97d391a8e2a31f012d31e527a10d2b7624f" - integrity sha512-68ofRgJNl/jYJbxFjCKE7IwhbfxOl1muPN4KbIqAIe32lm22KmU7E8OPvyy68HTNkI2iV/c8y2kSPSm2mW/Q9Q== - -"@rollup/rollup-linux-ppc64-gnu@4.58.0": - version "4.58.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.58.0.tgz#dd67db4a17c577eca6903d8f2e6427729318946f" - integrity sha512-dpz8vT0i+JqUKuSNPCP5SYyIV2Lh0sNL1+FhM7eLC457d5B9/BC3kDPp5BBftMmTNsBarcPcoz5UGSsnCiw4XQ== - -"@rollup/rollup-linux-ppc64-musl@4.58.0": - version "4.58.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.58.0.tgz#2faf62360f3b98d252a03c9b83ba38013048edf4" - integrity sha512-4gdkkf9UJ7tafnweBCR/mk4jf3Jfl0cKX9Np80t5i78kjIH0ZdezUv/JDI2VtruE5lunfACqftJ8dIMGN4oHew== - -"@rollup/rollup-linux-riscv64-gnu@4.58.0": - version "4.58.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.58.0.tgz#5281964be59302d441abcd8ad2440beb92999be3" - integrity sha512-YFS4vPnOkDTD/JriUeeZurFYoJhPf9GQQEF/v4lltp3mVcBmnsAdjEWhr2cjUCZzZNzxCG0HZOvJU44UGHSdzw== - -"@rollup/rollup-linux-riscv64-musl@4.58.0": - version "4.58.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.58.0.tgz#aff5c40462868bb3ec5dc3950b5c57f34b64e36e" - integrity sha512-x2xgZlFne+QVNKV8b4wwaCS8pwq3y14zedZ5DqLzjdRITvreBk//4Knbcvm7+lWmms9V9qFp60MtUd0/t/PXPw== - -"@rollup/rollup-linux-s390x-gnu@4.58.0": - version "4.58.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.58.0.tgz#4203a0d06289b6e684e4d2b643a0d16e66bb4435" - integrity sha512-jIhrujyn4UnWF8S+DHSkAkDEO3hLX0cjzxJZPLF80xFyzyUIYgSMRcYQ3+uqEoyDD2beGq7Dj7edi8OnJcS/hg== - -"@rollup/rollup-linux-x64-gnu@4.58.0": - version "4.58.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.58.0.tgz#1ec45245ea6b8699d14386e91ba78ea0107d982f" - integrity sha512-+410Srdoh78MKSJxTQ+hZ/Mx+ajd6RjjPwBPNd0R3J9FtL6ZA0GqiiyNjCO9In0IzZkCNrpGymSfn+kgyPQocg== - -"@rollup/rollup-linux-x64-musl@4.58.0": - version "4.58.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.58.0.tgz#42ca10181712a325a5392d423e49804813a390ce" - integrity sha512-ZjMyby5SICi227y1MTR3VYBpFTdZs823Rs/hpakufleBoufoOIB6jtm9FEoxn/cgO7l6PM2rCEl5Kre5vX0QrQ== - -"@rollup/rollup-openbsd-x64@4.58.0": - version "4.58.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.58.0.tgz#02cfab92d507419194f7ee9b4a76ad9d101ca4a1" - integrity sha512-ds4iwfYkSQ0k1nb8LTcyXw//ToHOnNTJtceySpL3fa7tc/AsE+UpUFphW126A6fKBGJD5dhRvg8zw1rvoGFxmw== - -"@rollup/rollup-openharmony-arm64@4.58.0": - version "4.58.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.58.0.tgz#34d1ffb17a72819611cf277474e2e6b35e6beaa7" - integrity sha512-fd/zpJniln4ICdPkjWFhZYeY/bpnaN9pGa6ko+5WD38I0tTqk9lXMgXZg09MNdhpARngmxiCg0B0XUamNw/5BQ== - -"@rollup/rollup-win32-arm64-msvc@4.58.0": - version "4.58.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.58.0.tgz#c9cbe2d5a5a741222301b473f29069781e8a0f77" - integrity sha512-YpG8dUOip7DCz3nr/JUfPbIUo+2d/dy++5bFzgi4ugOGBIox+qMbbqt/JoORwvI/C9Kn2tz6+Bieoqd5+B1CjA== - -"@rollup/rollup-win32-ia32-msvc@4.58.0": - version "4.58.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.58.0.tgz#b4b393de58f38f87fa66ca1371d865366756a24c" - integrity sha512-b9DI8jpFQVh4hIXFr0/+N/TzLdpBIoPzjt0Rt4xJbW3mzguV3mduR9cNgiuFcuL/TeORejJhCWiAXe3E/6PxWA== - -"@rollup/rollup-win32-x64-gnu@4.58.0": - version "4.58.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.58.0.tgz#583097cf2e8403c14df49da1a93ce94fde2bca22" - integrity sha512-CSrVpmoRJFN06LL9xhkitkwUcTZtIotYAF5p6XOR2zW0Zz5mzb3IPpcoPhB02frzMHFNo1reQ9xSF5fFm3hUsQ== - -"@rollup/rollup-win32-x64-msvc@4.58.0": - version "4.58.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.58.0.tgz#de01bb79f597fdba7c205e4d7d7f85760f8b2645" - integrity sha512-QFsBgQNTnh5K0t/sBsjJLq24YVqEIVkGpfN2VHsnN90soZyhaiA9UUHufcctVNL4ypJY0wrwad0wslx2KJQ1/w== +"@rollup/rollup-android-arm-eabi@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz#043f145716234529052ef9e1ce1d847ffbe9e674" + integrity sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA== + +"@rollup/rollup-android-arm64@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz#023e1bd146e7519087dfd9e8b29e4cf9f8ecd35c" + integrity sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA== + +"@rollup/rollup-darwin-arm64@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz#55ccb5487c02419954c57a7a80602885d616e1ee" + integrity sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw== + +"@rollup/rollup-darwin-x64@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz#254b65404b14488c83225e88b8819376ad71a784" + integrity sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew== + +"@rollup/rollup-freebsd-arm64@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz#6377ff38c052c76fcaffb7b2728d3172fe676fe6" + integrity sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w== + +"@rollup/rollup-freebsd-x64@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz#ba3902309d088eaf7139b916f09b7140b28b406d" + integrity sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g== + +"@rollup/rollup-linux-arm-gnueabihf@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz#e011b9a14638267e53b446286e838dbdaf53f167" + integrity sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g== + +"@rollup/rollup-linux-arm-musleabihf@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz#0bce9ce9a009490abd28fd922dd97ed521311afe" + integrity sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg== + +"@rollup/rollup-linux-arm64-gnu@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz#6f6cfbbf324fbb4ceff213abdf7f322fd45d25ff" + integrity sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ== + +"@rollup/rollup-linux-arm64-musl@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz#f7cb3eecaea9c151ef77342af05f38ae924bf795" + integrity sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA== + +"@rollup/rollup-linux-loong64-gnu@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz#499bfac6bb669fd88bb664357bf6be996a28b92f" + integrity sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ== + +"@rollup/rollup-linux-loong64-musl@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz#127dfac08764764396bbe04453c545d38a3ab518" + integrity sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw== + +"@rollup/rollup-linux-ppc64-gnu@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz#6a72f4d95852aac18326c5bf708393e8f3a41b70" + integrity sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw== + +"@rollup/rollup-linux-ppc64-musl@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz#ba8674666b00d6f9066cb9a5771a8430c34d2de6" + integrity sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg== + +"@rollup/rollup-linux-riscv64-gnu@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz#17cc38b2a71e302547cad29bcf78d0db2618c922" + integrity sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg== + +"@rollup/rollup-linux-riscv64-musl@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz#e36a41e2d8bd247331bd5cfc13b8c951d33454a2" + integrity sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg== + +"@rollup/rollup-linux-s390x-gnu@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz#1687265f1f4bdea0726c761a58c2db9933609d68" + integrity sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ== + +"@rollup/rollup-linux-x64-gnu@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz#56a6a0d9076f2a05a976031493b24a20ddcc0e77" + integrity sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg== + +"@rollup/rollup-linux-x64-musl@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz#bc240ebb5b9fd8d41ca8a80cb458452e8c187e0f" + integrity sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w== + +"@rollup/rollup-openbsd-x64@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz#6f80d48a006c4b2ffa7724e95a3e33f6975872af" + integrity sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw== + +"@rollup/rollup-openharmony-arm64@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz#8f6db6f70d0a48abd833b263cd6dd3e7199c4c0e" + integrity sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA== + +"@rollup/rollup-win32-arm64-msvc@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz#b68989bfa815d0b3d4e302ecd90bda744438b177" + integrity sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g== + +"@rollup/rollup-win32-ia32-msvc@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz#c098e45338c50f22f1b288476354f025b746285b" + integrity sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg== + +"@rollup/rollup-win32-x64-gnu@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz#2c9e15be155b79d05999953b1737b2903842e903" + integrity sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg== + +"@rollup/rollup-win32-x64-msvc@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz#23b860113e9f87eea015d1fa3a4240a52b42fcd4" + integrity sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ== "@types/chai@^5.2.2": version "5.2.3" @@ -1210,38 +1210,38 @@ reusify@^1.0.4: resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f" integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw== -rollup@^4.43.0: - version "4.58.0" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.58.0.tgz#cdbbb79d48ff94f192d9974988c1728f9dd1994c" - integrity sha512-wbT0mBmWbIvvq8NeEYWWvevvxnOyhKChir47S66WCxw1SXqhw7ssIYejnQEVt7XYQpsj2y8F9PM+Cr3SNEa0gw== +rollup@>=4.59.0, rollup@^4.43.0: + version "4.60.1" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.60.1.tgz#b4aa2bcb3a5e1437b5fad40d43fe42d4bde7a42d" + integrity sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w== dependencies: "@types/estree" "1.0.8" optionalDependencies: - "@rollup/rollup-android-arm-eabi" "4.58.0" - "@rollup/rollup-android-arm64" "4.58.0" - "@rollup/rollup-darwin-arm64" "4.58.0" - "@rollup/rollup-darwin-x64" "4.58.0" - "@rollup/rollup-freebsd-arm64" "4.58.0" - "@rollup/rollup-freebsd-x64" "4.58.0" - "@rollup/rollup-linux-arm-gnueabihf" "4.58.0" - "@rollup/rollup-linux-arm-musleabihf" "4.58.0" - "@rollup/rollup-linux-arm64-gnu" "4.58.0" - "@rollup/rollup-linux-arm64-musl" "4.58.0" - "@rollup/rollup-linux-loong64-gnu" "4.58.0" - "@rollup/rollup-linux-loong64-musl" "4.58.0" - "@rollup/rollup-linux-ppc64-gnu" "4.58.0" - "@rollup/rollup-linux-ppc64-musl" "4.58.0" - "@rollup/rollup-linux-riscv64-gnu" "4.58.0" - "@rollup/rollup-linux-riscv64-musl" "4.58.0" - "@rollup/rollup-linux-s390x-gnu" "4.58.0" - "@rollup/rollup-linux-x64-gnu" "4.58.0" - "@rollup/rollup-linux-x64-musl" "4.58.0" - "@rollup/rollup-openbsd-x64" "4.58.0" - "@rollup/rollup-openharmony-arm64" "4.58.0" - "@rollup/rollup-win32-arm64-msvc" "4.58.0" - "@rollup/rollup-win32-ia32-msvc" "4.58.0" - "@rollup/rollup-win32-x64-gnu" "4.58.0" - "@rollup/rollup-win32-x64-msvc" "4.58.0" + "@rollup/rollup-android-arm-eabi" "4.60.1" + "@rollup/rollup-android-arm64" "4.60.1" + "@rollup/rollup-darwin-arm64" "4.60.1" + "@rollup/rollup-darwin-x64" "4.60.1" + "@rollup/rollup-freebsd-arm64" "4.60.1" + "@rollup/rollup-freebsd-x64" "4.60.1" + "@rollup/rollup-linux-arm-gnueabihf" "4.60.1" + "@rollup/rollup-linux-arm-musleabihf" "4.60.1" + "@rollup/rollup-linux-arm64-gnu" "4.60.1" + "@rollup/rollup-linux-arm64-musl" "4.60.1" + "@rollup/rollup-linux-loong64-gnu" "4.60.1" + "@rollup/rollup-linux-loong64-musl" "4.60.1" + "@rollup/rollup-linux-ppc64-gnu" "4.60.1" + "@rollup/rollup-linux-ppc64-musl" "4.60.1" + "@rollup/rollup-linux-riscv64-gnu" "4.60.1" + "@rollup/rollup-linux-riscv64-musl" "4.60.1" + "@rollup/rollup-linux-s390x-gnu" "4.60.1" + "@rollup/rollup-linux-x64-gnu" "4.60.1" + "@rollup/rollup-linux-x64-musl" "4.60.1" + "@rollup/rollup-openbsd-x64" "4.60.1" + "@rollup/rollup-openharmony-arm64" "4.60.1" + "@rollup/rollup-win32-arm64-msvc" "4.60.1" + "@rollup/rollup-win32-ia32-msvc" "4.60.1" + "@rollup/rollup-win32-x64-gnu" "4.60.1" + "@rollup/rollup-win32-x64-msvc" "4.60.1" fsevents "~2.3.2" run-parallel@^1.1.9: diff --git a/crates/rust-client/src/note/note_screener.rs b/crates/rust-client/src/note/note_screener.rs index 140d7c3fea..b1682b635b 100644 --- a/crates/rust-client/src/note/note_screener.rs +++ b/crates/rust-client/src/note/note_screener.rs @@ -187,7 +187,8 @@ impl NoteScreener { // DEFAULT CALLBACK IMPLEMENTATIONS // ================================================================================================ -#[async_trait(?Send)] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] impl OnNoteReceived for NoteScreener { /// Default implementation of the [`OnNoteReceived`] callback. It queries the store for the /// committed note to check if it's relevant. If the note wasn't being tracked but it came in diff --git a/crates/rust-client/src/sync/state_sync.rs b/crates/rust-client/src/sync/state_sync.rs index aeeadb523a..efc45d00f0 100644 --- a/crates/rust-client/src/sync/state_sync.rs +++ b/crates/rust-client/src/sync/state_sync.rs @@ -948,7 +948,8 @@ mod tests { /// This ensures committed notes get their inclusion proofs set during sync. struct CommitAllScreener; - #[async_trait(?Send)] + #[cfg_attr(not(target_arch = "wasm32"), async_trait)] + #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] impl OnNoteReceived for CommitAllScreener { async fn on_note_received( &self, diff --git a/crates/testing/miden-client-tests/src/tests.rs b/crates/testing/miden-client-tests/src/tests.rs index 88b3ac2bf1..a0d9c8ee18 100644 --- a/crates/testing/miden-client-tests/src/tests.rs +++ b/crates/testing/miden-client-tests/src/tests.rs @@ -489,7 +489,8 @@ async fn sync_persists_auth_nodes_for_skipped_blocks() { // for every sync step. This means only the chain tip will have `include_block = true`. struct DiscardAllNotes; - #[async_trait(?Send)] + #[cfg_attr(not(target_arch = "wasm32"), async_trait)] + #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] impl OnNoteReceived for DiscardAllNotes { async fn on_note_received( &self, @@ -564,7 +565,8 @@ async fn sync_state_no_redundant_get_account_calls() { struct DiscardAllNotes; - #[async_trait(?Send)] + #[cfg_attr(not(target_arch = "wasm32"), async_trait)] + #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] impl OnNoteReceived for DiscardAllNotes { async fn on_note_received( &self, From 60cc0e1c631d66dbf0e6e6f18f0d1cdef973bb65 Mon Sep 17 00:00:00 2001 From: Ignacio Amigo Date: Wed, 15 Apr 2026 18:48:44 -0300 Subject: [PATCH 4/4] feat: refactor events --- Cargo.lock | 1 + .../src/tests/client_service.rs | 157 ++++++++++++- crates/client-service/Cargo.toml | 4 +- crates/client-service/src/awaiters.rs | 162 +++++++++++++ crates/client-service/src/config.rs | 43 +++- crates/client-service/src/events.rs | 66 ++++-- crates/client-service/src/handlers.rs | 82 +++++-- crates/client-service/src/lib.rs | 206 ++++++++++++++--- crates/client-service/src/test_utils.rs | 26 ++- crates/client-service/src/tx_queue.rs | 215 ++++++++++++++++++ crates/rust-client/src/sync/mod.rs | 60 ++++- crates/rust-client/src/sync/state_sync.rs | 44 ++-- crates/rust-client/src/transaction/mod.rs | 6 +- .../testing/miden-client-tests/src/tests.rs | 10 +- 14 files changed, 983 insertions(+), 99 deletions(-) create mode 100644 crates/client-service/src/awaiters.rs create mode 100644 crates/client-service/src/tx_queue.rs diff --git a/Cargo.lock b/Cargo.lock index 08d38c914e..19ef9ada33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3022,6 +3022,7 @@ version = "0.15.0" dependencies = [ "miden-client", "miden-protocol", + "rand 0.9.2", "tokio", "tracing", ] diff --git a/bin/integration-tests/src/tests/client_service.rs b/bin/integration-tests/src/tests/client_service.rs index 6148a3a6e3..2f05676462 100644 --- a/bin/integration-tests/src/tests/client_service.rs +++ b/bin/integration-tests/src/tests/client_service.rs @@ -3,14 +3,14 @@ use std::sync::Arc; use std::time::Duration; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use miden_client::account::AccountStorageMode; -use miden_client::asset::FungibleAsset; +use miden_client::asset::{Asset, FungibleAsset}; use miden_client::auth::RPO_FALCON_SCHEME_ID; use miden_client::note::NoteType; use miden_client::testing::common::*; -use miden_client::transaction::TransactionRequestBuilder; -use miden_client_service::{ClientService, ServiceConfig}; +use miden_client::transaction::{PaymentNoteDescription, TransactionRequestBuilder}; +use miden_client_service::{ClientEvent, ClientService, EventFilter, ServiceConfig}; use crate::tests::config::ClientConfig; @@ -113,3 +113,152 @@ pub async fn test_client_service_background_sync(client_config: ClientConfig) -> Ok(()) } + +/// End-to-end reactive flow across two [`ClientService`]s: +/// - A sends an asset to B via `submit_transaction`. +/// - B *reacts* to the `NoteReceived` event with a registered handler that builds a consume tx +/// and pushes it onto a [`TransactionQueueHandle`]. B never calls `submit_transaction` directly +/// — the handler + queue do it. +/// - Test observes the outcome purely through events (`NoteConsumed` on B, balance updated). +/// +/// Exercises: background sync on both sides, `on()` event handlers, `expect()` lossless +/// awaiters, the transaction queue (event → enqueue → submit), and the `persistence-mirror` +/// invariant (events fire only after the store commits). +pub async fn test_client_service_reactive_transfer(client_config: ClientConfig) -> Result<()> { + const TRANSFER: u64 = MINT_AMOUNT / 2; + + // --- Set up client A with faucet + wallet_a and fund wallet_a. --- + let (mut client_a, keystore_a) = client_config.clone().into_client().await?; + let (faucet, _faucet_key) = insert_new_fungible_faucet( + &mut client_a, + AccountStorageMode::Public, + &keystore_a, + RPO_FALCON_SCHEME_ID, + ) + .await + .context("create faucet on A")?; + let (wallet_a, _key_a) = insert_new_wallet( + &mut client_a, + AccountStorageMode::Public, + &keystore_a, + RPO_FALCON_SCHEME_ID, + ) + .await + .context("create wallet on A")?; + + let mint_tx = + mint_and_consume(&mut client_a, wallet_a.id(), faucet.id(), NoteType::Public).await; + wait_for_tx(&mut client_a, mint_tx).await?; + + // --- Set up client B with wallet_b. --- + let (mut client_b, keystore_b) = client_config.into_client().await?; + let (wallet_b, _key_b) = insert_new_wallet( + &mut client_b, + AccountStorageMode::Public, + &keystore_b, + RPO_FALCON_SCHEME_ID, + ) + .await + .context("create wallet on B")?; + let wallet_b_id = wallet_b.id(); + + // --- Wrap both in services with fast background sync. --- + let svc_config = ServiceConfig::new().with_sync_interval(Some(Duration::from_millis(500))); + let svc_a = Arc::new(ClientService::new(client_a, svc_config.clone())); + let svc_b = Arc::new(ClientService::new(client_b, svc_config)); + let _sync_a = svc_a.start_background_sync(); + let _sync_b = svc_b.start_background_sync(); + + // --- B starts a transaction queue; an event handler pushes consume-requests onto it. --- + // + // The NoteReceived event carries the full InputNoteRecord, so the handler doesn't need + // to touch the store at all — just convert, build, enqueue. Fire-and-forget via the + // transaction queue worker. Success surfaces via the NoteConsumed event awaited below. + let tx_queue = svc_b.start_transaction_queue(); + let queue_for_handler = tx_queue.clone(); + + svc_b.on(EventFilter::AnyNoteReceived, move |event, _svc| { + let queue = queue_for_handler.clone(); + async move { + let ClientEvent::NoteReceived { note } = event else { + return; + }; + let Ok(concrete_note) = (*note).clone().try_into() else { + return; + }; + let Ok(req) = TransactionRequestBuilder::new().build_consume_notes(vec![concrete_note]) + else { + return; + }; + drop(queue.enqueue(wallet_b_id, req)); + } + }); + + // --- Kick off the transfer on A. --- + let asset = FungibleAsset::new(faucet.id(), TRANSFER).context("build asset")?; + let (transfer_req, expected_note) = { + let mut client = svc_a.client().await; + let req = TransactionRequestBuilder::new() + .build_pay_to_id( + PaymentNoteDescription::new( + vec![Asset::Fungible(asset)], + wallet_a.id(), + wallet_b_id, + ), + NoteType::Public, + client.rng(), + ) + .context("build pay_to_id")?; + let note = req + .expected_output_own_notes() + .into_iter() + .next() + .context("pay_to_id produces an own output note")?; + (req, note) + }; + + // Pre-register the observable checkpoints we're going to assert against, BEFORE + // submitting. These are lossless — events emitted during the upcoming syncs can't be + // dropped by a slow broadcast subscriber. + let received_awaiter = svc_b.expect(EventFilter::NoteReceived(expected_note.id())); + let consumed_awaiter = svc_b.expect(EventFilter::AnyNoteConsumed); + + let transfer_tx_id = svc_a + .submit_transaction(wallet_a.id(), transfer_req) + .await + .context("A submit transfer")?; + println!("A submitted transfer tx: {transfer_tx_id}"); + + // B must first see the note (event drives the handler, which enqueues). + tokio::time::timeout(Duration::from_secs(60), received_awaiter) + .await + .context("timed out waiting for NoteReceived on B")? + .context("NoteReceived awaiter cancelled")?; + println!("B: NoteReceived event fired — handler should now be enqueueing consume"); + + // Then, the queue-submitted consume tx must land on-chain and trigger NoteConsumed. + tokio::time::timeout(Duration::from_secs(60), consumed_awaiter) + .await + .context("timed out waiting for NoteConsumed on B")? + .context("NoteConsumed awaiter cancelled")?; + println!("B: NoteConsumed event fired — consume tx committed"); + + // --- Verify the balance actually arrived. --- + let balance = { + let client = svc_b.client().await; + client + .account_reader(wallet_b_id) + .get_balance(faucet.id()) + .await + .context("read wallet_b balance")? + }; + if balance != TRANSFER { + bail!("wallet_b balance {balance} != expected {TRANSFER}"); + } + println!("wallet_b balance: {balance}"); + + // Queue shutdown: dropping tx_queue closes the channel, the worker exits. + drop(tx_queue); + + Ok(()) +} diff --git a/crates/client-service/Cargo.toml b/crates/client-service/Cargo.toml index 0607963b7c..99d1f373d2 100644 --- a/crates/client-service/Cargo.toml +++ b/crates/client-service/Cargo.toml @@ -29,7 +29,9 @@ tracing = { workspace = true } tokio = { features = ["rt", "sync", "time"], optional = true, workspace = true } [dev-dependencies] -tokio = { features = ["macros", "rt"], workspace = true } +miden-client = { features = ["testing"], workspace = true } +rand = { workspace = true } +tokio = { features = ["macros", "rt"], workspace = true } [lints] workspace = true diff --git a/crates/client-service/src/awaiters.rs b/crates/client-service/src/awaiters.rs new file mode 100644 index 0000000000..8eb1e228b6 --- /dev/null +++ b/crates/client-service/src/awaiters.rs @@ -0,0 +1,162 @@ +//! Pre-registered event awaiters. +//! +//! Unlike the broadcast channel used by [`subscribe`](crate::ClientService::subscribe), +//! awaiters are fulfilled synchronously during event emission and cannot be +//! lost to lag. This makes them suitable for RPC-style "wait for a specific +//! event" patterns where missing the event would be a correctness bug. + +use std::future::Future; +use std::pin::Pin; +use std::sync::Mutex; +use std::task::{Context, Poll}; + +use tokio::sync::oneshot; + +use crate::events::ClientEvent; +use crate::handlers::EventFilter; + +/// Registry of pending awaiters. Consulted in-line by `emit_events` before +/// the broadcast send so awaited events cannot be dropped by a slow broadcast +/// consumer. +pub(crate) struct AwaiterRegistry { + inner: Mutex>, +} + +struct Awaiter { + filter: EventFilter, + tx: oneshot::Sender, +} + +impl AwaiterRegistry { + pub(crate) fn new() -> Self { + Self { inner: Mutex::new(Vec::new()) } + } + + /// Registers an awaiter for the next event matching `filter`. Returns a + /// future that resolves to the matching event, or [`AwaiterCancelled`] if + /// the service is dropped before a match arrives. + pub(crate) fn register(&self, filter: EventFilter) -> AwaiterFuture { + let (tx, rx) = oneshot::channel(); + self.inner + .lock() + .expect("awaiter registry poisoned") + .push(Awaiter { filter, tx }); + AwaiterFuture { rx } + } + + /// Fulfills any awaiters matching the given events. Consumed awaiters are + /// removed; awaiters whose receivers have been dropped are pruned. + pub(crate) fn fulfill(&self, events: &[ClientEvent]) { + if events.is_empty() { + return; + } + let mut inner = self.inner.lock().expect("awaiter registry poisoned"); + let old = std::mem::take(&mut *inner); + for awaiter in old { + if awaiter.tx.is_closed() { + continue; // receiver dropped — prune + } + match events.iter().find(|e| awaiter.filter.matches(e)) { + Some(event) => { + // Fulfilled — consume the awaiter. + let _ = awaiter.tx.send(event.clone()); + }, + None => { + // No match this round — keep waiting. + inner.push(awaiter); + }, + } + } + } +} + +/// Future returned by [`ClientService::expect`](crate::ClientService::expect) +/// and used internally by [`ClientService::once`](crate::ClientService::once). +/// +/// Resolves with the first matching [`ClientEvent`]. Dropping the future +/// cancels the awaiter; the registry prunes stale entries on the next +/// emission. +pub struct AwaiterFuture { + rx: oneshot::Receiver, +} + +impl Future for AwaiterFuture { + type Output = Result; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + Pin::new(&mut self.rx).poll(cx).map_err(|_| AwaiterCancelled) + } +} + +/// Error returned when an awaiter is cancelled because the service was +/// dropped before a matching event arrived. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AwaiterCancelled; + +impl core::fmt::Display for AwaiterCancelled { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "awaiter cancelled: service was dropped before event arrived") + } +} + +impl std::error::Error for AwaiterCancelled {} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::{test_note_arc, test_note_id}; + + #[tokio::test] + async fn fulfills_matching_event() { + let registry = AwaiterRegistry::new(); + let note_id = test_note_id(); + let awaiter = registry.register(EventFilter::NoteCommitted(note_id)); + + registry.fulfill(&[ClientEvent::NoteCommitted { note_id }]); + + let event = awaiter.await.unwrap(); + assert_eq!(event.note_id(), Some(note_id)); + } + + #[tokio::test] + async fn ignores_non_matching_events() { + let registry = AwaiterRegistry::new(); + let note_id = test_note_id(); + let awaiter = registry.register(EventFilter::NoteCommitted(note_id)); + + registry.fulfill(&[ClientEvent::NoteReceived { note: test_note_arc() }]); + + let result = tokio::time::timeout(std::time::Duration::from_millis(50), awaiter).await; + assert!(result.is_err(), "awaiter should not have resolved"); + } + + #[tokio::test] + async fn dropped_awaiter_is_pruned() { + let registry = AwaiterRegistry::new(); + let note_id = test_note_id(); + { + let _awaiter = registry.register(EventFilter::NoteCommitted(note_id)); + // Awaiter dropped at end of block. + } + // Pruning happens opportunistically during fulfill. + registry.fulfill(&[ClientEvent::NoteCommitted { note_id }]); + assert!(registry.inner.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn only_first_match_wins() { + let registry = AwaiterRegistry::new(); + let note_id = test_note_id(); + let awaiter = registry.register(EventFilter::AnyNoteCommitted); + + registry.fulfill(&[ + ClientEvent::NoteCommitted { note_id }, + ClientEvent::NoteCommitted { note_id }, + ]); + + let event = awaiter.await.unwrap(); + assert!(matches!(event, ClientEvent::NoteCommitted { .. })); + // Second call should find no awaiter. + assert!(registry.inner.lock().unwrap().is_empty()); + } +} diff --git a/crates/client-service/src/config.rs b/crates/client-service/src/config.rs index 290f22572e..919d1873ac 100644 --- a/crates/client-service/src/config.rs +++ b/crates/client-service/src/config.rs @@ -1,21 +1,45 @@ //! Configuration options for the client service. +use core::num::NonZeroU32; use std::time::Duration; +/// Default interval between automatic background sync operations. +pub const DEFAULT_SYNC_INTERVAL: Duration = Duration::from_secs(5); + /// Configuration options for [`ClientService`](crate::ClientService). #[derive(Debug, Clone)] pub struct ServiceConfig { /// Interval between automatic background sync operations. /// /// Set to `None` to disable automatic background sync. - /// Default: 30 seconds. + /// Default: [`DEFAULT_SYNC_INTERVAL`]. pub sync_interval: Option, + + /// Whether `start_background_sync` should trigger an immediate sync before + /// waiting for the first interval tick. + /// + /// Default: `true`. + pub sync_on_start: bool, + + /// If set, each sync advances at most this many blocks past the current + /// sync height. When `None`, every sync runs to the current chain tip. + /// + /// Useful for catch-up scenarios: a client that has been offline for + /// thousands of blocks would otherwise emit a massive burst of events + /// (one per note/transaction/account across the whole range) and hold the + /// client mutex for the full duration of a single huge sync. Chunking + /// breaks that into bounded steps. + /// + /// Default: `None`. + pub sync_chunk_blocks: Option, } impl Default for ServiceConfig { fn default() -> Self { Self { - sync_interval: Some(Duration::from_secs(30)), + sync_interval: Some(DEFAULT_SYNC_INTERVAL), + sync_on_start: true, + sync_chunk_blocks: None, } } } @@ -39,4 +63,19 @@ impl ServiceConfig { self.sync_interval = None; self } + + /// Controls whether background sync triggers an immediate sync on start. + #[must_use] + pub fn with_sync_on_start(mut self, sync_on_start: bool) -> Self { + self.sync_on_start = sync_on_start; + self + } + + /// Configures chunked sync. When set, each sync advances at most `n` blocks + /// past the current sync height; `None` disables chunking. + #[must_use] + pub fn with_sync_chunk_blocks(mut self, n: Option) -> Self { + self.sync_chunk_blocks = n; + self + } } diff --git a/crates/client-service/src/events.rs b/crates/client-service/src/events.rs index 36b2f70c0f..965b689fc9 100644 --- a/crates/client-service/src/events.rs +++ b/crates/client-service/src/events.rs @@ -11,7 +11,7 @@ //! tokio::spawn(async move { //! while let Ok(event) = events.recv().await { //! match event { -//! ClientEvent::NoteCommitted { note_id } => println!("committed: {note_id}"), +//! ClientEvent::NoteReceived { note } => println!("received: {}", note.id()), //! ClientEvent::TransactionCommitted { transaction_id } => println!("confirmed: {transaction_id}"), //! _ => {} //! } @@ -22,9 +22,11 @@ //! ## 2. Persistent Handlers //! //! ```rust,ignore -//! service.on(EventFilter::AnyNoteReceived, |event, service| async move { -//! let client = service.client().await; -//! // query client state... +//! service.on(EventFilter::AnyNoteReceived, |event, _service| async move { +//! // Handler receives the full InputNoteRecord — no store lookup needed. +//! if let ClientEvent::NoteReceived { note } = event { +//! println!("new note {} carrying {:?}", note.id(), note.assets()); +//! } //! }); //! ``` //! @@ -35,6 +37,10 @@ //! service.once(EventFilter::TransactionCommitted(tx_id), Some(Duration::from_secs(60))).await?; //! ``` +use std::collections::BTreeMap; +use std::sync::Arc; + +use miden_client::store::InputNoteRecord; use miden_client::sync::SyncSummary; use miden_client::transaction::TransactionId; use miden_protocol::account::AccountId; @@ -47,8 +53,13 @@ use miden_protocol::note::NoteId; /// changes first. #[derive(Debug, Clone)] pub enum ClientEvent { - /// A new public note was received during sync. - NoteReceived { note_id: NoteId }, + /// A new public note was received during sync. Carries the full record so + /// handlers can build consume requests, inspect the script, etc. without a + /// store roundtrip. + /// + /// Wrapped in `Arc` so broadcasting to N subscribers is an O(N) refcount + /// bump rather than N deep clones of the (potentially multi-KB) record. + NoteReceived { note: Arc }, /// A tracked note was committed on-chain. NoteCommitted { note_id: NoteId }, /// A note was consumed. @@ -67,9 +78,8 @@ impl ClientEvent { /// Returns the [`NoteId`] if this is a note-related event. pub fn note_id(&self) -> Option { match self { - Self::NoteReceived { note_id } - | Self::NoteCommitted { note_id } - | Self::NoteConsumed { note_id } => Some(*note_id), + Self::NoteReceived { note } => Some(note.id()), + Self::NoteCommitted { note_id } | Self::NoteConsumed { note_id } => Some(*note_id), _ => None, } } @@ -103,8 +113,16 @@ impl ClientEvent { /// Decomposes a [`SyncSummary`] into individual [`ClientEvent`] variants. /// +/// `new_note_records` must contain the full [`InputNoteRecord`] for every entry in +/// `summary.new_public_notes`. Callers build this map by bulk-reading the store after +/// `apply_state_sync`. Entries missing from the map are silently skipped (the note will +/// still be in the store, just not in the event stream). +/// /// Granular events are emitted first, with [`ClientEvent::SyncCompleted`] last. -pub(crate) fn events_from_sync(summary: &SyncSummary) -> Vec { +pub(crate) fn events_from_sync( + summary: &SyncSummary, + new_note_records: &BTreeMap>, +) -> Vec { let capacity = summary.new_public_notes.len() + summary.committed_notes.len() + summary.consumed_notes.len() @@ -114,8 +132,10 @@ pub(crate) fn events_from_sync(summary: &SyncSummary) -> Vec { + 1; let mut events = Vec::with_capacity(capacity); - for ¬e_id in &summary.new_public_notes { - events.push(ClientEvent::NoteReceived { note_id }); + for note_id in &summary.new_public_notes { + if let Some(note) = new_note_records.get(note_id) { + events.push(ClientEvent::NoteReceived { note: Arc::clone(note) }); + } } for ¬e_id in &summary.committed_notes { events.push(ClientEvent::NoteCommitted { note_id }); @@ -141,26 +161,40 @@ pub(crate) fn events_from_sync(summary: &SyncSummary) -> Vec { #[cfg(test)] mod tests { use super::*; - use crate::test_utils::{empty_summary, test_note_id}; + use crate::test_utils::{empty_summary, test_note_arc, test_note_id}; #[test] fn empty_summary_produces_only_sync_completed() { - let events = events_from_sync(&empty_summary()); + let events = events_from_sync(&empty_summary(), &BTreeMap::new()); assert_eq!(events.len(), 1); assert!(matches!(events[0], ClientEvent::SyncCompleted { .. })); } #[test] fn sync_completed_is_last() { - let mut summary = empty_summary(); + let note = test_note_arc(); let note_id = test_note_id(); + let mut summary = empty_summary(); summary.new_public_notes = vec![note_id]; summary.committed_notes = vec![note_id]; - let events = events_from_sync(&summary); + let mut records = BTreeMap::new(); + records.insert(note_id, Arc::clone(¬e)); + + let events = events_from_sync(&summary, &records); assert_eq!(events.len(), 3); assert!(matches!(events[0], ClientEvent::NoteReceived { .. })); assert!(matches!(events[1], ClientEvent::NoteCommitted { .. })); assert!(matches!(events[2], ClientEvent::SyncCompleted { .. })); } + + #[test] + fn new_public_notes_without_records_are_skipped() { + let mut summary = empty_summary(); + summary.new_public_notes = vec![test_note_id()]; + let events = events_from_sync(&summary, &BTreeMap::new()); + // No record in the map → no NoteReceived emitted; SyncCompleted still fires. + assert_eq!(events.len(), 1); + assert!(matches!(events[0], ClientEvent::SyncCompleted { .. })); + } } diff --git a/crates/client-service/src/handlers.rs b/crates/client-service/src/handlers.rs index 6c476ac3eb..f74097135f 100644 --- a/crates/client-service/src/handlers.rs +++ b/crates/client-service/src/handlers.rs @@ -1,4 +1,10 @@ //! Event handler registration and dispatch. +//! +//! Each registered handler gets its own spawned worker task reading from a +//! bounded mpsc channel. `dispatch` pushes matching events onto the handler's +//! queue without blocking emission; if the queue is full the event is dropped +//! and a warning is logged. This caps the fanout at `num_handlers` tasks and +//! preserves per-handler event ordering. use std::future::Future; use std::pin::Pin; @@ -8,12 +14,16 @@ use std::sync::{Arc, RwLock}; use miden_client::transaction::TransactionId; use miden_protocol::account::AccountId; use miden_protocol::note::NoteId; +use tokio::sync::mpsc; +use tracing::warn; use crate::events::ClientEvent; +/// Bounded capacity of each handler's event queue. +const HANDLER_QUEUE_CAPACITY: usize = 256; + type BoxFuture = Pin + Send>>; -#[derive(Clone)] pub(crate) struct StoredHandler(Arc BoxFuture + Send + Sync>); impl StoredHandler { @@ -37,8 +47,9 @@ pub struct HandlerId(u64); /// Describes which events a handler or awaiter is interested in. /// -/// Used with [`ClientService::on`](crate::ClientService::on) and -/// [`ClientService::once`](crate::ClientService::once). +/// Used with [`ClientService::on`](crate::ClientService::on), +/// [`ClientService::once`](crate::ClientService::once), and +/// [`ClientService::expect`](crate::ClientService::expect). #[derive(Debug, Clone)] pub enum EventFilter { /// Match a specific note being received. @@ -66,8 +77,8 @@ pub enum EventFilter { impl EventFilter { pub(crate) fn matches(&self, event: &ClientEvent) -> bool { match (self, event) { - (Self::NoteReceived(id), ClientEvent::NoteReceived { note_id }) - | (Self::NoteCommitted(id), ClientEvent::NoteCommitted { note_id }) => id == note_id, + (Self::NoteReceived(id), ClientEvent::NoteReceived { note }) => *id == note.id(), + (Self::NoteCommitted(id), ClientEvent::NoteCommitted { note_id }) => id == note_id, (Self::AccountUpdated(id), ClientEvent::AccountUpdated { account_id }) => { id == account_id }, @@ -89,7 +100,10 @@ impl EventFilter { struct HandlerEntry { id: HandlerId, filter: EventFilter, - handler: StoredHandler, + /// Bounded sender feeding the handler's worker task. Dropping this (via + /// `unregister` or registry drop) closes the channel and terminates the + /// worker. + tx: mpsc::Sender, } pub(crate) struct HandlerRegistry { @@ -105,16 +119,30 @@ impl HandlerRegistry { } } + /// Registers a handler and spawns its dedicated worker task. pub fn register(&self, filter: EventFilter, handler: StoredHandler) -> HandlerId { let id = HandlerId(self.next_id.fetch_add(1, Ordering::Relaxed)); + let (tx, mut rx) = mpsc::channel::(HANDLER_QUEUE_CAPACITY); + + tokio::spawn(async move { + // The handler is invoked sequentially per event: preserves ordering + // within a handler. The loop exits when all senders drop (which + // happens on `unregister` or when the registry itself drops). + while let Some(event) = rx.recv().await { + handler.call(event).await; + } + }); + self.handlers.write().expect("handler registry poisoned").push(HandlerEntry { id, filter, - handler, + tx, }); id } + /// Unregisters a handler by id. The worker task exits once the channel + /// drains. pub fn unregister(&self, id: HandlerId) -> bool { let mut handlers = self.handlers.write().expect("handler registry poisoned"); let len_before = handlers.len(); @@ -122,17 +150,31 @@ impl HandlerRegistry { handlers.len() < len_before } - /// Dispatches events to all matching handlers. Each invocation is spawned - /// as a separate tokio task with no ordering guarantees. + /// Dispatches events to all matching handlers. + /// + /// Non-blocking: uses `try_send` on each handler's bounded queue. If a + /// handler's queue is full, the event is dropped and a warning is logged — + /// emission is never stalled by a slow handler. pub fn dispatch(&self, events: &[ClientEvent]) { + if events.is_empty() { + return; + } let handlers = self.handlers.read().expect("handler registry poisoned"); for event in events { for entry in handlers.iter() { - if entry.filter.matches(event) { - let handler = entry.handler.clone(); - let event = event.clone(); - tokio::spawn(async move { handler.call(event).await }); + if !entry.filter.matches(event) { + continue; + } + if let Err(mpsc::error::TrySendError::Full(_)) = entry.tx.try_send(event.clone()) { + // Full queue: handler is slow. Drop the event rather than + // stall emission. `Closed` (worker died) is silently + // ignored; the entry will be cleaned up on unregister. + warn!( + handler_id = ?entry.id, + capacity = HANDLER_QUEUE_CAPACITY, + "handler queue full, dropping event", + ); } } } @@ -142,18 +184,17 @@ impl HandlerRegistry { #[cfg(test)] mod tests { use super::*; - use crate::test_utils::{empty_summary, test_note_id}; + use crate::test_utils::{empty_summary, test_note_arc, test_note_id}; #[test] fn filter_matches_specific_note() { let note_id = test_note_id(); + let note = test_note_arc(); assert!( EventFilter::NoteCommitted(note_id).matches(&ClientEvent::NoteCommitted { note_id }) ); - assert!( - !EventFilter::NoteCommitted(note_id).matches(&ClientEvent::NoteReceived { note_id }) - ); + assert!(!EventFilter::NoteCommitted(note_id).matches(&ClientEvent::NoteReceived { note })); assert!( !EventFilter::NoteCommitted(note_id).matches(&ClientEvent::NoteConsumed { note_id }) ); @@ -162,8 +203,9 @@ mod tests { #[test] fn filter_matches_any_variant() { let note_id = test_note_id(); + let note = test_note_arc(); - assert!(EventFilter::AnyNoteReceived.matches(&ClientEvent::NoteReceived { note_id })); + assert!(EventFilter::AnyNoteReceived.matches(&ClientEvent::NoteReceived { note })); assert!(!EventFilter::AnyNoteReceived.matches(&ClientEvent::NoteCommitted { note_id })); assert!( EventFilter::AnySyncCompleted @@ -171,8 +213,8 @@ mod tests { ); } - #[test] - fn register_and_unregister() { + #[tokio::test] + async fn register_and_unregister() { let registry = HandlerRegistry::new(); let handler = StoredHandler::new(|_| async {}); let id = registry.register(EventFilter::NoteCommitted(test_note_id()), handler); diff --git a/crates/client-service/src/lib.rs b/crates/client-service/src/lib.rs index 1f79ff5c98..2f2631522a 100644 --- a/crates/client-service/src/lib.rs +++ b/crates/client-service/src/lib.rs @@ -39,25 +39,48 @@ extern crate alloc; +use std::collections::BTreeMap; use std::future::Future; use std::sync::Arc; use std::time::Duration; use miden_client::auth::TransactionAuthenticator; -use miden_client::sync::SyncSummary; +use miden_client::store::{InputNoteRecord, NoteFilter}; +use miden_client::sync::{SyncSummary, SyncTarget}; use miden_client::transaction::{TransactionId, TransactionRequest}; use miden_client::{Client, ClientError}; use miden_protocol::account::AccountId; +use miden_protocol::block::BlockNumber; +use miden_protocol::note::NoteId; use tokio::sync::{Mutex, MutexGuard, broadcast}; use tracing::{debug, info, warn}; +mod awaiters; pub(crate) mod events; mod handlers; +mod tx_queue; mod config; +pub use awaiters::{AwaiterCancelled, AwaiterFuture}; pub use config::ServiceConfig; pub use events::ClientEvent; pub use handlers::{EventFilter, HandlerId}; +/// Re-exported so consumers implementing a custom note filter for +/// [`ClientService::with_note_screener`] don't have to reach into `miden_client`. +/// +/// **This is a filter, not a listener.** Implementing [`OnNoteReceived`] changes what the +/// client persists during sync. To observe notes without changing persistence, use +/// [`ClientService::subscribe`] or [`ClientService::on`]. +pub use miden_client::sync::{NoteUpdateAction, OnNoteReceived}; +pub use tx_queue::{EnqueueError, EnqueuedTx, TransactionQueueHandle}; + +/// Capacity of the broadcast channel backing [`ClientService::subscribe`]. +/// +/// Subscribers that fall more than this many events behind will receive +/// `RecvError::Lagged` and permanently drop those events. A catch-up sync can +/// emit hundreds or thousands of events in a single burst, so this is sized +/// generously by default. +const EVENT_BROADCAST_CAPACITY: usize = 4096; #[cfg(test)] pub(crate) mod test_utils; @@ -74,6 +97,11 @@ where config: ServiceConfig, event_tx: broadcast::Sender, handlers: handlers::HandlerRegistry, + awaiters: awaiters::AwaiterRegistry, + /// Optional custom note filter. When `None`, sync uses the default + /// [`NoteScreener`](miden_client::note::NoteScreener) constructed from the client's store and + /// RPC API. + note_screener: Option>, } impl ClientService @@ -82,12 +110,46 @@ where { /// Creates a new service wrapping the given client. pub fn new(client: Client, config: ServiceConfig) -> Self { - let (event_tx, _) = broadcast::channel(256); + Self::build(client, config, None) + } + + /// Creates a new service with a caller-supplied note filter. + /// + /// The screener controls **what gets persisted to the store** during sync — Commit, Insert, + /// or Discard per incoming note. Events emitted by the service mirror persistence: a more + /// aggressive `Discard` policy produces fewer events, a more aggressive `Insert` policy + /// produces more. + /// + /// `OnNoteReceived` is a **filter**, not a listener. It is invoked *during* sync to decide + /// what the client keeps. If all you need is to observe notes (for analytics, UI, etc.), + /// use [`subscribe`](Self::subscribe) or [`on`](Self::on) instead — observers run after + /// persistence and do not affect store state. + /// + /// When `None` is passed (or [`new`](Self::new) is used), the default + /// [`NoteScreener`](miden_client::note::NoteScreener) is used: keep tracked notes, keep + /// public notes whose tag is tracked, keep public notes consumable by a tracked account, + /// discard everything else. + pub fn with_note_screener( + client: Client, + config: ServiceConfig, + screener: Arc, + ) -> Self { + Self::build(client, config, Some(screener)) + } + + fn build( + client: Client, + config: ServiceConfig, + note_screener: Option>, + ) -> Self { + let (event_tx, _) = broadcast::channel(EVENT_BROADCAST_CAPACITY); Self { client: Mutex::new(client), config, event_tx, handlers: handlers::HandlerRegistry::new(), + awaiters: awaiters::AwaiterRegistry::new(), + note_screener, } } @@ -110,17 +172,42 @@ where /// /// Acquires exclusive access, fetches the latest state, applies it to the /// local store, and emits [`ClientEvent`]s for each change observed. + /// + /// When [`ServiceConfig::sync_chunk_blocks`] is set, advances at most that + /// many blocks per call; otherwise syncs to the current chain tip. When a + /// custom note filter was supplied via [`with_note_screener`](Self::with_note_screener), + /// it is used instead of the default screener. pub async fn sync_state(&self) -> Result { debug!("Starting coordinated sync"); let mut client = self.client.lock().await; - let state_sync_update = client.get_sync_update().await?; + let screener: Arc = match &self.note_screener { + Some(s) => Arc::clone(s), + None => Arc::new(client.note_screener()), + }; + let upper_bound = match self.config.sync_chunk_blocks { + Some(chunk) => { + let current = client.get_sync_height().await?; + SyncTarget::BlockNumber(BlockNumber::from( + current.as_u32().saturating_add(chunk.get()), + )) + }, + None => SyncTarget::CommittedChainTip, + }; + + let state_sync_update = client.get_sync_update_with_screener(screener, upper_bound).await?; let summary: SyncSummary = (&state_sync_update).into(); client.apply_state_sync(state_sync_update).await?; info!(block_num = ?summary.block_num, "Sync completed"); - self.emit_events(events::events_from_sync(&summary)); + // Enrich the event stream: fetch full records for notes we just inserted so + // subscribers get the body inline without doing their own store roundtrip. + // One batched query per sync, skipped entirely if no new public notes arrived. + let new_note_records = fetch_new_note_records(&client, &summary.new_public_notes).await?; + drop(client); + + self.emit_events(events::events_from_sync(&summary, &new_note_records)); Ok(summary) } @@ -140,6 +227,9 @@ where /// Starts a background sync loop that periodically syncs with the network. /// + /// If [`ServiceConfig::sync_on_start`] is `true` (the default), an + /// immediate sync is run before the first interval tick. + /// /// Returns a handle that can be used to stop the background sync. /// If background sync is disabled in the config, returns an inactive handle. pub fn start_background_sync(self: &Arc) -> BackgroundSyncHandle { @@ -150,9 +240,22 @@ where let (shutdown_tx, mut shutdown_rx) = broadcast::channel(1); let service = Arc::clone(self); + let sync_on_start = self.config.sync_on_start; tokio::spawn(async move { - info!(?interval, "Starting background sync"); + info!(?interval, sync_on_start, "Starting background sync"); + + if sync_on_start { + match service.sync_state().await { + Ok(summary) => { + debug!(block_num = ?summary.block_num, "Initial sync completed"); + }, + Err(e) => { + warn!(error = %e, "Initial sync failed"); + }, + } + } + loop { tokio::select! { _ = shutdown_rx.recv() => { @@ -211,49 +314,82 @@ where self.handlers.unregister(id) } - /// Waits for the first event matching `filter`. + /// Pre-registers an awaiter for the next event matching `filter`. + /// + /// Returns a future that resolves with the first matching [`ClientEvent`]. + /// The awaiter is registered synchronously before this method returns, so + /// events emitted after this call cannot be missed — including events + /// triggered by actions taken while the future is being awaited. + /// + /// Prefer this over [`once`](Self::once) when awaiting an event caused by + /// an action you're about to perform: + /// + /// ```rust,ignore + /// let awaiter = service.expect(EventFilter::TransactionCommitted(tx_id)); + /// service.submit_transaction(account_id, request).await?; + /// let event = awaiter.await?; // cannot miss, even if sync bursts events + /// ``` + /// + /// Unlike [`subscribe`](Self::subscribe)-based awaiting, matching is + /// guaranteed: awaiters are fulfilled inline during event emission and are + /// not subject to broadcast channel lag. + pub fn expect(&self, filter: EventFilter) -> AwaiterFuture { + self.awaiters.register(filter) + } + + /// Waits for the first event matching `filter`, with an optional timeout. /// - /// Returns the matched [`ClientEvent`]. If `timeout` is `Some`, returns - /// an error if no matching event arrives within the duration. + /// Convenience wrapper over [`expect`](Self::expect) plus + /// [`tokio::time::timeout`]. Returns [`EventTimeoutError`] if the timeout + /// fires before a matching event arrives, or if the service is dropped. pub async fn once( &self, filter: EventFilter, timeout: Option, ) -> Result { - let mut rx = self.event_tx.subscribe(); - - let fut = async { - loop { - match rx.recv().await { - Ok(event) if filter.matches(&event) => return event, - Ok(_) => {}, - Err(broadcast::error::RecvError::Lagged(n)) => { - warn!(skipped = n, "Event receiver lagged"); - }, - Err(broadcast::error::RecvError::Closed) => { - std::future::pending::<()>().await; - unreachable!() - }, - } - } - }; - + let awaiter = self.expect(filter); match timeout { - Some(duration) => { - tokio::time::timeout(duration, fut).await.map_err(|_| EventTimeoutError) + Some(duration) => match tokio::time::timeout(duration, awaiter).await { + Ok(Ok(event)) => Ok(event), + Ok(Err(_)) | Err(_) => Err(EventTimeoutError), }, - None => Ok(fut.await), + None => awaiter.await.map_err(|_| EventTimeoutError), } } fn emit_events(&self, events: Vec) { + // Fulfill awaiters first: they are lossless and callers depend on them. + self.awaiters.fulfill(&events); + // Hand off to per-handler workers (bounded queues, backpressure on the + // handler side — never blocks emission). self.handlers.dispatch(&events); + // Best-effort broadcast for raw subscribers. for event in events { let _ = self.event_tx.send(event); } } } +/// Bulk-fetches input-note records for the given IDs and wraps each in an `Arc` for cheap +/// broadcasting. A single store query; returns an empty map if `note_ids` is empty. +async fn fetch_new_note_records( + client: &Client, + note_ids: &[NoteId], +) -> Result>, ClientError> +where + AUTH: TransactionAuthenticator + Send + Sync + 'static, +{ + if note_ids.is_empty() { + return Ok(BTreeMap::new()); + } + Ok(client + .get_input_notes(NoteFilter::List(note_ids.to_vec())) + .await? + .into_iter() + .map(|record| (record.id(), Arc::new(record))) + .collect()) +} + /// Error returned when a [`once`](ClientService::once) call times out. #[derive(Debug, Clone)] pub struct EventTimeoutError; @@ -298,7 +434,7 @@ impl Drop for BackgroundSyncHandle { #[cfg(test)] mod tests { use super::*; - use crate::test_utils::{empty_summary, test_note_id}; + use crate::test_utils::{empty_summary, test_note_arc, test_note_id}; fn assert_send_sync() {} @@ -411,7 +547,7 @@ mod tests { }); registry.register(EventFilter::NoteCommitted(note_id), handler); - registry.dispatch(&[ClientEvent::NoteReceived { note_id }]); + registry.dispatch(&[ClientEvent::NoteReceived { note: test_note_arc() }]); let result = tokio::time::timeout(Duration::from_millis(50), notify_rx).await; assert!(result.is_err()); @@ -420,10 +556,14 @@ mod tests { #[tokio::test] async fn events_from_sync_integration() { let note_id = test_note_id(); + let note = test_note_arc(); let mut summary = empty_summary(); summary.new_public_notes = vec![note_id]; - let events = events::events_from_sync(&summary); + let mut records = BTreeMap::new(); + records.insert(note_id, Arc::clone(¬e)); + + let events = events::events_from_sync(&summary, &records); assert_eq!(events.len(), 2); assert_eq!(events[0].note_id().unwrap(), note_id); assert!(events[1].summary().is_some()); @@ -432,7 +572,7 @@ mod tests { #[test] fn client_event_accessors() { let note_id = test_note_id(); - let event = ClientEvent::NoteReceived { note_id }; + let event = ClientEvent::NoteReceived { note: test_note_arc() }; assert_eq!(event.note_id(), Some(note_id)); assert_eq!(event.transaction_id(), None); diff --git a/crates/client-service/src/test_utils.rs b/crates/client-service/src/test_utils.rs index 01c52ed615..7c05e0aad3 100644 --- a/crates/client-service/src/test_utils.rs +++ b/crates/client-service/src/test_utils.rs @@ -1,9 +1,33 @@ +use std::sync::Arc; + +use miden_client::store::InputNoteRecord; use miden_client::sync::SyncSummary; +use miden_client::testing::NoteBuilder; +use miden_client::testing::account_id::ACCOUNT_ID_SENDER; +use miden_protocol::account::AccountId; use miden_protocol::block::BlockNumber; use miden_protocol::note::NoteId; +use rand::SeedableRng; +use rand::rngs::SmallRng; + +/// Returns a deterministic [`InputNoteRecord`] built from a fixed sender and RNG seed. +/// +/// All calls to this function produce a record with the same [`NoteId`], which is what +/// [`test_note_id`] returns. +pub(crate) fn test_note_record() -> InputNoteRecord { + let sender = AccountId::try_from(ACCOUNT_ID_SENDER).expect("valid sender id"); + // Fixed seed → deterministic serial_num → stable NoteId across calls. + let rng = SmallRng::seed_from_u64(0x00c0_ffee); + let note = NoteBuilder::new(sender, rng).build().expect("build test note"); + InputNoteRecord::from(note) +} + +pub(crate) fn test_note_arc() -> Arc { + Arc::new(test_note_record()) +} pub(crate) fn test_note_id() -> NoteId { - NoteId::from_raw(miden_protocol::EMPTY_WORD) + test_note_record().id() } pub(crate) fn empty_summary() -> SyncSummary { diff --git a/crates/client-service/src/tx_queue.rs b/crates/client-service/src/tx_queue.rs new file mode 100644 index 0000000000..547cb29c45 --- /dev/null +++ b/crates/client-service/src/tx_queue.rs @@ -0,0 +1,215 @@ +//! Sequential transaction submission queue. +//! +//! [`ClientService`](crate::ClientService) serializes mutations behind a single client mutex. +//! For most callers this is fine — you just `await` `submit_transaction` and let the mutex +//! queue you in FIFO order. When a caller wants **fire-and-forget** semantics — "take this +//! request, give me back a handle, process it whenever the client is free" — that's what the +//! transaction queue is for. +//! +//! A worker task owns an unbounded mpsc of pending submissions. Each enqueue returns an +//! [`EnqueuedTx`] future that resolves once the worker has submitted the transaction (or +//! failed). Dropping the [`TransactionQueueHandle`] closes the channel and the worker exits +//! after draining; in-flight submissions complete, not-yet-started submissions resolve with +//! [`EnqueueError::QueueShutDown`]. +//! +//! ## Example +//! +//! ```rust,ignore +//! let queue = service.start_transaction_queue(); +//! +//! // React to events by enqueueing a transaction — handler returns immediately. +//! let svc = Arc::clone(&service); +//! let q = queue.clone(); +//! svc.on(EventFilter::AnyNoteCommitted, move |_event, service| { +//! let q = q.clone(); +//! async move { +//! let client = service.client().await; +//! let req = build_some_request(&*client).await.unwrap(); +//! let account_id = client.account_id_to_act_on(); +//! drop(client); +//! +//! let handle = q.enqueue(account_id, req); +//! // Fire-and-forget: drop the handle, or tokio::spawn to log the result. +//! tokio::spawn(async move { +//! match handle.await { +//! Ok(tx_id) => tracing::info!(?tx_id, "tx submitted"), +//! Err(e) => tracing::warn!(error = %e, "tx failed"), +//! } +//! }); +//! } +//! }); +//! ``` + +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; + +use miden_client::ClientError; +use miden_client::auth::TransactionAuthenticator; +use miden_client::transaction::{TransactionId, TransactionRequest}; +use miden_protocol::account::AccountId; +use tokio::sync::{mpsc, oneshot}; +use tracing::debug; + +use crate::ClientService; + +/// Internal envelope carrying a submission request to the worker. +struct QueuedTx { + account_id: AccountId, + request: TransactionRequest, + result: oneshot::Sender>, +} + +/// Handle to a running transaction queue. +/// +/// Cheap to clone — multiple producers can share one queue. The worker shuts down once the +/// last handle is dropped (and the mpsc channel closes). +#[derive(Clone)] +pub struct TransactionQueueHandle { + sender: mpsc::UnboundedSender, +} + +impl TransactionQueueHandle { + /// Enqueues a transaction for sequential submission. Returns a future resolving to the + /// submission result. + /// + /// Dropping the returned [`EnqueuedTx`] before it resolves is safe — the submission still + /// runs, the result is just discarded. (Fire-and-forget mode.) + pub fn enqueue(&self, account_id: AccountId, request: TransactionRequest) -> EnqueuedTx { + let (result_tx, result_rx) = oneshot::channel(); + match self.sender.send(QueuedTx { account_id, request, result: result_tx }) { + Ok(()) => EnqueuedTx { rx: Some(result_rx) }, + // Queue already shut down; the future will resolve synchronously to an error. + Err(_) => EnqueuedTx { rx: None }, + } + } + + /// Returns `true` if the worker is still running (the channel has not been closed). + pub fn is_active(&self) -> bool { + !self.sender.is_closed() + } +} + +/// Future returned by [`TransactionQueueHandle::enqueue`]. +/// +/// Resolves to the submitted [`TransactionId`] on success, or an [`EnqueueError`] on failure. +pub struct EnqueuedTx { + rx: Option>>, +} + +impl Future for EnqueuedTx { + type Output = Result; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let Some(rx) = self.rx.as_mut() else { + return Poll::Ready(Err(EnqueueError::QueueShutDown)); + }; + match Pin::new(rx).poll(cx) { + Poll::Ready(Ok(Ok(tx_id))) => Poll::Ready(Ok(tx_id)), + Poll::Ready(Ok(Err(e))) => Poll::Ready(Err(EnqueueError::Submission(e))), + // Sender dropped without sending — worker exited before processing. + Poll::Ready(Err(_)) => Poll::Ready(Err(EnqueueError::QueueShutDown)), + Poll::Pending => Poll::Pending, + } + } +} + +/// Error returned by [`EnqueuedTx`]. +#[derive(Debug)] +pub enum EnqueueError { + /// The queue was shut down before the transaction was submitted. Either the + /// [`TransactionQueueHandle`] was dropped, or the service was dropped. + QueueShutDown, + /// Submission reached the network layer and failed. + Submission(ClientError), +} + +impl core::fmt::Display for EnqueueError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::QueueShutDown => { + write!(f, "transaction queue was shut down before submission") + }, + Self::Submission(_) => write!(f, "transaction submission failed"), + } + } +} + +impl std::error::Error for EnqueueError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::QueueShutDown => None, + Self::Submission(e) => Some(e), + } + } +} + +impl From for EnqueueError { + fn from(value: ClientError) -> Self { + Self::Submission(value) + } +} + +impl ClientService +where + AUTH: TransactionAuthenticator + Send + Sync + 'static, +{ + /// Starts a worker task that processes enqueued transactions sequentially. + /// + /// Transactions submitted via the returned handle are processed in FIFO order: each waits + /// for the previous to complete. The worker calls + /// [`submit_transaction`](Self::submit_transaction) internally, so queued transactions + /// share the same mutex as [`sync_state`](Self::sync_state) and direct client access — + /// they don't bypass coordination, they just free the caller from `await`ing synchronously. + /// + /// Returns a [`TransactionQueueHandle`] that can be cloned and shared. The worker shuts + /// down when all handles are dropped. + pub fn start_transaction_queue(self: &Arc) -> TransactionQueueHandle { + let (sender, mut receiver) = mpsc::unbounded_channel::(); + let service = Arc::clone(self); + + tokio::spawn(async move { + debug!("Transaction queue worker starting"); + while let Some(queued) = receiver.recv().await { + let result = service.submit_transaction(queued.account_id, queued.request).await; + // Result receiver may have been dropped (fire-and-forget caller) — ignore. + let _ = queued.result.send(result); + } + debug!("Transaction queue worker exiting"); + }); + + TransactionQueueHandle { sender } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn enqueue_on_dropped_handle_resolves_to_shutdown_error() { + // Simulate a handle that has no live worker by closing the channel. + let (sender, receiver) = mpsc::unbounded_channel::(); + drop(receiver); + let handle = TransactionQueueHandle { sender }; + + assert!(!handle.is_active()); + + // We can't easily construct a TransactionRequest in a unit test (it needs a real + // client + note), but we can verify the error path by constructing an EnqueuedTx + // whose rx is `None` directly. + let (dropped_tx, dropped_rx) = oneshot::channel::>(); + drop(dropped_tx); + let future = EnqueuedTx { rx: Some(dropped_rx) }; + let err = future.await.expect_err("should fail when sender drops"); + assert!(matches!(err, EnqueueError::QueueShutDown)); + } + + #[tokio::test] + async fn enqueue_on_never_connected_handle_is_immediate_error() { + let future = EnqueuedTx { rx: None }; + let err = future.await.expect_err("None rx should immediately error"); + assert!(matches!(err, EnqueueError::QueueShutDown)); + } +} diff --git a/crates/rust-client/src/sync/mod.rs b/crates/rust-client/src/sync/mod.rs index e2b3e663fd..69fe5aa901 100644 --- a/crates/rust-client/src/sync/mod.rs +++ b/crates/rust-client/src/sync/mod.rs @@ -58,6 +58,7 @@ use alloc::sync::Arc; use alloc::vec::Vec; use core::cmp::max; +use core::num::NonZeroU32; use miden_protocol::account::AccountId; use miden_protocol::block::BlockNumber; @@ -77,6 +78,10 @@ pub use tag::{NoteTagRecord, NoteTagSource}; mod state_sync; pub use state_sync::{NoteUpdateAction, OnNoteReceived, StateSync, StateSyncInput}; +// Re-exported for ergonomics so callers of `sync_state_to` / `get_sync_update_to` +// don't have to import from `rpc::domain`. +pub use crate::rpc::domain::sync::SyncTarget; + mod state_sync_update; pub use state_sync_update::{ AccountUpdates, @@ -117,6 +122,10 @@ where /// state. /// 7. The MMR is updated with the new peaks and authentication nodes. /// 8. All updates are applied to the store to be persisted. + /// + /// For chunked sync (advancing the client a bounded number of blocks at a time), use + /// [`get_sync_update_by_blocks()`](Self::get_sync_update_by_blocks) followed by + /// [`apply_state_sync()`](Self::apply_state_sync). pub async fn sync_state(&mut self) -> Result { self.ensure_genesis_in_place().await?; self.ensure_rpc_limits_in_place().await?; @@ -137,7 +146,9 @@ where let input = self.build_sync_input().await?; // Get the sync update from the network - let state_sync_update = state_sync.sync_state(&mut current_partial_mmr, input).await?; + let state_sync_update = state_sync + .sync_state(&mut current_partial_mmr, input, SyncTarget::CommittedChainTip) + .await?; let sync_summary: SyncSummary = (&state_sync_update).into(); debug!(sync_summary = ?sync_summary, "Sync summary computed"); @@ -202,14 +213,53 @@ where /// client.apply_state_sync(update).await?; /// ``` pub async fn get_sync_update(&self) -> Result { - let note_screener = self.note_screener(); - let state_sync = - StateSync::new(self.rpc_api.clone(), Arc::new(note_screener), self.tx_discard_delta); + self.get_sync_update_to(SyncTarget::CommittedChainTip).await + } + + /// Like [`get_sync_update()`](Self::get_sync_update) but only advances at most + /// `block_amount` blocks. + pub async fn get_sync_update_by_blocks( + &self, + block_amount: NonZeroU32, + ) -> Result { + let current = self.get_sync_height().await?; + let target = BlockNumber::from(current.as_u32().saturating_add(block_amount.get())); + self.get_sync_update_to(SyncTarget::BlockNumber(target)).await + } + + /// Like [`get_sync_update()`](Self::get_sync_update) but syncs up to the given + /// [`SyncTarget`] instead of the chain tip. + pub async fn get_sync_update_to( + &self, + upper_bound: SyncTarget, + ) -> Result { + self.get_sync_update_with_screener(Arc::new(self.note_screener()), upper_bound) + .await + } + + /// Like [`get_sync_update_to()`](Self::get_sync_update_to) but uses a caller-supplied + /// [`OnNoteReceived`] implementation to decide relevance per note. + /// + /// `OnNoteReceived` is a **filter**, not a listener. It controls what gets persisted to + /// the store during sync (Commit / Insert / Discard per incoming note). The returned + /// [`StateSyncUpdate`] — and, transitively, any events derived from it — reflects only the + /// notes the screener kept. + /// + /// If you want to *observe* notes without changing persistence semantics, use the default + /// screener and subscribe to events downstream. Implement this trait only when you need + /// different persistence behavior (e.g., tracking extra public notes, ignoring specific + /// tags, or auto-accepting private notes delivered out-of-band). + pub async fn get_sync_update_with_screener( + &self, + screener: Arc, + upper_bound: SyncTarget, + ) -> Result { + let state_sync = StateSync::new(self.rpc_api.clone(), screener, self.tx_discard_delta); let mut current_partial_mmr = self.store.get_current_partial_mmr().await?; let input = self.build_sync_input().await?; - state_sync.sync_state(&mut current_partial_mmr, input).await + state_sync.sync_state(&mut current_partial_mmr, input, upper_bound).await } /// Applies the state sync update to the store and prunes the irrelevant block headers. diff --git a/crates/rust-client/src/sync/state_sync.rs b/crates/rust-client/src/sync/state_sync.rs index efc45d00f0..2a6a2b9c7f 100644 --- a/crates/rust-client/src/sync/state_sync.rs +++ b/crates/rust-client/src/sync/state_sync.rs @@ -199,6 +199,7 @@ impl StateSync { &self, current_partial_mmr: &mut PartialMmr, input: StateSyncInput, + upper_bound: SyncTarget, ) -> Result { let StateSyncInput { accounts, @@ -221,7 +222,7 @@ impl StateSync { let note_tags = Arc::new(note_tags); let account_ids: Vec = accounts.iter().map(AccountHeader::id).collect(); let Some(mut sync_data) = self - .fetch_sync_data(state_sync_update.block_num, &account_ids, ¬e_tags) + .fetch_sync_data(state_sync_update.block_num, &account_ids, ¬e_tags, upper_bound) .await? else { // No progress — already at the tip. @@ -287,17 +288,17 @@ impl StateSync { current_block_num: BlockNumber, account_ids: &[AccountId], note_tags: &Arc>, + upper_bound: SyncTarget, ) -> Result, ClientError> { - // Step 1: Fetch the MMR delta and chain tip header. - let chain_mmr_info = self - .rpc_api - .sync_chain_mmr(current_block_num, SyncTarget::CommittedChainTip) - .await?; + // Step 1: Fetch the MMR delta and the header at the requested upper bound. + // The node clamps `SyncTarget::BlockNumber(n)` to the chain tip if `n` is + // past it, so the response's `block_to` is always a valid block num. + let chain_mmr_info = self.rpc_api.sync_chain_mmr(current_block_num, upper_bound).await?; let chain_tip = chain_mmr_info.block_to; - // No progress — already at the tip. + // No progress — already at the requested upper bound. if chain_tip == current_block_num { - info!(block_num = %current_block_num, "Already at chain tip, nothing to sync."); + info!(block_num = %current_block_num, "Already at sync upper bound, nothing to sync."); return Ok(None); } @@ -1054,7 +1055,10 @@ mod tests { uncommitted_transactions: vec![], }; - let update = state_sync.sync_state(&mut partial_mmr, sync_input).await.unwrap(); + let update = state_sync + .sync_state(&mut partial_mmr, sync_input, SyncTarget::CommittedChainTip) + .await + .unwrap(); let updated_notes: Vec<_> = update.note_updates.updated_input_notes().collect(); @@ -1097,7 +1101,10 @@ mod tests { assert_eq!(partial_mmr.forest().num_leaves(), 1); // First sync - let update = state_sync.sync_state(&mut partial_mmr, empty()).await.unwrap(); + let update = state_sync + .sync_state(&mut partial_mmr, empty(), SyncTarget::CommittedChainTip) + .await + .unwrap(); assert_eq!(update.block_num, chain_tip_1); let forest_1 = partial_mmr.forest(); @@ -1108,7 +1115,10 @@ mod tests { mock_rpc.advance_blocks(2); let chain_tip_2 = mock_rpc.get_chain_tip_block_num(); - let update = state_sync.sync_state(&mut partial_mmr, empty()).await.unwrap(); + let update = state_sync + .sync_state(&mut partial_mmr, empty(), SyncTarget::CommittedChainTip) + .await + .unwrap(); assert_eq!(update.block_num, chain_tip_2); let forest_2 = partial_mmr.forest(); @@ -1116,7 +1126,10 @@ mod tests { assert_eq!(forest_2.num_leaves(), chain_tip_2.as_u32() as usize + 1); // Third sync (no new blocks) - let update = state_sync.sync_state(&mut partial_mmr, empty()).await.unwrap(); + let update = state_sync + .sync_state(&mut partial_mmr, empty(), SyncTarget::CommittedChainTip) + .await + .unwrap(); assert_eq!(update.block_num, chain_tip_2); assert_eq!(partial_mmr.forest(), forest_2); @@ -1237,7 +1250,12 @@ mod tests { let mut partial_mmr = PartialMmr::from_peaks(genesis_peaks); let sync_data = state_sync - .fetch_sync_data(BlockNumber::GENESIS, &[], &Arc::new(note_tags.clone())) + .fetch_sync_data( + BlockNumber::GENESIS, + &[], + &Arc::new(note_tags.clone()), + SyncTarget::CommittedChainTip, + ) .await .unwrap() .expect("should have progressed past genesis"); diff --git a/crates/rust-client/src/transaction/mod.rs b/crates/rust-client/src/transaction/mod.rs index a03b036d14..cfed50e7b4 100644 --- a/crates/rust-client/src/transaction/mod.rs +++ b/crates/rust-client/src/transaction/mod.rs @@ -206,7 +206,7 @@ where &mut self, account_id: AccountId, transaction_request: TransactionRequest, - tx_prover: Arc, + tx_prover: Arc, ) -> Result { // Register any missing NTX scripts before the main transaction. // The registration path contains its own full execute -> prove -> submit pipeline. @@ -365,7 +365,7 @@ where pub async fn prove_transaction_with( &mut self, tx_result: &TransactionResult, - tx_prover: Arc, + tx_prover: Arc, ) -> Result { info!("Proving transaction..."); @@ -649,7 +649,7 @@ where &mut self, account_id: AccountId, scripts: &[NoteScript], - tx_prover: Arc, + tx_prover: Arc, ) -> Result<(), ClientError> { let mut missing_scripts = Vec::new(); diff --git a/crates/testing/miden-client-tests/src/tests.rs b/crates/testing/miden-client-tests/src/tests.rs index a0d9c8ee18..790a9e79f3 100644 --- a/crates/testing/miden-client-tests/src/tests.rs +++ b/crates/testing/miden-client-tests/src/tests.rs @@ -529,6 +529,7 @@ async fn sync_persists_auth_nodes_for_skipped_blocks() { output_notes: vec![], uncommitted_transactions: vec![], }, + miden_client::rpc::domain::sync::SyncTarget::CommittedChainTip, ) .await .unwrap(); @@ -615,7 +616,14 @@ async fn sync_state_no_redundant_get_account_calls() { output_notes: vec![], uncommitted_transactions: vec![], }; - let state_sync_update = state_sync.sync_state(&mut partial_mmr, input).await.unwrap(); + let state_sync_update = state_sync + .sync_state( + &mut partial_mmr, + input, + miden_client::rpc::domain::sync::SyncTarget::CommittedChainTip, + ) + .await + .unwrap(); // Only 1 updated public account entry, not N duplicates assert_eq!(