From 986df8af794562e9d9276b6a876494e94cbd1b9b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:00:16 +0000 Subject: [PATCH 1/3] fix(wallet): allow scoped SQLite asset-lock reconciliation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accept atomic tracked-asset-lock writes with either full wallet restore or the narrower tracked-asset-lock restore capability. SQLite attests only the latter, matching its public load and manager hydration paths. Preserve the typed consumption report, nonterminal recovery marker, proof, and persistence failure behavior. Co-Authored-By: Codex GPT-6 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- CHANGELOG.md | 2 + .../rs-platform-wallet-storage/Cargo.toml | 5 +- .../src/sqlite/persister.rs | 1 + .../tests/sqlite_persist_roundtrip.rs | 64 ++++++++++++++----- .../src/changeset/persistence_capabilities.rs | 57 +++++++++++++++-- .../src/wallet/asset_lock/sync/tracking.rs | 13 +++- .../wallet/shielded/fund_from_asset_lock.rs | 12 +++- 7 files changed, 126 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 198da619b6e..075105685c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ### Fixed +- **platform-wallet:** Allow SQLite asset-lock reconciliation with its scoped restore capability while retaining 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.4](https://github.com/dashpay/platform/compare/v4.2.0-beta.3...v4.2.0-beta.4) (2026-09-24) 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/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 28623c535e2..06163cdc935 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -1319,6 +1319,7 @@ impl PlatformWalletPersistence for SqlitePersister { .union(PersistenceCapabilities::PENDING_CONTACT_CRYPTO) .union(PersistenceCapabilities::DPNS_NAME_STATES) .union(PersistenceCapabilities::TRACKED_ASSET_LOCKS) + .union(PersistenceCapabilities::TRACKED_ASSET_LOCK_RESTORE) .union(PersistenceCapabilities::TRACKED_MASTERNODES) .union(PersistenceCapabilities::CORE_SWEEP_REMOVAL) .union(PersistenceCapabilities::DASHPAY_PAYMENTS); 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..9b531a33988 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() + .supports_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..eda0544c997 100644 --- a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs +++ b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs @@ -49,7 +49,8 @@ impl PersistenceCapabilities { /// DPNS name-state (username marketplace) rows can be persisted. pub const DPNS_NAME_STATES: Self = Self(1 << 8); /// Tracked asset-lock rows, including status and proof updates, can be - /// persisted. Restart hydration is the separate `WALLET_RESTORE` contract. + /// persisted. Restart hydration requires `TRACKED_ASSET_LOCK_RESTORE` + /// or the broader `WALLET_RESTORE` contract. pub const TRACKED_ASSET_LOCKS: Self = Self(1 << 9); /// Tracked (wallet-independent) masternodes are persisted AND restored /// across restarts @@ -106,13 +107,18 @@ impl PersistenceCapabilities { /// wired. pub const DASHPAY_PAYMENTS: Self = Self(1 << 12); + /// Nonterminal tracked asset-lock rows, including their exact status and + /// proof, are restored through `load()` into the wallet manager after restart. + /// This does not attest restoration of unrelated wallet fields. + pub const TRACKED_ASSET_LOCK_RESTORE: Self = Self(1 << 13); + /// Index of the highest bit declared above. It lives here, beside the /// constants, so adding a bit and bumping this is one edit in one place /// — and `every_declared_bit_has_a_stable_name` walks up to it, so a new /// bit that never reaches `KNOWN` fails a test instead of gating /// behaviour invisibly. The same test asserts nothing above it is named, /// which is what catches a bit added without bumping this. - const HIGHEST_DECLARED_BIT: u32 = 12; + const HIGHEST_DECLARED_BIT: u32 = 13; /// Capabilities required before exporting and funding an invitation voucher. pub const INVITATION_CREATION: Self = Self( @@ -126,11 +132,19 @@ 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. + /// Full-wallet restore capabilities sufficient for asset-lock reconciliation. + /// Use [`Self::supports_asset_lock_reconciliation`] to also accept a backend + /// that restores only the tracked asset-lock domain. pub const ASSET_LOCK_RECONCILIATION: Self = Self(Self::ATOMIC_CHANGESETS.0 | Self::TRACKED_ASSET_LOCKS.0 | Self::WALLET_RESTORE.0); + /// Whether atomic asset-lock updates can be restored after restart. + pub const fn supports_asset_lock_reconciliation(self) -> bool { + self.contains(Self::ATOMIC_CHANGESETS.union(Self::TRACKED_ASSET_LOCKS)) + && (self.contains(Self::WALLET_RESTORE) + || self.contains(Self::TRACKED_ASSET_LOCK_RESTORE)) + } + pub const fn from_bits_retain(bits: u64) -> Self { Self(bits) } @@ -205,6 +219,10 @@ impl PersistenceCapabilities { PersistenceCapabilities::DASHPAY_PAYMENTS, "dashpay_payments", ), + ( + PersistenceCapabilities::TRACKED_ASSET_LOCK_RESTORE, + "tracked_asset_lock_restore", + ), ]; KNOWN @@ -222,7 +240,7 @@ impl PersistenceCapabilities { /// failure the test exists to catch. Written as a module-level `const _` so /// it is evaluated in every build, test or not. const _: () = assert!( - PersistenceCapabilities::DASHPAY_PAYMENTS.bits() + PersistenceCapabilities::TRACKED_ASSET_LOCK_RESTORE.bits() == 1u64 << PersistenceCapabilities::HIGHEST_DECLARED_BIT, "HIGHEST_DECLARED_BIT must name the highest declared capability bit" ); @@ -250,12 +268,41 @@ mod tests { assert_eq!(PersistenceCapabilities::TRACKED_MASTERNODES.bits(), 0x400); assert_eq!(PersistenceCapabilities::CORE_SWEEP_REMOVAL.bits(), 0x800); assert_eq!(PersistenceCapabilities::DASHPAY_PAYMENTS.bits(), 0x1000); + assert_eq!( + PersistenceCapabilities::TRACKED_ASSET_LOCK_RESTORE.bits(), + 0x2000 + ); assert_eq!( PersistenceCapabilities::ASSET_LOCK_RECONCILIATION.bits(), 0x281 ); } + #[test] + fn reconciliation_requires_atomic_writes_and_either_restore_contract() { + for restore in [ + PersistenceCapabilities::WALLET_RESTORE, + PersistenceCapabilities::TRACKED_ASSET_LOCK_RESTORE, + PersistenceCapabilities::WALLET_RESTORE + .union(PersistenceCapabilities::TRACKED_ASSET_LOCK_RESTORE), + ] { + let writes = PersistenceCapabilities::ATOMIC_CHANGESETS + .union(PersistenceCapabilities::TRACKED_ASSET_LOCKS); + assert!(writes.union(restore).supports_asset_lock_reconciliation()); + assert!(!writes.supports_asset_lock_reconciliation()); + assert!(!restore.supports_asset_lock_reconciliation()); + assert!(!restore + .union(PersistenceCapabilities::ATOMIC_CHANGESETS) + .supports_asset_lock_reconciliation()); + assert!(!restore + .union(PersistenceCapabilities::TRACKED_ASSET_LOCKS) + .supports_asset_lock_reconciliation()); + } + assert!( + PersistenceCapabilities::ASSET_LOCK_RECONCILIATION.supports_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..12f4e368dd9 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 @@ -327,12 +327,19 @@ impl AssetLockManager { } let capabilities = self.persister.persistence_capabilities(); - let required = PersistenceCapabilities::ASSET_LOCK_RECONCILIATION; - if !capabilities.contains(required) { + if !capabilities.supports_asset_lock_reconciliation() { + let restore = if capabilities.contains(PersistenceCapabilities::WALLET_RESTORE) { + PersistenceCapabilities::WALLET_RESTORE + } else { + PersistenceCapabilities::TRACKED_ASSET_LOCK_RESTORE + }; + let required = PersistenceCapabilities::ATOMIC_CHANGESETS + .union(PersistenceCapabilities::TRACKED_ASSET_LOCKS) + .union(restore); let missing = capabilities.missing(required); return Err(PlatformWalletError::Persistence(format!( "asset-lock reconciliation requires persistence capabilities {:?} \ - (missing mask 0x{:x})", + (missing mask 0x{:x}; wallet_restore also satisfies tracked_asset_lock_restore)", missing.names(), missing.bits(), ))); 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..c8d629bb72c 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 - } else { + writes + } else if self.use_full_wallet_restore.load(Ordering::SeqCst) { PersistenceCapabilities::ASSET_LOCK_RECONCILIATION + } else { + writes.union(PersistenceCapabilities::TRACKED_ASSET_LOCK_RESTORE) } } @@ -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::<()>( From 9a21b34e15cde1911e7391f8301362a836e3cb50 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:39:22 +0000 Subject: [PATCH 2/3] fix(platform-wallet): require tracked asset locks to round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the existing tracked-lock capability cover persistence and nonterminal restart restore, and require only that contract plus atomic changesets for reconciliation. Admit the FFI bit only with persistence and paired restore callbacks. Fail Swift wallet restore if its tracked-lock fetch fails. Co-Authored-By: Codex 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- CHANGELOG.md | 4 +- .../rs-platform-wallet-ffi/src/persistence.rs | 31 ++++++++- .../src/sqlite/persister.rs | 1 - .../tests/sqlite_persist_roundtrip.rs | 6 +- .../src/changeset/persistence_capabilities.rs | 68 ++++++------------- .../src/wallet/asset_lock/sync/tracking.rs | 13 +--- .../wallet/shielded/fund_from_asset_lock.rs | 6 +- .../PlatformWalletManager.swift | 4 +- .../PlatformWalletPersistenceHandler.swift | 20 ++++-- .../AssetLockInputSpendRestoreTests.swift | 18 +++++ 10 files changed, 95 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 075105685c0..d13370dc8ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ ### Fixed -- **platform-wallet:** Allow SQLite asset-lock reconciliation with its scoped restore capability while retaining nonterminal recovery state and typed consumption errors. +- **platform-wallet:** Require `TRACKED_ASSET_LOCKS` to cover persistence and nonterminal restart restore; allow SQLite reconciliation with atomic tracked-lock storage while retaining recovery state and typed consumption errors. +- **platform-wallet-ffi:** Admit tracked-lock support only with persistence and paired restore callbacks; write-only hosts no longer attest this capability. +- **swift-sdk:** Fail wallet restore when tracked asset-lock rows cannot be read. - **platform-wallet-storage:** Restore confirmed Core spend and finality state on SQLite load so old funding transactions cannot make already-spent outputs selectable again. diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index d95a1f31db8..fbabc9068cc 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -146,6 +146,8 @@ pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_DEFERRED_CONTACT_CRYPTO: u64 = PLATFORM_WALLET_PERSISTENCE_CAPABILITY_PENDING_CONTACT_CRYPTO; pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_WALLET_RESTORE: u64 = 1 << 7; pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_DPNS_NAME_STATES: u64 = 1 << 8; +/// Tracked rows persist, and nonterminal rows restore with exact status and proof. +/// Requires asset-lock persistence and both wallet-list load/free callbacks. pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_TRACKED_ASSET_LOCKS: u64 = 1 << 9; /// Tracked (wallet-independent) masternodes are persisted AND restored /// across restarts. Requires the extension trio @@ -1547,7 +1549,7 @@ impl FFIPersister { if wallet_restore { capabilities = capabilities.union(PersistenceCapabilities::WALLET_RESTORE); } - if self.callbacks.on_persist_asset_locks_fn.is_some() { + if self.callbacks.on_persist_asset_locks_fn.is_some() && wallet_restore { capabilities = capabilities.union(PersistenceCapabilities::TRACKED_ASSET_LOCKS); } if self @@ -8467,6 +8469,33 @@ mod tests { drop(persister); } + #[test] + fn tracked_asset_locks_require_persist_and_complete_restore_callbacks() { + let required = PersistenceCapabilities::TRACKED_ASSET_LOCKS; + for (load, free) in [(false, false), (true, false), (false, true), (true, true)] { + let callbacks = PersistenceCallbacks { + on_persist_asset_locks_fn: Some(noop_asset_locks), + on_load_wallet_list_fn: load.then_some(noop_load_wallets), + on_load_wallet_list_free_fn: free.then_some(noop_free_wallets), + ..Default::default() + }; + assert_eq!( + declared_persister(callbacks, required) + .persistence_capabilities() + .contains(required), + load && free + ); + } + let callbacks = PersistenceCallbacks { + on_load_wallet_list_fn: Some(noop_load_wallets), + on_load_wallet_list_free_fn: Some(noop_free_wallets), + ..Default::default() + }; + assert!(!declared_persister(callbacks, required) + .persistence_capabilities() + .contains(required)); + } + #[test] fn asset_lock_reconciliation_requires_every_callback_leg() { fn complete_callbacks() -> PersistenceCallbacks { diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 06163cdc935..28623c535e2 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -1319,7 +1319,6 @@ impl PlatformWalletPersistence for SqlitePersister { .union(PersistenceCapabilities::PENDING_CONTACT_CRYPTO) .union(PersistenceCapabilities::DPNS_NAME_STATES) .union(PersistenceCapabilities::TRACKED_ASSET_LOCKS) - .union(PersistenceCapabilities::TRACKED_ASSET_LOCK_RESTORE) .union(PersistenceCapabilities::TRACKED_MASTERNODES) .union(PersistenceCapabilities::CORE_SWEEP_REMOVAL) .union(PersistenceCapabilities::DASHPAY_PAYMENTS); 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 9b531a33988..a1419d5add9 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs @@ -484,9 +484,9 @@ async fn tc010b_recovered_from_chain_lock_roundtrip() { let (persister, tmp, path) = fresh_persister(); assert!( - persister - .persistence_capabilities() - .supports_asset_lock_reconciliation(), + persister.persistence_capabilities().contains( + platform_wallet::changeset::PersistenceCapabilities::ASSET_LOCK_RECONCILIATION + ), "SQLite must support reconciliation of the asset-lock rows it restores" ); assert!(!persister diff --git a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs index eda0544c997..acee198e721 100644 --- a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs +++ b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs @@ -48,9 +48,10 @@ impl PersistenceCapabilities { pub const WALLET_RESTORE: Self = Self(1 << 7); /// DPNS name-state (username marketplace) rows can be persisted. pub const DPNS_NAME_STATES: Self = Self(1 << 8); - /// Tracked asset-lock rows, including status and proof updates, can be - /// persisted. Restart hydration requires `TRACKED_ASSET_LOCK_RESTORE` - /// or the broader `WALLET_RESTORE` contract. + /// Tracked asset-lock rows, including status and proof updates, are persisted. + /// Nonterminal rows are restored through `load()` into the wallet manager + /// after restart with their exact status and proof. Consumed rows may be + /// excluded from restore; unrelated wallet fields are not covered. pub const TRACKED_ASSET_LOCKS: Self = Self(1 << 9); /// Tracked (wallet-independent) masternodes are persisted AND restored /// across restarts @@ -107,18 +108,13 @@ impl PersistenceCapabilities { /// wired. pub const DASHPAY_PAYMENTS: Self = Self(1 << 12); - /// Nonterminal tracked asset-lock rows, including their exact status and - /// proof, are restored through `load()` into the wallet manager after restart. - /// This does not attest restoration of unrelated wallet fields. - pub const TRACKED_ASSET_LOCK_RESTORE: Self = Self(1 << 13); - /// Index of the highest bit declared above. It lives here, beside the /// constants, so adding a bit and bumping this is one edit in one place /// — and `every_declared_bit_has_a_stable_name` walks up to it, so a new /// bit that never reaches `KNOWN` fails a test instead of gating /// behaviour invisibly. The same test asserts nothing above it is named, /// which is what catches a bit added without bumping this. - const HIGHEST_DECLARED_BIT: u32 = 13; + const HIGHEST_DECLARED_BIT: u32 = 12; /// Capabilities required before exporting and funding an invitation voucher. pub const INVITATION_CREATION: Self = Self( @@ -132,18 +128,9 @@ impl PersistenceCapabilities { pub const SHIELDED_FVK_RESTART: Self = Self(Self::ATOMIC_CHANGESETS.0 | Self::SHIELDED_VIEWING_KEYS.0); - /// Full-wallet restore capabilities sufficient for asset-lock reconciliation. - /// Use [`Self::supports_asset_lock_reconciliation`] to also accept a backend - /// that restores only the tracked asset-lock domain. + /// Capabilities required for atomic asset-lock reconciliation across restarts. pub const ASSET_LOCK_RECONCILIATION: Self = - Self(Self::ATOMIC_CHANGESETS.0 | Self::TRACKED_ASSET_LOCKS.0 | Self::WALLET_RESTORE.0); - - /// Whether atomic asset-lock updates can be restored after restart. - pub const fn supports_asset_lock_reconciliation(self) -> bool { - self.contains(Self::ATOMIC_CHANGESETS.union(Self::TRACKED_ASSET_LOCKS)) - && (self.contains(Self::WALLET_RESTORE) - || self.contains(Self::TRACKED_ASSET_LOCK_RESTORE)) - } + Self(Self::ATOMIC_CHANGESETS.0 | Self::TRACKED_ASSET_LOCKS.0); pub const fn from_bits_retain(bits: u64) -> Self { Self(bits) @@ -219,10 +206,6 @@ impl PersistenceCapabilities { PersistenceCapabilities::DASHPAY_PAYMENTS, "dashpay_payments", ), - ( - PersistenceCapabilities::TRACKED_ASSET_LOCK_RESTORE, - "tracked_asset_lock_restore", - ), ]; KNOWN @@ -240,7 +223,7 @@ impl PersistenceCapabilities { /// failure the test exists to catch. Written as a module-level `const _` so /// it is evaluated in every build, test or not. const _: () = assert!( - PersistenceCapabilities::TRACKED_ASSET_LOCK_RESTORE.bits() + PersistenceCapabilities::DASHPAY_PAYMENTS.bits() == 1u64 << PersistenceCapabilities::HIGHEST_DECLARED_BIT, "HIGHEST_DECLARED_BIT must name the highest declared capability bit" ); @@ -268,39 +251,26 @@ mod tests { assert_eq!(PersistenceCapabilities::TRACKED_MASTERNODES.bits(), 0x400); assert_eq!(PersistenceCapabilities::CORE_SWEEP_REMOVAL.bits(), 0x800); assert_eq!(PersistenceCapabilities::DASHPAY_PAYMENTS.bits(), 0x1000); - assert_eq!( - PersistenceCapabilities::TRACKED_ASSET_LOCK_RESTORE.bits(), - 0x2000 - ); assert_eq!( PersistenceCapabilities::ASSET_LOCK_RECONCILIATION.bits(), - 0x281 + 0x201 ); } #[test] - fn reconciliation_requires_atomic_writes_and_either_restore_contract() { - for restore in [ + fn reconciliation_requires_atomic_tracked_asset_lock_round_trip() { + 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, - PersistenceCapabilities::TRACKED_ASSET_LOCK_RESTORE, - PersistenceCapabilities::WALLET_RESTORE - .union(PersistenceCapabilities::TRACKED_ASSET_LOCK_RESTORE), ] { - let writes = PersistenceCapabilities::ATOMIC_CHANGESETS - .union(PersistenceCapabilities::TRACKED_ASSET_LOCKS); - assert!(writes.union(restore).supports_asset_lock_reconciliation()); - assert!(!writes.supports_asset_lock_reconciliation()); - assert!(!restore.supports_asset_lock_reconciliation()); - assert!(!restore - .union(PersistenceCapabilities::ATOMIC_CHANGESETS) - .supports_asset_lock_reconciliation()); - assert!(!restore - .union(PersistenceCapabilities::TRACKED_ASSET_LOCKS) - .supports_asset_lock_reconciliation()); + assert!(!incomplete.contains(PersistenceCapabilities::ASSET_LOCK_RECONCILIATION)); } - assert!( - PersistenceCapabilities::ASSET_LOCK_RECONCILIATION.supports_asset_lock_reconciliation() - ); } #[test] 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 12f4e368dd9..55ca496183e 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 @@ -327,19 +327,12 @@ impl AssetLockManager { } let capabilities = self.persister.persistence_capabilities(); - if !capabilities.supports_asset_lock_reconciliation() { - let restore = if capabilities.contains(PersistenceCapabilities::WALLET_RESTORE) { - PersistenceCapabilities::WALLET_RESTORE - } else { - PersistenceCapabilities::TRACKED_ASSET_LOCK_RESTORE - }; - let required = PersistenceCapabilities::ATOMIC_CHANGESETS - .union(PersistenceCapabilities::TRACKED_ASSET_LOCKS) - .union(restore); + let required = PersistenceCapabilities::ASSET_LOCK_RECONCILIATION; + if !capabilities.contains(required) { let missing = capabilities.missing(required); return Err(PlatformWalletError::Persistence(format!( "asset-lock reconciliation requires persistence capabilities {:?} \ - (missing mask 0x{:x}; wallet_restore also satisfies tracked_asset_lock_restore)", + (missing mask 0x{:x})", missing.names(), missing.bits(), ))); 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 c8d629bb72c..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 @@ -900,11 +900,11 @@ mod tests { let writes = PersistenceCapabilities::ATOMIC_CHANGESETS .union(PersistenceCapabilities::TRACKED_ASSET_LOCKS); if self.omit_reconciliation_capabilities.load(Ordering::SeqCst) { - writes + PersistenceCapabilities::ATOMIC_CHANGESETS } else if self.use_full_wallet_restore.load(Ordering::SeqCst) { - PersistenceCapabilities::ASSET_LOCK_RECONCILIATION + writes.union(PersistenceCapabilities::WALLET_RESTORE) } else { - writes.union(PersistenceCapabilities::TRACKED_ASSET_LOCK_RESTORE) + writes } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index b5551dae598..5c378880de6 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -80,8 +80,8 @@ public struct PlatformWalletPersistenceCapabilities: Equatable, Sendable { /// counterparty) are mirrored durably. Mirrors /// `PersistenceCapabilities::DPNS_NAME_STATES`. public static let dpnsNameStates: UInt64 = 1 << 8 - /// Tracked asset-lock rows, including status and proof updates, can be - /// persisted. Restart hydration is separately attested by `walletRestore`. + /// Tracked asset-lock rows are persisted, and nonterminal rows restore + /// after restart with their exact status and proof. public static let trackedAssetLocks: UInt64 = 1 << 9 /// Tracked (wallet-independent) masternodes are persisted and restored /// across restarts. Mirrors diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 0410f6ef794..e07c23a7979 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -951,18 +951,16 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// an app kill can resume from the latest status without /// rebroadcasting the asset-lock transaction. public func loadCachedAssetLocks(walletId: Data) -> [AssetLockEntrySnapshot] { - onQueue { loadCachedAssetLocksOnQueue(walletId: walletId) } + onQueue { (try? loadCachedAssetLocksOnQueue(walletId: walletId)) ?? [] } } /// On-queue implementation reused by the load-wallet-list path /// without re-entering `onQueue`. - func loadCachedAssetLocksOnQueue(walletId: Data) -> [AssetLockEntrySnapshot] { + func loadCachedAssetLocksOnQueue(walletId: Data) throws -> [AssetLockEntrySnapshot] { let descriptor = FetchDescriptor( predicate: PersistentAssetLock.predicate(walletId: walletId) ) - guard let records = try? backgroundContext.fetch(descriptor) else { - return [] - } + let records = try modelFetcher.fetch(descriptor, in: backgroundContext) return records.map { record in AssetLockEntrySnapshot( outPointHex: record.outPointHex, @@ -7327,7 +7325,17 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // that was killed mid-flight can resume from the latest // status without rebroadcasting. Empty / null when the // wallet has no persisted locks. - let assetLockRows = loadCachedAssetLocksOnQueue(walletId: w.walletId) + let assetLockRows: [AssetLockEntrySnapshot] + do { + assetLockRows = try loadCachedAssetLocksOnQueue(walletId: w.walletId) + } catch { + allocation.release() + SDKLogger.event( + "persistence_wallet_load_failed", category: .persistence, severity: .error, + fields: ["phase": .publicText("tracked_asset_locks")], error: error + ) + return (nil, 0, true) + } let (assetLockBuf, assetLockCount) = buildAssetLockRestoreBuffer( rows: assetLockRows, allocation: allocation diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockInputSpendRestoreTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockInputSpendRestoreTests.swift index 8a8a9b92a5e..86ef1cceb84 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockInputSpendRestoreTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockInputSpendRestoreTests.swift @@ -147,6 +147,24 @@ final class AssetLockInputSpendRestoreTests: XCTestCase { return Int(entries[0].unresolved_asset_lock_tx_records_count) } + func testTrackedAssetLockFetchFailureRejectsWalletRestore() throws { + let container = try DashModelContainer.createInMemory() + try seed(in: container, legacyTxoWalletId: false) + let injector = FetchFaultInjector(faulting: PersistentAssetLock.self, afterServing: 1) + let handler = PlatformWalletPersistenceHandler( + modelContainer: container, + network: .testnet, + modelFetcher: injector + ) + + let loaded = handler.loadWalletList() + defer { handler.loadWalletListFree(entries: loaded.entries.map(UnsafeRawPointer.init)) } + XCTAssertTrue(loaded.errored) + XCTAssertNil(loaded.entries) + XCTAssertEqual(loaded.count, 0) + XCTAssertEqual(injector.observedReads.filter { $0 == "PersistentAssetLock" }.count, 2) + } + /// The ordinary case: the TXO carries its wallet id, and the confirmed /// spender's record is restored so the conflict screen's history scan /// can act at startup. From c0fd3b133bf37d0fc79dc8e55d8ad4b25aee662f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:56:49 +0000 Subject: [PATCH 3/3] fix(platform-wallet): limit reconciliation to atomic tracked-lock writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Require only atomic tracked-lock persistence for reconciliation of an already-loaded wallet. Preserve existing capability meanings and FFI host admission behavior, with wallet restore work outside this change. Keep regression coverage for SQLite reopen/hydration, typed consumption errors, and persistence failure handling. Co-Authored-By: Codex 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- CHANGELOG.md | 4 +- .../rs-platform-wallet-ffi/src/persistence.rs | 49 ++----------------- .../src/changeset/persistence_capabilities.rs | 11 ++--- .../src/wallet/asset_lock/sync/tracking.rs | 2 +- .../PlatformWalletManager.swift | 4 +- .../PlatformWalletPersistenceHandler.swift | 20 +++----- .../AssetLockInputSpendRestoreTests.swift | 18 ------- 7 files changed, 19 insertions(+), 89 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d13370dc8ad..de7e403b4fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,7 @@ ### Fixed -- **platform-wallet:** Require `TRACKED_ASSET_LOCKS` to cover persistence and nonterminal restart restore; allow SQLite reconciliation with atomic tracked-lock storage while retaining recovery state and typed consumption errors. -- **platform-wallet-ffi:** Admit tracked-lock support only with persistence and paired restore callbacks; write-only hosts no longer attest this capability. -- **swift-sdk:** Fail wallet restore when tracked asset-lock rows cannot be read. +- **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. diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index fbabc9068cc..e1093479067 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -146,8 +146,6 @@ pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_DEFERRED_CONTACT_CRYPTO: u64 = PLATFORM_WALLET_PERSISTENCE_CAPABILITY_PENDING_CONTACT_CRYPTO; pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_WALLET_RESTORE: u64 = 1 << 7; pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_DPNS_NAME_STATES: u64 = 1 << 8; -/// Tracked rows persist, and nonterminal rows restore with exact status and proof. -/// Requires asset-lock persistence and both wallet-list load/free callbacks. pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_TRACKED_ASSET_LOCKS: u64 = 1 << 9; /// Tracked (wallet-independent) masternodes are persisted AND restored /// across restarts. Requires the extension trio @@ -1549,7 +1547,7 @@ impl FFIPersister { if wallet_restore { capabilities = capabilities.union(PersistenceCapabilities::WALLET_RESTORE); } - if self.callbacks.on_persist_asset_locks_fn.is_some() && wallet_restore { + if self.callbacks.on_persist_asset_locks_fn.is_some() { capabilities = capabilities.union(PersistenceCapabilities::TRACKED_ASSET_LOCKS); } if self @@ -8470,41 +8468,12 @@ mod tests { } #[test] - fn tracked_asset_locks_require_persist_and_complete_restore_callbacks() { - let required = PersistenceCapabilities::TRACKED_ASSET_LOCKS; - for (load, free) in [(false, false), (true, false), (false, true), (true, true)] { - let callbacks = PersistenceCallbacks { - on_persist_asset_locks_fn: Some(noop_asset_locks), - on_load_wallet_list_fn: load.then_some(noop_load_wallets), - on_load_wallet_list_free_fn: free.then_some(noop_free_wallets), - ..Default::default() - }; - assert_eq!( - declared_persister(callbacks, required) - .persistence_capabilities() - .contains(required), - load && free - ); - } - let callbacks = PersistenceCallbacks { - on_load_wallet_list_fn: Some(noop_load_wallets), - on_load_wallet_list_free_fn: Some(noop_free_wallets), - ..Default::default() - }; - assert!(!declared_persister(callbacks, required) - .persistence_capabilities() - .contains(required)); - } - - #[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() } } @@ -8520,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/src/changeset/persistence_capabilities.rs b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs index acee198e721..eeba64110cb 100644 --- a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs +++ b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs @@ -48,10 +48,8 @@ impl PersistenceCapabilities { pub const WALLET_RESTORE: Self = Self(1 << 7); /// DPNS name-state (username marketplace) rows can be persisted. pub const DPNS_NAME_STATES: Self = Self(1 << 8); - /// Tracked asset-lock rows, including status and proof updates, are persisted. - /// Nonterminal rows are restored through `load()` into the wallet manager - /// after restart with their exact status and proof. Consumed rows may be - /// excluded from restore; unrelated wallet fields are not covered. + /// Tracked asset-lock rows, including status and proof updates, can be + /// persisted. Restart hydration is the separate `WALLET_RESTORE` contract. pub const TRACKED_ASSET_LOCKS: Self = Self(1 << 9); /// Tracked (wallet-independent) masternodes are persisted AND restored /// across restarts @@ -128,7 +126,8 @@ impl PersistenceCapabilities { pub const SHIELDED_FVK_RESTART: Self = Self(Self::ATOMIC_CHANGESETS.0 | Self::SHIELDED_VIEWING_KEYS.0); - /// Capabilities required for atomic asset-lock reconciliation across restarts. + /// 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); @@ -258,7 +257,7 @@ mod tests { } #[test] - fn reconciliation_requires_atomic_tracked_asset_lock_round_trip() { + 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)); 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/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 5c378880de6..b5551dae598 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -80,8 +80,8 @@ public struct PlatformWalletPersistenceCapabilities: Equatable, Sendable { /// counterparty) are mirrored durably. Mirrors /// `PersistenceCapabilities::DPNS_NAME_STATES`. public static let dpnsNameStates: UInt64 = 1 << 8 - /// Tracked asset-lock rows are persisted, and nonterminal rows restore - /// after restart with their exact status and proof. + /// Tracked asset-lock rows, including status and proof updates, can be + /// persisted. Restart hydration is separately attested by `walletRestore`. public static let trackedAssetLocks: UInt64 = 1 << 9 /// Tracked (wallet-independent) masternodes are persisted and restored /// across restarts. Mirrors diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index e07c23a7979..0410f6ef794 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -951,16 +951,18 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// an app kill can resume from the latest status without /// rebroadcasting the asset-lock transaction. public func loadCachedAssetLocks(walletId: Data) -> [AssetLockEntrySnapshot] { - onQueue { (try? loadCachedAssetLocksOnQueue(walletId: walletId)) ?? [] } + onQueue { loadCachedAssetLocksOnQueue(walletId: walletId) } } /// On-queue implementation reused by the load-wallet-list path /// without re-entering `onQueue`. - func loadCachedAssetLocksOnQueue(walletId: Data) throws -> [AssetLockEntrySnapshot] { + func loadCachedAssetLocksOnQueue(walletId: Data) -> [AssetLockEntrySnapshot] { let descriptor = FetchDescriptor( predicate: PersistentAssetLock.predicate(walletId: walletId) ) - let records = try modelFetcher.fetch(descriptor, in: backgroundContext) + guard let records = try? backgroundContext.fetch(descriptor) else { + return [] + } return records.map { record in AssetLockEntrySnapshot( outPointHex: record.outPointHex, @@ -7325,17 +7327,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // that was killed mid-flight can resume from the latest // status without rebroadcasting. Empty / null when the // wallet has no persisted locks. - let assetLockRows: [AssetLockEntrySnapshot] - do { - assetLockRows = try loadCachedAssetLocksOnQueue(walletId: w.walletId) - } catch { - allocation.release() - SDKLogger.event( - "persistence_wallet_load_failed", category: .persistence, severity: .error, - fields: ["phase": .publicText("tracked_asset_locks")], error: error - ) - return (nil, 0, true) - } + let assetLockRows = loadCachedAssetLocksOnQueue(walletId: w.walletId) let (assetLockBuf, assetLockCount) = buildAssetLockRestoreBuffer( rows: assetLockRows, allocation: allocation diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockInputSpendRestoreTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockInputSpendRestoreTests.swift index 86ef1cceb84..8a8a9b92a5e 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockInputSpendRestoreTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockInputSpendRestoreTests.swift @@ -147,24 +147,6 @@ final class AssetLockInputSpendRestoreTests: XCTestCase { return Int(entries[0].unresolved_asset_lock_tx_records_count) } - func testTrackedAssetLockFetchFailureRejectsWalletRestore() throws { - let container = try DashModelContainer.createInMemory() - try seed(in: container, legacyTxoWalletId: false) - let injector = FetchFaultInjector(faulting: PersistentAssetLock.self, afterServing: 1) - let handler = PlatformWalletPersistenceHandler( - modelContainer: container, - network: .testnet, - modelFetcher: injector - ) - - let loaded = handler.loadWalletList() - defer { handler.loadWalletListFree(entries: loaded.entries.map(UnsafeRawPointer.init)) } - XCTAssertTrue(loaded.errored) - XCTAssertNil(loaded.entries) - XCTAssertEqual(loaded.count, 0) - XCTAssertEqual(injector.observedReads.filter { $0 == "PersistentAssetLock" }.count, 2) - } - /// The ordinary case: the TXO carries its wallet id, and the confirmed /// spender's record is restored so the conflict screen's history scan /// can act at startup.