From e7f06d23c02cf79dfabe92f797da70a88274f53e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 9 Aug 2026 06:11:20 +0700 Subject: [PATCH 1/6] fix(platform-wallet): finalize reconstructed asset locks as RecoveredFromChain, in-session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #4342's restore-scan reconstruction never actually produced its RecoveredFromChain terminal on a real restore (observed on a restored testnet wallet: all 9 reconstructed locks stuck at Broadcast for the whole session, ChainLocked after a restart — both of which hosts read as "in flight", so every historical funding tx rendered as a pending transfer): - The filter scan detects historical funding txs BEFORE any chainlock is applied, so entries insert at the pre-finality Broadcast status (the recovered_status non-final arm) rather than RecoveredFromChain. - The bulk promotion to InChainLockedBlock that the tip chainlock performs surfaces ONLY as ChainLockProcessed, which the wallet-event adapter mapped to metadata persistence alone — the promoted records never re-flowed through reconstruction, so the entries stayed pre-finality until a restart happened to re-emit them. - And when enrichment did run, it upgraded to ChainLocked, not RecoveredFromChain. Fixes: - enrich_from_record now upgrades proof-less Broadcast/IS-locked entries to RecoveredFromChain + chain proof. A lock a live flow is completing leaves that window within seconds (wait_for_proof attaches the proof via advance_asset_lock_status, which still overwrites unconditionally in the benign race), so what remains proof-less at finality is by elimination a lock nobody is completing — "final on Core, consumption unknown" is the truthful terminal. - The adapter routes ChainLockProcessed.locked_transactions through a new enrich_tracked_asset_locks_from_chain_lock: funding-family account keys are filtered lock-free, the promoted records are read back from the wallet, and the same per-record reconstruction step (extracted as apply_record) runs — so the upgrade lands in the same session, riding the same drained batch to the store. Co-Authored-By: Claude Fable 5 --- .../src/changeset/core_bridge.rs | 182 +++++++++++++ .../wallet/asset_lock/sync/reconstruction.rs | 255 +++++++++++++++--- .../src/wallet/asset_lock/sync/recovery.rs | 31 ++- .../src/wallet/asset_lock/tracked.rs | 9 +- 4 files changed, 434 insertions(+), 43 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 2849d10f1e..33e7369d7e 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -569,6 +569,27 @@ async fn reconstruct_asset_locks_for_event( .filter(|r| reconstruction::is_reconstruction_candidate(r)) .collect(), ), + // A chainlock's bulk promotion (`InBlock` → + // `InChainLockedBlock`) surfaces ONLY here — the promoted + // records never re-flow as `TransactionDetected` / + // `BlockProcessed`. Without this arm, entries a restore scan + // inserted at pre-finality statuses stayed there for the whole + // session (the scan detects historical funding txs before any + // chainlock is applied); see + // `enrich_tracked_asset_locks_from_chain_lock`. + WalletEvent::ChainLockProcessed { + wallet_id, + chain_lock, + locked_transactions, + } => { + return reconstruction::enrich_tracked_asset_locks_from_chain_lock( + wallet_manager, + wallet_id, + chain_lock.block_height, + locked_transactions, + ) + .await; + } _ => return AssetLockChangeSet::default(), }; if candidates.is_empty() { @@ -1886,6 +1907,167 @@ mod tests { handle.await.expect("adapter task joins"); } + /// The `ChainLockProcessed` arm end to end: a lock the scan + /// reconstructed at a pre-finality status (its block wasn't + /// chain-locked yet — the restore-scan norm) upgrades to + /// `RecoveredFromChain` + chain proof when the chainlock + /// promotion names its txid, in the same session, with the + /// upgraded row riding the drained batch to the store. Before the + /// arm existed, the promotion surfaced only as metadata and the + /// entry stayed pre-finality until an app restart re-emitted its + /// record. + #[tokio::test] + async fn chain_lock_processed_event_upgrades_reconstructed_lock() { + use std::sync::atomic::AtomicBool; + use std::sync::Arc; + + use dashcore::ephemerealdata::chain_lock::ChainLock; + use dashcore::hashes::Hash as _; + use key_wallet::account::account_type::StandardAccountType; + use key_wallet::account::AccountType; + use key_wallet::managed_account::transaction_record::{ + TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::transaction_router::TransactionType; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext}; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + use tokio::sync::Notify; + + use super::spawn_wallet_event_adapter; + use crate::test_support::{ + funded_wallet_manager, AlwaysRejectedBroadcaster, NoopTestPersister, + }; + use crate::wallet::asset_lock::manager::AssetLockManager; + use crate::wallet::asset_lock::tracked::AssetLockStatus; + use crate::wallet::persister::WalletPersister; + + let (wallet_manager, wallet_id, _generation, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let sdk = Arc::new( + dash_sdk::SdkBuilder::new_mock() + .with_network(dashcore::Network::Testnet) + .build() + .expect("mock sdk"), + ); + let asset_lock_manager = AssetLockManager::new( + sdk, + Arc::clone(&wallet_manager), + wallet_id, + Arc::new(Notify::new()), + Arc::new(AlwaysRejectedBroadcaster), + WalletPersister::new( + wallet_id, + Arc::new(NoopTestPersister) as Arc, + ), + ); + let (tx, _path) = asset_lock_manager + .build_asset_lock_transaction( + 1_000_000, + 0, + AssetLockFundingType::IdentityRegistration, + 0, + &signer, + ) + .await + .expect("build asset lock"); + + let record_with = |context: TransactionContext| { + TransactionRecord::new( + tx.clone(), + AccountType::IdentityRegistration, + context, + TransactionType::AssetLock, + TransactionDirection::Internal, + vec![], + vec![], + 0, + ) + }; + + let (obs_tx, mut obs_rx) = unbounded_channel(); + let persister = Arc::new(ProbePersister::new(obs_tx)); + let (event_tx, event_rx) = unbounded_channel(); + let cancel = CancellationToken::new(); + let sync_fault = Arc::new(AtomicBool::new(false)); + let handle = spawn_wallet_event_adapter( + Arc::clone(&wallet_manager), + Arc::clone(&persister), + event_rx, + Arc::clone(&sync_fault), + cancel.clone(), + ); + + // Scan sighting in a not-yet-chain-locked block → tracked at + // the pre-finality Broadcast status, no proof. + event_tx + .send(WalletEvent::BlockProcessed { + wallet_id, + height: 4321, + chain_lock: None, + inserted: vec![record_with(TransactionContext::InBlock(BlockInfo::new( + 4321, + dashcore::BlockHash::all_zeros(), + 1_650_000_000, + )))], + updated: vec![], + matured: vec![], + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + addresses_derived: vec![], + }) + .expect("send block event"); + let observed = obs_rx.recv().await.expect("first batch stored"); + assert_eq!(observed.n_asset_locks, 1, "pre-finality reconstruction"); + + let out_point = dashcore::OutPoint::new(tx.txid(), 0); + { + let wm = wallet_manager.read().await; + let lock = wm + .get_wallet_info(&wallet_id) + .expect("wallet") + .tracked_asset_locks + .get(&out_point) + .expect("tracked entry"); + assert_eq!(lock.status, AssetLockStatus::Broadcast); + assert!(lock.proof.is_none()); + } + + // The chainlock promotion names the txid under its funding + // account. No record accompanies it — under the default + // `keep-finalized-transactions=OFF` feature the promotion + // evicted it, which is exactly why the arm must not need one. + event_tx + .send(WalletEvent::ChainLockProcessed { + wallet_id, + chain_lock: ChainLock::dummy(4321), + locked_transactions: BTreeMap::from([( + AccountType::IdentityRegistration, + vec![tx.txid()], + )]), + }) + .expect("send chainlock event"); + + let observed = obs_rx.recv().await.expect("second batch stored"); + assert_eq!( + observed.n_asset_locks, 1, + "the upgraded row must ride the chainlock drain" + ); + { + let wm = wallet_manager.read().await; + let lock = wm + .get_wallet_info(&wallet_id) + .expect("wallet") + .tracked_asset_locks + .get(&out_point) + .expect("tracked entry"); + assert_eq!(lock.status, AssetLockStatus::RecoveredFromChain); + assert!(lock.proof.is_some(), "chain proof attached"); + } + + cancel.cancel(); + handle.await.expect("adapter task joins"); + } + /// A drain whose only payload is reconstructed asset-lock rows (no /// core rows at all) must still reach the store — the empty-skip /// predicate considers both sub-changesets. diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs index dc536d1582..bd21de7d97 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs @@ -36,6 +36,7 @@ //! insert-if-absent: locks tracked live by the build pipeline (which //! tracks *before* broadcast) always win over a reconstruction. +use std::collections::BTreeMap; use std::sync::Arc; use dashcore::blockdata::transaction::special_transaction::TransactionPayload; @@ -238,23 +239,35 @@ fn reconstruct_candidates( .collect() } -/// Upgrade an already-tracked, still-unproven entry when a record -/// proves its funding tx chain-locked. +/// Upgrade an already-tracked, still-unproven entry to +/// [`AssetLockStatus::RecoveredFromChain`] when a record proves its +/// funding tx chain-locked. /// /// Insert-if-absent protects live entries from being *replaced* by a /// reconstruction — but it also meant a proof-less entry (a -/// reconstruction from a mempool detection, or a live `Broadcast` row -/// stranded by an app kill) could never receive the chain proof a -/// later `BlockProcessed` record carries. This closes that gap without +/// reconstruction from a pre-finality detection, or a live `Broadcast` +/// row stranded by an app kill) could never receive the chain proof a +/// later finalized record carries. This closes that gap without /// clobbering anything a live flow owns: /// /// - only entries whose `proof` is `None` AND whose status is /// [`Broadcast`](AssetLockStatus::Broadcast) / /// [`InstantSendLocked`](AssetLockStatus::InstantSendLocked) are -/// touched — the two pre-finality states every existing path -/// (`wait_for_proof`, `resume_asset_lock`) advances to -/// `ChainLocked` + chain proof on observing the same finality, so -/// this converges with, never contradicts, the live pipeline; +/// touched. A lock a live flow is actively completing leaves that +/// window within seconds: `wait_for_proof` attaches the IS/CL proof +/// through `advance_asset_lock_status` (making `proof` non-`None`), +/// and consumption tombstones it. What *remains* proof-less at +/// Broadcast/IS-locked when on-chain finality arrives is, by +/// elimination, a lock nobody is completing — a restore-scan +/// reconstruction or a stranded row — so the truthful terminal is +/// `RecoveredFromChain` ("final on Core, Platform-side consumption +/// unknown"), NOT `ChainLocked`, which every consumer reads as "in +/// flight". (An earlier revision upgraded to `ChainLocked`; after a +/// restore that rendered every historical funding tx as a pending +/// transfer.) In the benign race where a live `wait_for_proof` is +/// still running, its own `advance_asset_lock_status` overwrites the +/// status unconditionally moments later, so the live pipeline still +/// wins; /// - [`Built`](AssetLockStatus::Built) (owned by an in-flight build), /// [`Consumed`](AssetLockStatus::Consumed) (terminal), and /// proof-carrying entries are left untouched. @@ -296,7 +309,7 @@ fn enrich_from_record( prior_status = ?entry.status, "attaching chain proof to tracked asset lock from finalized scan record" ); - entry.status = AssetLockStatus::ChainLocked; + entry.status = AssetLockStatus::RecoveredFromChain; entry.proof = Some(dpp::prelude::AssetLockProof::Chain(chain_proof( height, out_point, ))); @@ -335,25 +348,118 @@ pub(crate) async fn reconstruct_tracked_asset_locks( return cs; }; for record in records { - // `reconstruct_candidates` reads `info.tracked_asset_locks` - // under the same write lock that guards the insert below, so - // insert-if-absent holds without a re-check. - for lock in reconstruct_candidates(info, record) { - tracing::info!( - outpoint = %lock.out_point, - funding_type = ?lock.funding_type, - amount = lock.amount, - status = ?lock.status, - has_proof = lock.proof.is_some(), - "reconstructed tracked asset lock from on-chain record" + apply_record(info, record, &mut cs); + } + cs +} + +/// One record's full reconstruction step: insert-if-absent, then let a +/// finalized record upgrade what's already tracked but still unproven +/// (the inserts carry their own proof already, so enrichment only ever +/// touches pre-existing entries). Callers own the wallet-manager write +/// lock — `reconstruct_candidates` reads `info.tracked_asset_locks` +/// under that same lock, so insert-if-absent holds without a re-check. +fn apply_record( + info: &mut PlatformWalletInfo, + record: &TransactionRecord, + cs: &mut AssetLockChangeSet, +) { + for lock in reconstruct_candidates(info, record) { + tracing::info!( + outpoint = %lock.out_point, + funding_type = ?lock.funding_type, + amount = lock.amount, + status = ?lock.status, + has_proof = lock.proof.is_some(), + "reconstructed tracked asset lock from on-chain record" + ); + cs.asset_locks.insert(lock.out_point, (&lock).into()); + info.tracked_asset_locks.insert(lock.out_point, lock); + } + enrich_from_record(info, record, cs); +} + +/// [`ChainLockProcessed`](key_wallet_manager::events::WalletEvent::ChainLockProcessed) +/// sibling of [`enrich_from_record`]: upgrade the tracked entries whose +/// funding txs a chainlock just promoted to `InChainLockedBlock`. +/// +/// Why this exists: during a restore scan the filter walk detects the +/// historical asset-lock funding txs *before* any chainlock is applied, +/// so their entries insert at the pre-finality +/// [`Broadcast`](AssetLockStatus::Broadcast) status. The promotion to +/// chain-locked happens later, in bulk, when the tip chainlock arrives +/// (`apply_chain_lock` promotes every `InBlock` record at height `<=` +/// the chainlock) — and that promotion surfaces ONLY as a +/// `ChainLockProcessed` event, whose records never re-flow through +/// `TransactionDetected`/`BlockProcessed`. Without this hook the +/// entries stayed pre-finality for the whole session (observed on a +/// restored testnet wallet 2026-08-09: all 9 reconstructed locks stuck +/// at `Broadcast` until an app restart re-emitted their records). +/// +/// Deliberately record-free: under the default +/// `keep-finalized-transactions=OFF` feature the promotion **evicts** +/// the full records and the event retains only their txids (see +/// `ManagedCoreFundsAccount::apply_chain_lock`), so reading the record +/// back here would find nothing — an earlier record-based revision of +/// this hook silently no-opped for exactly that reason. Everything +/// needed lives elsewhere: the entries to upgrade are keyed by txid in +/// `tracked_asset_locks`, and the chainlock's own height is a valid +/// `ChainAssetLockProof` height for anything the chainlock buries (the +/// same fact the resume path's CL-from-metadata fallback relies on). +/// +/// The same upgrade guard as [`enrich_from_record`] applies: only +/// proof-less entries at `Broadcast` / `InstantSendLocked` are touched +/// — nothing live is completing an entry that is still proof-less when +/// on-chain finality arrives, so `RecoveredFromChain` is the truthful +/// terminal. +/// +/// The event keys promoted txids by owning account type; +/// funding-family filtering happens on those keys, so the common case +/// (a chainlock promoting plain payments, or promoting nothing) never +/// takes the wallet-manager write lock. +pub(crate) async fn enrich_tracked_asset_locks_from_chain_lock( + wallet_manager: &Arc>>, + wallet_id: &WalletId, + chain_lock_height: u32, + locked_transactions: &BTreeMap>, +) -> AssetLockChangeSet { + let mut cs = AssetLockChangeSet::default(); + let funding_txids: std::collections::BTreeSet = locked_transactions + .iter() + .filter(|(account_type, _)| funding_family(account_type).is_some()) + .flat_map(|(_, txids)| txids.iter().copied()) + .collect(); + if funding_txids.is_empty() { + return cs; + } + let mut wm = wallet_manager.write().await; + let Some(info) = wm.get_wallet_info_mut(wallet_id) else { + return cs; + }; + for (out_point, entry) in info.tracked_asset_locks.iter_mut() { + if !funding_txids.contains(&out_point.txid) { + continue; + } + let upgradable = entry.proof.is_none() + && matches!( + entry.status, + AssetLockStatus::Broadcast | AssetLockStatus::InstantSendLocked ); - cs.asset_locks.insert(lock.out_point, (&lock).into()); - info.tracked_asset_locks.insert(lock.out_point, lock); + if !upgradable { + continue; } - // Then let a finalized record upgrade what's already tracked - // but still unproven (its own inserts above carry their proof - // already, so this only ever touches pre-existing entries). - enrich_from_record(info, record, &mut cs); + tracing::info!( + outpoint = %out_point, + chain_lock_height, + prior_status = ?entry.status, + "attaching chain proof to tracked asset lock from chainlock promotion" + ); + entry.status = AssetLockStatus::RecoveredFromChain; + entry.proof = Some(dpp::prelude::AssetLockProof::Chain(chain_proof( + chain_lock_height, + *out_point, + ))); + cs.asset_locks.insert(*out_point, (&*entry).into()); } cs } @@ -602,8 +708,10 @@ mod tests { /// A finalized record must upgrade an already-tracked, still /// unproven entry in place (attach the chain proof, advance to - /// `ChainLocked`) — the insert-if-absent rule protects live entries - /// from replacement but must not strand them proof-less forever. + /// `RecoveredFromChain` — nothing live is completing an entry that + /// is still proof-less when finality arrives) — the + /// insert-if-absent rule protects live entries from replacement but + /// must not strand them proof-less forever. #[tokio::test] async fn finalized_record_enriches_unproven_tracked_entry() { let (wallet_manager, wallet_id, tx) = @@ -636,7 +744,7 @@ mod tests { .asset_locks .get(&out_point) .expect("upgraded changeset entry"); - assert_eq!(entry.status, AssetLockStatus::ChainLocked); + assert_eq!(entry.status, AssetLockStatus::RecoveredFromChain); match &entry.proof { Some(dpp::prelude::AssetLockProof::Chain(chain)) => { assert_eq!(chain.core_chain_locked_height, 910); @@ -651,10 +759,80 @@ mod tests { .tracked_asset_locks .get(&out_point) .expect("in-memory entry"); - assert_eq!(lock.status, AssetLockStatus::ChainLocked); + assert_eq!(lock.status, AssetLockStatus::RecoveredFromChain); assert!(lock.proof.is_some()); } + /// The chainlock-promotion hook must finish what a pre-finality + /// scan started: a lock reconstructed at `Broadcast` (the scan saw + /// the tx before any chainlock was applied) upgrades to + /// `RecoveredFromChain` + a chain proof at the chainlock's height + /// when the `ChainLockProcessed` promotion names its txid — without + /// a restart, without the record re-flowing through + /// `TransactionDetected`/`BlockProcessed`, and without reading the + /// record back at all (the promotion evicts it under the default + /// `keep-finalized-transactions=OFF` feature). + #[tokio::test] + async fn chain_lock_promotion_upgrades_pre_finality_reconstruction() { + let (wallet_manager, wallet_id, tx) = + wallet_with_built_asset_lock(AssetLockFundingType::IdentityRegistration, 0).await; + let out_point = OutPoint::new(tx.txid(), 0); + + // Restore-scan sighting before the chainlock: tracked at + // Broadcast, no proof. + let mempool_record = record_for( + &tx, + AccountType::IdentityRegistration, + TransactionContext::Mempool, + ); + let cs = + reconstruct_tracked_asset_locks(&wallet_manager, &wallet_id, &[&mempool_record]).await; + assert_eq!( + cs.asset_locks.get(&out_point).expect("tracked").status, + AssetLockStatus::Broadcast + ); + + // The chainlock promotion names the txid under its funding + // account. No record is available — eviction already happened. + let locked_transactions: BTreeMap> = + BTreeMap::from([(AccountType::IdentityRegistration, vec![tx.txid()])]); + + let cs = enrich_tracked_asset_locks_from_chain_lock( + &wallet_manager, + &wallet_id, + 910, + &locked_transactions, + ) + .await; + + let entry = cs + .asset_locks + .get(&out_point) + .expect("upgraded changeset entry"); + assert_eq!(entry.status, AssetLockStatus::RecoveredFromChain); + match &entry.proof { + Some(dpp::prelude::AssetLockProof::Chain(chain)) => { + assert_eq!(chain.core_chain_locked_height, 910); + assert_eq!(chain.out_point, out_point); + } + other => panic!("expected a chain proof at the chainlock height, got {other:?}"), + } + + // A promotion that names no funding-family account is a no-op + // (and must not take the wallet-manager write lock). + let unrelated: BTreeMap> = BTreeMap::from([( + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + vec![tx.txid()], + )]); + let cs = + enrich_tracked_asset_locks_from_chain_lock(&wallet_manager, &wallet_id, 911, &unrelated) + .await; + assert!(Merge::is_empty(&cs)); + } + /// Enrichment must not touch entries a live flow owns: a `Built` /// entry (in-flight build) and a `Consumed` tombstone stay exactly /// as they are even when a finalized record for their tx arrives. @@ -786,7 +964,9 @@ mod tests { /// A reconstructed chain-locked lock is explicitly resumable: the /// attached proof feeds `resume_asset_lock` without another proof - /// wait, and the status advances to `ChainLocked` on the way out. + /// wait — and the status stays `RecoveredFromChain` on the way out + /// (a resume proves nothing new about Platform-side consumption, + /// so it must not re-enter the pending window). #[tokio::test] async fn recovered_lock_resumes_from_attached_proof() { let (wallet_manager, wallet_id, tx) = @@ -826,5 +1006,16 @@ mod tests { } other => panic!("expected the reconstructed chain proof, got {other:?}"), } + let wm = wallet_manager.read().await; + assert_eq!( + wm.get_wallet_info(&wallet_id) + .expect("wallet") + .tracked_asset_locks + .get(&out_point) + .expect("entry") + .status, + AssetLockStatus::RecoveredFromChain, + "a resume must not downgrade a recovered lock into the pending window" + ); } } diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs index 5a89934bf8..c43ab86924 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs @@ -314,10 +314,12 @@ impl AssetLockManager { // Platform rejects an already-spent outpoint with a // typed error, and a genuinely unspent lock is real // recoverable value. The reconstruction path only - // assigns this status to finalized records and attaches - // the chain proof at creation (non-final detections - // enter as `Broadcast`/`InstantSendLocked` and take - // those arms, re-broadcast included), so the proof is + // assigns this status alongside a chain proof — at + // creation for records already finalized, or via + // `enrich_from_record` when finality arrives later + // (non-final detections enter as + // `Broadcast`/`InstantSendLocked` and take those arms, + // re-broadcast included, until then) — so the proof is // present by construction; the `None` arm is a // defensive fallback for a row whose persisted proof // was lost, and its wait resolves from the already @@ -344,10 +346,23 @@ impl AssetLockManager { } }; - // 3. Advance status and attach proof. - let new_status = match &proof { - dpp::prelude::AssetLockProof::Instant(_) => AssetLockStatus::InstantSendLocked, - dpp::prelude::AssetLockProof::Chain(_) => AssetLockStatus::ChainLocked, + // 3. Advance status and attach proof. A `RecoveredFromChain` + // entry keeps its status: the resume proved (or refreshed) + // Core-side finality, which that status already asserts — it + // proved nothing new about Platform-side consumption, so + // advancing into `InstantSendLocked`/`ChainLocked` (which + // consumers read as "in flight") would silently re-enter the + // pending window and resurrect the false-"Pending" rendering + // on every restored lock whose resume didn't end in a spend. + // Consumption is recorded separately (`consume_asset_lock`) + // when the credit output actually lands on Platform. + let new_status = if status == AssetLockStatus::RecoveredFromChain { + AssetLockStatus::RecoveredFromChain + } else { + match &proof { + dpp::prelude::AssetLockProof::Instant(_) => AssetLockStatus::InstantSendLocked, + dpp::prelude::AssetLockProof::Chain(_) => AssetLockStatus::ChainLocked, + } }; let cs = self .advance_asset_lock_status(out_point, new_status, Some(proof.clone())) diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs index 7cffce10d9..7214df271f 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs @@ -61,10 +61,13 @@ pub enum AssetLockStatus { /// (`wallet::asset_lock::sync::reconstruction`) emits this for /// finalized asset-lock transactions whose credit outputs pay this /// wallet's funding accounts. Non-final detections (mempool / - /// unconfirmed-block sightings) never get this status; they enter - /// as [`Broadcast`](Self::Broadcast) / + /// unconfirmed-block sightings) enter as + /// [`Broadcast`](Self::Broadcast) / /// [`InstantSendLocked`](Self::InstantSendLocked) like any other - /// pre-finality lock. + /// pre-finality lock, and are upgraded to this status when a later + /// record — or the chainlock promotion that buries their block — + /// proves finality while nothing live is completing them + /// (`enrich_from_record`). /// /// Core-side finality is therefore guaranteed (a /// `ChainAssetLockProof` from the record's height is attached at From 60b78fb83d15d2e635ff4066b7b83f2c7dca0be9 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 9 Aug 2026 07:09:31 +0700 Subject: [PATCH 2/6] test(platform-wallet): pin down the live-flow race with recovery classification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up: enrichment's "nothing live is completing it" rule is structural (proof-less + pre-finality), not provenance-based, so a chainlock promotion CAN transiently classify a still-waiting live lock RecoveredFromChain. Pin the convergence guarantee with a regression test — the live pipeline's unconditional advance_asset_lock_status overwrites the transient classification and consumption still reaches Consumed — and document the lifecycle on the variant. Co-Authored-By: Claude Fable 5 --- .../wallet/asset_lock/sync/reconstruction.rs | 94 +++++++++++++++++++ .../src/wallet/asset_lock/tracked.rs | 20 ++++ 2 files changed, 114 insertions(+) diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs index bd21de7d97..f9a17b6d04 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs @@ -1018,4 +1018,98 @@ mod tests { "a resume must not downgrade a recovered lock into the pending window" ); } + + /// The benign race with a LIVE flow, end to end: a chainlock + /// promotion may reach a proof-less `Broadcast` entry while the + /// live pipeline's `wait_for_proof` is still running, transiently + /// classifying it `RecoveredFromChain`. The live pipeline's own + /// writers don't consult the entry — `wait_for_proof` resolves the + /// proof from records/events and the build pipeline then calls + /// `advance_asset_lock_status`, which overwrites status and proof + /// unconditionally — so the live flow always wins the race and the + /// transient recovery classification never sticks; consumption + /// still lands the `Consumed` terminal afterwards. + #[tokio::test] + async fn live_flow_advance_overwrites_chain_lock_recovery_classification() { + let (wallet_manager, wallet_id, tx) = + wallet_with_built_asset_lock(AssetLockFundingType::IdentityRegistration, 0).await; + let out_point = OutPoint::new(tx.txid(), 0); + + // The live lock, stranded at proof-less Broadcast long enough + // for its block to chain-lock (IS lock never arrived). + let mempool_record = record_for( + &tx, + AccountType::IdentityRegistration, + TransactionContext::Mempool, + ); + let _ = reconstruct_tracked_asset_locks(&wallet_manager, &wallet_id, &[&mempool_record]) + .await; + + // The chainlock promotion races in first. + let locked_transactions: BTreeMap> = + BTreeMap::from([(AccountType::IdentityRegistration, vec![tx.txid()])]); + let cs = enrich_tracked_asset_locks_from_chain_lock( + &wallet_manager, + &wallet_id, + 77, + &locked_transactions, + ) + .await; + assert_eq!( + cs.asset_locks.get(&out_point).expect("entry").status, + AssetLockStatus::RecoveredFromChain + ); + + // The live flow's proof then arrives and its pipeline advances + // the entry exactly as `build.rs` does — the recovery + // classification is overwritten, not merged around. + let sdk = Arc::new( + dash_sdk::SdkBuilder::new_mock() + .with_network(Network::Testnet) + .build() + .expect("mock sdk"), + ); + let manager = AssetLockManager::new( + sdk, + Arc::clone(&wallet_manager), + wallet_id, + Arc::new(Notify::new()), + Arc::new(AlwaysRejectedBroadcaster), + WalletPersister::new( + wallet_id, + Arc::new(NoopTestPersister) as Arc, + ), + ); + let live_proof = dpp::prelude::AssetLockProof::Chain(chain_proof(78, out_point)); + manager + .advance_asset_lock_status(&out_point, AssetLockStatus::ChainLocked, Some(live_proof)) + .await + .expect("live advance"); + { + let wm = wallet_manager.read().await; + assert_eq!( + wm.get_wallet_info(&wallet_id) + .expect("wallet") + .tracked_asset_locks + .get(&out_point) + .expect("entry") + .status, + AssetLockStatus::ChainLocked, + "the live pipeline's advance must overwrite the transient recovery classification" + ); + } + + // And consumption still reaches its terminal. + let _ = manager.consume_asset_lock(&out_point).await.expect("consume"); + let wm = wallet_manager.read().await; + assert_eq!( + wm.get_wallet_info(&wallet_id) + .expect("wallet") + .tracked_asset_locks + .get(&out_point) + .expect("entry") + .status, + AssetLockStatus::Consumed + ); + } } diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs index 7214df271f..f8b06e9356 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs @@ -69,6 +69,26 @@ pub enum AssetLockStatus { /// proves finality while nothing live is completing them /// (`enrich_from_record`). /// + /// # Lifecycle vs live flows + /// + /// "Nothing live is completing them" is enforced structurally, not + /// by a provenance flag: enrichment only touches proof-less + /// entries, and a lock a live flow drives leaves the proof-less + /// window as soon as its proof resolves (`wait_for_proof` → + /// `advance_asset_lock_status`, which overwrites status + proof + /// unconditionally, and `resume_asset_lock`, which advances from + /// its step-1 status snapshot). In the one race where a chainlock + /// promotion reaches a still-waiting live lock first, the live + /// pipeline's own write lands moments later and wins — the + /// transient recovery classification never sticks (regression: + /// `live_flow_advance_overwrites_chain_lock_recovery_classification`). + /// The only transition OUT of this status is therefore a live + /// writer: an explicit resume completing the spend (`Consumed` via + /// `consume_asset_lock`) or re-driving the proof pipeline; a + /// resume that proves nothing new keeps this status + /// (`resume_asset_lock` preserves it rather than re-entering the + /// pending window). + /// /// Core-side finality is therefore guaranteed (a /// `ChainAssetLockProof` from the record's height is attached at /// creation), but **Platform-side consumption is unknown**: the From c8bdd4bcc0263e61317f8a7bc50fb590788c9ad3 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 9 Aug 2026 07:16:24 +0700 Subject: [PATCH 3/6] chore(platform-wallet): rustfmt Co-Authored-By: Claude Fable 5 --- .../wallet/asset_lock/sync/reconstruction.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs index f9a17b6d04..dde6821636 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs @@ -827,9 +827,13 @@ mod tests { }, vec![tx.txid()], )]); - let cs = - enrich_tracked_asset_locks_from_chain_lock(&wallet_manager, &wallet_id, 911, &unrelated) - .await; + let cs = enrich_tracked_asset_locks_from_chain_lock( + &wallet_manager, + &wallet_id, + 911, + &unrelated, + ) + .await; assert!(Merge::is_empty(&cs)); } @@ -1042,8 +1046,8 @@ mod tests { AccountType::IdentityRegistration, TransactionContext::Mempool, ); - let _ = reconstruct_tracked_asset_locks(&wallet_manager, &wallet_id, &[&mempool_record]) - .await; + let _ = + reconstruct_tracked_asset_locks(&wallet_manager, &wallet_id, &[&mempool_record]).await; // The chainlock promotion races in first. let locked_transactions: BTreeMap> = @@ -1100,7 +1104,10 @@ mod tests { } // And consumption still reaches its terminal. - let _ = manager.consume_asset_lock(&out_point).await.expect("consume"); + let _ = manager + .consume_asset_lock(&out_point) + .await + .expect("consume"); let wm = wallet_manager.read().await; assert_eq!( wm.get_wallet_info(&wallet_id) From bb0a4d02ac55ab0365ef9c6962b8c7a909efe677 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 9 Aug 2026 14:54:44 +0700 Subject: [PATCH 4/6] fix(platform-wallet): Consumed is terminal at every asset-lock write layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up (stale recovery writes vs live state): the wallet-event adapter persists enrichment snapshots from its batched drain, while live flows persist synchronously through the changeset queue — so a stale RecoveredFromChain snapshot, taken under the wallet lock before a live consumption, could land at the store AFTER the Consumed write and regress durable/host state (merge and the upserts were unconditional last-write-wins). A total status ordering would be wrong — every non-terminal transition is legitimately bidirectional (live advances overwrite RecoveredFromChain, defensive resumes re-enter Broadcast) — but Consumed is terminal, and terminality is enforceable without vetoing real transitions. Enforce it at all three write layers, making the arrival order of racing snapshots immaterial: - AssetLockChangeSet::merge skips a non-Consumed entry over a Consumed one (guards intra-batch folds); - the rs-platform-wallet-storage upsert adds a WHERE guard rejecting non-consumed over consumed; - swift-sdk persistAssetLocks skips a non-4 snapshot over a statusRaw-4 row. Tests: the exact adversarial interleaving through the real sqlite persister (Broadcast → Consumed → stale RecoveredFromChain stays Consumed; Consumed still lands over RecoveredFromChain), plus a merge unit test covering both directions and the legitimate non-terminal LWW. Co-Authored-By: Claude Fable 5 --- .../src/sqlite/schema/asset_locks.rs | 17 ++- .../tests/sqlite_persist_roundtrip.rs | 100 ++++++++++++++++++ .../src/changeset/changeset.rs | 95 ++++++++++++++++- .../PlatformWalletPersistenceHandler.swift | 13 +++ 4 files changed, 222 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs index ab3ee0e206..87cbbd814a 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs @@ -27,6 +27,20 @@ pub fn apply( cs: &AssetLockChangeSet, ) -> Result<(), WalletStorageError> { if !cs.asset_locks.is_empty() { + // The upsert's WHERE clause enforces the one terminal lifecycle + // rule: a stored `consumed` row is never overwritten by a + // non-consumed snapshot. Racing writers persist through + // different paths (the wallet-event adapter's batched drain vs + // the live flows' synchronous changeset queue), so a stale + // reconstruction/enrichment snapshot can land AFTER the + // consumption write — this guard makes that arrival order + // immaterial. Every other transition is deliberately + // last-write-wins: non-terminal statuses move both ways (live + // advances overwrite `recovered_from_chain`, defensive resumes + // re-enter `broadcast`), so terminality is the only ordering + // the store can enforce without vetoing legitimate writes. + // `AssetLockChangeSet::merge` applies the same rule when + // batches fold before reaching the store. let mut stmt = tx.prepare_cached( "INSERT INTO asset_locks \ (wallet_id, outpoint, status, account_index, identity_index, amount_duffs, lifecycle_blob) \ @@ -36,7 +50,8 @@ pub fn apply( account_index = excluded.account_index, \ identity_index = excluded.identity_index, \ amount_duffs = excluded.amount_duffs, \ - lifecycle_blob = excluded.lifecycle_blob", + lifecycle_blob = excluded.lifecycle_blob \ + WHERE asset_locks.status != 'consumed' OR excluded.status = 'consumed'", )?; for (op, entry) in &cs.asset_locks { let op_bytes = blob::encode_outpoint(op)?; 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 bafa6ffca7..71315b41b5 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs @@ -522,6 +522,106 @@ fn tc010b_recovered_from_chain_lock_roundtrip() { drop(tmp); } +/// TC-010c: the store-order race between the wallet-event adapter and +/// the live flows, applied in the exact adversarial order. A stale +/// reconstruction/enrichment snapshot (`RecoveredFromChain`) that the +/// adapter's batched drain persists AFTER the live flow's synchronous +/// `Consumed` write must NOT regress the durable row — `Consumed` is +/// terminal and the upsert's WHERE guard rejects the late arrival. +/// Every other direction stays last-write-wins, including `Consumed` +/// landing over `RecoveredFromChain`. +#[test] +fn tc010c_stale_recovery_snapshot_cannot_regress_consumed_row() { + use dashcore::hashes::Hash; + use dashcore::{OutPoint, Transaction, Txid}; + use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + use platform_wallet::changeset::{AssetLockChangeSet, AssetLockEntry}; + use platform_wallet::wallet::asset_lock::tracked::AssetLockStatus; + + let entry_with = |outpoint: OutPoint, status: AssetLockStatus| AssetLockEntry { + out_point: outpoint, + transaction: Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }, + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 0, + amount_duffs: 1_000_000, + status: status.clone(), + proof: match status { + AssetLockStatus::RecoveredFromChain => { + Some(dpp::prelude::AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: 900, + out_point: outpoint, + })) + } + _ => None, + }, + }; + let store_one = |persister: &SqlitePersister, w, outpoint, status| { + let mut locks = AssetLockChangeSet::default(); + locks + .asset_locks + .insert(outpoint, entry_with(outpoint, status)); + persister + .store( + w, + PlatformWalletChangeSet { + asset_locks: Some(locks), + ..Default::default() + }, + ) + .unwrap(); + }; + + let (persister, tmp, path) = fresh_persister(); + let w = wid(0xFA); + ensure_wallet_meta(&persister, &w); + + // Outpoint A: live lock consumed, THEN the stale recovery snapshot + // arrives (the adapter drained its batch after the live write). + let a = OutPoint { + txid: Txid::from_byte_array([0x51; 32]), + vout: 0, + }; + store_one(&persister, w, a, AssetLockStatus::Broadcast); + store_one(&persister, w, a, AssetLockStatus::Consumed); + store_one(&persister, w, a, AssetLockStatus::RecoveredFromChain); + + // Outpoint B: the legitimate direction — a recovered lock is + // explicitly resumed and consumed; the terminal write must land. + let b = OutPoint { + txid: Txid::from_byte_array([0x52; 32]), + vout: 0, + }; + store_one(&persister, w, b, AssetLockStatus::RecoveredFromChain); + store_one(&persister, w, b, AssetLockStatus::Consumed); + + 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, + ) + .unwrap(); + assert_eq!( + bucketed[&0][&a].status, + AssetLockStatus::Consumed, + "a stale RecoveredFromChain snapshot landing after Consumed must be rejected" + ); + assert_eq!( + bucketed[&0][&b].status, + AssetLockStatus::Consumed, + "Consumed must still land over RecoveredFromChain" + ); + drop(tmp); +} + /// TC-012: DashPay profile + payment overlay round-trip through the /// dashpay_* tables via bincode-serde blobs. #[test] diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index c6de98fdae..e2d7d16887 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -953,8 +953,29 @@ pub struct AssetLockEntry { impl Merge for AssetLockChangeSet { fn merge(&mut self, other: Self) { - // Last write wins — later status is higher finality. - self.asset_locks.extend(other.asset_locks); + // Last write wins, with ONE lifecycle exception: `Consumed` is + // the terminal state, so a non-Consumed snapshot never replaces + // a Consumed one. Writers race here — the wallet-event + // adapter's batched drain can fold (or persist) a stale + // reconstruction/enrichment snapshot AFTER the live flow's + // synchronous consumption write — and every non-terminal + // transition is legitimately bidirectional (a live advance + // overwrites `RecoveredFromChain`, a defensive resume + // re-enters `Broadcast`), so terminality is the only ordering + // the merge can enforce without vetoing real transitions. The + // durable stores apply the same rule (sqlite upsert guard, + // swift-sdk `persistAssetLocks`), making the store order of + // racing snapshots immaterial. + for (out_point, entry) in other.asset_locks { + if entry.status != AssetLockStatus::Consumed { + if let Some(existing) = self.asset_locks.get(&out_point) { + if existing.status == AssetLockStatus::Consumed { + continue; + } + } + } + self.asset_locks.insert(out_point, entry); + } self.removed.extend(other.removed); } @@ -1663,6 +1684,76 @@ mod tests { assert!(cs.is_empty()); } + /// Asset-lock merge is last-write-wins EXCEPT for the Consumed + /// terminal: when the wallet-event adapter's batched drain folds a + /// stale reconstruction/enrichment snapshot after (or before) the + /// live flow's consumption write, the fold must never regress + /// Consumed — while Consumed itself must still land over anything. + #[test] + fn asset_lock_merge_never_regresses_consumed() { + use dashcore::hashes::Hash; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + + let outpoint = OutPoint { + txid: Txid::from_byte_array([0x61; 32]), + vout: 0, + }; + let entry_with = |status: AssetLockStatus| AssetLockEntry { + out_point: outpoint, + transaction: Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }, + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 0, + amount_duffs: 1, + status, + proof: None, + }; + let cs_with = |status: AssetLockStatus| { + let mut cs = AssetLockChangeSet::default(); + cs.asset_locks.insert(outpoint, entry_with(status)); + cs + }; + + // Stale recovery snapshot folded AFTER the consumption write. + let mut folded = cs_with(AssetLockStatus::Consumed); + folded.merge(cs_with(AssetLockStatus::RecoveredFromChain)); + assert_eq!( + folded.asset_locks[&outpoint].status, + AssetLockStatus::Consumed, + "a non-Consumed snapshot must not replace the Consumed terminal" + ); + + // The legitimate direction still lands. + let mut folded = cs_with(AssetLockStatus::RecoveredFromChain); + folded.merge(cs_with(AssetLockStatus::Consumed)); + assert_eq!( + folded.asset_locks[&outpoint].status, + AssetLockStatus::Consumed + ); + + // Non-terminal transitions stay last-write-wins in both + // directions (live advances overwrite RecoveredFromChain, and + // enrichment overwrites Broadcast). + let mut folded = cs_with(AssetLockStatus::RecoveredFromChain); + folded.merge(cs_with(AssetLockStatus::ChainLocked)); + assert_eq!( + folded.asset_locks[&outpoint].status, + AssetLockStatus::ChainLocked + ); + let mut folded = cs_with(AssetLockStatus::Broadcast); + folded.merge(cs_with(AssetLockStatus::RecoveredFromChain)); + assert_eq!( + folded.asset_locks[&outpoint].status, + AssetLockStatus::RecoveredFromChain + ); + } + #[test] fn contested_dpns_merge_replaces_canonical_snapshot_and_allows_empty() { let id = Identifier::from([0x51; 32]); diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index d1a89a4c89..dd93c12426 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -257,6 +257,19 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { predicate: #Predicate { $0.outPointHex == outPointHex } ) if let existing = try? backgroundContext.fetch(descriptor).first { + // Consumed (4) is the terminal lifecycle state — never + // let a non-Consumed snapshot regress it. Writers race: + // the wallet-event adapter's batched drain can deliver a + // stale reconstruction/enrichment snapshot AFTER the + // live flow's synchronous consumption write, and this + // upsert is otherwise last-write-wins. Mirrors the same + // guard in `AssetLockChangeSet::merge` and the + // rs-platform-wallet-storage sqlite upsert; all other + // transitions stay last-write-wins because non-terminal + // statuses legitimately move both ways. + if existing.statusRaw == 4 && entry.statusRaw != 4 { + continue + } existing.walletId = walletId existing.transactionBytes = entry.transactionBytes existing.fundingTypeRaw = entry.fundingTypeRaw From c97da6f88a441619bfc34f21a3f3ab3d3b6a5292 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 9 Aug 2026 15:10:53 +0700 Subject: [PATCH 5/6] chore(ci): codecov-ignore rs-platform-wallet-storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Its tests run in the wallet fast-path workflow (tests-rs-wallet.yml), which intentionally omits coverage upload — so on wallet-scoped PRs codecov receives no data for this crate and codecov/patch fails spuriously on any change to it (observed on #4347: 14 "missing" lines that the sqlite roundtrip suite in fact exercises). Mirrors the existing rs-platform-wallet/src ignore. Co-Authored-By: Claude Fable 5 --- .codecov.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.codecov.yml b/.codecov.yml index d1b3dee57b..432c1cff2a 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -108,6 +108,11 @@ ignore: - "packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/**/v1_methods.rs" # Platform wallet — requires Core wallet integration, not unit-testable - "packages/rs-platform-wallet/src/**" + # Platform wallet storage — its tests run in the wallet fast-path + # workflow (tests-rs-wallet.yml), which intentionally omits coverage + # upload, so codecov never receives data for this crate on + # wallet-scoped PRs and patch status would fail spuriously + - "packages/rs-platform-wallet-storage/**" # Proof-verifier response types and unproved handling - "packages/rs-drive-proof-verifier/src/types.rs" - "packages/rs-drive-proof-verifier/src/unproved.rs" From 68723cd5b2bbaccdf68e47811c9a79baab93911b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 9 Aug 2026 15:21:39 +0700 Subject: [PATCH 6/6] fix(platform-wallet): terminal rule covers removal tombstones too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up: the Consumed-is-terminal guards covered stale upserts but not stale `removed` tombstones — merge could retain a Consumed entry alongside a tombstone, and both stores apply upserts before removals, so the delete would win. The only removal emitter (untrack_asset_lock) fires exclusively for Built rows whose broadcast was definitively rejected, so a Consumed/removed pair for one outpoint has no legitimate producer; this is defense in depth matching the upsert guard, and consistent with Consumed rows being deliberately retained for historical lookup. - merge: a Consumed entry clears an earlier-folded tombstone, and an incoming tombstone is dropped when the effective entry is Consumed; - sqlite delete gains `AND status != 'consumed'`; - swift persistAssetLocks skips deleting a statusRaw-4 row; - tests: merge covers all three tombstone directions, and the sqlite interleaving test adds stale-removal-after-Consumed (row survives) plus the legitimate rejected-Built removal (row deletes). Co-Authored-By: Claude Fable 5 --- .../src/sqlite/schema/asset_locks.rs | 12 +++- .../tests/sqlite_persist_roundtrip.rs | 34 +++++++++++ .../src/changeset/changeset.rs | 57 +++++++++++++++++-- .../PlatformWalletPersistenceHandler.swift | 9 +++ 4 files changed, 104 insertions(+), 8 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs index 87cbbd814a..b0c21a58a4 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs @@ -71,8 +71,16 @@ pub fn apply( } } if !cs.removed.is_empty() { - let mut stmt = - tx.prepare_cached("DELETE FROM asset_locks WHERE wallet_id = ?1 AND outpoint = ?2")?; + // Same terminal rule as the upsert guard: a stored `consumed` + // row is never deleted by a stale tombstone. Consumed rows are + // deliberately retained for historical lookup, and the only + // removal emitter (`untrack_asset_lock`) fires exclusively for + // Built rows whose broadcast was rejected — so a removal + // reaching a consumed row is by construction a stale write. + let mut stmt = tx.prepare_cached( + "DELETE FROM asset_locks \ + WHERE wallet_id = ?1 AND outpoint = ?2 AND status != 'consumed'", + )?; for op in &cs.removed { let op_bytes = blob::encode_outpoint(op)?; stmt.execute(params![wallet_id.as_slice(), &op_bytes[..]])?; 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 71315b41b5..3f05d82c76 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs @@ -602,6 +602,32 @@ fn tc010c_stale_recovery_snapshot_cannot_regress_consumed_row() { store_one(&persister, w, b, AssetLockStatus::RecoveredFromChain); store_one(&persister, w, b, AssetLockStatus::Consumed); + // A stale tombstone obeys the same terminal rule: a removal landing + // after the Consumed write must not delete the row… + let store_removed = |persister: &SqlitePersister, w, outpoint| { + let mut locks = AssetLockChangeSet::default(); + locks.removed.insert(outpoint); + persister + .store( + w, + PlatformWalletChangeSet { + asset_locks: Some(locks), + ..Default::default() + }, + ) + .unwrap(); + }; + store_removed(&persister, w, a); + + // …while the legitimate removal path (a rejected Built row) still + // deletes. + let c = OutPoint { + txid: Txid::from_byte_array([0x53; 32]), + vout: 0, + }; + store_one(&persister, w, c, AssetLockStatus::Built); + store_removed(&persister, w, c); + drop(persister); let p2 = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); let bucketed = platform_wallet_storage::sqlite::schema::asset_locks::load_state( @@ -619,6 +645,14 @@ fn tc010c_stale_recovery_snapshot_cannot_regress_consumed_row() { AssetLockStatus::Consumed, "Consumed must still land over RecoveredFromChain" ); + assert!( + bucketed[&0][&a].status == AssetLockStatus::Consumed, + "a stale removal must not delete the Consumed row" + ); + assert!( + !bucketed[&0].contains_key(&c), + "a legitimate removal of a rejected Built row must still delete" + ); drop(tmp); } diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index e2d7d16887..c19aff6ad6 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -967,16 +967,35 @@ impl Merge for AssetLockChangeSet { // swift-sdk `persistAssetLocks`), making the store order of // racing snapshots immaterial. for (out_point, entry) in other.asset_locks { - if entry.status != AssetLockStatus::Consumed { - if let Some(existing) = self.asset_locks.get(&out_point) { - if existing.status == AssetLockStatus::Consumed { - continue; - } + if entry.status == AssetLockStatus::Consumed { + // A Consumed write supersedes any earlier-folded + // tombstone for the outpoint — Consumed rows are + // deliberately retained for historical lookup (see the + // variant doc), so the terminal write wins over a stale + // removal exactly as it wins over a stale status. + self.removed.remove(&out_point); + } else if let Some(existing) = self.asset_locks.get(&out_point) { + if existing.status == AssetLockStatus::Consumed { + continue; } } self.asset_locks.insert(out_point, entry); } - self.removed.extend(other.removed); + // Tombstones folded after a Consumed upsert are dropped for the + // same reason. The only removal emitter (`untrack_asset_lock`) + // fires exclusively for Built rows whose broadcast was + // definitively rejected, so a Consumed/removed pair for one + // outpoint has no legitimate producer — this is defense in + // depth matching the upsert guard. + for out_point in other.removed { + let consumed = self + .asset_locks + .get(&out_point) + .is_some_and(|entry| entry.status == AssetLockStatus::Consumed); + if !consumed { + self.removed.insert(out_point); + } + } } fn is_empty(&self) -> bool { @@ -1752,6 +1771,32 @@ mod tests { folded.asset_locks[&outpoint].status, AssetLockStatus::RecoveredFromChain ); + + // Tombstones obey the same terminal rule. A removal folded + // after a Consumed entry is dropped… + let removal = || { + let mut cs = AssetLockChangeSet::default(); + cs.removed.insert(outpoint); + cs + }; + let mut folded = cs_with(AssetLockStatus::Consumed); + folded.merge(removal()); + assert!( + folded.removed.is_empty(), + "a tombstone must not survive over a Consumed entry" + ); + // …a Consumed entry folded after a tombstone clears it… + let mut folded = removal(); + folded.merge(cs_with(AssetLockStatus::Consumed)); + assert!(folded.removed.is_empty()); + assert_eq!( + folded.asset_locks[&outpoint].status, + AssetLockStatus::Consumed + ); + // …and a legitimate removal (rejected Built row) still folds. + let mut folded = cs_with(AssetLockStatus::Built); + folded.merge(removal()); + assert!(folded.removed.contains(&outpoint)); } #[test] diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index dd93c12426..dd96353ba4 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -301,6 +301,15 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { predicate: #Predicate { $0.outPointHex == hex } ) if let existing = try? backgroundContext.fetch(descriptor).first { + // Same terminal rule as the upsert guard above: a + // Consumed (4) row is deliberately retained for + // historical lookup and the only removal emitter + // (`untrack_asset_lock`) targets rejected Built + // rows — a removal reaching a consumed row is by + // construction a stale write. + if existing.statusRaw == 4 { + continue + } backgroundContext.delete(existing) } }