diff --git a/CHANGELOG.md b/CHANGELOG.md index 3738f94cf7c..62c73a98cf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ### Fixed +- **platform-wallet:** Allow reconciliation of already-loaded asset locks with atomic tracked-lock persistence without requiring unrelated wallet restore support; retain nonterminal recovery state and typed consumption errors. + - **platform-wallet-storage:** Restore confirmed Core spend and finality state on SQLite load so old funding transactions cannot make already-spent outputs selectable again. ## [4.2.0-beta.7](https://github.com/dashpay/platform/compare/v4.2.0-beta.6...v4.2.0-beta.7) (2026-09-29) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index d95a1f31db8..e1093479067 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -8468,14 +8468,12 @@ mod tests { } #[test] - fn asset_lock_reconciliation_requires_every_callback_leg() { + fn asset_lock_reconciliation_requires_atomic_persistence_callbacks() { fn complete_callbacks() -> PersistenceCallbacks { PersistenceCallbacks { on_changeset_begin_fn: Some(noop_begin), on_changeset_end_fn: Some(noop_end), on_persist_asset_locks_fn: Some(noop_asset_locks), - on_load_wallet_list_fn: Some(noop_load_wallets), - on_load_wallet_list_free_fn: Some(noop_free_wallets), ..Default::default() } } @@ -8491,18 +8489,8 @@ mod tests { missing_end.on_changeset_end_fn = None; let mut missing_asset_locks = complete_callbacks(); missing_asset_locks.on_persist_asset_locks_fn = None; - let mut missing_load = complete_callbacks(); - missing_load.on_load_wallet_list_fn = None; - let mut missing_load_free = complete_callbacks(); - missing_load_free.on_load_wallet_list_free_fn = None; - - for callbacks in [ - missing_begin, - missing_end, - missing_asset_locks, - missing_load, - missing_load_free, - ] { + + for callbacks in [missing_begin, missing_end, missing_asset_locks] { assert!(!declared_persister(callbacks, required) .persistence_capabilities() .contains(required)); diff --git a/packages/rs-platform-wallet-storage/Cargo.toml b/packages/rs-platform-wallet-storage/Cargo.toml index d506d239da1..fe1ed8c06f6 100644 --- a/packages/rs-platform-wallet-storage/Cargo.toml +++ b/packages/rs-platform-wallet-storage/Cargo.toml @@ -199,9 +199,8 @@ serial_test = "3" # so `secrets_mock_store_test_util.rs` proves the feature gate from the # outside — `cfg(test)` alone would satisfy it from within the crate. platform-wallet-storage = { path = ".", default-features = false, features = ["sqlite", "cli", "secrets", "kv", "__test-helpers", "test-util"] } -# The adapter is a tokio task; the durability test drives it and awaits its -# join handle. Current-thread runtime only — nothing here needs threads. -tokio = { version = "1", features = ["rt", "macros", "sync"] } +# Manager hydration tests need the multi-thread runtime for worker shutdown. +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] } # The adapter's cancellation handle — the test passes a never-fired token, but # the parameter is part of the public signature. tokio-util = { version = "0.7", default-features = false } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs b/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs index 9757deee4de..a1419d5add9 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs @@ -449,8 +449,8 @@ fn tc010_asset_lock_roundtrip() { /// reconstruction's status, admitted by the V004 CHECK widening — /// round-trips through the writer's TEXT status mapping and the /// lifecycle blob, chain proof included. -#[test] -fn tc010b_recovered_from_chain_lock_roundtrip() { +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tc010b_recovered_from_chain_lock_roundtrip() { use dashcore::hashes::Hash; use dashcore::{OutPoint, Transaction, Txid}; use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; @@ -483,6 +483,15 @@ fn tc010b_recovered_from_chain_lock_roundtrip() { locks.asset_locks.insert(outpoint, entry.clone()); let (persister, tmp, path) = fresh_persister(); + assert!( + persister.persistence_capabilities().contains( + platform_wallet::changeset::PersistenceCapabilities::ASSET_LOCK_RECONCILIATION + ), + "SQLite must support reconciliation of the asset-lock rows it restores" + ); + assert!(!persister + .persistence_capabilities() + .contains(platform_wallet::changeset::PersistenceCapabilities::WALLET_RESTORE)); let w = wid(0xFB); ensure_wallet_meta(&persister, &w); persister @@ -497,20 +506,45 @@ fn tc010b_recovered_from_chain_lock_roundtrip() { drop(persister); let p2 = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); - let bucketed = platform_wallet_storage::sqlite::schema::asset_locks::load_state( - &p2.lock_conn_for_test(), - &w, - &platform_wallet_storage::LoadCtx::strict(), - ) - .unwrap(); - let tracked = &bucketed[&0][&outpoint]; + let state = p2 + .load() + .expect("public load restores recovered asset locks"); + let tracked = &state.wallets[&w].unused_asset_locks[&0][&outpoint]; assert_eq!(tracked.status, AssetLockStatus::RecoveredFromChain); - match &tracked.proof { - Some(dpp::prelude::AssetLockProof::Chain(chain)) => { - assert_eq!(chain.core_chain_locked_height, 411_495); - } - other => panic!("chain proof must survive the roundtrip, got {other:?}"), - } + assert_eq!(tracked.proof, entry.proof); + assert_eq!(tracked.amount, entry.amount_duffs); + assert_eq!(tracked.funding_type, entry.funding_type); + + struct NoopHandler; + impl platform_wallet::events::EventHandler for NoopHandler {} + impl platform_wallet::PlatformEventHandler for NoopHandler {} + + let sdk = std::sync::Arc::new(dash_sdk::SdkBuilder::new_mock().build().unwrap()); + let manager = platform_wallet::PlatformWalletManager::new( + sdk, + std::sync::Arc::new(p2), + std::sync::Arc::new(NoopHandler), + ); + manager + .load_from_persistor() + .await + .expect("manager hydration"); + let wallet_manager = manager.wallet_manager_arc(); + let wallets = wallet_manager.read().await; + let restored = &wallets + .get_wallet_info(&w) + .expect("restored wallet") + .tracked_asset_locks[&outpoint]; + assert_eq!(restored.status, tracked.status); + assert_eq!(restored.proof, tracked.proof); + assert_eq!(restored.out_point, tracked.out_point); + assert_eq!(restored.transaction, tracked.transaction); + assert_eq!(restored.amount, tracked.amount); + assert_eq!(restored.account_index, tracked.account_index); + assert_eq!(restored.identity_index, tracked.identity_index); + assert_eq!(restored.funding_type, tracked.funding_type); + drop(wallets); + assert!(manager.shutdown().await.all_clean()); drop(tmp); } diff --git a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs index d930daf1ec9..eeba64110cb 100644 --- a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs +++ b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs @@ -126,10 +126,10 @@ impl PersistenceCapabilities { pub const SHIELDED_FVK_RESTART: Self = Self(Self::ATOMIC_CHANGESETS.0 | Self::SHIELDED_VIEWING_KEYS.0); - /// Capabilities required to durably reconcile an asset-lock status and - /// restore that exact row after process restart. + /// Capabilities required to atomically persist reconciliation of an + /// already-loaded tracked asset lock. pub const ASSET_LOCK_RECONCILIATION: Self = - Self(Self::ATOMIC_CHANGESETS.0 | Self::TRACKED_ASSET_LOCKS.0 | Self::WALLET_RESTORE.0); + Self(Self::ATOMIC_CHANGESETS.0 | Self::TRACKED_ASSET_LOCKS.0); pub const fn from_bits_retain(bits: u64) -> Self { Self(bits) @@ -252,10 +252,26 @@ mod tests { assert_eq!(PersistenceCapabilities::DASHPAY_PAYMENTS.bits(), 0x1000); assert_eq!( PersistenceCapabilities::ASSET_LOCK_RECONCILIATION.bits(), - 0x281 + 0x201 ); } + #[test] + fn reconciliation_requires_atomic_tracked_asset_lock_persistence() { + let required = PersistenceCapabilities::ATOMIC_CHANGESETS + .union(PersistenceCapabilities::TRACKED_ASSET_LOCKS); + assert!(required.contains(PersistenceCapabilities::ASSET_LOCK_RECONCILIATION)); + assert_eq!(required, PersistenceCapabilities::ASSET_LOCK_RECONCILIATION); + for incomplete in [ + PersistenceCapabilities::NONE, + PersistenceCapabilities::ATOMIC_CHANGESETS, + PersistenceCapabilities::TRACKED_ASSET_LOCKS, + PersistenceCapabilities::WALLET_RESTORE, + ] { + assert!(!incomplete.contains(PersistenceCapabilities::ASSET_LOCK_RECONCILIATION)); + } + } + #[test] fn required_sets_report_only_missing_bits() { let actual = diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/tracking.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/tracking.rs index 55ca496183e..08aeb3b6965 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/tracking.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/tracking.rs @@ -313,7 +313,7 @@ impl AssetLockManager { /// marker is stored and flushed synchronously. A failure rolls back the /// in-memory mutation only when the backend has not committed the store and /// did not retain a transient retry buffer. Before mutating, the backend - /// must attest atomic tracked-asset-lock persistence and restart restore. + /// must attest atomic tracked-asset-lock persistence. pub(crate) async fn mark_asset_lock_consumption_unknown( &self, out_point: &OutPoint, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs index 097fd169f45..a1f92ce96da 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs @@ -888,6 +888,7 @@ mod tests { fail_next_flush: Mutex>, store_commits_inline: AtomicBool, omit_reconciliation_capabilities: AtomicBool, + use_full_wallet_restore: AtomicBool, } impl PlatformWalletPersistence for RecordingPersistence { @@ -896,10 +897,14 @@ mod tests { } fn persistence_capabilities(&self) -> PersistenceCapabilities { + let writes = PersistenceCapabilities::ATOMIC_CHANGESETS + .union(PersistenceCapabilities::TRACKED_ASSET_LOCKS); if self.omit_reconciliation_capabilities.load(Ordering::SeqCst) { - PersistenceCapabilities::NONE + PersistenceCapabilities::ATOMIC_CHANGESETS + } else if self.use_full_wallet_restore.load(Ordering::SeqCst) { + writes.union(PersistenceCapabilities::WALLET_RESTORE) } else { - PersistenceCapabilities::ASSET_LOCK_RECONCILIATION + writes } } @@ -1080,6 +1085,9 @@ mod tests { AssetLockFundingType::AssetLockShieldedAddressTopUp, ] { let ctx = consumption_report_context_for(funding_type).await; + ctx.persistence + .use_full_wallet_restore + .store(true, Ordering::SeqCst); let error = ctx .manager .reconcile_asset_lock_submit_result::<()>(