Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 3 additions & 15 deletions packages/rs-platform-wallet-ffi/src/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Expand All @@ -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));
Expand Down
5 changes: 2 additions & 3 deletions packages/rs-platform-wallet-storage/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ impl<B: TransactionBroadcaster + ?Sized> AssetLockManager<B> {
/// 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -888,6 +888,7 @@ mod tests {
fail_next_flush: Mutex<Option<PersistenceErrorKind>>,
store_commits_inline: AtomicBool,
omit_reconciliation_capabilities: AtomicBool,
use_full_wallet_restore: AtomicBool,
}

impl PlatformWalletPersistence for RecordingPersistence {
Expand All @@ -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
}
}

Expand Down Expand Up @@ -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::<()>(
Expand Down
Loading