From 58e6bb4d6415507c33e92adb1d3fcb9ee962adea Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Thu, 17 Sep 2026 14:09:19 +0200 Subject: [PATCH 1/9] fix(sdk): fetch and persist managed identity credit balances --- packages/rs-platform-wallet-ffi/src/wallet.rs | 26 +- .../src/wallet/identity/network/balance.rs | 264 ++++++++++++++++++ .../src/wallet/identity/network/mod.rs | 1 + .../ManagedPlatformWallet.swift | 22 ++ 4 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 packages/rs-platform-wallet/src/wallet/identity/network/balance.rs diff --git a/packages/rs-platform-wallet-ffi/src/wallet.rs b/packages/rs-platform-wallet-ffi/src/wallet.rs index b27125b65da..d57745b9f94 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet.rs @@ -2,9 +2,33 @@ use crate::error::*; use crate::handle::*; -use crate::runtime::runtime; +use crate::runtime::{block_on_worker, runtime}; +use crate::types::read_identifier; use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return}; +/// Read a managed identity's balance from Platform, update it and flush persistence. +/// +/// # Safety +/// `identity_id` points to 32 readable bytes, and `out_balance` is writable. +/// `handle` must refer to a live platform wallet for the duration of this call. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_refresh_identity_balance( + handle: Handle, + identity_id: *const u8, + out_balance: *mut u64, +) -> PlatformWalletFFIResult { + check_ptr!(out_balance); + unsafe { *out_balance = 0 }; + let id = unwrap_result_or_return!(unsafe { read_identifier(identity_id) }); + let option = PLATFORM_WALLET_STORAGE.with_item(handle, |wallet| { + let identity = wallet.identity().clone(); + block_on_worker(async move { identity.refresh_identity_balance(&id).await }) + }); + let result = unwrap_option_or_return!(option); + unsafe { *out_balance = unwrap_result_or_return!(result) }; + PlatformWalletFFIResult::ok() +} + /// Get the wallet ID (32 bytes). #[no_mangle] pub unsafe extern "C" fn platform_wallet_get_id( diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/balance.rs b/packages/rs-platform-wallet/src/wallet/identity/network/balance.rs new file mode 100644 index 00000000000..a77784f00ae --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/network/balance.rs @@ -0,0 +1,264 @@ +//! Read and persist a managed identity's current Platform credit balance. + +use dash_sdk::platform::Fetch; +use dash_sdk::query_types::IdentityBalance; +use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; +use dpp::prelude::Identifier; + +use crate::error::PlatformWalletError; +use crate::BlockTime; + +use super::IdentityWallet; + +impl IdentityWallet { + /// Fetch the balance directly from Platform and persist it for this wallet. + /// + /// Uses the dedicated balance query rather than the local identity snapshot. + /// No keys, signatures, or state transitions are required. Network failure + /// leaves the previous balance untouched; persistence failures are returned. + pub async fn refresh_identity_balance( + &self, + identity_id: &Identifier, + ) -> Result { + { + let wm = self.wallet_manager.read().await; + let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { + PlatformWalletError::WalletNotFound("Wallet info not found".to_string()) + })?; + if info.identity_manager.identity(identity_id).is_none() { + return Err(PlatformWalletError::IdentityNotFound(*identity_id)); + } + } + + let (balance, metadata) = + IdentityBalance::fetch_with_metadata(&self.sdk, *identity_id, None).await?; + let balance = balance.ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + + let current_balance = { + let mut wm = self.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&self.wallet_id).ok_or_else(|| { + PlatformWalletError::WalletNotFound("Wallet info not found".to_string()) + })?; + // Recheck after the network await: never recreate a removed identity. + let managed = info + .identity_manager + .managed_identity_mut(identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + + // A slower, overlapping query must not overwrite a newer response. + if managed + .last_updated_balance_block_time + .is_none_or(|previous| metadata.height >= previous.height) + { + managed.identity.set_balance(balance); + managed.last_updated_balance_block_time = Some(BlockTime::new( + metadata.height, + metadata.core_chain_locked_height, + metadata.time_ms, + )); + self.persister + .store(managed.snapshot_changeset().into()) + .map_err(|e| PlatformWalletError::Persistence(e.to_string()))?; + } + managed.identity.balance() + }; + + self.persister + .flush() + .map_err(|e| PlatformWalletError::Persistence(e.to_string()))?; + Ok(current_balance) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::changeset::{ + ClientStartState, IdentityEntry, PersistenceError, PlatformWalletChangeSet, + PlatformWalletPersistence, + }; + use crate::events::{EventHandler, PlatformEventHandler}; + use crate::wallet::identity::IdentityManager; + use dpp::identity::{v0::IdentityV0, Identity}; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Mutex}; + + const OLD_BALANCE: u64 = 2_818_262_560; + const AFTER_DPNS: u64 = 2_743_797_100; + + #[derive(Default)] + struct BalancePersister { + queued: Mutex>, + committed: Mutex>, + fail_flush: AtomicBool, + } + + impl PlatformWalletPersistence for BalancePersister { + fn store( + &self, + wallet_id: [u8; 32], + changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + if let Some(identities) = changeset.identities { + self.queued.lock().unwrap().extend( + identities + .identities + .into_values() + .map(|entry| (wallet_id, entry)), + ); + } + Ok(()) + } + + fn flush(&self, _: [u8; 32]) -> Result<(), PersistenceError> { + if self.fail_flush.load(Ordering::SeqCst) { + return Err(PersistenceError::backend("injected flush failure")); + } + self.committed + .lock() + .unwrap() + .extend(self.queued.lock().unwrap().drain(..)); + Ok(()) + } + + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + + struct NoopEvents; + impl EventHandler for NoopEvents {} + impl PlatformEventHandler for NoopEvents {} + + async fn fixture(balance: Option) -> (IdentityWallet, Identifier, Arc) { + let id = Identifier::from([0xAA; 32]); + let mut sdk = dash_sdk::SdkBuilder::new_mock().build().unwrap(); + sdk.mock() + .expect_fetch::(id, balance) + .await + .unwrap(); + let backend = Arc::new(BalancePersister::default()); + let manager = + crate::PlatformWalletManager::new(Arc::new(sdk), backend.clone(), Arc::new(NoopEvents)); + let wallet = manager + .create_wallet_from_seed_bytes( + key_wallet::Network::Testnet, + &[42; 64], + WalletAccountCreationOptions::None, + Some(0), + ) + .await + .unwrap(); + let iw = wallet.identity().clone(); + { + let mut wm = iw.wallet_manager.write().await; + wm.get_wallet_info_mut(&iw.wallet_id) + .unwrap() + .identity_manager + .add_identity( + Identity::V0(IdentityV0 { + id, + public_keys: Default::default(), + balance: OLD_BALANCE, + revision: 7, + }), + 0, + iw.wallet_id, + &iw.persister, + ) + .unwrap(); + } + backend.queued.lock().unwrap().clear(); + backend.committed.lock().unwrap().clear(); + (iw, id, backend) + } + + async fn local_balance(iw: &IdentityWallet, id: &Identifier) -> u64 { + iw.wallet_manager + .read() + .await + .get_wallet_info(&iw.wallet_id) + .unwrap() + .identity_manager + .identity(id) + .unwrap() + .identity + .balance() + } + + #[tokio::test] + async fn should_fetch_balance_after_dpns_and_flush_a_reloadable_wallet_snapshot() { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + assert_eq!(iw.refresh_identity_balance(&id).await.unwrap(), AFTER_DPNS); + assert_eq!(local_balance(&iw, &id).await, AFTER_DPNS); + assert!(backend.queued.lock().unwrap().is_empty()); + let committed = backend.committed.lock().unwrap(); + assert_eq!(committed.len(), 1); + let (wallet_id, entry) = &committed[0]; + assert_eq!(*wallet_id, iw.wallet_id); + assert_eq!(entry.wallet_id, Some(iw.wallet_id)); + assert_eq!(entry.revision, 7); + let mut reloaded = IdentityManager::new(); + reloaded.apply_identity_entry(entry.clone()); + assert_eq!( + reloaded.identity(&id).unwrap().identity.balance(), + AFTER_DPNS + ); + } + + #[tokio::test] + async fn should_persist_a_real_zero_balance() { + let (iw, id, backend) = fixture(Some(0)).await; + assert_eq!(iw.refresh_identity_balance(&id).await.unwrap(), 0); + assert_eq!(backend.committed.lock().unwrap()[0].1.balance, 0); + } + + #[tokio::test] + async fn should_preserve_balance_when_platform_has_no_balance() { + let (iw, id, backend) = fixture(None).await; + assert!(iw.refresh_identity_balance(&id).await.is_err()); + assert_eq!(local_balance(&iw, &id).await, OLD_BALANCE); + assert!(backend.committed.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn should_reject_identity_outside_this_wallet_without_changing_balance() { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + let other = Identifier::from([0xBB; 32]); + assert!(matches!(iw.refresh_identity_balance(&other).await, + Err(PlatformWalletError::IdentityNotFound(found)) if found == other)); + assert_eq!(local_balance(&iw, &id).await, OLD_BALANCE); + assert!(backend.committed.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn should_not_overwrite_a_more_recent_balance_response() { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + { + let mut wm = iw.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&iw.wallet_id) + .unwrap() + .identity_manager + .managed_identity_mut(&id) + .unwrap(); + // Mock responses have height 0; this state came from a newer block. + managed.last_updated_balance_block_time = Some(BlockTime::new(1, 1, 1)); + } + assert_eq!(iw.refresh_identity_balance(&id).await.unwrap(), OLD_BALANCE); + assert!(backend.committed.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn should_report_persistence_failure_instead_of_claiming_a_durable_refresh() { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + backend.fail_flush.store(true, Ordering::SeqCst); + assert!(matches!( + iw.refresh_identity_balance(&id).await, + Err(PlatformWalletError::Persistence(_)) + )); + assert!(backend.committed.lock().unwrap().is_empty()); + assert_eq!(backend.queued.lock().unwrap()[0].1.balance, AFTER_DPNS); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs index ec766a7d64e..4bd019c8392 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -20,6 +20,7 @@ //! `SpvBroadcaster` so most call sites don't need to name it. // Core handle + identity-lifecycle operations. +mod balance; mod contract; mod discovery; mod document; diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 84dae9131a2..917519ee3cd 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -27,6 +27,28 @@ public final class ManagedPlatformWallet: @unchecked Sendable { _ = platform_wallet_destroy(handle) } + /// Read a managed identity's credit balance from Platform and persist it. + /// This read-only operation requires no signer or wallet unlock. + public func refreshIdentityBalance(identityId: Identifier) async throws -> UInt64 { + guard identityId.count == 32 else { + throw PlatformWalletError.invalidParameter("identityId must be 32 bytes") + } + return try await Task.detached(priority: .userInitiated) { [self] in + try withExtendedLifetime(self) { + var balance: UInt64 = 0 + let result = identityId.withUnsafeBytes { bytes in + platform_wallet_refresh_identity_balance( + handle, + bytes.bindMemory(to: UInt8.self).baseAddress!, + &balance + ) + } + try result.check() + return balance + } + }.value + } + // MARK: - Balance (lock-free) /// Wallet balance breakdown. These are atomic reads — no lock contention. From 10cdbf191dba026b4049a0c0e6dc7c144b1f1991 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Fri, 18 Sep 2026 14:44:47 +0200 Subject: [PATCH 2/9] fix(wallet): preserve balance freshness and durable refresh state --- .../ERROR_CODE_REGISTRY.md | 6 +- packages/rs-platform-wallet-ffi/src/error.rs | 24 ++ .../rs-platform-wallet-ffi/src/manager.rs | 54 +++- .../rs-platform-wallet-ffi/src/persistence.rs | 302 +++++++++++++++++- packages/rs-platform-wallet/src/error.rs | 17 +- .../src/wallet/identity/network/balance.rs | 263 +++++++++++++-- .../wallet/identity/network/registration.rs | 11 +- .../identity/network/top_up_from_addresses.rs | 3 +- .../src/wallet/identity/network/transfer.rs | 9 +- .../identity/network/transfer_to_addresses.rs | 3 +- .../src/wallet/identity/network/withdrawal.rs | 11 +- .../identity/state/managed_identity/sync.rs | 15 + .../src/wallet/persister.rs | 7 + .../src/wallet/platform_wallet.rs | 32 +- .../src/wallet/shielded/operations.rs | 84 ++++- .../platform/transition/top_up_identity.rs | 48 ++- .../src/platform/transition/transfer.rs | 48 ++- .../transition/withdraw_from_identity.rs | 55 +++- .../Persistence/DashModelContainer.swift | 6 +- .../PersistentIdentityBalanceMetadata.swift | 26 ++ .../PlatformWalletPersistenceHandler.swift | 91 ++++++ .../PlatformWallet/PlatformWalletResult.swift | 9 +- .../DashLegacySchemaMigrationTests.swift | 27 ++ .../DashModelMigrationTests.swift | 30 +- ...ntityBalanceMetadataPersistenceTests.swift | 121 +++++++ .../IdentityBalanceMetadataSchemaTests.swift | 47 +++ packages/swift-sdk/schema-models.json | 3 +- 27 files changed, 1255 insertions(+), 97 deletions(-) create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentityBalanceMetadata.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityBalanceMetadataPersistenceTests.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityBalanceMetadataSchemaTests.swift diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index a754ff2c6e2..3399060f53b 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -114,12 +114,13 @@ These are shipped ABI. Do not renumber. | 98 | `NotFound` | Sentinel — `Option` returned as an error | | 99 | `ErrorUnknown` | Sentinel — unmapped/flattened errors | -**Next allocatable integer: 58** — 27–57 are all claimed (27, 29, 31, 34–42 +**Next allocatable integer: 59** — 27–58 are all claimed (27, 29, 31, 34–42 and 46 merged; 43–45 proposed by active #4313 at head `0302b188ab`; 47 and 48 proposed by active #4356 (47 renumbered from 42, 48 from 43 — see their rows below); 49–54 proposed by active #4586 (the persister operation × kind block); 55–57 proposed by #4715 for pending identity-funded -shield debits and durable recovery errors; 28, 30, +shield debits and durable recovery errors; 58 proposed by #4799 for an unavailable +identity balance response; 28, 30, 32 and 33 reserved). **28, 30, 32 and 33 are RESERVED, not free**: 28 and 30 were vacated when the reservation trio moved to 34–36; 32 and 33 lapsed when their in-repo owners @@ -170,6 +171,7 @@ Fork-era numbers remain in the collision history, which is immutable record. | 55 | `ErrorShieldedIdentityDebitPending` | #4715 | Proposed — an earlier identity-funded shield is unresolved. This request was not built or broadcast; wait for shielded sync to reconcile the original debit. Rust's blanket and shielded-operation mappers preserve the code and message; Swift and Kotlin expose matching typed errors | | 56 | `ErrorShieldedRecoveryCorrupted` | #4715 | Proposed — durable shielded recovery data is malformed or invalid; preserved for diagnosis. Rust, Swift and Kotlin preserve this typed error | | 57 | `ErrorShieldedRecoveryKeysRequired` | #4715 | Proposed — recovery needs the account and compatible keys; ciphertext damage can produce the same symptom. Rust, Swift and Kotlin preserve this typed error | +| 58 | `ErrorIdentityBalanceUnavailable` | #4799 | Proposed — Platform returned no balance for a managed identity. Retrying the read is safe; this is distinct from missing wallet ownership and must not trigger registration or funding | **Code 31 left this table on 2026-08-04.** `ErrorSigningKeyUnavailable` sat here as #4183's proposal until #4183 merged (`189a3abb1c`); it is now in the merged diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 020bccc138c..5e7bc342baf 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -596,6 +596,10 @@ pub enum PlatformWalletFFIResultCode { /// Incompatible keys and damaged ciphertext can produce the same symptom. ErrorShieldedRecoveryKeysRequired = 57, + /// Platform returned no balance for a managed identity. Retrying this read + /// is safe; this does not imply missing ownership or require registration. + ErrorIdentityBalanceUnavailable = 58, + /// The named thing does not exist. /// /// Originally (and still mostly) the code for every `Option` returned as an @@ -860,6 +864,9 @@ impl From for PlatformWalletFFIResult { // assigned a dedicated code yet — those still carry the // typed Display rendering as the message. let code = match &error { + PlatformWalletError::IdentityBalanceUnavailable(_) => { + PlatformWalletFFIResultCode::ErrorIdentityBalanceUnavailable + } PlatformWalletError::NoSpendableInputs { .. } | PlatformWalletError::OnlyOutputAddressesFunded { .. } | PlatformWalletError::OnlyDustInputs { .. } => { @@ -2570,3 +2577,20 @@ mod tests { .into_owned() } } + +#[cfg(test)] +mod identity_balance_error_tests { + use super::*; + #[test] + fn should_distinguish_unavailable_network_balance_with_a_retryable_read_code() { + let result = PlatformWalletFFIResult::from( + PlatformWalletError::IdentityBalanceUnavailable([7; 32].into()), + ); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorIdentityBalanceUnavailable + ); + assert_eq!(result.code as u32, 58); + assert!(!result.message.is_null()); + } +} diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index c3d8ba193d4..20f0c421b20 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -8,7 +8,8 @@ use crate::event_handler::{ }; use crate::handle::*; use crate::persistence::{ - FFIPersister, FreeTrackedMasternodesFn, LoadTrackedMasternodesFn, PersistDpnsNameStatesFn, + FFIPersister, FreeTrackedMasternodesFn, LoadIdentityBalanceBlockTimeFn, + LoadTrackedMasternodesFn, PersistDpnsNameStatesFn, PersistIdentityBalanceBlockTimeFn, PersistTrackedMasternodesFn, PersistWalletChangesetChainLockHeightFn, PersistWalletChangesetSweepsFn, PersistWalletChangesetUtxoVerdictsFn, PersistenceCallbacks, PersistenceCallbacksExtension, PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks, @@ -268,6 +269,14 @@ unsafe fn persistence_extension_callbacks( on_persist_wallet_changeset_utxo_verdicts_fn, PersistWalletChangesetUtxoVerdictsFn ), + persist_identity_balance_block_time: slot!( + on_persist_identity_balance_block_time_fn, + PersistIdentityBalanceBlockTimeFn + ), + load_identity_balance_block_time: slot!( + on_load_identity_balance_block_time_fn, + LoadIdentityBalanceBlockTimeFn + ), } } @@ -1254,6 +1263,49 @@ mod tests { assert!(read_unknown.wallet_changeset_utxo_verdicts.is_none()); } + #[test] + fn should_size_gate_identity_balance_watermark_slots_independently() { + unsafe extern "C" fn persist( + _: *mut c_void, + _: *const u8, + _: *const u8, + _: *const crate::types::BlockTime, + ) -> i32 { + 0 + } + unsafe extern "C" fn load( + _: *mut c_void, + _: *const u8, + _: *const u8, + _: *mut bool, + _: *mut crate::types::BlockTime, + ) -> i32 { + 0 + } + let mut ext = PersistenceCallbacksExtension { + on_persist_identity_balance_block_time_fn: Some(persist), + on_load_identity_balance_block_time_fn: Some(load), + ..Default::default() + }; + let parsed = unsafe { persistence_extension_callbacks(&ext) }; + assert!(parsed.persist_identity_balance_block_time.is_some()); + assert!(parsed.load_identity_balance_block_time.is_some()); + ext.struct_size = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_identity_balance_block_time_fn + ); + let parsed = unsafe { persistence_extension_callbacks(&ext) }; + assert!(parsed.persist_identity_balance_block_time.is_some()); + assert!(parsed.load_identity_balance_block_time.is_none()); + ext.struct_size = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_identity_balance_block_time_fn + ); + let parsed = unsafe { persistence_extension_callbacks(&ext) }; + assert!(parsed.persist_identity_balance_block_time.is_none()); + assert!(parsed.load_identity_balance_block_time.is_none()); + } + /// A host whose `struct_size` stops right after the chainlock-height /// slot (built before the credit-verdict slot existed) keeps every /// earlier slot and simply never has the verdict slot read; a host diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index dd049a67b83..82bbfec6222 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -257,6 +257,25 @@ pub type PersistWalletChangesetUtxoVerdictsFn = unsafe extern "C" fn( verdicts_count: usize, ) -> i32; +/// Persist one identity's optional balance freshness stamp in the same +/// transaction as its legacy identity row. A null stamp means no watermark. +pub type PersistIdentityBalanceBlockTimeFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + identity_id: *const u8, + block_time: *const crate::types::BlockTime, +) -> i32; + +/// Load the stamp without allocating a host-owned array. `out_found` separates +/// an absent legacy stamp from a valid all-zero block time. +pub type LoadIdentityBalanceBlockTimeFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + identity_id: *const u8, + out_found: *mut bool, + out_block_time: *mut crate::types::BlockTime, +) -> i32; + /// Size- and version-tagged additive persistence callbacks. /// /// `context` is the context in the accompanying [`PersistenceCallbacks`] @@ -368,6 +387,25 @@ pub struct PersistenceCallbacksExtension { verdicts_count: usize, ) -> i32, >, + /// Additive sidecar: never change the stride of `IdentityEntryFFI` or + /// `IdentityRestoreEntryFFI`, which old hosts still allocate unchanged. + pub on_persist_identity_balance_block_time_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + identity_id: *const u8, + block_time: *const crate::types::BlockTime, + ) -> i32, + >, + pub on_load_identity_balance_block_time_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + identity_id: *const u8, + out_found: *mut bool, + out_block_time: *mut crate::types::BlockTime, + ) -> i32, + >, } impl Default for PersistenceCallbacksExtension { @@ -383,6 +421,8 @@ impl Default for PersistenceCallbacksExtension { on_persist_wallet_changeset_sweeps_fn: None, on_persist_wallet_changeset_chain_lock_height_fn: None, on_persist_wallet_changeset_utxo_verdicts_fn: None, + on_persist_identity_balance_block_time_fn: None, + on_load_identity_balance_block_time_fn: None, } } } @@ -399,6 +439,8 @@ pub struct PersistenceExtensionCallbacks { pub wallet_changeset_sweeps: Option, pub wallet_changeset_chain_lock_height: Option, pub wallet_changeset_utxo_verdicts: Option, + pub persist_identity_balance_block_time: Option, + pub load_identity_balance_block_time: Option, } /// Return value by which a persistence callback reports a **retryable** @@ -1333,6 +1375,8 @@ pub struct FFIPersister { /// Additive tracked-masternode persistence trio (persist / load / /// free), likewise extension-negotiated. tracked_masternodes_callbacks: PersistenceExtensionCallbacks, + persist_identity_balance_block_time_callback: Option, + load_identity_balance_block_time_callback: Option, /// Semantic capability declaration supplied separately from the callback /// vtable by the additive manager-create API. Keeping this out of /// `PersistenceCallbacks` preserves that established C struct's size. @@ -1453,6 +1497,9 @@ impl FFIPersister { .wallet_changeset_chain_lock_height, wallet_changeset_utxo_verdicts_callback: extensions.wallet_changeset_utxo_verdicts, tracked_masternodes_callbacks: extensions, + persist_identity_balance_block_time_callback: extensions + .persist_identity_balance_block_time, + load_identity_balance_block_time_callback: extensions.load_identity_balance_block_time, declared_capabilities, round_lock: Mutex::new(RoundGuardState::default()), } @@ -2175,6 +2222,43 @@ impl PlatformWalletPersistence for FFIPersister { } } + // Stamp sidecars share the begin/end transaction with identity scalars. + // Run after the legacy callback has staged any new identity rows. + if let (Some(id_cs), Some(cb)) = ( + changeset.identities.as_ref(), + self.persist_identity_balance_block_time_callback, + ) { + for entry in id_cs.identities.values() { + let stamp = entry + .last_updated_balance_block_time + .map(crate::types::BlockTime::from); + let rc = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + entry.id.as_bytes().as_ptr(), + stamp.as_ref().map_or(std::ptr::null(), |stamp| stamp), + ) + }; + if rc != 0 { + outcome.record(rc); + } + } + for identity_id in &id_cs.removed { + let rc = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + identity_id.as_bytes().as_ptr(), + std::ptr::null(), + ) + }; + if rc != 0 { + outcome.record(rc); + } + } + } + // Send DashPay payment-history rows — the `dashpay_payments_overlay` // ONLY. `record_dashpay_payment`, the single writer for every // payment mutation (live sends, confirm-sweep flips, reconstruction @@ -3108,7 +3192,35 @@ impl PlatformWalletPersistence for FFIPersister { // fires before we leave this function. let entries = unsafe { slice::from_raw_parts(entries_ptr, count) }; for entry in entries { - let (wallet_state, platform_address_state) = build_wallet_start_state(entry)?; + let (mut wallet_state, platform_address_state) = build_wallet_start_state(entry)?; + if let Some(cb) = self.load_identity_balance_block_time_callback { + for identities in wallet_state.identity_manager.wallet_identities.values_mut() { + for managed in identities.values_mut() { + let mut found = false; + let mut stamp = crate::types::BlockTime { + height: 0, + core_height: 0, + timestamp: 0, + }; + let rc = unsafe { + cb( + self.callbacks.context, + entry.wallet_id.as_ptr(), + managed.id().as_bytes().as_ptr(), + &mut found, + &mut stamp, + ) + }; + if rc != 0 { + return Err(persist_callback_error( + rc, + "Loading identity balance block time failed".to_string(), + )); + } + managed.last_updated_balance_block_time = found.then(|| stamp.into()); + } + } + } out.wallets.insert(entry.wallet_id, wallet_state); if let Some(platform_address_state) = platform_address_state { out.platform_addresses @@ -7078,6 +7190,174 @@ mod tests { use super::*; + #[test] + fn should_restore_identity_balance_watermark_without_changing_legacy_rows() { + unsafe extern "C" fn load_wallet( + _: *mut c_void, + entries: *mut *const WalletRestoreEntryFFI, + count: *mut usize, + ) -> i32 { + // The legacy restore row contains only integers and raw pointers. + let mut identity: IdentityRestoreEntryFFI = std::mem::zeroed(); + identity.identity_id = [7; 32]; + identity.balance = 123; + *entries = Box::into_raw(Box::new(WalletRestoreEntryFFI { + wallet_id: [42; 32], + identities: Box::into_raw(Box::new(identity)), + identities_count: 1, + ..Default::default() + })); + *count = 1; + 0 + } + unsafe extern "C" fn free_wallet( + _: *mut c_void, + entries: *const WalletRestoreEntryFFI, + _: usize, + ) { + let row = Box::from_raw(entries.cast_mut()); + drop(Box::from_raw(row.identities.cast_mut())); + } + unsafe extern "C" fn load_stamp( + ctx: *mut c_void, + wallet: *const u8, + identity: *const u8, + found: *mut bool, + stamp: *mut crate::types::BlockTime, + ) -> i32 { + assert_eq!(std::slice::from_raw_parts(wallet, 32), &[42; 32]); + assert_eq!(std::slice::from_raw_parts(identity, 32), &[7; 32]); + let mode = *(ctx as *const u8); + if mode == 2 { + return -1; + } + *found = mode == 1; + // Known all-zero metadata must remain Some, not collapse to None. + *stamp = crate::types::BlockTime { + height: 0, + core_height: 0, + timestamp: 0, + }; + 0 + } + for mode in [0_u8, 1, 2] { + let persister = FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks { + context: (&mode as *const u8).cast_mut().cast(), + on_load_wallet_list_fn: Some(load_wallet), + on_load_wallet_list_free_fn: Some(free_wallet), + ..Default::default() + }, + PersistenceCapabilities::NONE, + PersistenceExtensionCallbacks { + load_identity_balance_block_time: Some(load_stamp), + ..Default::default() + }, + ); + let result = persister.load(); + if mode == 2 { + assert!(result.is_err()); + continue; + } + let restored = result.expect("restore watermark"); + let managed = &restored.wallets[&[42; 32]] + .identity_manager + .wallet_identities[&[42; 32]][&0]; + assert_eq!(managed.last_updated_balance_block_time.is_some(), mode == 1); + assert_eq!( + dpp::identity::accessors::IdentityGettersV0::balance(&managed.identity), + 123 + ); + } + let legacy = FFIPersister::new(PersistenceCallbacks { + on_load_wallet_list_fn: Some(load_wallet), + on_load_wallet_list_free_fn: Some(free_wallet), + ..Default::default() + }) + .load() + .expect("old hosts still restore"); + assert!( + legacy.wallets[&[42; 32]].identity_manager.wallet_identities[&[42; 32]][&0] + .last_updated_balance_block_time + .is_none() + ); + } + + #[test] + fn should_roll_back_failed_identity_balance_watermark_store_and_clear_on_removal() { + use platform_wallet::changeset::{IdentityChangeSet, IdentityEntry}; + use std::sync::Mutex; + #[derive(Default)] + struct Host { + events: Mutex>, + fail: bool, + } + unsafe extern "C" fn persist( + ctx: *mut c_void, + _: *const u8, + _: *const u8, + stamp: *const crate::types::BlockTime, + ) -> i32 { + let host = &*(ctx as *const Host); + host.events.lock().unwrap().push((true, stamp.is_null())); + if host.fail { + -1 + } else { + 0 + } + } + unsafe extern "C" fn end(ctx: *mut c_void, _: *const u8, success: bool) -> i32 { + let host = &*(ctx as *const Host); + host.events.lock().unwrap().push((false, success)); + 0 + } + for fail in [false, true] { + let host = Host { + fail, + ..Default::default() + }; + let persister = FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks { + context: (&host as *const Host).cast_mut().cast(), + on_changeset_begin_fn: Some(noop_begin), + on_changeset_end_fn: Some(end), + ..Default::default() + }, + PersistenceCapabilities::ATOMIC_CHANGESETS, + PersistenceExtensionCallbacks { + persist_identity_balance_block_time: Some(persist), + ..Default::default() + }, + ); + let mut managed = platform_wallet::ManagedIdentity::new( + dpp::identity::Identity::V0(dpp::identity::v0::IdentityV0::default()), + 0, + ); + managed.last_updated_balance_block_time = Some(platform_wallet::BlockTime { + height: 42, + core_height: 7, + timestamp: 99, + }); + let mut identities = IdentityChangeSet::default(); + identities + .identities + .insert(managed.id(), IdentityEntry::from_managed(&managed)); + identities.removed.insert([9; 32].into()); + let result = persister.store( + [42; 32], + PlatformWalletChangeSet { + identities: Some(identities), + ..Default::default() + }, + ); + assert_eq!(result.is_err(), fail); + assert_eq!( + *host.events.lock().unwrap(), + vec![(true, false), (true, true), (false, !fail)] + ); + } + } + // --- persists_durably: the fail-closed durability attestation --- unsafe extern "C" fn noop_begin(_ctx: *mut c_void, _wallet_id: *const u8) -> i32 { @@ -8349,6 +8629,26 @@ mod tests { PersistenceCallbacksExtension, on_persist_wallet_changeset_utxo_verdicts_fn ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_identity_balance_block_time_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_identity_balance_block_time_fn + ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_identity_balance_block_time_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_identity_balance_block_time_fn + ) + std::mem::size_of::>(), std::mem::size_of::() ); assert_eq!( diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index d19d2aa401d..d87b3f1ba58 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -32,16 +32,10 @@ pub enum PlatformWalletError { #[error("failed to load persisted client state: {0}")] PersisterLoad(#[source] crate::changeset::PersistenceError), - /// The persister failed to store the wallet-registration changeset. + /// A wallet changeset could not be stored. Wallet registration and balance + /// refresh preserve the typed cause; other best-effort writers may log it. /// See [`Self::PersisterLoad`] for why the typed cause is carried. - /// - /// Scope: wallet registration is the only write that reports this today. - /// A contact un-ignore flattens its failure into `Persistence(String)`, - /// the asset-lock pool write returns the raw `PersistenceError` on its own - /// signature, and the fire-and-forget writes (DPNS marketplace, platform - /// addresses, asset-lock tracking) log and swallow it. A host branching on - /// the classification gets it for registration and nowhere else yet. - #[error("failed to persist wallet registration changeset: {0}")] + #[error("failed to persist wallet changeset: {0}")] PersisterStore(#[source] crate::changeset::PersistenceError), /// Restoring persisted platform-address state into a freshly registered @@ -62,6 +56,11 @@ pub enum PlatformWalletError { #[error("Identity not found: {0}")] IdentityNotFound(Identifier), + /// The wallet owns the identity, but the queried Platform node has no + /// balance yet. This may be transient immediately after registration. + #[error("Platform balance unavailable for identity {0}; retry against an up-to-date node")] + IdentityBalanceUnavailable(Identifier), + #[error("No primary identity set")] NoPrimaryIdentity, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/balance.rs b/packages/rs-platform-wallet/src/wallet/identity/network/balance.rs index a77784f00ae..3a76fd52782 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/balance.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/balance.rs @@ -6,6 +6,7 @@ use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; use dpp::prelude::Identifier; use crate::error::PlatformWalletError; +use crate::wallet::identity::state::managed_identity::ManagedIdentity; use crate::BlockTime; use super::IdentityWallet; @@ -25,14 +26,20 @@ impl IdentityWallet { let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { PlatformWalletError::WalletNotFound("Wallet info not found".to_string()) })?; - if info.identity_manager.identity(identity_id).is_none() { + if info + .identity_manager + .wallet_identity(&self.wallet_id, identity_id) + .is_none() + { return Err(PlatformWalletError::IdentityNotFound(*identity_id)); } } let (balance, metadata) = IdentityBalance::fetch_with_metadata(&self.sdk, *identity_id, None).await?; - let balance = balance.ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + let balance = balance.ok_or(PlatformWalletError::IdentityBalanceUnavailable( + *identity_id, + ))?; let current_balance = { let mut wm = self.wallet_manager.write().await; @@ -42,32 +49,54 @@ impl IdentityWallet { // Recheck after the network await: never recreate a removed identity. let managed = info .identity_manager - .managed_identity_mut(identity_id) + .wallet_identity_mut(&self.wallet_id, identity_id) .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; - // A slower, overlapping query must not overwrite a newer response. - if managed - .last_updated_balance_block_time - .is_none_or(|previous| metadata.height >= previous.height) - { - managed.identity.set_balance(balance); - managed.last_updated_balance_block_time = Some(BlockTime::new( + self.persist_refreshed_balance( + managed, + balance, + BlockTime::new( metadata.height, metadata.core_chain_locked_height, metadata.time_ms, - )); - self.persister - .store(managed.snapshot_changeset().into()) - .map_err(|e| PlatformWalletError::Persistence(e.to_string()))?; - } + ), + )?; managed.identity.balance() }; - self.persister - .flush() - .map_err(|e| PlatformWalletError::Persistence(e.to_string()))?; Ok(current_balance) } + + // The manager write lock must cover persistence and publication together. + fn persist_refreshed_balance( + &self, + managed: &mut ManagedIdentity, + balance: u64, + block_time: BlockTime, + ) -> Result<(), PlatformWalletError> { + // Reject both older and equal-height responses: a confirmed local + // transaction at that height takes precedence over a refresh. + if managed + .last_updated_balance_block_time + .is_none_or(|previous| block_time.height > previous.height) + { + let mut candidate = managed.clone(); + candidate.identity.set_balance(balance); + candidate.last_updated_balance_block_time = Some(block_time); + self.persister + .store(candidate.snapshot_changeset().into()) + .map_err(|e| self.persister.classify_store_failure(e))?; + // Inline backends already committed; their flush callback is + // only a notification. Buffered backends must finish first. + if !self.persister.store_commits_inline() { + self.persister + .flush() + .map_err(|e| PlatformWalletError::Persistence(e.to_string()))?; + } + *managed = candidate; + } + Ok(()) + } } #[cfg(test)] @@ -92,14 +121,35 @@ mod tests { queued: Mutex>, committed: Mutex>, fail_flush: AtomicBool, + fail_store: AtomicBool, + transient_store: AtomicBool, + store_reissuable: AtomicBool, + commits_inline: AtomicBool, + flush_count: std::sync::atomic::AtomicUsize, } impl PlatformWalletPersistence for BalancePersister { + fn store_transient_is_reissuable(&self) -> bool { + self.store_reissuable.load(Ordering::SeqCst) + } + + fn store_commits_inline(&self) -> bool { + self.commits_inline.load(Ordering::SeqCst) + } fn store( &self, wallet_id: [u8; 32], changeset: PlatformWalletChangeSet, ) -> Result<(), PersistenceError> { + if self.transient_store.load(Ordering::SeqCst) { + return Err(PersistenceError::backend_with_kind( + crate::changeset::PersistenceErrorKind::Transient, + "busy", + )); + } + if self.fail_store.load(Ordering::SeqCst) { + return Err(PersistenceError::backend("injected store failure")); + } if let Some(identities) = changeset.identities { self.queued.lock().unwrap().extend( identities @@ -108,10 +158,17 @@ mod tests { .map(|entry| (wallet_id, entry)), ); } + if self.store_commits_inline() { + self.committed + .lock() + .unwrap() + .extend(self.queued.lock().unwrap().drain(..)); + } Ok(()) } fn flush(&self, _: [u8; 32]) -> Result<(), PersistenceError> { + self.flush_count.fetch_add(1, Ordering::SeqCst); if self.fail_flush.load(Ordering::SeqCst) { return Err(PersistenceError::backend("injected flush failure")); } @@ -217,7 +274,9 @@ mod tests { #[tokio::test] async fn should_preserve_balance_when_platform_has_no_balance() { let (iw, id, backend) = fixture(None).await; - assert!(iw.refresh_identity_balance(&id).await.is_err()); + assert!( + matches!(iw.refresh_identity_balance(&id).await, Err(PlatformWalletError::IdentityBalanceUnavailable(found)) if found == id) + ); assert_eq!(local_balance(&iw, &id).await, OLD_BALANCE); assert!(backend.committed.lock().unwrap().is_empty()); } @@ -260,5 +319,171 @@ mod tests { )); assert!(backend.committed.lock().unwrap().is_empty()); assert_eq!(backend.queued.lock().unwrap()[0].1.balance, AFTER_DPNS); + assert_eq!(local_balance(&iw, &id).await, OLD_BALANCE); + } + + #[tokio::test] + async fn should_keep_confirmed_transaction_balance_when_query_is_older_or_equal() { + for proof_height in [0, 1] { + let (iw, id, backend) = fixture(Some(OLD_BALANCE)).await; + { + let mut wm = iw.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&iw.wallet_id) + .unwrap() + .identity_manager + .wallet_identity_mut(&iw.wallet_id, &id) + .unwrap(); + managed.set_confirmed_balance(AFTER_DPNS, proof_height); + iw.persister + .store(managed.snapshot_changeset().into()) + .unwrap(); + iw.persister.flush().unwrap(); + } + let before = backend.flush_count.load(Ordering::SeqCst); + assert_eq!(iw.refresh_identity_balance(&id).await.unwrap(), AFTER_DPNS); + assert_eq!(backend.flush_count.load(Ordering::SeqCst), before); + assert_eq!(backend.committed.lock().unwrap().len(), 1); + assert_eq!(backend.committed.lock().unwrap()[0].1.balance, AFTER_DPNS); + } + } + + #[tokio::test] + async fn should_not_publish_balance_or_watermark_when_store_fails() { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + backend.fail_store.store(true, Ordering::SeqCst); + assert!(matches!( + iw.refresh_identity_balance(&id).await, + Err(PlatformWalletError::PersisterStore(_)) + )); + assert_eq!(local_balance(&iw, &id).await, OLD_BALANCE); + assert!(iw + .wallet_manager + .read() + .await + .get_wallet_info(&iw.wallet_id) + .unwrap() + .identity_manager + .identity(&id) + .unwrap() + .last_updated_balance_block_time + .is_none()); + backend.fail_store.store(false, Ordering::SeqCst); + assert_eq!(iw.refresh_identity_balance(&id).await.unwrap(), AFTER_DPNS); + assert_eq!(backend.committed.lock().unwrap().len(), 1); + } + + #[tokio::test] + async fn should_not_flush_an_inline_commit() { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + backend.commits_inline.store(true, Ordering::SeqCst); + backend.fail_flush.store(true, Ordering::SeqCst); + assert_eq!(iw.refresh_identity_balance(&id).await.unwrap(), AFTER_DPNS); + assert_eq!(backend.committed.lock().unwrap().len(), 1); + assert_eq!(backend.flush_count.load(Ordering::SeqCst), 0); + } + #[tokio::test] + async fn should_reject_an_observed_identity_without_fetching_or_persisting() { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + { + let mut wm = iw.wallet_manager.write().await; + let manager = &mut wm + .get_wallet_info_mut(&iw.wallet_id) + .unwrap() + .identity_manager; + let identity = manager.remove_identity(&id, &iw.persister).unwrap(); + manager + .add_out_of_wallet_identity(identity, &iw.persister) + .unwrap(); + } + backend.queued.lock().unwrap().clear(); + assert!(matches!(iw.refresh_identity_balance(&id).await, + Err(PlatformWalletError::IdentityNotFound(found)) if found == id)); + assert_eq!(local_balance(&iw, &id).await, OLD_BALANCE); + assert!(backend.queued.lock().unwrap().is_empty()); + assert_eq!(backend.flush_count.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn should_persist_an_older_retry_after_a_failed_newer_store() { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + let mut wm = iw.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&iw.wallet_id) + .unwrap() + .identity_manager + .wallet_identity_mut(&iw.wallet_id, &id) + .unwrap(); + backend.fail_store.store(true, Ordering::SeqCst); + assert!(iw + .persist_refreshed_balance(managed, 100, BlockTime::new(10, 10, 10)) + .is_err()); + assert_eq!(managed.identity.balance(), OLD_BALANCE); + assert!(managed.last_updated_balance_block_time.is_none()); + backend.fail_store.store(false, Ordering::SeqCst); + iw.persist_refreshed_balance(managed, 200, BlockTime::new(9, 9, 9)) + .unwrap(); + assert_eq!(managed.identity.balance(), 200); + let committed = backend.committed.lock().unwrap(); + assert_eq!(committed.len(), 1); + assert_eq!(committed[0].1.balance, 200); + assert_eq!( + committed[0] + .1 + .last_updated_balance_block_time + .unwrap() + .height, + 9 + ); + } + #[tokio::test] + async fn should_reject_a_query_newer_than_the_last_refresh_but_older_than_a_transaction() { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + let mut wm = iw.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&iw.wallet_id) + .unwrap() + .identity_manager + .wallet_identity_mut(&iw.wallet_id, &id) + .unwrap(); + iw.persist_refreshed_balance(managed, 300, BlockTime::new(8, 8, 8)) + .unwrap(); + managed.set_confirmed_balance(100, 10); + for height in [9, 10] { + iw.persist_refreshed_balance( + managed, + 200, + BlockTime::new(height, height as u32, height), + ) + .unwrap(); + assert_eq!(managed.identity.balance(), 100); + } + assert_eq!(backend.committed.lock().unwrap().len(), 1); + iw.persist_refreshed_balance(managed, 50, BlockTime::new(11, 11, 11)) + .unwrap(); + assert_eq!(managed.identity.balance(), 50); + assert_eq!(backend.committed.lock().unwrap().len(), 2); + } + #[tokio::test] + async fn should_preserve_store_failure_kind_with_backend_retry_guarantee() { + use crate::changeset::PersistenceErrorKind; + for reissuable in [false, true] { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + backend.transient_store.store(true, Ordering::SeqCst); + backend.store_reissuable.store(reissuable, Ordering::SeqCst); + let error = iw.refresh_identity_balance(&id).await.unwrap_err(); + let PlatformWalletError::PersisterStore(source) = error else { + panic!("lost typed store failure") + }; + assert_eq!( + source.kind(), + Some(if reissuable { + PersistenceErrorKind::Transient + } else { + PersistenceErrorKind::Fatal + }) + ); + assert_eq!(local_balance(&iw, &id).await, OLD_BALANCE); + } } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs b/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs index 9cdb0e59977..f86093d1908 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs @@ -50,7 +50,6 @@ use std::collections::BTreeMap; -use dpp::identity::accessors::IdentitySettersV0; use dpp::identity::signer::Signer; use dpp::identity::v0::IdentityV0; use dpp::identity::Identity; @@ -63,7 +62,7 @@ use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundin use dash_sdk::platform::transition::put_identity::PutIdentity; use dash_sdk::platform::transition::put_settings::PutSettings; -use dash_sdk::platform::transition::top_up_identity::TopUpIdentity; +use dash_sdk::platform::transition::top_up_identity::TopUpIdentityWithHeight; use crate::error::{is_instant_lock_proof_invalid, PlatformWalletError}; use crate::wallet::asset_lock::orchestration::{ @@ -467,7 +466,7 @@ impl IdentityWallet { // same outpoint. let proof_out_point = out_point_from_proof(&proof); let (submit_result, effective_proof) = match submit_with_cl_height_retry(settings, |s| { - identity.top_up_identity_with_signer( + identity.top_up_identity_with_signer_with_height( &self.sdk, proof.clone(), &path, @@ -490,7 +489,7 @@ impl IdentityWallet { .upgrade_to_chain_lock_proof(&out_point, None) .await?; let submit_result = submit_with_cl_height_retry(settings, |s| { - identity.top_up_identity_with_signer( + identity.top_up_identity_with_signer_with_height( &self.sdk, chain_proof.clone(), &path, @@ -503,7 +502,7 @@ impl IdentityWallet { } Err(e) => (Err(e), proof.clone()), }; - let new_balance = self + let (new_balance, proof_height) = self .asset_locks .reconcile_asset_lock_submit_result( submit_result, @@ -529,7 +528,7 @@ impl IdentityWallet { match wm.get_wallet_info_mut(&self.wallet_id) { Some(info) => { if let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) { - managed.identity.set_balance(new_balance); + managed.set_confirmed_balance(new_balance, proof_height); if let Err(e) = self.persister.store(managed.snapshot_changeset().into()) { tracing::error!( identity = %identity_id, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs b/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs index 35f79e8ac37..5c01218315b 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs @@ -2,7 +2,6 @@ use std::collections::BTreeMap; -use dpp::identity::accessors::IdentitySettersV0; use dpp::identity::signer::Signer; use dpp::prelude::Identifier; @@ -97,7 +96,7 @@ impl IdentityWallet { ) })?; if let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) { - managed.identity.set_balance(new_balance); + managed.set_confirmed_balance(new_balance, proof_height); if let Err(e) = self.persister.store(managed.snapshot_changeset().into()) { tracing::error!( identity = %identity_id, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/transfer.rs b/packages/rs-platform-wallet/src/wallet/identity/network/transfer.rs index b12b81ed1a2..e291579b9b2 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/transfer.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/transfer.rs @@ -2,7 +2,6 @@ use async_trait::async_trait; use dpp::address_funds::AddressWitness; -use dpp::identity::accessors::IdentitySettersV0; use dpp::identity::Identity; use dpp::identity::IdentityPublicKey; use dpp::platform_value::BinaryData; @@ -12,7 +11,7 @@ use dpp::ProtocolError; use dpp::identity::signer::Signer; use dash_sdk::platform::transition::put_settings::PutSettings; -use dash_sdk::platform::transition::transfer::TransferToIdentity; +use dash_sdk::platform::transition::transfer::{TransferToIdentity, TransferToIdentityWithHeight}; use crate::error::PlatformWalletError; @@ -96,8 +95,8 @@ impl IdentityWallet { .ok_or(PlatformWalletError::IdentityNotFound(*from_id))? }; - let (sender_balance, _receiver_balance) = identity - .transfer_credits( + let ((sender_balance, _receiver_balance), proof_height) = identity + .transfer_credits_with_height( &self.sdk, *to_id, amount, @@ -128,7 +127,7 @@ impl IdentityWallet { ) })?; if let Some(managed) = info.identity_manager.managed_identity_mut(from_id) { - managed.identity.set_balance(sender_balance); + managed.set_confirmed_balance(sender_balance, proof_height); if let Err(e) = self.persister.store(managed.snapshot_changeset().into()) { tracing::error!( identity = %from_id, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/transfer_to_addresses.rs b/packages/rs-platform-wallet/src/wallet/identity/network/transfer_to_addresses.rs index cbc00326e2a..24726bd8b72 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/transfer_to_addresses.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/transfer_to_addresses.rs @@ -4,7 +4,6 @@ use std::collections::BTreeMap; use async_trait::async_trait; use dpp::address_funds::AddressWitness; -use dpp::identity::accessors::IdentitySettersV0; use dpp::identity::signer::Signer; use dpp::identity::IdentityPublicKey; use dpp::platform_value::BinaryData; @@ -132,7 +131,7 @@ impl IdentityWallet { .identity_manager .managed_identity_mut(identity_id) { - managed.identity.set_balance(new_balance); + managed.set_confirmed_balance(new_balance, proof_height); if let Err(e) = self.persister.store(managed.snapshot_changeset().into()) { tracing::error!( identity = %identity_id, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/withdrawal.rs b/packages/rs-platform-wallet/src/wallet/identity/network/withdrawal.rs index ddc8fb0e287..e1d3f6bb2b1 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/withdrawal.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/withdrawal.rs @@ -3,7 +3,6 @@ use async_trait::async_trait; use dashcore::Address as DashAddress; use dpp::address_funds::AddressWitness; -use dpp::identity::accessors::IdentitySettersV0; use dpp::identity::Identity; use dpp::identity::IdentityPublicKey; use dpp::identity::Purpose; @@ -14,7 +13,9 @@ use dpp::ProtocolError; use dpp::identity::signer::Signer; use dash_sdk::platform::transition::put_settings::PutSettings; -use dash_sdk::platform::transition::withdraw_from_identity::WithdrawFromIdentity; +use dash_sdk::platform::transition::withdraw_from_identity::{ + WithdrawFromIdentity, WithdrawFromIdentityWithHeight, +}; use crate::error::PlatformWalletError; @@ -93,8 +94,8 @@ impl IdentityWallet { .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))? }; - let new_balance = identity - .withdraw( + let (new_balance, proof_height) = identity + .withdraw_with_height( &self.sdk, Some(to_address.clone()), amount, @@ -124,7 +125,7 @@ impl IdentityWallet { ) })?; if let Some(managed) = info_guard.identity_manager.identity_mut(identity_id) { - managed.identity.set_balance(new_balance); + managed.set_confirmed_balance(new_balance, proof_height); if let Err(e) = self.persister.store(managed.snapshot_changeset().into()) { tracing::error!( identity = %identity_id, diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs index 6e09b493126..158015debb4 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs @@ -3,9 +3,24 @@ use super::ManagedIdentity; use crate::wallet::persister::WalletPersister; use crate::BlockTime; +use dpp::identity::accessors::IdentitySettersV0; use dpp::prelude::TimestampMillis; impl ManagedIdentity { + /// Apply a transaction result together with its proof height so lagging + /// balance queries (or older transaction completions) cannot replace it. + pub(crate) fn set_confirmed_balance(&mut self, balance: u64, height: u64) { + if self + .last_updated_balance_block_time + .is_none_or(|previous| height >= previous.height) + { + self.identity.set_balance(balance); + // These transaction APIs expose only the proof height; zero marks + // unavailable Core height and time, rather than retaining stale values. + self.last_updated_balance_block_time = Some(BlockTime::new(height, 0, 0)); + } + } + /// Update the last balance update block time. /// /// Persists the resulting changeset via `persister` and returns `()`. diff --git a/packages/rs-platform-wallet/src/wallet/persister.rs b/packages/rs-platform-wallet/src/wallet/persister.rs index fec0ce4638f..fbfefcb2f9d 100644 --- a/packages/rs-platform-wallet/src/wallet/persister.rs +++ b/packages/rs-platform-wallet/src/wallet/persister.rs @@ -13,6 +13,7 @@ use crate::changeset::{ ClientStartState, DpnsNameStateEntry, PersistenceCapabilities, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, }; +use crate::error::PlatformWalletError; use crate::wallet::platform_wallet::WalletId; use dpp::prelude::Identifier; @@ -66,6 +67,12 @@ impl WalletPersister { self.inner.store(self.wallet_id, changeset) } + /// Preserve backend failure kinds, granting retry only when the backend + /// guarantees a failed store retained and committed nothing. + pub(crate) fn classify_store_failure(&self, error: PersistenceError) -> PlatformWalletError { + PlatformWalletError::from_store_failure(self.inner.as_ref(), error) + } + pub(crate) fn flush(&self) -> Result<(), PersistenceError> { self.inner.flush(self.wallet_id) } diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 06ba7152022..0e24ba122ba 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -35,7 +35,6 @@ use crate::error::PlatformWalletError; use dash_sdk::platform::transition::put_settings::PutSettings; use dpp::address_funds::PlatformAddress; use dpp::fee::Credits; -use dpp::identity::accessors::IdentitySettersV0; use dpp::identity::signer::Signer; use dpp::identity::{Identity, IdentityPublicKey}; use dpp::prelude::Identifier; @@ -1467,18 +1466,19 @@ impl PlatformWallet { let _shield_guard = self.shield_guard.lock().await; let keyset = self.derive_spend_keyset(seed, account).await?; - let proven_balance = super::shielded::operations::identity_top_up_from_pool( - &self.sdk, - coordinator.store(), - Some(&self.persister), - self.wallet_id, - &keyset, - account, - *identity_id, - amount, - &prover, - ) - .await?; + let (proven_balance, proof_height) = + super::shielded::operations::identity_top_up_from_pool_with_height( + &self.sdk, + coordinator.store(), + Some(&self.persister), + self.wallet_id, + &keyset, + account, + *identity_id, + amount, + &prover, + ) + .await?; // The target may be one of this wallet's identities. Apply the proof-attested // balance rather than adding `amount` locally: the fee is carved from the @@ -1490,7 +1490,7 @@ impl PlatformWallet { .get_wallet_info_mut(&self.wallet_id) .and_then(|info| info.identity_manager.managed_identity_mut(identity_id)); if let Some(managed) = managed { - managed.identity.set_balance(balance); + managed.set_confirmed_balance(balance, proof_height); if let Err(e) = self.persister.store(managed.snapshot_changeset().into()) { tracing::error!( identity = %identity_id, @@ -2078,7 +2078,7 @@ impl PlatformWallet { })? .clone() }; - let new_balance = super::shielded::operations::shield_from_identity_to( + let (new_balance, proof_height) = super::shielded::operations::shield_from_identity_to( &self.sdk, coordinator.store(), Some(&self.persister), @@ -2104,7 +2104,7 @@ impl PlatformWallet { .get_wallet_info_mut(&self.wallet_id) .and_then(|info| info.identity_manager.managed_identity_mut(identity_id)); if let Some(managed) = managed { - managed.identity.set_balance(new_balance); + managed.set_confirmed_balance(new_balance, proof_height); if let Err(e) = self.persister.store(managed.snapshot_changeset().into()) { // Broadcast already happened. Returning a transaction error // could prompt a second payment; it cannot undo the debit. diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 0eda804a40d..76f4fa469c0 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -938,7 +938,7 @@ pub(in crate::wallet) async fn shield_from_identity_to< memo: [u8; 36], signer: &Sig, prover: &P, -) -> Result { +) -> Result<(Credits, u64), PlatformWalletError> { let ShieldRecipient { address: recipient_addr, counterparty: external_counterparty, @@ -1060,18 +1060,19 @@ pub(in crate::wallet) async fn shield_from_identity_to< // `wait_for_affected_state` only converts the proof generically, so the variant // and the identity are enforced here: only this identity's balance proof is // accepted as the post-debit balance. - let proof_outcome: Result = match state_transition - .wait_for_affected_state::(sdk, None) + let proof_outcome: Result<(Credits, u64), String> = match state_transition + .wait_for_affected_state_with_metadata::(sdk, None) .await { - Ok(StateTransitionProofResult::VerifiedPartialIdentity(partial)) + Ok((StateTransitionProofResult::VerifiedPartialIdentity(partial), metadata)) if partial.id == identity_id => { partial .balance .ok_or_else(|| "the identity proof did not include the updated balance".to_string()) + .map(|balance| (balance, metadata.height)) } - Ok(StateTransitionProofResult::VerifiedPartialIdentity(partial)) => Err(format!( + Ok((StateTransitionProofResult::VerifiedPartialIdentity(partial), _)) => Err(format!( "the proof returned identity {} but {} initiated the shield", partial.id, identity_id )), @@ -1362,6 +1363,36 @@ pub async fn identity_top_up_from_pool( amount: u64, prover: &P, ) -> Result, PlatformWalletError> { + identity_top_up_from_pool_with_height( + sdk, + store, + persister, + wallet_id, + keys, + account, + identity_id, + amount, + prover, + ) + .await + .map(|(result, _)| result) +} + +#[allow(clippy::too_many_arguments)] +pub(in crate::wallet) async fn identity_top_up_from_pool_with_height< + S: ShieldedStore, + P: OrchardProver, +>( + sdk: &Arc, + store: &Arc>, + persister: Option<&WalletPersister>, + wallet_id: WalletId, + keys: &OrchardKeySet, + account: u32, + identity_id: Identifier, + amount: u64, + prover: &P, +) -> Result<(Option, u64), PlatformWalletError> { let views = keys.viewing_keys(); let change_addr = default_orchard_address(&views)?; let id = SubwalletId::new(wallet_id, account); @@ -1431,7 +1462,7 @@ pub async fn identity_top_up_from_pool( // still proves the reserved notes are consumed and authenticates the // credited identity's balance; the shield, shield-from-identity and // identity-create paths accept the same class of outcome. - broadcast_shielded_spend_with_redrive( + broadcast_shielded_spend_with_redrive_with_height( sdk, store, id, @@ -1447,7 +1478,7 @@ pub async fn identity_top_up_from_pool( .await; match result { - Ok(proof) => { + Ok((proof, proof_height)) => { record_activity_status( store, persister, @@ -1500,7 +1531,7 @@ pub async fn identity_top_up_from_pool( None } }; - Ok(proven_balance) + Ok((proven_balance, proof_height)) } Err(e @ PlatformWalletError::ShieldedSpendUnconfirmed { .. }) => Err(e), Err(e) => { @@ -2903,6 +2934,33 @@ async fn broadcast_shielded_spend_with_redrive( operation: &'static str, wait: SpendResultWait, ) -> Result { + broadcast_shielded_spend_with_redrive_with_height( + sdk, + store, + id, + pending_entry, + anchor, + notes, + state_transition, + operation, + wait, + ) + .await + .map(|(result, _)| result) +} + +#[allow(clippy::too_many_arguments)] +async fn broadcast_shielded_spend_with_redrive_with_height( + sdk: &Arc, + store: &Arc>, + id: SubwalletId, + pending_entry: &Option, + anchor: [u8; 32], + notes: &[ShieldedNote], + state_transition: &StateTransition, + operation: &'static str, + wait: SpendResultWait, +) -> Result<(StateTransitionProofResult, u64), PlatformWalletError> { let result = broadcast_shielded_spend(sdk, state_transition, operation, wait).await; if matches!( &result, @@ -3314,7 +3372,7 @@ async fn broadcast_shielded_spend( state_transition: &StateTransition, operation: &'static str, wait: SpendResultWait, -) -> Result { +) -> Result<(StateTransitionProofResult, u64), PlatformWalletError> { match state_transition.broadcast(sdk, None).await { Ok(()) => {} Err(e) if broadcast_definitely_failed(&e) => { @@ -3338,16 +3396,18 @@ async fn broadcast_shielded_spend( let waited = match wait { SpendResultWait::ExecutionProved => { state_transition - .wait_for_response::(sdk, None) + .wait_for_response_with_metadata::(sdk, None) .await } SpendResultWait::AffectedState => { state_transition - .wait_for_affected_state::(sdk, None) + .wait_for_affected_state_with_metadata::(sdk, None) .await } }; - waited.map_err(|wait_err| classify_spend_wait_failure(operation, &wait_err)) + waited + .map(|(proof, metadata)| (proof, metadata.height)) + .map_err(|wait_err| classify_spend_wait_failure(operation, &wait_err)) } /// Classify a `wait_for_response` failure for an already-broadcast diff --git a/packages/rs-sdk/src/platform/transition/top_up_identity.rs b/packages/rs-sdk/src/platform/transition/top_up_identity.rs index 13b1257f5a2..371ef9dbbcc 100644 --- a/packages/rs-sdk/src/platform/transition/top_up_identity.rs +++ b/packages/rs-sdk/src/platform/transition/top_up_identity.rs @@ -47,6 +47,23 @@ pub trait TopUpIdentity: Waitable { AS: dpp::key_wallet::signer::Signer + Send + Sync; } +/// Balance operations that also expose the committed proof height. +#[async_trait::async_trait] +pub trait TopUpIdentityWithHeight: Waitable { + /// Returns the confirmed balance result and its proof block height. + #[cfg(feature = "core_key_wallet")] + async fn top_up_identity_with_signer_with_height( + &self, + sdk: &Sdk, + asset_lock_proof: AssetLockProof, + asset_lock_proof_path: &dpp::key_wallet::bip32::DerivationPath, + asset_lock_signer: &AS, + settings: Option, + ) -> Result<(u64, u64), Error> + where + AS: dpp::key_wallet::signer::Signer + Send + Sync; +} + #[async_trait::async_trait] impl TopUpIdentity for Identity { async fn top_up_identity_with_private_key( @@ -86,6 +103,32 @@ impl TopUpIdentity for Identity { asset_lock_signer: &AS, settings: Option, ) -> Result + where + AS: dpp::key_wallet::signer::Signer + Send + Sync, + { + self.top_up_identity_with_signer_with_height( + sdk, + asset_lock_proof, + asset_lock_proof_path, + asset_lock_signer, + settings, + ) + .await + .map(|(balance, _)| balance) + } +} + +#[async_trait::async_trait] +impl TopUpIdentityWithHeight for Identity { + #[cfg(feature = "core_key_wallet")] + async fn top_up_identity_with_signer_with_height( + &self, + sdk: &Sdk, + asset_lock_proof: AssetLockProof, + asset_lock_proof_path: &dpp::key_wallet::bip32::DerivationPath, + asset_lock_signer: &AS, + settings: Option, + ) -> Result<(u64, u64), Error> where AS: dpp::key_wallet::signer::Signer + Send + Sync, { @@ -103,12 +146,13 @@ impl TopUpIdentity for Identity { ) .await?; ensure_valid_state_transition_structure(&state_transition, sdk.version())?; - let identity: PartialIdentity = state_transition - .broadcast_and_wait_for_affected_state(sdk, settings) + let (identity, metadata): (PartialIdentity, _) = state_transition + .broadcast_and_wait_for_affected_state_with_metadata(sdk, settings) .await?; identity .balance .ok_or(Error::Generic("expected an identity balance".to_string())) + .map(|balance| (balance, metadata.height)) } } diff --git a/packages/rs-sdk/src/platform/transition/transfer.rs b/packages/rs-sdk/src/platform/transition/transfer.rs index 5908acc91b6..191e342a7b8 100644 --- a/packages/rs-sdk/src/platform/transition/transfer.rs +++ b/packages/rs-sdk/src/platform/transition/transfer.rs @@ -35,6 +35,21 @@ pub trait TransferToIdentity: Waitable { ) -> Result<(u64, u64), Error>; } +/// Balance operations that also expose the committed proof height. +#[async_trait::async_trait] +pub trait TransferToIdentityWithHeight: Waitable { + /// Returns the confirmed balance result and its proof block height. + async fn transfer_credits_with_height + Send>( + &self, + sdk: &Sdk, + to_identity_id: Identifier, + amount: u64, + signing_transfer_key_to_use: Option<&IdentityPublicKey>, + signer: S, + settings: Option, + ) -> Result<((u64, u64), u64), Error>; +} + #[async_trait::async_trait] impl TransferToIdentity for Identity { async fn transfer_credits + Send>( @@ -46,6 +61,30 @@ impl TransferToIdentity for Identity { signer: S, settings: Option, ) -> Result<(u64, u64), Error> { + self.transfer_credits_with_height( + sdk, + to_identity_id, + amount, + signing_transfer_key_to_use, + signer, + settings, + ) + .await + .map(|(balance, _)| balance) + } +} + +#[async_trait::async_trait] +impl TransferToIdentityWithHeight for Identity { + async fn transfer_credits_with_height + Send>( + &self, + sdk: &Sdk, + to_identity_id: Identifier, + amount: u64, + signing_transfer_key_to_use: Option<&IdentityPublicKey>, + signer: S, + settings: Option, + ) -> Result<((u64, u64), u64), Error> { let new_identity_nonce = sdk.get_identity_nonce(self.id(), true, settings).await?; let user_fee_increase = settings.and_then(|settings| settings.user_fee_increase); let state_transition = IdentityCreditTransferTransition::try_from_identity( @@ -62,9 +101,10 @@ impl TransferToIdentity for Identity { .await?; ensure_valid_state_transition_structure(&state_transition, sdk.version())?; - let (sender, receiver): (PartialIdentity, PartialIdentity) = state_transition - .broadcast_and_wait_for_affected_state(sdk, settings) - .await?; + let ((sender, receiver), metadata): ((PartialIdentity, PartialIdentity), _) = + state_transition + .broadcast_and_wait_for_affected_state_with_metadata(sdk, settings) + .await?; let sender_balance = sender.balance.ok_or_else(|| { Error::Generic("expected an identity balance after transfer (sender)".to_string()) @@ -74,6 +114,6 @@ impl TransferToIdentity for Identity { Error::Generic("expected an identity balance after transfer (receiver)".to_string()) })?; - Ok((sender_balance, receiver_balance)) + Ok(((sender_balance, receiver_balance), metadata.height)) } } diff --git a/packages/rs-sdk/src/platform/transition/withdraw_from_identity.rs b/packages/rs-sdk/src/platform/transition/withdraw_from_identity.rs index a4d80b84b54..d9dfd7b2792 100644 --- a/packages/rs-sdk/src/platform/transition/withdraw_from_identity.rs +++ b/packages/rs-sdk/src/platform/transition/withdraw_from_identity.rs @@ -34,6 +34,23 @@ pub trait WithdrawFromIdentity { ) -> Result; } +/// Balance operations that also expose the committed proof height. +#[async_trait::async_trait] +pub trait WithdrawFromIdentityWithHeight { + /// Returns the confirmed balance result and its proof block height. + #[allow(clippy::too_many_arguments)] + async fn withdraw_with_height + Send>( + &self, + sdk: &Sdk, + address: Option
, + amount: u64, + core_fee_per_byte: Option, + signing_withdrawal_key_to_use: Option<&IdentityPublicKey>, + signer: S, + settings: Option, + ) -> Result<(u64, u64), Error>; +} + #[async_trait::async_trait] impl WithdrawFromIdentity for Identity { async fn withdraw + Send>( @@ -46,6 +63,33 @@ impl WithdrawFromIdentity for Identity { signer: S, settings: Option, ) -> Result { + self.withdraw_with_height( + sdk, + address, + amount, + core_fee_per_byte, + signing_withdrawal_key_to_use, + signer, + settings, + ) + .await + .map(|(balance, _)| balance) + } +} + +#[async_trait::async_trait] +impl WithdrawFromIdentityWithHeight for Identity { + #[allow(clippy::too_many_arguments)] + async fn withdraw_with_height + Send>( + &self, + sdk: &Sdk, + address: Option
, + amount: u64, + core_fee_per_byte: Option, + signing_withdrawal_key_to_use: Option<&IdentityPublicKey>, + signer: S, + settings: Option, + ) -> Result<(u64, u64), Error> { let new_identity_nonce = sdk.get_identity_nonce(self.id(), true, settings).await?; let script = address.map(|address| CoreScript::new(address.script_pubkey())); let user_fee_increase = settings.and_then(|settings| settings.user_fee_increase); @@ -66,16 +110,17 @@ impl WithdrawFromIdentity for Identity { .await?; ensure_valid_state_transition_structure(&state_transition, sdk.version())?; - let result = state_transition - .broadcast_and_wait_for_affected_state(sdk, settings) + let (result, metadata) = state_transition + .broadcast_and_wait_for_affected_state_with_metadata(sdk, settings) .await?; match result { - StateTransitionProofResult::VerifiedPartialIdentity(identity) => { - identity.balance.ok_or(Error::Generic( + StateTransitionProofResult::VerifiedPartialIdentity(identity) => identity + .balance + .ok_or(Error::Generic( "expected an identity balance after withdrawal".to_string(), )) - } + .map(|balance| (balance, metadata.height)), _ => Err(Error::Generic("proved a non identity".to_string())), } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index 24555d75bc6..ef7b8a13bb5 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -92,7 +92,8 @@ public enum DashModelContainer { PersistentAssetLock.self, PersistentInvitation.self, PersistentMasternode.self, - PersistentTrackedMasternode.self + PersistentTrackedMasternode.self, + PersistentIdentityBalanceMetadata.self ] } @@ -225,7 +226,8 @@ public enum DashSchemaV1: VersionedSchema { } /// Unreleased V2 combines the tracked-masternode, asset-lock recipient, sweep, -/// public-key usage limits, and contract-bound variants. V1 is the accepted +/// public-key usage limits, contract-bound variants, and balance freshness metadata. +/// V1 is the accepted /// historical baseline. Intermediate beta layouts are not supported release schemas. /// /// After App Store publication a separate DashSchemaSnapshotV2 preserves the diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentityBalanceMetadata.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentityBalanceMetadata.swift new file mode 100644 index 00000000000..db0d21b4886 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentityBalanceMetadata.swift @@ -0,0 +1,26 @@ +import Foundation +import SwiftData + +/// Freshness of the identity balance, committed atomically with its identity row. +/// A separate entity preserves every released identity model's schema hash. +@Model +public final class PersistentIdentityBalanceMetadata { + #Unique([\.networkRaw, \.walletId, \.identityId]) + public var networkRaw: UInt32 + public var walletId: Data + public var identityId: Data + // Bit patterns preserve the full unsigned FFI domain in SQLite integers. + public var platformHeight: Int64 + public var coreHeight: UInt32 + public var timestampMillis: Int64 + + public init(networkRaw: UInt32, walletId: Data, identityId: Data, + platformHeight: UInt64, coreHeight: UInt32, timestampMillis: UInt64) { + self.networkRaw = networkRaw + self.walletId = walletId + self.identityId = identityId + self.platformHeight = Int64(bitPattern: platformHeight) + self.coreHeight = coreHeight + self.timestampMillis = Int64(bitPattern: timestampMillis) + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 366f5814a08..034f205fe1b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -3199,6 +3199,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // so `upsertUtxo` has them in hand (see `roundUtxoCreditVerdicts`). extensionCallbacks.on_persist_wallet_changeset_utxo_verdicts_fn = persistWalletChangesetUtxoVerdictsCallback + extensionCallbacks.on_persist_identity_balance_block_time_fn = persistIdentityBalanceBlockTimeCallback + extensionCallbacks.on_load_identity_balance_block_time_fn = loadIdentityBalanceBlockTimeCallback return extensionCallbacks } @@ -4915,6 +4917,56 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { return (written, skipped, failed) } + // MARK: - Identity balance freshness (additive persistence extension) + + private func balanceMetadataDescriptor(walletId: Data, identityId: Data) throws + -> FetchDescriptor { + guard let network = self.network ?? walletNetwork(walletId: walletId) else { + throw PlatformWalletError.walletOperation("Cannot resolve identity balance metadata network") + } + let networkRaw = network.rawValue + return FetchDescriptor(predicate: #Predicate { + $0.networkRaw == networkRaw && $0.walletId == walletId && $0.identityId == identityId + }) + } + + func persistIdentityBalanceBlockTime(walletId: Data, identityId: Data, blockTime: BlockTime?) throws { + try onQueue { + guard inChangeset else { + throw PlatformWalletError.walletOperation("Balance metadata requires an identity changeset") + } + let descriptor = try balanceMetadataDescriptor(walletId: walletId, identityId: identityId) + let existing = try backgroundContext.fetch(descriptor).first + guard let blockTime else { + if let existing { backgroundContext.delete(existing) } + return + } + if let existing { + existing.platformHeight = Int64(bitPattern: blockTime.height) + existing.coreHeight = blockTime.core_height + existing.timestampMillis = Int64(bitPattern: blockTime.timestamp) + } else { + guard let network = self.network ?? walletNetwork(walletId: walletId) else { + throw PlatformWalletError.walletOperation("Cannot resolve identity balance metadata network") + } + backgroundContext.insert(PersistentIdentityBalanceMetadata( + networkRaw: network.rawValue, walletId: walletId, identityId: identityId, + platformHeight: blockTime.height, coreHeight: blockTime.core_height, + timestampMillis: blockTime.timestamp)) + } + // endChangeset performs the atomic save with the balance itself. + } + } + + func loadIdentityBalanceBlockTime(walletId: Data, identityId: Data) throws -> BlockTime? { + try onQueue { + let descriptor = try balanceMetadataDescriptor(walletId: walletId, identityId: identityId) + guard let row = try backgroundContext.fetch(descriptor).first else { return nil } + return BlockTime(height: UInt64(bitPattern: row.platformHeight), core_height: row.coreHeight, + timestamp: UInt64(bitPattern: row.timestampMillis)) + } + } + // MARK: - Identity snapshot structs /// Swift-side snapshot of the Rust `IdentityEntryFFI` with C @@ -6295,6 +6347,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) let walletRow = try backgroundContext.fetch(walletDescriptor).first let walletNetwork = walletRow?.network + if let metadataNetwork = self.network ?? walletNetwork { + let raw = metadataNetwork.rawValue + let metadata = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId && $0.networkRaw == raw }) + for row in try backgroundContext.fetch(metadata) { + backgroundContext.delete(row) + } + } if let walletRow = walletRow { // Wallet → identities is `.nullify`; this delete @@ -11470,3 +11530,34 @@ extension PlatformWalletPersistenceHandler { } } } + +private func persistIdentityBalanceBlockTimeCallback( + context: UnsafeMutableRawPointer?, walletId: UnsafePointer?, + identityId: UnsafePointer?, blockTime: UnsafePointer? +) -> Int32 { + guard let context, let walletId, let identityId else { return -1 } + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + do { + try handler.persistIdentityBalanceBlockTime( + walletId: Data(bytes: walletId, count: 32), identityId: Data(bytes: identityId, count: 32), + blockTime: blockTime?.pointee) + return 0 + } catch { return -1 } +} + +private func loadIdentityBalanceBlockTimeCallback( + context: UnsafeMutableRawPointer?, walletId: UnsafePointer?, identityId: UnsafePointer?, + outFound: UnsafeMutablePointer?, outBlockTime: UnsafeMutablePointer? +) -> Int32 { + guard let context, let walletId, let identityId, let outFound, let outBlockTime else { return -1 } + outFound.pointee = false + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + do { + if let stamp = try handler.loadIdentityBalanceBlockTime( + walletId: Data(bytes: walletId, count: 32), identityId: Data(bytes: identityId, count: 32)) { + outBlockTime.pointee = stamp + outFound.pointee = true + } + return 0 + } catch { return -1 } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 2827c6921b1..a68427794de 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -256,6 +256,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { case errorShieldedRecoveryCorrupted = 56 /// Recovery needs its account and compatible keys; damaged ciphertext can look the same. case errorShieldedRecoveryKeysRequired = 57 + /// Platform returned no balance. Retrying the read is safe; ownership is unchanged. + case errorIdentityBalanceUnavailable = 58 /// The named thing does not exist. Besides the handle/lookup failures this /// has always covered, BOTH deferred-send paths report the /// wallet-was-REMOVED case here. @@ -381,6 +383,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorShieldedRecoveryCorrupted case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHIELDED_RECOVERY_KEYS_REQUIRED: self = .errorShieldedRecoveryKeysRequired + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_IDENTITY_BALANCE_UNAVAILABLE: + self = .errorIdentityBalanceUnavailable case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -516,6 +520,8 @@ public enum PlatformWalletError: LocalizedError { case invalidNetwork(String) case walletOperation(String) case identityNotFound(String) + /// A managed identity has no balance in the Platform response; the read may be retried. + case identityBalanceUnavailable(String) case contactNotFound(String) case utf8Conversion(String) case serialization(String) @@ -752,7 +758,7 @@ public enum PlatformWalletError: LocalizedError { switch self { case .nullPointer(let m), .invalidHandle(let m), .invalidParameter(let m), .invalidIdentifier(let m), .invalidNetwork(let m), .walletOperation(let m), - .identityNotFound(let m), .contactNotFound(let m), .utf8Conversion(let m), + .identityNotFound(let m), .identityBalanceUnavailable(let m), .contactNotFound(let m), .utf8Conversion(let m), .serialization(let m), .deserialization(let m), .memoryAllocation(let m), .arithmeticOverflow(let m), .noSelectableInputs(let m), .coreInsufficientFunds(let m), @@ -859,6 +865,7 @@ public enum PlatformWalletError: LocalizedError { case .errorDeserialization: self = .deserialization(detail) case .errorWalletOperation: self = .walletOperation(detail) case .errorIdentityNotFound: self = .identityNotFound(detail) + case .errorIdentityBalanceUnavailable: self = .identityBalanceUnavailable(detail) case .errorContactNotFound: self = .contactNotFound(detail) case .errorInvalidNetwork: self = .invalidNetwork(detail) case .errorInvalidIdentifier: self = .invalidIdentifier(detail) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift index e0c4e28ff6a..1b3ba788568 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift @@ -342,6 +342,33 @@ final class DashLegacySchemaMigrationTests: XCTestCase { } } + func testHistoricalBridgeAddsEmptyWatermarkStorageAndPreservesNewStampOnReopen() throws { + try withStore { url in + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + try verifyRows(container.mainContext) + XCTAssertEqual(try container.mainContext.fetchCount( + FetchDescriptor()), 0) + container.mainContext.insert(PersistentIdentityBalanceMetadata( + networkRaw: Network.testnet.rawValue, walletId: Data(repeating: 0x61, count: 32), + identityId: Data(repeating: 0x73, count: 32), platformHeight: 42, + coreHeight: 7, timestampMillis: 123)) + try container.mainContext.save() + } + let reopened = try open(url, hooks: .init(visit: { _, _ in + XCTFail("Persisting a watermark must not trigger another legacy migration") + })) + try verifyRows(reopened.mainContext) + let metadata = try XCTUnwrap(reopened.mainContext.fetch( + FetchDescriptor()).first) + XCTAssertEqual(metadata.walletId, Data(repeating: 0x61, count: 32)) + XCTAssertEqual(metadata.identityId, Data(repeating: 0x73, count: 32)) + XCTAssertEqual(metadata.platformHeight, 42) + XCTAssertEqual(metadata.coreHeight, 7) + XCTAssertEqual(metadata.timestampMillis, 123) + } + } + func testWalletDeletionRemovesMigrationSnapshotsWithoutReopeningCachedContainer() throws { try withStore { url in let survivorId = Data(repeating: 0x63, count: 32) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift index e7b2182783a..6b3afc29a50 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -341,11 +341,37 @@ final class DashModelMigrationTests: XCTestCase { } } - func testV2AddsTrackedMasternodesToTheBaselineEntitySet() { + func testV2AddsTrackedMasternodesAndBalanceMetadataToTheBaselineEntitySet() { XCTAssertEqual( Set(Schema(versionedSchema: DashSchemaV2.self).entities.map(\.name)) .subtracting(Schema(versionedSchema: DashSchemaV1.self).entities.map(\.name)), - ["PersistentTrackedMasternode"]) + ["PersistentTrackedMasternode", "PersistentIdentityBalanceMetadata"]) + } + + @MainActor + func testV1BalanceGetsNoWatermarkUntilOneIsPersistedInLiveV2() throws { + let (directory, url) = try copyFixture(Self.fixtures[0]) + defer { try? FileManager.default.removeItem(at: directory) } + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + let context = container.mainContext + XCTAssertEqual(try context.fetch(FetchDescriptor()).first?.balance, 5) + XCTAssertEqual(try context.fetchCount(FetchDescriptor()), 0) + context.insert(PersistentIdentityBalanceMetadata( + networkRaw: Network.testnet.rawValue, walletId: Self.fixtureWalletId, + identityId: Self.fixtureIdentityId, platformHeight: 42, coreHeight: 7, + timestampMillis: 123)) + try context.save() + } + let reopened = try DashModelContainer.create(url: url) + let metadata = try XCTUnwrap(reopened.mainContext.fetch( + FetchDescriptor()).first) + XCTAssertEqual(metadata.identityId, Self.fixtureIdentityId) + XCTAssertEqual(metadata.walletId, Self.fixtureWalletId) + XCTAssertEqual(metadata.platformHeight, 42) + XCTAssertEqual(metadata.coreHeight, 7) + XCTAssertEqual(metadata.timestampMillis, 123) + XCTAssertEqual(try reopened.mainContext.fetch(FetchDescriptor()).first?.balance, 5) } /// The accepted V1 graph predates key limits. Its keys must arrive in diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityBalanceMetadataPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityBalanceMetadataPersistenceTests.swift new file mode 100644 index 00000000000..cbe1679e300 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityBalanceMetadataPersistenceTests.swift @@ -0,0 +1,121 @@ +import XCTest +import SwiftData +import DashSDKFFI +@testable import SwiftDashSDK + +final class IdentityBalanceMetadataPersistenceTests: XCTestCase { + private let walletId = Data(repeating: 42, count: 32) + private let identityId = Data(repeating: 7, count: 32) + + private func persist(_ handler: PlatformWalletPersistenceHandler, balance: UInt64, + stamp: BlockTime?, success: Bool = true) throws { + handler.beginChangeset(walletId: walletId) + handler.persistIdentities(walletId: walletId, upserts: [.init( + identityId: identityId, balance: balance, revision: 1, identityIndex: 0, + label: nil, status: 2, walletId: walletId, dpnsNames: [], + dashpayProfile: nil, contactProfiles: [])], removed: []) + try handler.persistIdentityBalanceBlockTime(walletId: walletId, identityId: identityId, blockTime: stamp) + XCTAssertEqual(handler.endChangeset(walletId: walletId, success: success), success) + } + + private func seedWallet(_ container: ModelContainer) throws { + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + } + + func testBalanceAndWatermarkCommitAndRollbackTogether() throws { + let container = try DashModelContainer.createInMemory() + try seedWallet(container) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + try persist(handler, balance: 100, stamp: BlockTime(height: 20, core_height: 10, timestamp: 999)) + try persist(handler, balance: 50, stamp: BlockTime(height: 21, core_height: 11, timestamp: 1000), success: false) + let rows = try ModelContext(container).fetch(FetchDescriptor()) + XCTAssertEqual(rows.first?.balance, 100) + let stamp = try XCTUnwrap(handler.loadIdentityBalanceBlockTime(walletId: walletId, identityId: identityId)) + XCTAssertEqual(stamp.height, 20) + XCTAssertEqual(stamp.timestamp, 999) + } + + func testMetadataSurvivesClosingAndReopeningStore() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("wallet.store") + try writeStore(url) + let reopened = try DashModelContainer.create(url: url) + let handler = PlatformWalletPersistenceHandler(modelContainer: reopened, network: .testnet) + let stamp = try XCTUnwrap(handler.loadIdentityBalanceBlockTime(walletId: walletId, identityId: identityId)) + XCTAssertEqual(stamp.height, UInt64.max) + XCTAssertEqual(stamp.core_height, UInt32.max) + XCTAssertEqual(stamp.timestamp, UInt64.max) + XCTAssertEqual(try ModelContext(reopened).fetch(FetchDescriptor()).first?.balance, 123) + } + + private func writeStore(_ url: URL) throws { + let container = try DashModelContainer.create(url: url) + try seedWallet(container) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + try persist(handler, balance: 123, stamp: BlockTime(height: .max, core_height: .max, timestamp: .max)) + } + + func testAbsentAndZeroMetadataAreDistinctAndScopedByNetworkAndWallet() throws { + let container = try DashModelContainer.createInMemory() + try seedWallet(container) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertNil(try handler.loadIdentityBalanceBlockTime(walletId: walletId, identityId: identityId)) + try persist(handler, balance: 0, stamp: BlockTime(height: 0, core_height: 0, timestamp: 0)) + XCTAssertNotNil(try handler.loadIdentityBalanceBlockTime(walletId: walletId, identityId: identityId)) + XCTAssertNil(try handler.loadIdentityBalanceBlockTime(walletId: Data(repeating: 43, count: 32), identityId: identityId)) + let otherNetwork = PlatformWalletPersistenceHandler(modelContainer: container, network: .mainnet) + XCTAssertNil(try otherNetwork.loadIdentityBalanceBlockTime(walletId: walletId, identityId: identityId)) + try persist(handler, balance: 0, stamp: nil) + XCTAssertNil(try handler.loadIdentityBalanceBlockTime(walletId: walletId, identityId: identityId)) + } + + func testWalletDeletionRemovesMetadata() throws { + let container = try DashModelContainer.createInMemory() + try seedWallet(container) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + try persist(handler, balance: 1, stamp: BlockTime(height: 1, core_height: 1, timestamp: 1)) + try handler.deleteWalletData(walletId: walletId) + XCTAssertNil(try handler.loadIdentityBalanceBlockTime(walletId: walletId, identityId: identityId)) + } + + func testExtensionCallbacksRoundTripAndClearMetadata() throws { + let container = try DashModelContainer.createInMemory() + try seedWallet(container) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + let callbacks = handler.makePersistenceCallbacksExtension() + let persist = try XCTUnwrap(callbacks.on_persist_identity_balance_block_time_fn) + let load = try XCTUnwrap(callbacks.on_load_identity_balance_block_time_fn) + let context = Unmanaged.passUnretained(handler).toOpaque() + walletId.withUnsafeBytes { wallet in + identityId.withUnsafeBytes { identity in + let walletBytes = wallet.bindMemory(to: UInt8.self).baseAddress! + let identityBytes = identity.bindMemory(to: UInt8.self).baseAddress! + var stamp = BlockTime(height: 42, core_height: 7, timestamp: 123) + handler.beginChangeset(walletId: walletId) + XCTAssertEqual(persist(context, walletBytes, identityBytes, &stamp), 0) + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + var found = false + var loaded = BlockTime() + XCTAssertEqual(load(context, walletBytes, identityBytes, &found, &loaded), 0) + XCTAssertTrue(found) + XCTAssertEqual(loaded.height, 42) + XCTAssertEqual(loaded.core_height, 7) + XCTAssertEqual(loaded.timestamp, 123) + handler.beginChangeset(walletId: walletId) + XCTAssertEqual(persist(context, walletBytes, identityBytes, nil), 0) + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + XCTAssertEqual(load(context, walletBytes, identityBytes, &found, &loaded), 0) + XCTAssertFalse(found) + XCTAssertEqual(load(nil, walletBytes, identityBytes, &found, &loaded), -1) + } + } + } + + func testUnavailableBalanceHasDistinctSwiftError() throws { + XCTAssertEqual(PlatformWalletResultCode(ffi: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_IDENTITY_BALANCE_UNAVAILABLE).rawValue, 58) + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityBalanceMetadataSchemaTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityBalanceMetadataSchemaTests.swift new file mode 100644 index 00000000000..8d0fb78c04f --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityBalanceMetadataSchemaTests.swift @@ -0,0 +1,47 @@ +import XCTest +import SwiftData +@testable import SwiftDashSDK + +final class IdentityBalanceMetadataSchemaTests: XCTestCase { + func testFullWidthMetadataSurvivesStoreReopen() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("wallet.store") + try writeMetadata(url) + let reopened = try DashModelContainer.create(url: url) + let context = ModelContext(reopened) + let rows = try context.fetch(FetchDescriptor()) + XCTAssertEqual(rows.count, 1) + let row = try XCTUnwrap(rows.first) + XCTAssertEqual(UInt64(bitPattern: row.platformHeight), .max) + XCTAssertEqual(row.coreHeight, .max) + XCTAssertEqual(UInt64(bitPattern: row.timestampMillis), .max) + XCTAssertEqual(row.walletId, Data(repeating: 42, count: 32)) + XCTAssertEqual(row.identityId, Data(repeating: 7, count: 32)) + } + + private func writeMetadata(_ url: URL) throws { + let container = try DashModelContainer.create(url: url) + let context = ModelContext(container) + context.insert(PersistentIdentityBalanceMetadata( + networkRaw: Network.testnet.rawValue, walletId: Data(repeating: 42, count: 32), + identityId: Data(repeating: 7, count: 32), platformHeight: .max, + coreHeight: .max, timestampMillis: .max)) + try context.save() + } + + func testSameIdentityMetadataIsIsolatedByNetworkAndWallet() throws { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + for (network, wallet, height): (UInt32, UInt8, UInt64) in [(0, 42, 1), (1, 42, 2), (1, 43, 3)] { + context.insert(PersistentIdentityBalanceMetadata(networkRaw: network, + walletId: Data(repeating: wallet, count: 32), identityId: Data(repeating: 7, count: 32), + platformHeight: height, coreHeight: 0, timestampMillis: 0)) + } + try context.save() + let rows = try ModelContext(container).fetch(FetchDescriptor()) + XCTAssertEqual(rows.count, 3) + XCTAssertEqual(Set(rows.map { $0.platformHeight }), [1, 2, 3]) + } +} diff --git a/packages/swift-sdk/schema-models.json b/packages/swift-sdk/schema-models.json index 8eda24d7f8f..439a181f8e3 100644 --- a/packages/swift-sdk/schema-models.json +++ b/packages/swift-sdk/schema-models.json @@ -35,7 +35,8 @@ "PersistentAssetLock": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift", "PersistentInvitation": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentInvitation.swift", "PersistentMasternode": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentMasternode.swift", - "PersistentTrackedMasternode": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTrackedMasternode.swift" + "PersistentTrackedMasternode": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTrackedMasternode.swift", + "PersistentIdentityBalanceMetadata": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentityBalanceMetadata.swift" }, "value_types": [ { From ca22ce6753df6f4271471378b6f9e34c96d792a5 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 21 Sep 2026 11:42:41 +0200 Subject: [PATCH 3/9] fix(ci): quote Rust runner script paths with spaces --- .github/workflows/tests-rs-workspace.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/tests-rs-workspace.yml b/.github/workflows/tests-rs-workspace.yml index f6a979e1402..dfd70175092 100644 --- a/.github/workflows/tests-rs-workspace.yml +++ b/.github/workflows/tests-rs-workspace.yml @@ -32,6 +32,11 @@ jobs: || github.event.pull_request.head.repo.full_name == github.repository || github.event.pull_request.head.repo.owner.login == 'thepastaclaw' timeout-minutes: 90 + defaults: + run: + # Self-hosted macOS runners may keep their work directory on a volume + # with spaces in its name. Quote the generated script path explicitly. + shell: bash -e "{0}" steps: - name: Check out repo uses: actions/checkout@v4 From 3cc596c979dc2050a7f014fc86858e7aded1c004 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 21 Sep 2026 11:44:55 +0200 Subject: [PATCH 4/9] fix(swift): expose identity balance metadata in storage explorer --- .../Views/StorageExplorerView.swift | 8 +++++ .../Views/StorageModelListViews.swift | 33 +++++++++++++++++++ .../Views/StorageRecordDetailViews.swift | 23 +++++++++++++ 3 files changed, 64 insertions(+) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift index 64bd42c8867..a567917dedf 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift @@ -27,6 +27,13 @@ struct StorageExplorerView: View { modelRow("Identities", icon: "person.crop.circle", type: PersistentIdentity.self) { IdentityStorageListView(network: network) } + modelRow( + "Identity Balance Metadata", + icon: "clock.badge.checkmark", + type: PersistentIdentityBalanceMetadata.self + ) { + IdentityBalanceMetadataStorageListView(network: network) + } // Identity-relationship caches: cascade-owned by // `PersistentIdentity`, surfaced as their own explorer // sections so the row counts and per-row drill-downs @@ -277,6 +284,7 @@ struct StorageExplorerView: View { // Models with a direct `networkRaw` column — predicate-friendly, // no in-memory pass needed. directCount(PersistentIdentity.self, predicate: #Predicate { $0.networkRaw == raw }) + directCount(PersistentIdentityBalanceMetadata.self, predicate: #Predicate { $0.networkRaw == raw }) directCount(PersistentDPNSName.self, predicate: #Predicate { $0.networkRaw == raw }) directCount(PersistentDashpayProfile.self, predicate: #Predicate { $0.networkRaw == raw }) directCount(PersistentDashpayContactRequest.self, predicate: #Predicate { $0.networkRaw == raw }) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift index 6a735390481..b4627318cf0 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift @@ -30,6 +30,39 @@ struct IdentityStorageListView: View { } } +// MARK: - PersistentIdentityBalanceMetadata + +struct IdentityBalanceMetadataStorageListView: View { + let network: Network + @Query private var records: [PersistentIdentityBalanceMetadata] + + private var filtered: [PersistentIdentityBalanceMetadata] { + records.filter { $0.networkRaw == network.rawValue }.sorted { + // Stored Int64 values carry unsigned bit patterns, so sort the decoded heights. + UInt64(bitPattern: $0.platformHeight) > UInt64(bitPattern: $1.platformHeight) + } + } + + var body: some View { + let visible = filtered + List(visible) { record in + NavigationLink(destination: IdentityBalanceMetadataStorageDetailView(record: record)) { + VStack(alignment: .leading, spacing: 4) { + Text(record.identityId.toHexString()) + .font(.body).lineLimit(1).truncationMode(.middle) + Text("Wallet: \(record.walletId.toHexString())") + .font(.caption).foregroundColor(.secondary) + .lineLimit(1).truncationMode(.middle) + Text("Platform height \(UInt64(bitPattern: record.platformHeight))") + .font(.caption).foregroundColor(.secondary) + } + } + } + .navigationTitle("Balance Metadata (\(visible.count))") + .overlay { if visible.isEmpty { ContentUnavailableView("No Records", systemImage: "clock.badge.checkmark") } } + } +} + // MARK: - PersistentDocument struct DocumentStorageListView: View { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index 5a1007a6391..6bbceba4243 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -63,6 +63,29 @@ private func jsonString(_ data: Data?) -> String? { return str } +// MARK: - PersistentIdentityBalanceMetadata + +struct IdentityBalanceMetadataStorageDetailView: View { + let record: PersistentIdentityBalanceMetadata + + var body: some View { + Form { + Section("Identity") { + FieldRow(label: "Network", value: Network(rawValue: record.networkRaw)?.displayName ?? "raw \(record.networkRaw)") + FieldRow(label: "Wallet ID", value: hexString(record.walletId)) + FieldRow(label: "Identity ID", value: hexString(record.identityId)) + } + Section("Balance Freshness") { + FieldRow(label: "Platform Height", value: String(UInt64(bitPattern: record.platformHeight))) + FieldRow(label: "Core Height", value: String(record.coreHeight)) + FieldRow(label: "Timestamp (ms)", value: String(UInt64(bitPattern: record.timestampMillis))) + } + } + .navigationTitle("Balance Metadata") + .navigationBarTitleDisplayMode(.inline) + } +} + // MARK: - PersistentIdentity struct IdentityStorageDetailView: View { From 90ea75d9ad5ad51f77a36fe06a9c8cd092c37fff Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 21 Sep 2026 12:07:22 +0200 Subject: [PATCH 5/9] fix(wallet): retain verified balance snapshots through persistence retries --- .../src/changeset/changeset.rs | 5 +- .../src/wallet/identity/network/balance.rs | 253 +++++++++++++++--- .../wallet/identity/network/registration.rs | 22 +- .../identity/network/top_up_from_addresses.rs | 28 +- .../src/wallet/identity/network/transfer.rs | 22 +- .../identity/network/transfer_to_addresses.rs | 23 +- .../src/wallet/identity/network/withdrawal.rs | 20 +- .../state/managed_identity/identity_ops.rs | 2 + .../identity/state/managed_identity/mod.rs | 5 + .../identity/state/managed_identity/sync.rs | 117 +++++++- .../src/wallet/identity/types/block_time.rs | 23 ++ .../src/wallet/platform_wallet.rs | 38 ++- .../src/wallet/shielded/operations.rs | 31 ++- .../platform/transition/top_up_identity.rs | 22 +- .../top_up_identity_from_addresses.rs | 61 ++++- .../src/platform/transition/transfer.rs | 21 +- .../transition/transfer_to_addresses.rs | 39 ++- .../transition/withdraw_from_identity.rs | 21 +- 18 files changed, 570 insertions(+), 183 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 775639f2bb2..92f230733e4 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -934,12 +934,13 @@ impl IdentityEntry { /// [`ManagedIdentity::keys_snapshot_changeset`](crate::wallet::identity::ManagedIdentity) /// into an [`IdentityKeysChangeSet`]. pub fn from_managed(managed: &ManagedIdentity) -> Self { + let (balance, last_updated_balance_block_time) = managed.balance_snapshot_for_persistence(); Self { id: managed.identity.id(), - balance: managed.identity.balance(), + balance, revision: managed.identity.revision(), identity_index: managed.identity_index, - last_updated_balance_block_time: managed.last_updated_balance_block_time, + last_updated_balance_block_time, last_synced_keys_block_time: managed.last_synced_keys_block_time, dpns_names: managed.dpns_names.clone(), contested_dpns_names: managed.contested_dpns_names.clone(), diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/balance.rs b/packages/rs-platform-wallet/src/wallet/identity/network/balance.rs index 3a76fd52782..99d5b8b9eef 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/balance.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/balance.rs @@ -2,7 +2,7 @@ use dash_sdk::platform::Fetch; use dash_sdk::query_types::IdentityBalance; -use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; +use dpp::identity::accessors::IdentityGettersV0; use dpp::prelude::Identifier; use crate::error::PlatformWalletError; @@ -74,28 +74,7 @@ impl IdentityWallet { balance: u64, block_time: BlockTime, ) -> Result<(), PlatformWalletError> { - // Reject both older and equal-height responses: a confirmed local - // transaction at that height takes precedence over a refresh. - if managed - .last_updated_balance_block_time - .is_none_or(|previous| block_time.height > previous.height) - { - let mut candidate = managed.clone(); - candidate.identity.set_balance(balance); - candidate.last_updated_balance_block_time = Some(block_time); - self.persister - .store(candidate.snapshot_changeset().into()) - .map_err(|e| self.persister.classify_store_failure(e))?; - // Inline backends already committed; their flush callback is - // only a notification. Buffered backends must finish first. - if !self.persister.store_commits_inline() { - self.persister - .flush() - .map_err(|e| PlatformWalletError::Persistence(e.to_string()))?; - } - *managed = candidate; - } - Ok(()) + managed.persist_refreshed_balance(balance, block_time, &self.persister) } } @@ -334,11 +313,11 @@ mod tests { .identity_manager .wallet_identity_mut(&iw.wallet_id, &id) .unwrap(); - managed.set_confirmed_balance(AFTER_DPNS, proof_height); - iw.persister - .store(managed.snapshot_changeset().into()) - .unwrap(); - iw.persister.flush().unwrap(); + managed.persist_confirmed_balance( + AFTER_DPNS, + BlockTime::new(proof_height, 42, 1000), + &iw.persister, + ); } let before = backend.flush_count.load(Ordering::SeqCst); assert_eq!(iw.refresh_identity_balance(&id).await.unwrap(), AFTER_DPNS); @@ -405,7 +384,7 @@ mod tests { } #[tokio::test] - async fn should_persist_an_older_retry_after_a_failed_newer_store() { + async fn should_retry_the_newer_snapshot_before_accepting_an_older_response() { let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; let mut wm = iw.wallet_manager.write().await; let managed = wm @@ -423,17 +402,17 @@ mod tests { backend.fail_store.store(false, Ordering::SeqCst); iw.persist_refreshed_balance(managed, 200, BlockTime::new(9, 9, 9)) .unwrap(); - assert_eq!(managed.identity.balance(), 200); + assert_eq!(managed.identity.balance(), 100); let committed = backend.committed.lock().unwrap(); assert_eq!(committed.len(), 1); - assert_eq!(committed[0].1.balance, 200); + assert_eq!(committed[0].1.balance, 100); assert_eq!( committed[0] .1 .last_updated_balance_block_time .unwrap() .height, - 9 + 10 ); } #[tokio::test] @@ -448,7 +427,7 @@ mod tests { .unwrap(); iw.persist_refreshed_balance(managed, 300, BlockTime::new(8, 8, 8)) .unwrap(); - managed.set_confirmed_balance(100, 10); + managed.persist_confirmed_balance(100, BlockTime::new(10, 42, 1000), &iw.persister); for height in [9, 10] { iw.persist_refreshed_balance( managed, @@ -458,11 +437,11 @@ mod tests { .unwrap(); assert_eq!(managed.identity.balance(), 100); } - assert_eq!(backend.committed.lock().unwrap().len(), 1); + assert_eq!(backend.committed.lock().unwrap().len(), 2); iw.persist_refreshed_balance(managed, 50, BlockTime::new(11, 11, 11)) .unwrap(); assert_eq!(managed.identity.balance(), 50); - assert_eq!(backend.committed.lock().unwrap().len(), 2); + assert_eq!(backend.committed.lock().unwrap().len(), 3); } #[tokio::test] async fn should_preserve_store_failure_kind_with_backend_retry_guarantee() { @@ -486,4 +465,208 @@ mod tests { assert_eq!(local_balance(&iw, &id).await, OLD_BALANCE); } } + fn reload_balance(backend: &BalancePersister, id: &Identifier) -> (u64, Option) { + let mut reloaded = IdentityManager::new(); + for (_, entry) in backend.committed.lock().unwrap().iter() { + reloaded.apply_identity_entry(entry.clone()); + } + let managed = reloaded.identity(id).unwrap(); + ( + managed.identity.balance(), + managed.last_updated_balance_block_time, + ) + } + + #[tokio::test] + async fn should_retry_failed_height_ten_flush_before_height_nine_and_reload_ten() { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + let mut wm = iw.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&iw.wallet_id) + .unwrap() + .identity_manager + .wallet_identity_mut(&iw.wallet_id, &id) + .unwrap(); + let newer = BlockTime::new(10, 42, 1000); + backend.fail_flush.store(true, Ordering::SeqCst); + assert!(iw.persist_refreshed_balance(managed, 100, newer).is_err()); + assert_eq!(managed.identity.balance(), OLD_BALANCE); + assert!(iw + .persist_refreshed_balance(managed, 200, BlockTime::new(9, 41, 900)) + .is_err()); + assert!(backend + .queued + .lock() + .unwrap() + .iter() + .all(|(_, entry)| entry.balance == 100)); + backend.fail_flush.store(false, Ordering::SeqCst); + iw.persist_refreshed_balance(managed, 200, BlockTime::new(9, 41, 900)) + .unwrap(); + assert_eq!(managed.identity.balance(), 100); + assert_eq!(reload_balance(&backend, &id), (100, Some(newer))); + assert!(backend.queued.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn should_carry_pending_balance_through_an_unrelated_scalar_write() { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + let mut wm = iw.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&iw.wallet_id) + .unwrap() + .identity_manager + .wallet_identity_mut(&iw.wallet_id, &id) + .unwrap(); + let newer = BlockTime::new(10, 42, 1000); + backend.fail_flush.store(true, Ordering::SeqCst); + assert!(iw.persist_refreshed_balance(managed, 100, newer).is_err()); + // This used to queue the published OLD_BALANCE behind the height-10 row. + managed.update_keys_sync_block_time(BlockTime::new(11, 43, 1100), &iw.persister); + backend.fail_flush.store(false, Ordering::SeqCst); + iw.persister.flush().unwrap(); + assert_eq!(reload_balance(&backend, &id), (100, Some(newer))); + iw.persist_refreshed_balance(managed, 200, BlockTime::new(9, 41, 900)) + .unwrap(); + assert_eq!(managed.identity.balance(), 100); + assert_eq!( + managed.last_synced_keys_block_time, + Some(BlockTime::new(11, 43, 1100)) + ); + } + + #[tokio::test] + async fn should_retry_failed_confirmed_balance_store_before_equal_or_older_refresh() { + for refresh_height in [9, 10] { + for inline in [false, true] { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + let mut wm = iw.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&iw.wallet_id) + .unwrap() + .identity_manager + .wallet_identity_mut(&iw.wallet_id, &id) + .unwrap(); + let confirmed = BlockTime::new(10, 42, 1000); + backend.commits_inline.store(inline, Ordering::SeqCst); + backend.fail_store.store(true, Ordering::SeqCst); + assert_eq!( + managed.persist_confirmed_balance(100, confirmed, &iw.persister), + 100 + ); + assert_eq!(managed.last_updated_balance_block_time, Some(confirmed)); + assert!(!managed.needs_balance_update(1050, 100)); + assert!(backend.committed.lock().unwrap().is_empty()); + backend.fail_store.store(false, Ordering::SeqCst); + iw.persist_refreshed_balance(managed, 200, BlockTime::new(refresh_height, 41, 900)) + .unwrap(); + assert_eq!(reload_balance(&backend, &id), (100, Some(confirmed))); + } + } + } + + #[tokio::test] + async fn should_keep_one_snapshot_per_height_and_return_the_retained_balance() { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + let mut wm = iw.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&iw.wallet_id) + .unwrap() + .identity_manager + .wallet_identity_mut(&iw.wallet_id, &id) + .unwrap(); + let confirmed = BlockTime::new(10, 42, 1000); + assert_eq!( + managed.persist_confirmed_balance(100, confirmed, &iw.persister), + 100 + ); + for height in [9, 10] { + // A delayed result cannot replace the height-pinned state, return a + // different balance to the host, or queue an obsolete snapshot. + assert_eq!( + managed.persist_confirmed_balance( + 200, + BlockTime::new(height, 41, 900), + &iw.persister + ), + 100 + ); + } + assert_eq!(backend.committed.lock().unwrap().len(), 1); + assert_eq!(reload_balance(&backend, &id), (100, Some(confirmed))); + } + + #[tokio::test] + async fn should_compare_query_and_transaction_proofs_on_the_same_chain_height() { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + let mut wm = iw.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&iw.wallet_id) + .unwrap() + .identity_manager + .wallet_identity_mut(&iw.wallet_id, &id) + .unwrap(); + let query = BlockTime::new(1000, 42, 1000); + iw.persist_refreshed_balance(managed, 100, query).unwrap(); + assert_eq!( + managed.persist_confirmed_balance(200, BlockTime::new(999, 41, 900), &iw.persister), + 100 + ); + assert_eq!(backend.committed.lock().unwrap().len(), 1); + assert_eq!(reload_balance(&backend, &id), (100, Some(query))); + } + + #[tokio::test] + async fn should_replace_a_failed_query_write_with_a_newer_transaction_snapshot() { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + let mut wm = iw.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&iw.wallet_id) + .unwrap() + .identity_manager + .wallet_identity_mut(&iw.wallet_id, &id) + .unwrap(); + backend.fail_flush.store(true, Ordering::SeqCst); + assert!(iw + .persist_refreshed_balance(managed, 200, BlockTime::new(9, 41, 900)) + .is_err()); + backend.fail_flush.store(false, Ordering::SeqCst); + let confirmed = BlockTime::new(10, 42, 1000); + assert_eq!( + managed.persist_confirmed_balance(100, confirmed, &iw.persister), + 100 + ); + assert_eq!(reload_balance(&backend, &id), (100, Some(confirmed))); + let count = backend.committed.lock().unwrap().len(); + iw.persist_refreshed_balance(managed, 200, BlockTime::new(9, 41, 900)) + .unwrap(); + assert_eq!(backend.committed.lock().unwrap().len(), count); + } + #[tokio::test] + async fn should_publish_a_confirmation_matching_a_failed_query_without_losing_retry() { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + let mut wm = iw.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&iw.wallet_id) + .unwrap() + .identity_manager + .wallet_identity_mut(&iw.wallet_id, &id) + .unwrap(); + let confirmed = BlockTime::new(10, 42, 1000); + backend.fail_store.store(true, Ordering::SeqCst); + assert!(iw + .persist_refreshed_balance(managed, 100, confirmed) + .is_err()); + assert_eq!(managed.identity.balance(), OLD_BALANCE); + assert_eq!( + managed.persist_confirmed_balance(100, confirmed, &iw.persister), + 100 + ); + assert_eq!(managed.last_updated_balance_block_time, Some(confirmed)); + assert!(backend.committed.lock().unwrap().is_empty()); + backend.fail_store.store(false, Ordering::SeqCst); + iw.persist_refreshed_balance(managed, 200, BlockTime::new(9, 41, 900)) + .unwrap(); + assert_eq!(reload_balance(&backend, &id), (100, Some(confirmed))); + } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs b/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs index f86093d1908..517bb230f80 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/registration.rs @@ -62,13 +62,14 @@ use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundin use dash_sdk::platform::transition::put_identity::PutIdentity; use dash_sdk::platform::transition::put_settings::PutSettings; -use dash_sdk::platform::transition::top_up_identity::TopUpIdentityWithHeight; +use dash_sdk::platform::transition::top_up_identity::TopUpIdentityWithMetadata; use crate::error::{is_instant_lock_proof_invalid, PlatformWalletError}; use crate::wallet::asset_lock::orchestration::{ out_point_from_proof, submit_with_cl_height_retry, FundingResolution, ResolvedFunding, }; use crate::wallet::asset_lock::AssetLockFunding; +use crate::BlockTime; use super::*; @@ -466,7 +467,7 @@ impl IdentityWallet { // same outpoint. let proof_out_point = out_point_from_proof(&proof); let (submit_result, effective_proof) = match submit_with_cl_height_retry(settings, |s| { - identity.top_up_identity_with_signer_with_height( + identity.top_up_identity_with_signer_with_metadata( &self.sdk, proof.clone(), &path, @@ -489,7 +490,7 @@ impl IdentityWallet { .upgrade_to_chain_lock_proof(&out_point, None) .await?; let submit_result = submit_with_cl_height_retry(settings, |s| { - identity.top_up_identity_with_signer_with_height( + identity.top_up_identity_with_signer_with_metadata( &self.sdk, chain_proof.clone(), &path, @@ -502,7 +503,7 @@ impl IdentityWallet { } Err(e) => (Err(e), proof.clone()), }; - let (new_balance, proof_height) = self + let (mut new_balance, metadata) = self .asset_locks .reconcile_asset_lock_submit_result( submit_result, @@ -528,14 +529,11 @@ impl IdentityWallet { match wm.get_wallet_info_mut(&self.wallet_id) { Some(info) => { if let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) { - managed.set_confirmed_balance(new_balance, proof_height); - if let Err(e) = self.persister.store(managed.snapshot_changeset().into()) { - tracing::error!( - identity = %identity_id, - error = %e, - "Failed to persist identity balance update after top_up" - ); - } + new_balance = managed.persist_confirmed_balance( + new_balance, + BlockTime::from(metadata), + &self.persister, + ); } } None => { diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs b/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs index 5c01218315b..38096472926 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs @@ -6,7 +6,7 @@ use dpp::identity::signer::Signer; use dpp::prelude::Identifier; use dash_sdk::platform::transition::put_settings::PutSettings; -use dash_sdk::platform::transition::top_up_identity_from_addresses::TopUpIdentityFromAddresses; +use dash_sdk::platform::transition::top_up_identity_from_addresses::TopUpIdentityFromAddressesWithMetadata; use dpp::address_funds::PlatformAddress; use dpp::fee::Credits; @@ -14,6 +14,7 @@ use dpp::fee::Credits; use dash_sdk::query_types::AddressInfos; use crate::error::PlatformWalletError; +use crate::BlockTime; use super::*; @@ -71,8 +72,8 @@ impl IdentityWallet { .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))? }; - let (address_infos, new_balance, proof_height) = identity - .top_up_from_addresses(&self.sdk, inputs, address_signer, settings) + let (address_infos, mut new_balance, metadata) = identity + .top_up_from_addresses_with_metadata(&self.sdk, inputs, address_signer, settings) .await .map_err(|e| { crate::error::promote_address_nonce_error(&e).unwrap_or_else(|| { @@ -83,11 +84,9 @@ impl IdentityWallet { }) })?; - // Update the identity's balance in the local manager and - // queue the snapshot so the new balance survives relaunch. - // See the comment on `top_up` for rationale on driving the - // persister directly from the call site instead of through - // a dedicated `ManagedIdentity::set_balance` method. + let proof_height = metadata.height; + + // Reconcile the verified identity snapshot; failed cache writes remain pending. { let mut wm = self.wallet_manager.write().await; let info = wm.get_wallet_info_mut(&self.wallet_id).ok_or_else(|| { @@ -96,14 +95,11 @@ impl IdentityWallet { ) })?; if let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) { - managed.set_confirmed_balance(new_balance, proof_height); - if let Err(e) = self.persister.store(managed.snapshot_changeset().into()) { - tracing::error!( - identity = %identity_id, - error = %e, - "Failed to persist identity balance update after top_up_from_addresses" - ); - } + new_balance = managed.persist_confirmed_balance( + new_balance, + BlockTime::from(metadata), + &self.persister, + ); } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/transfer.rs b/packages/rs-platform-wallet/src/wallet/identity/network/transfer.rs index e291579b9b2..899d0532b61 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/transfer.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/transfer.rs @@ -11,9 +11,12 @@ use dpp::ProtocolError; use dpp::identity::signer::Signer; use dash_sdk::platform::transition::put_settings::PutSettings; -use dash_sdk::platform::transition::transfer::{TransferToIdentity, TransferToIdentityWithHeight}; +use dash_sdk::platform::transition::transfer::{ + TransferToIdentity, TransferToIdentityWithMetadata, +}; use crate::error::PlatformWalletError; +use crate::BlockTime; use super::*; @@ -95,8 +98,8 @@ impl IdentityWallet { .ok_or(PlatformWalletError::IdentityNotFound(*from_id))? }; - let ((sender_balance, _receiver_balance), proof_height) = identity - .transfer_credits_with_height( + let ((sender_balance, _receiver_balance), metadata) = identity + .transfer_credits_with_metadata( &self.sdk, *to_id, amount, @@ -127,14 +130,11 @@ impl IdentityWallet { ) })?; if let Some(managed) = info.identity_manager.managed_identity_mut(from_id) { - managed.set_confirmed_balance(sender_balance, proof_height); - if let Err(e) = self.persister.store(managed.snapshot_changeset().into()) { - tracing::error!( - identity = %from_id, - error = %e, - "Failed to persist identity balance update after transfer (external signer)" - ); - } + managed.persist_confirmed_balance( + sender_balance, + BlockTime::from(metadata), + &self.persister, + ); } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/transfer_to_addresses.rs b/packages/rs-platform-wallet/src/wallet/identity/network/transfer_to_addresses.rs index 24726bd8b72..12fd3eee08d 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/transfer_to_addresses.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/transfer_to_addresses.rs @@ -11,12 +11,13 @@ use dpp::prelude::Identifier; use dpp::ProtocolError; use dash_sdk::platform::transition::put_settings::PutSettings; -use dash_sdk::platform::transition::transfer_to_addresses::TransferToAddresses; +use dash_sdk::platform::transition::transfer_to_addresses::TransferToAddressesWithMetadata; use dpp::address_funds::PlatformAddress; use dpp::fee::Credits; use crate::error::PlatformWalletError; +use crate::BlockTime; use super::*; @@ -99,8 +100,8 @@ impl IdentityWallet { .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))? }; - let (address_infos, new_balance, proof_height) = identity - .transfer_credits_to_addresses( + let (address_infos, mut new_balance, metadata) = identity + .transfer_credits_to_addresses_with_metadata( &self.sdk, recipient_addresses, None, // signing_transfer_key_to_use @@ -120,6 +121,8 @@ impl IdentityWallet { }) })?; + let proof_height = metadata.height; + { let mut wm = self.wallet_manager.write().await; let info_guard = wm.get_wallet_info_mut(&self.wallet_id).ok_or_else(|| { @@ -131,15 +134,11 @@ impl IdentityWallet { .identity_manager .managed_identity_mut(identity_id) { - managed.set_confirmed_balance(new_balance, proof_height); - if let Err(e) = self.persister.store(managed.snapshot_changeset().into()) { - tracing::error!( - identity = %identity_id, - error = %e, - "Failed to persist identity balance update after \ - transfer_to_addresses (external signer)" - ); - } + new_balance = managed.persist_confirmed_balance( + new_balance, + BlockTime::from(metadata), + &self.persister, + ); } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/withdrawal.rs b/packages/rs-platform-wallet/src/wallet/identity/network/withdrawal.rs index e1d3f6bb2b1..f791a4cdfb3 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/withdrawal.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/withdrawal.rs @@ -14,10 +14,11 @@ use dpp::identity::signer::Signer; use dash_sdk::platform::transition::put_settings::PutSettings; use dash_sdk::platform::transition::withdraw_from_identity::{ - WithdrawFromIdentity, WithdrawFromIdentityWithHeight, + WithdrawFromIdentity, WithdrawFromIdentityWithMetadata, }; use crate::error::PlatformWalletError; +use crate::BlockTime; use super::*; @@ -94,8 +95,8 @@ impl IdentityWallet { .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))? }; - let (new_balance, proof_height) = identity - .withdraw_with_height( + let (new_balance, metadata) = identity + .withdraw_with_metadata( &self.sdk, Some(to_address.clone()), amount, @@ -125,14 +126,11 @@ impl IdentityWallet { ) })?; if let Some(managed) = info_guard.identity_manager.identity_mut(identity_id) { - managed.set_confirmed_balance(new_balance, proof_height); - if let Err(e) = self.persister.store(managed.snapshot_changeset().into()) { - tracing::error!( - identity = %identity_id, - error = %e, - "Failed to persist identity balance update after withdraw (external signer)" - ); - } + managed.persist_confirmed_balance( + new_balance, + BlockTime::from(metadata), + &self.persister, + ); } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index 1f95e1e1c23..1a4a839651a 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -80,6 +80,7 @@ impl ManagedIdentity { identity, identity_index: Some(identity_index), last_updated_balance_block_time: None, + pending_balance_snapshot: None, last_synced_keys_block_time: None, status: Default::default(), dpns_names: Vec::new(), @@ -100,6 +101,7 @@ impl ManagedIdentity { identity, identity_index: None, last_updated_balance_block_time: None, + pending_balance_snapshot: None, last_synced_keys_block_time: None, status: Default::default(), dpns_names: Vec::new(), diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs index c1afd616375..6038779d572 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs @@ -53,6 +53,11 @@ pub struct ManagedIdentity { /// Last block time when balance was updated for this identity pub last_updated_balance_block_time: Option, + /// Latest verified balance still owed to persistence. Retained after either + /// store or flush fails; scalar snapshots must carry it until a successful + /// retry. Query results are published only after that retry commits. + pending_balance_snapshot: Option<(u64, BlockTime)>, + /// Last block time when keys were synced for this identity pub last_synced_keys_block_time: Option, diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs index 158015debb4..d954c42d7d1 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs @@ -1,24 +1,119 @@ //! Synchronization and block time management for ManagedIdentity use super::ManagedIdentity; +use crate::error::PlatformWalletError; use crate::wallet::persister::WalletPersister; use crate::BlockTime; -use dpp::identity::accessors::IdentitySettersV0; +use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; use dpp::prelude::TimestampMillis; impl ManagedIdentity { - /// Apply a transaction result together with its proof height so lagging - /// balance queries (or older transaction completions) cannot replace it. - pub(crate) fn set_confirmed_balance(&mut self, balance: u64, height: u64) { - if self - .last_updated_balance_block_time - .is_none_or(|previous| height >= previous.height) - { + /// The pending balance also rides unrelated scalar snapshots so a later + /// profile/key-sync write cannot queue the old balance behind a failed flush. + pub(crate) fn balance_snapshot_for_persistence(&self) -> (u64, Option) { + if let Some((balance, block_time)) = self.pending_balance_snapshot { + if self + .last_updated_balance_block_time + .is_none_or(|current| block_time.height >= current.height) + { + return (balance, Some(block_time)); + } + } + ( + self.identity.balance(), + self.last_updated_balance_block_time, + ) + } + + fn balance_snapshot_is_newer(&self, block_time: BlockTime) -> bool { + // Both Fetch and the transaction affected-state waits + // verify a GroveDB root AND its quorum-signed StateId. Their heights + // identify committed snapshots of the same chain, not independent node + // clocks. Equal heights cannot order two transaction completions. + let (_, previous) = self.balance_snapshot_for_persistence(); + if let Some(previous) = previous { + if block_time.height < previous.height { + tracing::warn!( + identity = %self.id(), + response_height = block_time.height, + retained_height = previous.height, + "Ignoring an older verified balance snapshot" + ); + return false; + } + if block_time.height == previous.height { + tracing::debug!(identity = %self.id(), height = block_time.height, + "Ignoring a duplicate-height verified balance snapshot"); + return false; + } + } + true + } + + /// Retry an idempotent scalar snapshot before the watermark can suppress it. + /// The caller holds the wallet-manager write lock through commit/publication. + pub(crate) fn retry_pending_balance( + &mut self, + persister: &WalletPersister, + ) -> Result<(), PlatformWalletError> { + if self.pending_balance_snapshot.is_none() { + return Ok(()); + } + let (balance, block_time) = self.balance_snapshot_for_persistence(); + persister + .store(self.snapshot_changeset().into()) + .map_err(|e| persister.classify_store_failure(e))?; + if !persister.store_commits_inline() { + persister + .flush() + .map_err(|e| PlatformWalletError::Persistence(e.to_string()))?; + } + self.identity.set_balance(balance); + self.last_updated_balance_block_time = block_time; + self.pending_balance_snapshot = None; + Ok(()) + } + + pub(crate) fn persist_refreshed_balance( + &mut self, + balance: u64, + block_time: BlockTime, + persister: &WalletPersister, + ) -> Result<(), PlatformWalletError> { + self.retry_pending_balance(persister)?; + if self.balance_snapshot_is_newer(block_time) { + self.pending_balance_snapshot = Some((balance, block_time)); + self.retry_pending_balance(persister)?; + } + Ok(()) + } + + /// Keep a verified post-broadcast snapshot even if its cache write fails. + /// A cache failure must not invite a second payment. Retain the write for a + /// later refresh/transaction, and return the balance actually kept locally. + pub(crate) fn persist_confirmed_balance( + &mut self, + balance: u64, + block_time: BlockTime, + persister: &WalletPersister, + ) -> u64 { + if self.balance_snapshot_is_newer(block_time) { self.identity.set_balance(balance); - // These transaction APIs expose only the proof height; zero marks - // unavailable Core height and time, rather than retaining stale values. - self.last_updated_balance_block_time = Some(BlockTime::new(height, 0, 0)); + self.last_updated_balance_block_time = Some(block_time); + self.pending_balance_snapshot = Some((balance, block_time)); + } + // A transaction response can confirm the exact pending query snapshot + // whose cache write failed. Publish that verified value now, while + // retaining the same write obligation if storage is still unavailable. + if self.pending_balance_snapshot == Some((balance, block_time)) { + self.identity.set_balance(balance); + self.last_updated_balance_block_time = Some(block_time); + } + if let Err(error) = self.retry_pending_balance(persister) { + tracing::error!(identity = %self.id(), %error, + "Failed to persist confirmed identity balance; snapshot retained for retry"); } + self.identity.balance() } /// Update the last balance update block time. diff --git a/packages/rs-platform-wallet/src/wallet/identity/types/block_time.rs b/packages/rs-platform-wallet/src/wallet/identity/types/block_time.rs index b4291e5b57a..073ad187b9d 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/types/block_time.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/types/block_time.rs @@ -39,10 +39,33 @@ impl BlockTime { } } +impl From for BlockTime { + fn from(metadata: dash_sdk::dapi_grpc::platform::v0::ResponseMetadata) -> Self { + Self::new( + metadata.height, + metadata.core_chain_locked_height, + metadata.time_ms, + ) + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn should_preserve_all_verified_response_block_fields() { + let metadata = dash_sdk::dapi_grpc::platform::v0::ResponseMetadata { + height: 1000, + core_chain_locked_height: 42, + time_ms: 1_700_000_000_000, + ..Default::default() + }; + let block_time = BlockTime::from(metadata); + assert_eq!(block_time, BlockTime::new(1000, 42, 1_700_000_000_000)); + assert!(!block_time.is_older_than(1_700_000_000_050, 100)); + } + #[test] fn test_block_time_creation() { let block_time = BlockTime::new(100000, 900000, 1234567890); diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 0e24ba122ba..7cbe111776d 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -23,6 +23,8 @@ use super::platform_addresses::merge_platform_payment_candidate_addresses; use super::platform_addresses::PlatformAddressWallet; #[cfg(feature = "shielded")] use super::shielded::operations::shield_fee_reserve_credits; +#[cfg(feature = "shielded")] +use crate::BlockTime; // Phase 4d.3 deleted the `ShieldedWallet` wrapper; per-account // keysets now live in `self.shielded_keys` directly. Spend // operations source the shared commitment-tree store from @@ -1466,8 +1468,8 @@ impl PlatformWallet { let _shield_guard = self.shield_guard.lock().await; let keyset = self.derive_spend_keyset(seed, account).await?; - let (proven_balance, proof_height) = - super::shielded::operations::identity_top_up_from_pool_with_height( + let (mut proven_balance, metadata) = + super::shielded::operations::identity_top_up_from_pool_with_metadata( &self.sdk, coordinator.store(), Some(&self.persister), @@ -1490,14 +1492,11 @@ impl PlatformWallet { .get_wallet_info_mut(&self.wallet_id) .and_then(|info| info.identity_manager.managed_identity_mut(identity_id)); if let Some(managed) = managed { - managed.set_confirmed_balance(balance, proof_height); - if let Err(e) = self.persister.store(managed.snapshot_changeset().into()) { - tracing::error!( - identity = %identity_id, - error = %e, - "Failed to persist identity balance update after shielded top-up" - ); - } + proven_balance = Some(managed.persist_confirmed_balance( + balance, + BlockTime::from(metadata), + &self.persister, + )); } } @@ -2078,7 +2077,7 @@ impl PlatformWallet { })? .clone() }; - let (new_balance, proof_height) = super::shielded::operations::shield_from_identity_to( + let (mut new_balance, metadata) = super::shielded::operations::shield_from_identity_to( &self.sdk, coordinator.store(), Some(&self.persister), @@ -2104,18 +2103,11 @@ impl PlatformWallet { .get_wallet_info_mut(&self.wallet_id) .and_then(|info| info.identity_manager.managed_identity_mut(identity_id)); if let Some(managed) = managed { - managed.set_confirmed_balance(new_balance, proof_height); - if let Err(e) = self.persister.store(managed.snapshot_changeset().into()) { - // Broadcast already happened. Returning a transaction error - // could prompt a second payment; it cannot undo the debit. - // Keep the proven in-memory balance and report the cache - // failure separately in diagnostics. - tracing::error!( - identity = %identity_id, - error = %e, - "Failed to persist identity balance update after shield from identity" - ); - } + new_balance = managed.persist_confirmed_balance( + new_balance, + BlockTime::from(metadata), + &self.persister, + ); } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 76f4fa469c0..399d5160f1f 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -37,6 +37,7 @@ use crate::wallet::platform_wallet::WalletId; use std::collections::{BTreeMap, HashSet}; use std::sync::Arc; +use dash_sdk::dapi_grpc::platform::v0::ResponseMetadata; use dash_sdk::platform::fetch_current_no_parameters::FetchCurrent; use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; use dash_sdk::platform::transition::identity_create_from_shielded_pool::IdentityCreateFromShieldedPool; @@ -938,7 +939,7 @@ pub(in crate::wallet) async fn shield_from_identity_to< memo: [u8; 36], signer: &Sig, prover: &P, -) -> Result<(Credits, u64), PlatformWalletError> { +) -> Result<(Credits, ResponseMetadata), PlatformWalletError> { let ShieldRecipient { address: recipient_addr, counterparty: external_counterparty, @@ -1060,7 +1061,7 @@ pub(in crate::wallet) async fn shield_from_identity_to< // `wait_for_affected_state` only converts the proof generically, so the variant // and the identity are enforced here: only this identity's balance proof is // accepted as the post-debit balance. - let proof_outcome: Result<(Credits, u64), String> = match state_transition + let proof_outcome: Result<(Credits, ResponseMetadata), String> = match state_transition .wait_for_affected_state_with_metadata::(sdk, None) .await { @@ -1070,7 +1071,7 @@ pub(in crate::wallet) async fn shield_from_identity_to< partial .balance .ok_or_else(|| "the identity proof did not include the updated balance".to_string()) - .map(|balance| (balance, metadata.height)) + .map(|balance| (balance, metadata)) } Ok((StateTransitionProofResult::VerifiedPartialIdentity(partial), _)) => Err(format!( "the proof returned identity {} but {} initiated the shield", @@ -1363,7 +1364,7 @@ pub async fn identity_top_up_from_pool( amount: u64, prover: &P, ) -> Result, PlatformWalletError> { - identity_top_up_from_pool_with_height( + identity_top_up_from_pool_with_metadata( sdk, store, persister, @@ -1379,7 +1380,7 @@ pub async fn identity_top_up_from_pool( } #[allow(clippy::too_many_arguments)] -pub(in crate::wallet) async fn identity_top_up_from_pool_with_height< +pub(in crate::wallet) async fn identity_top_up_from_pool_with_metadata< S: ShieldedStore, P: OrchardProver, >( @@ -1392,7 +1393,7 @@ pub(in crate::wallet) async fn identity_top_up_from_pool_with_height< identity_id: Identifier, amount: u64, prover: &P, -) -> Result<(Option, u64), PlatformWalletError> { +) -> Result<(Option, ResponseMetadata), PlatformWalletError> { let views = keys.viewing_keys(); let change_addr = default_orchard_address(&views)?; let id = SubwalletId::new(wallet_id, account); @@ -1462,7 +1463,7 @@ pub(in crate::wallet) async fn identity_top_up_from_pool_with_height< // still proves the reserved notes are consumed and authenticates the // credited identity's balance; the shield, shield-from-identity and // identity-create paths accept the same class of outcome. - broadcast_shielded_spend_with_redrive_with_height( + broadcast_shielded_spend_with_redrive_with_metadata( sdk, store, id, @@ -1478,7 +1479,7 @@ pub(in crate::wallet) async fn identity_top_up_from_pool_with_height< .await; match result { - Ok((proof, proof_height)) => { + Ok((proof, metadata)) => { record_activity_status( store, persister, @@ -1531,7 +1532,7 @@ pub(in crate::wallet) async fn identity_top_up_from_pool_with_height< None } }; - Ok((proven_balance, proof_height)) + Ok((proven_balance, metadata)) } Err(e @ PlatformWalletError::ShieldedSpendUnconfirmed { .. }) => Err(e), Err(e) => { @@ -2934,7 +2935,7 @@ async fn broadcast_shielded_spend_with_redrive( operation: &'static str, wait: SpendResultWait, ) -> Result { - broadcast_shielded_spend_with_redrive_with_height( + broadcast_shielded_spend_with_redrive_with_metadata( sdk, store, id, @@ -2950,7 +2951,7 @@ async fn broadcast_shielded_spend_with_redrive( } #[allow(clippy::too_many_arguments)] -async fn broadcast_shielded_spend_with_redrive_with_height( +async fn broadcast_shielded_spend_with_redrive_with_metadata( sdk: &Arc, store: &Arc>, id: SubwalletId, @@ -2960,7 +2961,7 @@ async fn broadcast_shielded_spend_with_redrive_with_height( state_transition: &StateTransition, operation: &'static str, wait: SpendResultWait, -) -> Result<(StateTransitionProofResult, u64), PlatformWalletError> { +) -> Result<(StateTransitionProofResult, ResponseMetadata), PlatformWalletError> { let result = broadcast_shielded_spend(sdk, state_transition, operation, wait).await; if matches!( &result, @@ -3372,7 +3373,7 @@ async fn broadcast_shielded_spend( state_transition: &StateTransition, operation: &'static str, wait: SpendResultWait, -) -> Result<(StateTransitionProofResult, u64), PlatformWalletError> { +) -> Result<(StateTransitionProofResult, ResponseMetadata), PlatformWalletError> { match state_transition.broadcast(sdk, None).await { Ok(()) => {} Err(e) if broadcast_definitely_failed(&e) => { @@ -3405,9 +3406,7 @@ async fn broadcast_shielded_spend( .await } }; - waited - .map(|(proof, metadata)| (proof, metadata.height)) - .map_err(|wait_err| classify_spend_wait_failure(operation, &wait_err)) + waited.map_err(|wait_err| classify_spend_wait_failure(operation, &wait_err)) } /// Classify a `wait_for_response` failure for an already-broadcast diff --git a/packages/rs-sdk/src/platform/transition/top_up_identity.rs b/packages/rs-sdk/src/platform/transition/top_up_identity.rs index 371ef9dbbcc..aa98bf00219 100644 --- a/packages/rs-sdk/src/platform/transition/top_up_identity.rs +++ b/packages/rs-sdk/src/platform/transition/top_up_identity.rs @@ -3,6 +3,8 @@ use super::put_settings::PutSettings; use super::validation::ensure_valid_state_transition_structure; use super::waitable::Waitable; use crate::{Error, Sdk}; +#[cfg(feature = "core_key_wallet")] +use dapi_grpc::platform::v0::ResponseMetadata; use dpp::dashcore::PrivateKey; use dpp::identity::{Identity, PartialIdentity}; use dpp::prelude::AssetLockProof; @@ -47,19 +49,19 @@ pub trait TopUpIdentity: Waitable { AS: dpp::key_wallet::signer::Signer + Send + Sync; } -/// Balance operations that also expose the committed proof height. +/// Balance operations that also expose the committed proof metadata. #[async_trait::async_trait] -pub trait TopUpIdentityWithHeight: Waitable { - /// Returns the confirmed balance result and its proof block height. +pub trait TopUpIdentityWithMetadata: Waitable { + /// Returns the confirmed balance result and its proof metadata. #[cfg(feature = "core_key_wallet")] - async fn top_up_identity_with_signer_with_height( + async fn top_up_identity_with_signer_with_metadata( &self, sdk: &Sdk, asset_lock_proof: AssetLockProof, asset_lock_proof_path: &dpp::key_wallet::bip32::DerivationPath, asset_lock_signer: &AS, settings: Option, - ) -> Result<(u64, u64), Error> + ) -> Result<(u64, ResponseMetadata), Error> where AS: dpp::key_wallet::signer::Signer + Send + Sync; } @@ -106,7 +108,7 @@ impl TopUpIdentity for Identity { where AS: dpp::key_wallet::signer::Signer + Send + Sync, { - self.top_up_identity_with_signer_with_height( + self.top_up_identity_with_signer_with_metadata( sdk, asset_lock_proof, asset_lock_proof_path, @@ -119,16 +121,16 @@ impl TopUpIdentity for Identity { } #[async_trait::async_trait] -impl TopUpIdentityWithHeight for Identity { +impl TopUpIdentityWithMetadata for Identity { #[cfg(feature = "core_key_wallet")] - async fn top_up_identity_with_signer_with_height( + async fn top_up_identity_with_signer_with_metadata( &self, sdk: &Sdk, asset_lock_proof: AssetLockProof, asset_lock_proof_path: &dpp::key_wallet::bip32::DerivationPath, asset_lock_signer: &AS, settings: Option, - ) -> Result<(u64, u64), Error> + ) -> Result<(u64, ResponseMetadata), Error> where AS: dpp::key_wallet::signer::Signer + Send + Sync, { @@ -153,6 +155,6 @@ impl TopUpIdentityWithHeight for Identity { identity .balance .ok_or(Error::Generic("expected an identity balance".to_string())) - .map(|balance| (balance, metadata.height)) + .map(|balance| (balance, metadata)) } } diff --git a/packages/rs-sdk/src/platform/transition/top_up_identity_from_addresses.rs b/packages/rs-sdk/src/platform/transition/top_up_identity_from_addresses.rs index 27377577cc9..01526d11fae 100644 --- a/packages/rs-sdk/src/platform/transition/top_up_identity_from_addresses.rs +++ b/packages/rs-sdk/src/platform/transition/top_up_identity_from_addresses.rs @@ -1,3 +1,4 @@ +use dapi_grpc::platform::v0::ResponseMetadata; use std::collections::{BTreeMap, BTreeSet}; use super::address_inputs::fetch_inputs_with_nonce; @@ -42,6 +43,28 @@ pub trait TopUpIdentityFromAddresses>: Waitable { ) -> Result<(AddressInfos, Credits, u64), Error>; } +/// Identity top-ups that preserve the full metadata of the balance proof. +#[async_trait::async_trait] +pub trait TopUpIdentityFromAddressesWithMetadata>: Waitable { + /// Top up with automatically resolved address nonces and return proof metadata. + async fn top_up_from_addresses_with_metadata( + &self, + sdk: &Sdk, + inputs: BTreeMap, + signer: &S, + settings: Option, + ) -> Result<(AddressInfos, Credits, ResponseMetadata), Error>; + + /// Top up with explicit address nonces and return proof metadata. + async fn top_up_from_addresses_with_nonce_with_metadata( + &self, + sdk: &Sdk, + inputs: BTreeMap, + signer: &S, + settings: Option, + ) -> Result<(AddressInfos, Credits, ResponseMetadata), Error>; +} + #[async_trait::async_trait] impl> TopUpIdentityFromAddresses for Identity { async fn top_up_from_addresses( @@ -51,9 +74,9 @@ impl> TopUpIdentityFromAddresses for Identity { signer: &S, settings: Option, ) -> Result<(AddressInfos, Credits, u64), Error> { - let inputs_with_nonce = nonce_inc(fetch_inputs_with_nonce(sdk, &inputs).await?); - self.top_up_from_addresses_with_nonce(sdk, inputs_with_nonce, signer, settings) + self.top_up_from_addresses_with_metadata(sdk, inputs, signer, settings) .await + .map(|(infos, balance, metadata)| (infos, balance, metadata.height)) } async fn top_up_from_addresses_with_nonce( @@ -63,6 +86,38 @@ impl> TopUpIdentityFromAddresses for Identity { signer: &S, settings: Option, ) -> Result<(AddressInfos, Credits, u64), Error> { + self.top_up_from_addresses_with_nonce_with_metadata(sdk, inputs, signer, settings) + .await + .map(|(infos, balance, metadata)| (infos, balance, metadata.height)) + } +} + +#[async_trait::async_trait] +impl> TopUpIdentityFromAddressesWithMetadata for Identity { + async fn top_up_from_addresses_with_metadata( + &self, + sdk: &Sdk, + inputs: BTreeMap, + signer: &S, + settings: Option, + ) -> Result<(AddressInfos, Credits, ResponseMetadata), Error> { + let inputs_with_nonce = nonce_inc(fetch_inputs_with_nonce(sdk, &inputs).await?); + self.top_up_from_addresses_with_nonce_with_metadata( + sdk, + inputs_with_nonce, + signer, + settings, + ) + .await + } + + async fn top_up_from_addresses_with_nonce_with_metadata( + &self, + sdk: &Sdk, + inputs: BTreeMap, + signer: &S, + settings: Option, + ) -> Result<(AddressInfos, Credits, ResponseMetadata), Error> { let user_fee_increase = settings .as_ref() .and_then(|settings| settings.user_fee_increase) @@ -111,7 +166,7 @@ impl> TopUpIdentityFromAddresses for Identity { ) })?; - Ok((address_infos, balance, metadata.height)) + Ok((address_infos, balance, metadata)) } other => Err(Error::InvalidProvedResponse(format!( "identity proof was expected for {:?}, but received {:?}", diff --git a/packages/rs-sdk/src/platform/transition/transfer.rs b/packages/rs-sdk/src/platform/transition/transfer.rs index 191e342a7b8..7801f1c775b 100644 --- a/packages/rs-sdk/src/platform/transition/transfer.rs +++ b/packages/rs-sdk/src/platform/transition/transfer.rs @@ -1,3 +1,4 @@ +use dapi_grpc::platform::v0::ResponseMetadata; use dpp::identifier::Identifier; use dpp::identity::accessors::IdentityGettersV0; @@ -35,11 +36,11 @@ pub trait TransferToIdentity: Waitable { ) -> Result<(u64, u64), Error>; } -/// Balance operations that also expose the committed proof height. +/// Balance operations that also expose the committed proof metadata. #[async_trait::async_trait] -pub trait TransferToIdentityWithHeight: Waitable { - /// Returns the confirmed balance result and its proof block height. - async fn transfer_credits_with_height + Send>( +pub trait TransferToIdentityWithMetadata: Waitable { + /// Returns the confirmed balance result and its proof metadata. + async fn transfer_credits_with_metadata + Send>( &self, sdk: &Sdk, to_identity_id: Identifier, @@ -47,7 +48,7 @@ pub trait TransferToIdentityWithHeight: Waitable { signing_transfer_key_to_use: Option<&IdentityPublicKey>, signer: S, settings: Option, - ) -> Result<((u64, u64), u64), Error>; + ) -> Result<((u64, u64), ResponseMetadata), Error>; } #[async_trait::async_trait] @@ -61,7 +62,7 @@ impl TransferToIdentity for Identity { signer: S, settings: Option, ) -> Result<(u64, u64), Error> { - self.transfer_credits_with_height( + self.transfer_credits_with_metadata( sdk, to_identity_id, amount, @@ -75,8 +76,8 @@ impl TransferToIdentity for Identity { } #[async_trait::async_trait] -impl TransferToIdentityWithHeight for Identity { - async fn transfer_credits_with_height + Send>( +impl TransferToIdentityWithMetadata for Identity { + async fn transfer_credits_with_metadata + Send>( &self, sdk: &Sdk, to_identity_id: Identifier, @@ -84,7 +85,7 @@ impl TransferToIdentityWithHeight for Identity { signing_transfer_key_to_use: Option<&IdentityPublicKey>, signer: S, settings: Option, - ) -> Result<((u64, u64), u64), Error> { + ) -> Result<((u64, u64), ResponseMetadata), Error> { let new_identity_nonce = sdk.get_identity_nonce(self.id(), true, settings).await?; let user_fee_increase = settings.and_then(|settings| settings.user_fee_increase); let state_transition = IdentityCreditTransferTransition::try_from_identity( @@ -114,6 +115,6 @@ impl TransferToIdentityWithHeight for Identity { Error::Generic("expected an identity balance after transfer (receiver)".to_string()) })?; - Ok(((sender_balance, receiver_balance), metadata.height)) + Ok(((sender_balance, receiver_balance), metadata)) } } diff --git a/packages/rs-sdk/src/platform/transition/transfer_to_addresses.rs b/packages/rs-sdk/src/platform/transition/transfer_to_addresses.rs index 84005cecc93..7bf487ae671 100644 --- a/packages/rs-sdk/src/platform/transition/transfer_to_addresses.rs +++ b/packages/rs-sdk/src/platform/transition/transfer_to_addresses.rs @@ -1,3 +1,4 @@ +use dapi_grpc::platform::v0::ResponseMetadata; use std::collections::{BTreeMap, BTreeSet}; use super::address_inputs::collect_address_infos_from_proof; @@ -42,6 +43,20 @@ pub trait TransferToAddresses: Waitable { ) -> Result<(AddressInfos, Credits, u64), Error>; } +/// Identity transfers that preserve the full metadata of the balance proof. +#[async_trait::async_trait] +pub trait TransferToAddressesWithMetadata: Waitable { + /// Return recipient address infos, the identity balance, and proof metadata. + async fn transfer_credits_to_addresses_with_metadata + Send>( + &self, + sdk: &Sdk, + recipient_addresses: BTreeMap, + signing_transfer_key_to_use: Option<&IdentityPublicKey>, + signer: &S, + settings: Option, + ) -> Result<(AddressInfos, Credits, ResponseMetadata), Error>; +} + #[async_trait::async_trait] impl TransferToAddresses for Identity { async fn transfer_credits_to_addresses + Send>( @@ -52,6 +67,28 @@ impl TransferToAddresses for Identity { signer: &S, settings: Option, ) -> Result<(AddressInfos, Credits, u64), Error> { + self.transfer_credits_to_addresses_with_metadata( + sdk, + recipient_addresses, + signing_transfer_key_to_use, + signer, + settings, + ) + .await + .map(|(infos, balance, metadata)| (infos, balance, metadata.height)) + } +} + +#[async_trait::async_trait] +impl TransferToAddressesWithMetadata for Identity { + async fn transfer_credits_to_addresses_with_metadata + Send>( + &self, + sdk: &Sdk, + recipient_addresses: BTreeMap, + signing_transfer_key_to_use: Option<&IdentityPublicKey>, + signer: &S, + settings: Option, + ) -> Result<(AddressInfos, Credits, ResponseMetadata), Error> { if recipient_addresses.is_empty() { return Err(Error::Generic( "recipient_addresses must contain at least one address".to_string(), @@ -109,7 +146,7 @@ impl TransferToAddresses for Identity { ) })?; - Ok((address_infos, balance, metadata.height)) + Ok((address_infos, balance, metadata)) } other => Err(Error::InvalidProvedResponse(format!( "identity proof was expected for {:?}, but received {:?}", diff --git a/packages/rs-sdk/src/platform/transition/withdraw_from_identity.rs b/packages/rs-sdk/src/platform/transition/withdraw_from_identity.rs index d9dfd7b2792..c12250084be 100644 --- a/packages/rs-sdk/src/platform/transition/withdraw_from_identity.rs +++ b/packages/rs-sdk/src/platform/transition/withdraw_from_identity.rs @@ -1,3 +1,4 @@ +use dapi_grpc::platform::v0::ResponseMetadata; use dpp::dashcore::Address; use dpp::identity::accessors::IdentityGettersV0; @@ -34,12 +35,12 @@ pub trait WithdrawFromIdentity { ) -> Result; } -/// Balance operations that also expose the committed proof height. +/// Balance operations that also expose the committed proof metadata. #[async_trait::async_trait] -pub trait WithdrawFromIdentityWithHeight { - /// Returns the confirmed balance result and its proof block height. +pub trait WithdrawFromIdentityWithMetadata { + /// Returns the confirmed balance result and its proof metadata. #[allow(clippy::too_many_arguments)] - async fn withdraw_with_height + Send>( + async fn withdraw_with_metadata + Send>( &self, sdk: &Sdk, address: Option
, @@ -48,7 +49,7 @@ pub trait WithdrawFromIdentityWithHeight { signing_withdrawal_key_to_use: Option<&IdentityPublicKey>, signer: S, settings: Option, - ) -> Result<(u64, u64), Error>; + ) -> Result<(u64, ResponseMetadata), Error>; } #[async_trait::async_trait] @@ -63,7 +64,7 @@ impl WithdrawFromIdentity for Identity { signer: S, settings: Option, ) -> Result { - self.withdraw_with_height( + self.withdraw_with_metadata( sdk, address, amount, @@ -78,9 +79,9 @@ impl WithdrawFromIdentity for Identity { } #[async_trait::async_trait] -impl WithdrawFromIdentityWithHeight for Identity { +impl WithdrawFromIdentityWithMetadata for Identity { #[allow(clippy::too_many_arguments)] - async fn withdraw_with_height + Send>( + async fn withdraw_with_metadata + Send>( &self, sdk: &Sdk, address: Option
, @@ -89,7 +90,7 @@ impl WithdrawFromIdentityWithHeight for Identity { signing_withdrawal_key_to_use: Option<&IdentityPublicKey>, signer: S, settings: Option, - ) -> Result<(u64, u64), Error> { + ) -> Result<(u64, ResponseMetadata), Error> { let new_identity_nonce = sdk.get_identity_nonce(self.id(), true, settings).await?; let script = address.map(|address| CoreScript::new(address.script_pubkey())); let user_fee_increase = settings.and_then(|settings| settings.user_fee_increase); @@ -120,7 +121,7 @@ impl WithdrawFromIdentityWithHeight for Identity { .ok_or(Error::Generic( "expected an identity balance after withdrawal".to_string(), )) - .map(|balance| (balance, metadata.height)), + .map(|balance| (balance, metadata)), _ => Err(Error::Generic("proved a non identity".to_string())), } } From 19410e7f3453a0be1642b50252df58fe9b7af354 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 21 Sep 2026 12:07:23 +0200 Subject: [PATCH 6/9] fix(swift): preserve legacy balance metadata persistence --- .../PlatformWalletPersistenceHandler.swift | 28 ++++---- ...ntityBalanceMetadataPersistenceTests.swift | 69 +++++++++++++++++++ 2 files changed, 81 insertions(+), 16 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 034f205fe1b..e673399b3b0 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -4919,11 +4919,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // MARK: - Identity balance freshness (additive persistence extension) - private func balanceMetadataDescriptor(walletId: Data, identityId: Data) throws + private func balanceMetadataDescriptor(walletId: Data, identityId: Data) -> FetchDescriptor { - guard let network = self.network ?? walletNetwork(walletId: walletId) else { - throw PlatformWalletError.walletOperation("Cannot resolve identity balance metadata network") - } + // Match legacy identity persistence when the wallet's network is unresolved. + let network = self.network ?? walletNetwork(walletId: walletId) ?? .testnet let networkRaw = network.rawValue return FetchDescriptor(predicate: #Predicate { $0.networkRaw == networkRaw && $0.walletId == walletId && $0.identityId == identityId @@ -4935,7 +4934,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { guard inChangeset else { throw PlatformWalletError.walletOperation("Balance metadata requires an identity changeset") } - let descriptor = try balanceMetadataDescriptor(walletId: walletId, identityId: identityId) + let descriptor = balanceMetadataDescriptor(walletId: walletId, identityId: identityId) let existing = try backgroundContext.fetch(descriptor).first guard let blockTime else { if let existing { backgroundContext.delete(existing) } @@ -4946,9 +4945,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { existing.coreHeight = blockTime.core_height existing.timestampMillis = Int64(bitPattern: blockTime.timestamp) } else { - guard let network = self.network ?? walletNetwork(walletId: walletId) else { - throw PlatformWalletError.walletOperation("Cannot resolve identity balance metadata network") - } + let network = self.network ?? walletNetwork(walletId: walletId) ?? .testnet backgroundContext.insert(PersistentIdentityBalanceMetadata( networkRaw: network.rawValue, walletId: walletId, identityId: identityId, platformHeight: blockTime.height, coreHeight: blockTime.core_height, @@ -4960,7 +4957,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { func loadIdentityBalanceBlockTime(walletId: Data, identityId: Data) throws -> BlockTime? { try onQueue { - let descriptor = try balanceMetadataDescriptor(walletId: walletId, identityId: identityId) + let descriptor = balanceMetadataDescriptor(walletId: walletId, identityId: identityId) guard let row = try backgroundContext.fetch(descriptor).first else { return nil } return BlockTime(height: UInt64(bitPattern: row.platformHeight), core_height: row.coreHeight, timestamp: UInt64(bitPattern: row.timestampMillis)) @@ -6347,13 +6344,12 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) let walletRow = try backgroundContext.fetch(walletDescriptor).first let walletNetwork = walletRow?.network - if let metadataNetwork = self.network ?? walletNetwork { - let raw = metadataNetwork.rawValue - let metadata = FetchDescriptor( - predicate: #Predicate { $0.walletId == walletId && $0.networkRaw == raw }) - for row in try backgroundContext.fetch(metadata) { - backgroundContext.delete(row) - } + // walletId is network-scoped, and sidecars have no wallet relationship. + // Purge them even if a previous deletion already removed the wallet row. + let metadata = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId }) + for row in try backgroundContext.fetch(metadata) { + backgroundContext.delete(row) } if let walletRow = walletRow { diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityBalanceMetadataPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityBalanceMetadataPersistenceTests.swift index cbe1679e300..b88380491cf 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityBalanceMetadataPersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityBalanceMetadataPersistenceTests.swift @@ -73,6 +73,75 @@ final class IdentityBalanceMetadataPersistenceTests: XCTestCase { XCTAssertNil(try handler.loadIdentityBalanceBlockTime(walletId: walletId, identityId: identityId)) } + func testLegacyHandlerDefaultsUnresolvedWalletNetworkToTestnet() throws { + for hasWalletRow in [false, true] { + let container = try DashModelContainer.createInMemory() + if hasWalletRow { + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId)) + try context.save() + } + let handler = PlatformWalletPersistenceHandler(modelContainer: container) + XCTAssertNil(try handler.loadIdentityBalanceBlockTime(walletId: walletId, identityId: identityId)) + try persist(handler, balance: 100, stamp: BlockTime(height: 20, core_height: 10, timestamp: 999)) + + let context = ModelContext(container) + let identity = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + let metadata = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + XCTAssertEqual(identity.balance, 100) + XCTAssertEqual(identity.networkRaw, Network.testnet.rawValue) + XCTAssertEqual(metadata.networkRaw, identity.networkRaw) + let reader = PlatformWalletPersistenceHandler(modelContainer: container) + let stamp = try XCTUnwrap(reader.loadIdentityBalanceBlockTime(walletId: walletId, identityId: identityId)) + XCTAssertEqual(stamp.height, 20) + XCTAssertEqual(stamp.core_height, 10) + XCTAssertEqual(stamp.timestamp, 999) + + try persist(handler, balance: 50, stamp: nil) + XCTAssertNil(try handler.loadIdentityBalanceBlockTime(walletId: walletId, identityId: identityId)) + } + } + + func testLegacyHandlerUsesResolvedWalletNetworkBeforeTestnetFallback() throws { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .mainnet)) + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container) + try persist(handler, balance: 100, stamp: BlockTime(height: 20, core_height: 10, timestamp: 999)) + let rows = try ModelContext(container).fetch(FetchDescriptor()) + XCTAssertEqual(rows.count, 1) + XCTAssertEqual(rows.first?.networkRaw, Network.mainnet.rawValue) + XCTAssertEqual(try handler.loadIdentityBalanceBlockTime(walletId: walletId, identityId: identityId)?.height, 20) + } + + func testWalletDeletionPurgesOrphanedMetadataWithoutNetworkAndPreservesOtherWallets() throws { + for hasWalletRow in [false, true] { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + if hasWalletRow { + context.insert(PersistentWallet(walletId: walletId)) + } + let otherWalletId = Data(repeating: 43, count: 32) + for id in [walletId, otherWalletId] { + for network in [Network.testnet, .mainnet] { + context.insert(PersistentIdentityBalanceMetadata( + networkRaw: network.rawValue, walletId: id, identityId: identityId, + platformHeight: 20, coreHeight: 10, timestampMillis: 999)) + } + } + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container) + try handler.deleteWalletData(walletId: walletId) + // Retrying after the wallet row is gone remains safe and idempotent. + try handler.deleteWalletData(walletId: walletId) + let remaining = try ModelContext(container).fetch(FetchDescriptor()) + XCTAssertEqual(remaining.count, 2) + XCTAssertTrue(remaining.allSatisfy { $0.walletId == otherWalletId }) + XCTAssertEqual(Set(remaining.map(\.networkRaw)), Set([Network.testnet.rawValue, Network.mainnet.rawValue])) + } + } + func testWalletDeletionRemovesMetadata() throws { let container = try DashModelContainer.createInMemory() try seedWallet(container) From a219967733a6ac68937a3d3d493c08303fddebe3 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 21 Sep 2026 12:10:27 +0200 Subject: [PATCH 7/9] fix(ffi): scope balance metadata to restorable wallet identities --- .../src/managed_identity.rs | 80 ++++++++++++++++++- .../rs-platform-wallet-ffi/src/persistence.rs | 64 ++++++++++++++- .../PlatformWallet/ManagedIdentity.swift | 7 +- 3 files changed, 145 insertions(+), 6 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/managed_identity.rs b/packages/rs-platform-wallet-ffi/src/managed_identity.rs index d2b99febc81..dec965e28ab 100644 --- a/packages/rs-platform-wallet-ffi/src/managed_identity.rs +++ b/packages/rs-platform-wallet-ffi/src/managed_identity.rs @@ -116,7 +116,12 @@ pub unsafe extern "C" fn managed_identity_get_last_updated_balance_block_time( PlatformWalletFFIResult::ok() } -/// Set last updated balance block time. +/// Set the block time on this detached managed-identity handle only. +/// +/// Wallet lookups return snapshot clones. This setter neither updates the live +/// wallet's verified balance watermark nor persists metadata. Obtain a fresh +/// snapshot after `platform_wallet_refresh_identity_balance` to observe the +/// authoritative wallet state; this function is not a live-watermark reset API. #[no_mangle] pub unsafe extern "C" fn managed_identity_set_last_updated_balance_block_time( identity_handle: Handle, @@ -379,6 +384,79 @@ mod tests { } } + #[test] + fn should_keep_manual_balance_block_time_changes_on_detached_handles() { + use crate::identity_manager::{ + identity_manager_add_identity, identity_manager_create, identity_manager_destroy, + identity_manager_get_identity, + }; + + unsafe { + let original = + MANAGED_IDENTITY_STORAGE.insert(ManagedIdentity::new(create_test_identity(), 0)); + let mut manager = NULL_HANDLE; + assert_eq!( + identity_manager_create(&mut manager).code, + PlatformWalletFFIResultCode::Success + ); + assert_eq!( + identity_manager_add_identity(manager, original).code, + PlatformWalletFFIResultCode::Success + ); + let mut snapshot = NULL_HANDLE; + assert_eq!( + identity_manager_get_identity(manager, [1u8; 32].as_ptr(), &mut snapshot).code, + PlatformWalletFFIResultCode::Success + ); + let stamp = BlockTime { + height: u64::MAX, + core_height: u32::MAX, + timestamp: u64::MAX, + }; + assert_eq!( + managed_identity_set_last_updated_balance_block_time(snapshot, &stamp).code, + PlatformWalletFFIResultCode::Success + ); + assert_eq!( + MANAGED_IDENTITY_STORAGE.with_item(snapshot, |identity| identity + .last_updated_balance_block_time + .unwrap() + .height), + Some(u64::MAX) + ); + assert_eq!( + IDENTITY_MANAGER_STORAGE.with_item(manager, |manager| manager + .managed_identity(&Identifier::from([1; 32])) + .unwrap() + .last_updated_balance_block_time), + Some(None) + ); + + // Even adding the edited snapshot to another standalone manager + // imports only its DPP identity, not its manually assigned metadata. + let mut other_manager = NULL_HANDLE; + assert_eq!( + identity_manager_create(&mut other_manager).code, + PlatformWalletFFIResultCode::Success + ); + assert_eq!( + identity_manager_add_identity(other_manager, snapshot).code, + PlatformWalletFFIResultCode::Success + ); + assert_eq!( + IDENTITY_MANAGER_STORAGE.with_item(other_manager, |manager| manager + .managed_identity(&Identifier::from([1; 32])) + .unwrap() + .last_updated_balance_block_time), + Some(None) + ); + managed_identity_destroy(snapshot); + managed_identity_destroy(original); + identity_manager_destroy(manager); + identity_manager_destroy(other_manager); + } + } + #[test] fn test_block_time_operations() { unsafe { diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 82bbfec6222..d95a1f31db8 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -2229,8 +2229,12 @@ impl PlatformWalletPersistence for FFIPersister { self.persist_identity_balance_block_time_callback, ) { for entry in id_cs.identities.values() { + // The restore ABI carries only identities owned by this wallet. + // Clear any previous sidecar when ownership is absent or changed; + // observed identities must not leave an unrestorable watermark. let stamp = entry .last_updated_balance_block_time + .filter(|_| entry.wallet_id == Some(wallet_id)) .map(crate::types::BlockTime::from); let rc = unsafe { cb( @@ -5799,10 +5803,11 @@ fn build_wallet_start_state( // Per-wallet identities go straight into the wallet_identities // sub-map keyed by registration index. Out-of-wallet identities - // are not surfaced here — there's no SwiftData path for them - // today (PersistentIdentity always links to a wallet) — so the - // out-of-wallet bucket starts empty and is populated only via - // runtime DPNS resolution / observation. + // are not surfaced by this restore ABI: Swift supplies only identities + // linked to the wallet row. Observed SwiftData rows remain unlinked, so + // the out-of-wallet bucket starts empty and is populated only via + // runtime DPNS resolution / observation. Balance sidecars are likewise + // persisted only for wallet-owned identities. let bucket = build_wallet_identity_bucket(entry)?; let mut wallet_identities = BTreeMap::new(); if !bucket.is_empty() { @@ -7283,6 +7288,56 @@ mod tests { ); } + #[test] + fn should_persist_balance_watermarks_only_for_the_owning_wallet() { + use platform_wallet::changeset::{IdentityChangeSet, IdentityEntry}; + use std::sync::Mutex; + + unsafe extern "C" fn persist( + ctx: *mut c_void, + wallet: *const u8, + _: *const u8, + stamp: *const crate::types::BlockTime, + ) -> i32 { + assert_eq!(std::slice::from_raw_parts(wallet, 32), &[42; 32]); + let stored = &*(ctx as *const Mutex>); + *stored.lock().unwrap() = stamp.as_ref().map(|stamp| stamp.height); + 0 + } + + let stored = Mutex::new(None::); + let persister = FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks { + context: (&stored as *const Mutex>).cast_mut().cast(), + on_changeset_begin_fn: Some(noop_begin), + on_changeset_end_fn: Some(noop_end), + ..Default::default() + }, + PersistenceCapabilities::ATOMIC_CHANGESETS, + PersistenceExtensionCallbacks { + persist_identity_balance_block_time: Some(persist), + ..Default::default() + }, + ); + let mut managed = platform_wallet::ManagedIdentity::new_out_of_wallet( + dpp::identity::Identity::V0(dpp::identity::v0::IdentityV0::default()), + ); + managed.last_updated_balance_block_time = Some(platform_wallet::BlockTime::new(42, 7, 99)); + for owner in [Some([42; 32]), None, Some([42; 32]), Some([43; 32])] { + managed.wallet_id = owner; + managed.identity_index = owner.map(|_| 0); + let mut identities = IdentityChangeSet::default(); + identities + .identities + .insert(managed.id(), IdentityEntry::from_managed(&managed)); + persister.store([42; 32], identities.into()).unwrap(); + assert_eq!( + *stored.lock().unwrap(), + (owner == Some([42; 32])).then_some(42) + ); + } + } + #[test] fn should_roll_back_failed_identity_balance_watermark_store_and_clear_on_removal() { use platform_wallet::changeset::{IdentityChangeSet, IdentityEntry}; @@ -7333,6 +7388,7 @@ mod tests { dpp::identity::Identity::V0(dpp::identity::v0::IdentityV0::default()), 0, ); + managed.wallet_id = Some([42; 32]); managed.last_updated_balance_block_time = Some(platform_wallet::BlockTime { height: 42, core_height: 7, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedIdentity.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedIdentity.swift index 85b115aaec0..39b9feb3e0e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedIdentity.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedIdentity.swift @@ -224,7 +224,12 @@ public final class ManagedIdentity: @unchecked Sendable { return blockTime } - /// Set the last updated balance block time + /// Set the block time on this detached identity snapshot only. + /// + /// This does not change or persist the live wallet's verified balance watermark. + /// Use `ManagedPlatformWallet.refreshIdentityBalance(identityId:)`, then obtain + /// a new managed identity snapshot to read the authoritative balance and time. + /// This method cannot reset a live wallet's watermark. public func setLastUpdatedBalanceBlockTime(_ blockTime: BlockTime) throws { var bt = blockTime try managed_identity_set_last_updated_balance_block_time(handle, &bt).check() From 0afcf2e67ba15d5d08b161abe75203f3de320e6c Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 21 Sep 2026 13:27:34 +0200 Subject: [PATCH 8/9] fix(wallet): preserve balance metadata across replay and cleanup --- .github/workflows/tests-rs-workspace.yml | 2 +- .../src/changeset/changeset.rs | 36 ++++++++- .../src/wallet/identity/network/balance.rs | 77 +++++++++++++++++++ .../identity/state/managed_identity/sync.rs | 13 ++++ .../wallet/identity/state/manager/apply.rs | 6 +- .../PlatformWalletPersistenceHandler.swift | 38 +++++---- ...ntityBalanceMetadataPersistenceTests.swift | 51 ++++++++++++ 7 files changed, 205 insertions(+), 18 deletions(-) diff --git a/.github/workflows/tests-rs-workspace.yml b/.github/workflows/tests-rs-workspace.yml index dfd70175092..f85c6a222d6 100644 --- a/.github/workflows/tests-rs-workspace.yml +++ b/.github/workflows/tests-rs-workspace.yml @@ -36,7 +36,7 @@ jobs: run: # Self-hosted macOS runners may keep their work directory on a volume # with spaces in its name. Quote the generated script path explicitly. - shell: bash -e "{0}" + shell: bash --noprofile --norc -eo pipefail "{0}" steps: - name: Check out repo uses: actions/checkout@v4 diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 92f230733e4..1fe4d1eb514 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -1092,9 +1092,9 @@ impl Merge for IdentityChangeSet { if entry.revision >= existing.revision { existing.balance = entry.balance; existing.revision = entry.revision; + existing.last_updated_balance_block_time = + entry.last_updated_balance_block_time; } - existing.last_updated_balance_block_time = - entry.last_updated_balance_block_time; existing.last_synced_keys_block_time = entry.last_synced_keys_block_time; existing.status = entry.status; // `wallet_id` is immutable per identity (SHA256 of @@ -2438,6 +2438,38 @@ mod tests { } } + #[test] + fn should_merge_balance_and_watermark_under_the_same_revision_gate() { + let id = Identifier::from([0x53; 32]); + for revision in [6, 7, 8] { + let mut old = identity_entry_with_contested(id, &[]); + old.revision = 7; + old.balance = 100; + old.last_updated_balance_block_time = Some(BlockTime::new(10, 42, 1000)); + let mut incoming = old.clone(); + incoming.revision = revision; + incoming.balance = 200; + incoming.last_updated_balance_block_time = Some(BlockTime::new(20, 50, 2000)); + let expected = if revision >= 7 { + incoming.clone() + } else { + old.clone() + }; + let mut changes = IdentityChangeSet::default(); + changes.identities.insert(id, old); + let mut later = IdentityChangeSet::default(); + later.identities.insert(id, incoming); + changes.merge(later); + let merged = &changes.identities[&id]; + assert_eq!(merged.balance, expected.balance); + assert_eq!(merged.revision, expected.revision); + assert_eq!( + merged.last_updated_balance_block_time, + expected.last_updated_balance_block_time + ); + } + } + #[test] fn test_empty_changeset() { let cs = PlatformWalletChangeSet::default(); diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/balance.rs b/packages/rs-platform-wallet/src/wallet/identity/network/balance.rs index 99d5b8b9eef..392f8d27fbf 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/balance.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/balance.rs @@ -669,4 +669,81 @@ mod tests { .unwrap(); assert_eq!(reload_balance(&backend, &id), (100, Some(confirmed))); } + + #[tokio::test] + async fn should_preserve_balance_watermark_and_pending_write_when_replay_revision_is_rejected() + { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + let mut wm = iw.wallet_manager.write().await; + let manager = &mut wm + .get_wallet_info_mut(&iw.wallet_id) + .unwrap() + .identity_manager; + let managed = manager.wallet_identity_mut(&iw.wallet_id, &id).unwrap(); + let previous = BlockTime::new(9, 41, 900); + managed.last_updated_balance_block_time = Some(previous); + let pending = BlockTime::new(10, 42, 1000); + backend.fail_store.store(true, Ordering::SeqCst); + assert!(iw.persist_refreshed_balance(managed, 100, pending).is_err()); + let mut entry = IdentityEntry::from_managed(managed); + entry.revision = 6; + entry.balance = 200; + entry.last_updated_balance_block_time = Some(BlockTime::new(20, 50, 2000)); + manager.apply_identity_entry(entry); + let managed = manager.wallet_identity_mut(&iw.wallet_id, &id).unwrap(); + assert_eq!(managed.identity.balance(), OLD_BALANCE); + assert_eq!(managed.identity.revision(), 7); + assert_eq!(managed.last_updated_balance_block_time, Some(previous)); + assert_eq!( + managed.balance_snapshot_for_persistence(), + (100, Some(pending)) + ); + backend.fail_store.store(false, Ordering::SeqCst); + managed.retry_pending_balance(&iw.persister).unwrap(); + assert_eq!(reload_balance(&backend, &id), (100, Some(pending))); + // The rejected entry must not poison the gate for a later proven read. + let next = BlockTime::new(20, 50, 2000); + iw.persist_refreshed_balance(managed, 150, next).unwrap(); + assert_eq!(reload_balance(&backend, &id), (150, Some(next))); + } + + #[tokio::test] + async fn should_clear_superseded_pending_balance_when_replaying_an_accepted_entry() { + for revision in [7, 8] { + for watermark in [ + None, + Some(BlockTime::new(9, 41, 900)), + Some(BlockTime::new(11, 43, 1100)), + ] { + let (iw, id, backend) = fixture(Some(AFTER_DPNS)).await; + let mut wm = iw.wallet_manager.write().await; + let manager = &mut wm + .get_wallet_info_mut(&iw.wallet_id) + .unwrap() + .identity_manager; + let managed = manager.wallet_identity_mut(&iw.wallet_id, &id).unwrap(); + backend.fail_store.store(true, Ordering::SeqCst); + assert!(iw + .persist_refreshed_balance(managed, 100, BlockTime::new(10, 42, 1000)) + .is_err()); + let mut entry = IdentityEntry::from_managed(managed); + entry.revision = revision; + entry.balance = 200; + entry.last_updated_balance_block_time = watermark; + manager.apply_identity_entry(entry); + let managed = manager.wallet_identity_mut(&iw.wallet_id, &id).unwrap(); + assert_eq!(managed.identity.balance(), 200); + assert_eq!(managed.identity.revision(), revision); + assert_eq!(managed.last_updated_balance_block_time, watermark); + assert_eq!(managed.balance_snapshot_for_persistence(), (200, watermark)); + backend.fail_store.store(false, Ordering::SeqCst); + managed.retry_pending_balance(&iw.persister).unwrap(); + assert!(backend.queued.lock().unwrap().is_empty()); + assert!(backend.committed.lock().unwrap().is_empty()); + managed.update_keys_sync_block_time(BlockTime::new(30, 60, 3000), &iw.persister); + iw.persister.flush().unwrap(); + assert_eq!(reload_balance(&backend, &id), (200, watermark)); + } + } + } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs index d954c42d7d1..0450f0f690c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs @@ -8,6 +8,19 @@ use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; use dpp::prelude::TimestampMillis; impl ManagedIdentity { + /// Replay a persisted snapshot selected by the manager's revision policy. + /// Its balance and watermark replace the old pair together, including any + /// uncommitted snapshot superseded by the accepted persisted entry. + pub(crate) fn restore_persisted_balance( + &mut self, + balance: u64, + block_time: Option, + ) { + self.identity.set_balance(balance); + self.last_updated_balance_block_time = block_time; + self.pending_balance_snapshot = None; + } + /// The pending balance also rides unrelated scalar snapshots so a later /// profile/key-sync write cannot queue the old balance behind a failed flush. pub(crate) fn balance_snapshot_for_persistence(&self) -> (u64, Option) { diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs b/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs index 7317eb129cf..03f8ed10004 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs @@ -49,10 +49,12 @@ impl IdentityManager { // Updating an existing identity — find it across both buckets. if let Some(existing) = self.locate_mut(&id) { if entry.revision >= existing.identity.revision() { - existing.identity.set_balance(entry.balance); + existing.restore_persisted_balance( + entry.balance, + entry.last_updated_balance_block_time, + ); existing.identity.set_revision(entry.revision); } - existing.last_updated_balance_block_time = entry.last_updated_balance_block_time; existing.last_synced_keys_block_time = entry.last_synced_keys_block_time; existing.status = entry.status; *existing.dashpay_profile_mut() = entry.dashpay_profile; diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index e673399b3b0..fb514f11845 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -2144,14 +2144,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { return try backgroundContext.fetch(descriptor).first } - /// Predicate matching the `PersistentWallet` row owned by THIS - /// handler. A handler is constructed per-network, so when - /// `self.network` is set we scope to `(walletId, networkRaw)` — - /// otherwise the mainnet handler would find and overwrite the - /// devnet row (and vice versa) now that the same `walletId` can - /// have one row per network. When `self.network` is `nil` (the - /// advanced `configure(sdkPointer:network:nil)` path) we fall - /// back to walletId-only matching to preserve that behaviour. + /// Match this handler's wallet and, when supplied, its network. + /// Wallet IDs are network-scoped and globally unique in the current model; + /// checking the network also rejects stale or mismatched rows. Legacy + /// `network: nil` handlers retain walletId-only matching. private func walletRecordPredicate(walletId: Data) -> Predicate { if let network = self.network { let networkRaw = network.rawValue @@ -6344,11 +6340,27 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) let walletRow = try backgroundContext.fetch(walletDescriptor).first let walletNetwork = walletRow?.network - // walletId is network-scoped, and sidecars have no wallet relationship. - // Purge them even if a previous deletion already removed the wallet row. - let metadata = FetchDescriptor( - predicate: #Predicate { $0.walletId == walletId }) - for row in try backgroundContext.fetch(metadata) { + // Sidecars retain an explicit network key. Preserve other-network + // rows even when retrying after this handler's wallet row is gone. + let metadataNetwork = self.network ?? walletNetwork + let metadata: FetchDescriptor + if let raw = metadataNetwork?.rawValue { + metadata = FetchDescriptor(predicate: #Predicate { + $0.walletId == walletId && $0.networkRaw == raw + }) + } else { + metadata = FetchDescriptor(predicate: #Predicate { $0.walletId == walletId }) + } + // Without a network, only unclaimed sidecars are safe to purge. + // Do not infer ownership from an unrelated handler's network. + var claimedNetworks = Set() + if metadataNetwork == nil { + let owners = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId }) + claimedNetworks = Set(try backgroundContext.fetch(owners).compactMap(\.networkRaw)) + } + for row in try backgroundContext.fetch(metadata) + where !claimedNetworks.contains(row.networkRaw) { backgroundContext.delete(row) } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityBalanceMetadataPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityBalanceMetadataPersistenceTests.swift index b88380491cf..c80eb07e7ef 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityBalanceMetadataPersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityBalanceMetadataPersistenceTests.swift @@ -122,6 +122,8 @@ final class IdentityBalanceMetadataPersistenceTests: XCTestCase { if hasWalletRow { context.insert(PersistentWallet(walletId: walletId)) } + // Neither network has a claiming wallet row for this ID. + // Both sidecars are orphans; another wallet's rows must survive. let otherWalletId = Data(repeating: 43, count: 32) for id in [walletId, otherWalletId] { for network in [Network.testnet, .mainnet] { @@ -142,6 +144,55 @@ final class IdentityBalanceMetadataPersistenceTests: XCTestCase { } } + func testNetworkScopedDeletionPreservesSameIdSidecarsOnOtherNetworks() throws { + for walletNetwork in [Network.testnet, .mainnet] { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + // The model has globally unique wallet IDs: use one real wallet row, + // including a surviving mainnet row when the testnet row is absent. + context.insert(PersistentWallet(walletId: walletId, network: walletNetwork)) + for network in [Network.testnet, .mainnet] { + context.insert(PersistentIdentityBalanceMetadata( + networkRaw: network.rawValue, walletId: walletId, identityId: identityId, + platformHeight: 20, coreHeight: 10, timestampMillis: 999)) + } + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + try handler.deleteWalletData(walletId: walletId) + try handler.deleteWalletData(walletId: walletId) + + let readContext = ModelContext(container) + let remaining = try readContext.fetch(FetchDescriptor()) + XCTAssertEqual(remaining.count, 1) + XCTAssertEqual(remaining.first?.networkRaw, Network.mainnet.rawValue) + let wallets = try readContext.fetch(FetchDescriptor()) + XCTAssertEqual(wallets.count, walletNetwork == .mainnet ? 1 : 0) + if walletNetwork == .mainnet { + XCTAssertEqual(wallets.first?.network, .mainnet) + } + } + } + + func testLegacyDeletionUsesWalletNetworkThenPurgesUnclaimedOrphansOnRetry() throws { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .mainnet)) + for network in [Network.testnet, .mainnet] { + context.insert(PersistentIdentityBalanceMetadata( + networkRaw: network.rawValue, walletId: walletId, identityId: identityId, + platformHeight: 20, coreHeight: 10, timestampMillis: 999)) + } + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container) + try handler.deleteWalletData(walletId: walletId) + let remaining = try ModelContext(container).fetch(FetchDescriptor()) + XCTAssertEqual(remaining.count, 1) + XCTAssertEqual(remaining.first?.networkRaw, Network.testnet.rawValue) + // With no wallet row or explicit network left, this row is unclaimed. + try handler.deleteWalletData(walletId: walletId) + XCTAssertTrue(try ModelContext(container).fetch(FetchDescriptor()).isEmpty) + } + func testWalletDeletionRemovesMetadata() throws { let container = try DashModelContainer.createInMemory() try seedWallet(container) From 07e69509b6fe4747d5d787aaa771bff9bbd1709b Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 21 Sep 2026 13:58:08 +0200 Subject: [PATCH 9/9] docs(swift): explain unresolved balance metadata network fallback --- .../PlatformWallet/PlatformWalletPersistenceHandler.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index fb514f11845..d9ffdea6697 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -4941,6 +4941,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { existing.coreHeight = blockTime.core_height existing.timestampMillis = Int64(bitPattern: blockTime.timestamp) } else { + // An unresolved legacy network can mislabel this sidecar as testnet; + // scoped deletion may retain it until an unscoped orphan purge. let network = self.network ?? walletNetwork(walletId: walletId) ?? .testnet backgroundContext.insert(PersistentIdentityBalanceMetadata( networkRaw: network.rawValue, walletId: walletId, identityId: identityId,