>,
}
impl AdapterFaultState {
@@ -730,6 +736,35 @@ fn commit_wallet(
payments,
} = wallet_batch;
{
+ // A truncation this wallet's store rejected goes first: everything
+ // folded since describes the wallet after it.
+ if let Some(unapplied) = fault.unapplied_truncations.remove(&wallet_id) {
+ let mut owed = CoreChangeSet {
+ truncations: unapplied,
+ ..CoreChangeSet::default()
+ };
+ owed.merge(core);
+ core = owed;
+ }
+ // Withheld, not faulted: see `CORE_CHAIN_TRUNCATION` for why freezing
+ // the watermark, as the sweep gate below does, would recover nothing.
+ // The merge has already rewritten the rest of the round as the
+ // truncation left it, so that part is still correct to store.
+ if !core.truncations.is_empty()
+ && !persister
+ .persistence_capabilities()
+ .contains(PersistenceCapabilities::CORE_CHAIN_TRUNCATION)
+ {
+ tracing::error!(
+ wallet_id = %hex::encode(wallet_id),
+ truncations = core.truncations.len(),
+ fork_height = core.truncations.iter().map(|t| t.height).min(),
+ "Persister does not advertise CORE_CHAIN_TRUNCATION; withholding this round's \
+ chain truncation. Stored wallet state keeps the replaced branch above the fork \
+ height until the host rescans."
+ );
+ core.truncations.clear();
+ }
// Sent-payment verdicts reach a host only through the payment-overlay
// slot, which a persister advertises with `DASHPAY_PAYMENTS`. A host
// without it would take the round, return `Ok`, and drop the verdict
@@ -845,7 +880,24 @@ fn commit_wallet
(
dashpay_payments_overlay: payments,
..PlatformWalletChangeSet::default()
};
+ let offered_truncations = cs
+ .core
+ .as_ref()
+ .map(|core| core.truncations.clone())
+ .unwrap_or_default();
let store_result = persister.store(wallet_id, cs);
+ if let Err(error) = &store_result {
+ // A backend that kept the failed round for its own retry still
+ // holds the truncation. A second copy would land after what that
+ // round stores behind it, and cut that away.
+ let kept_by_backend =
+ error.is_transient() && !persister.store_transient_is_reissuable();
+ if !offered_truncations.is_empty() && !kept_by_backend {
+ fault
+ .unapplied_truncations
+ .insert(wallet_id, offered_truncations);
+ }
+ }
// Recorded only once the store has RETURNED, and whether it accepted
// or rejected — both are answers. A wallet whose store panicked never
// reaches this line, and one the loop never got to is never pushed at
@@ -1185,7 +1237,20 @@ async fn build_core_changeset(
collect_usage_deltas(wallet_manager, wallet_id, vec![&**record]).await;
let mut folded = owned.clone();
crate::changeset::changeset::fold_same_txid_records(&mut folded);
+ // A lock that arrives with the transaction lives only in the
+ // record's context, which mining replaces. Stored on its own it
+ // survives that, so a truncation returning the transaction to
+ // the mempool leaves its outputs InstantSend-locked in the store
+ // as it does in the wallet. Read from the event, not the
+ // snapshot above, which may already be past that truncation.
+ let instant_locks_for_non_final_records = match &record.context {
+ TransactionContext::InstantSend(lock) => {
+ BTreeMap::from([(record.txid, lock.clone())])
+ }
+ _ => BTreeMap::new(),
+ };
CoreChangeSet {
+ instant_locks_for_non_final_records,
// New UTXOs from the owned slices only (a watch-only
// chain's outputs are the contact's coins); spends
// from ALL slices, so a contact spending an output a
@@ -1349,20 +1414,25 @@ async fn build_core_changeset(
restored_outpoints,
..
} => {
- // TODO(#5380): mirror the truncation to the persister. The wallet
- // has already dropped these records in memory, but `CoreChangeSet`
- // cannot express it: the heights are monotonic-max on merge, a
- // `SweepBatch` needs a winning transaction, and nothing demotes a
- // confirmed record to unconfirmed. Until it can, the stored rows
- // and the stored sync height stay as they were before the fork.
- tracing::warn!(
+ // The wallet has already dropped these records. Every other arm
+ // only appends or raises, so without this one the store keeps
+ // the replaced branch and a height above the fork.
+ tracing::info!(
height,
removed = txids.len(),
unconfirmed = unconfirmed_txids.len(),
restored = restored_outpoints.len(),
- "Chain truncation is not mirrored to the persister; stored wallet state keeps the replaced branch"
+ "Mirroring a chain truncation to the persister"
);
- CoreChangeSet::default()
+ CoreChangeSet {
+ truncations: vec![ChainTruncation {
+ height: *height,
+ txids: txids.clone(),
+ unconfirmed_txids: unconfirmed_txids.clone(),
+ restored_outpoints: restored_outpoints.clone(),
+ }],
+ ..CoreChangeSet::default()
+ }
}
WalletEvent::SyncHeightAdvanced { height, .. } => CoreChangeSet {
synced_height: Some(*height),
@@ -2187,6 +2257,7 @@ impl CoreChangeSet {
self.records.is_empty()
&& self.account_records.is_empty()
&& self.sweeps.is_empty()
+ && self.truncations.is_empty()
&& self.spent_utxos.is_empty()
&& self.new_utxos.is_empty()
&& self.instant_locks_for_non_final_records.is_empty()
@@ -2280,24 +2351,36 @@ mod swept_transaction_projection_tests {
}
}
- /// Pins the gap tracked in #5380: a truncation reaches the bridge but
- /// produces no persistable change, least of all a sweep (it has no
- /// winning transaction) or a height (heights only move up on merge).
+ /// A truncation carries txids and outpoints but no records, so like a
+ /// sweep it has to reach the persister on the strength of those alone.
#[tokio::test]
- async fn should_persist_nothing_for_a_chain_truncation() {
+ async fn should_mirror_a_chain_truncation_to_the_persister() {
let event = WalletEvent::ChainTruncated {
wallet_id: WALLET_ID,
height: WINNER_HEIGHT,
txids: vec![txid(1)],
unconfirmed_txids: vec![txid(2)],
- restored_outpoints: Vec::new(),
+ restored_outpoints: vec![outpoint(3, 0)],
balance: WalletCoreBalance::default(),
account_balances: BTreeMap::new(),
};
let cs = build_core_changeset(&test_manager(), &event).await;
- assert!(cs.is_empty(), "a truncation is not mirrored yet");
+ assert_eq!(
+ cs.truncations,
+ vec![ChainTruncation {
+ height: WINNER_HEIGHT,
+ txids: vec![txid(1)],
+ unconfirmed_txids: vec![txid(2)],
+ restored_outpoints: vec![outpoint(3, 0)],
+ }]
+ );
+ assert!(cs.sweeps.is_empty(), "a truncation has no winner to name");
+ assert!(
+ !cs.is_empty_no_records(),
+ "the adapter must not skip the round"
+ );
}
#[tokio::test]
@@ -4965,6 +5048,8 @@ mod tests {
/// must be coupled to, since the sweep never re-emits once its round
/// is durable.
n_sweeps: usize,
+ /// Chain truncations the round carries, verbatim.
+ truncations: Vec,
/// The round's `dashpay_payments_overlay` verbatim — `None` when the
/// round carried none, which is also what a payments-blind persister
/// must see after the capability gate has withheld one.
@@ -4987,6 +5072,10 @@ mod tests {
struct ProbePersister {
obs: UnboundedSender,
fail_once: Mutex>,
+ /// Wallets whose NEXT `store()` fails as `Transient`. The probe does
+ /// not attest `store_transient_is_reissuable`, so this is a backend
+ /// that kept the failed round for its own retry.
+ fail_transiently_once: Mutex>,
/// Wallets whose NEXT `store()` panics instead of returning. Models a
/// backend that dies mid-write — which surfaces as a `JoinError`
/// instead of unwinding the whole adapter task.
@@ -5006,6 +5095,7 @@ mod tests {
Self {
obs,
fail_once: Mutex::new(HashSet::new()),
+ fail_transiently_once: Mutex::new(HashSet::new()),
panic_once: Mutex::new(HashSet::new()),
block_until: Mutex::new(None),
blocked: Arc::new(AtomicBool::new(false)),
@@ -5034,6 +5124,9 @@ mod tests {
fn fail_next(&self, wallet_id: WalletId) {
self.fail_once.lock().unwrap().insert(wallet_id);
}
+ fn fail_next_transiently(&self, wallet_id: WalletId) {
+ self.fail_transiently_once.lock().unwrap().insert(wallet_id);
+ }
fn panic_next(&self, wallet_id: WalletId) {
self.panic_once.lock().unwrap().insert(wallet_id);
}
@@ -5061,7 +5154,12 @@ mod tests {
if self.panic_once.lock().unwrap().remove(&wallet_id) {
panic!("probe persister: store panicked for {wallet_id:?}");
}
- let rejected = self.fail_once.lock().unwrap().remove(&wallet_id);
+ let kept_for_retry = self
+ .fail_transiently_once
+ .lock()
+ .unwrap()
+ .remove(&wallet_id);
+ let rejected = self.fail_once.lock().unwrap().remove(&wallet_id) || kept_for_retry;
let _ = self.obs.send(StoreObserved {
wallet_id,
synced_height: core.and_then(|c| c.synced_height),
@@ -5078,6 +5176,7 @@ mod tests {
.map(|a| a.removed.len())
.unwrap_or(0),
n_sweeps: core.map(|c| c.sweeps.len()).unwrap_or(0),
+ truncations: core.map(|c| c.truncations.clone()).unwrap_or_default(),
dashpay_payments: changeset.dashpay_payments_overlay.clone(),
dashpay_identity_payments: changeset.identities.as_ref().map(|ids| {
ids.identities
@@ -5087,7 +5186,12 @@ mod tests {
}),
rejected,
});
- if rejected {
+ if kept_for_retry {
+ Err(PersistenceError::backend_with_kind(
+ crate::changeset::traits::PersistenceErrorKind::Transient,
+ "probe: forced transient store failure",
+ ))
+ } else if rejected {
Err(PersistenceError::backend("probe: forced store rejection"))
} else {
Ok(())
@@ -6104,6 +6208,195 @@ mod tests {
handle.await.unwrap();
}
+ /// Height of the last block the truncations in this module keep.
+ const FORK_HEIGHT: u32 = 30;
+
+ fn truncated_event(wallet_id: WalletId) -> WalletEvent {
+ use dashcore::hashes::Hash as _;
+ WalletEvent::ChainTruncated {
+ wallet_id,
+ height: FORK_HEIGHT,
+ txids: vec![dashcore::Txid::from_byte_array([0x61; 32])],
+ unconfirmed_txids: Vec::new(),
+ restored_outpoints: Vec::new(),
+ balance: WalletCoreBalance::default(),
+ account_balances: BTreeMap::new(),
+ }
+ }
+
+ /// The next round the probe sees, or a failed test instead of a hang.
+ async fn next_store(
+ obs_rx: &mut tokio::sync::mpsc::UnboundedReceiver,
+ ) -> StoreObserved {
+ tokio::time::timeout(std::time::Duration::from_secs(5), obs_rx.recv())
+ .await
+ .expect("a round must reach store() within the timeout")
+ .expect("a round must reach store()")
+ }
+
+ /// A persister that never attested `CORE_CHAIN_TRUNCATION` is not handed
+ /// one, and — unlike an unattested sweep — is not faulted for it: nothing
+ /// replays a truncation, so freezing its watermark would recover nothing.
+ #[tokio::test]
+ async fn should_withhold_a_truncation_from_a_persister_that_cannot_apply_it() {
+ let wallet_id = [31u8; 32];
+ let (tx, rx) = unbounded_channel::();
+ let (obs_tx, mut obs_rx) = unbounded_channel();
+ let persister = Arc::new(ProbePersister::new(obs_tx));
+ let sync_fault = Arc::new(AtomicBool::new(false));
+ let cancel = CancellationToken::new();
+ let handle = tokio::spawn(run_wallet_event_adapter(
+ test_manager(),
+ Arc::downgrade(&persister),
+ rx,
+ Arc::clone(&sync_fault),
+ cancel.clone(),
+ ));
+
+ tx.send(truncated_event(wallet_id)).unwrap();
+ tx.send(sync_height_event(wallet_id, FORK_HEIGHT + 5))
+ .unwrap();
+ let mut seen = Vec::new();
+ while seen
+ .iter()
+ .all(|round: &StoreObserved| round.synced_height.is_none())
+ {
+ seen.push(next_store(&mut obs_rx).await);
+ }
+
+ assert!(
+ seen.iter().all(|round| round.truncations.is_empty()),
+ "a persister that cannot apply a truncation must not be handed one"
+ );
+ assert_eq!(
+ seen.last().and_then(|round| round.synced_height),
+ Some(FORK_HEIGHT + 5),
+ "the rest of the stream keeps persisting, watermark included"
+ );
+ assert!(!sync_fault.load(Ordering::Relaxed));
+
+ cancel.cancel();
+ drop(tx);
+ handle.await.unwrap();
+ }
+
+ #[tokio::test]
+ async fn should_hand_a_truncation_to_a_persister_that_attests_it() {
+ let wallet_id = [32u8; 32];
+ let (tx, rx) = unbounded_channel::();
+ let (obs_tx, mut obs_rx) = unbounded_channel();
+ let persister = Arc::new(ProbePersister::with_capabilities(
+ obs_tx,
+ crate::changeset::PersistenceCapabilities::CORE_CHAIN_TRUNCATION,
+ ));
+ let cancel = CancellationToken::new();
+ let handle = tokio::spawn(run_wallet_event_adapter(
+ test_manager(),
+ Arc::downgrade(&persister),
+ rx,
+ Arc::new(AtomicBool::new(false)),
+ cancel.clone(),
+ ));
+
+ tx.send(truncated_event(wallet_id)).unwrap();
+ let round = next_store(&mut obs_rx).await;
+
+ assert_eq!(round.truncations.len(), 1);
+ assert_eq!(round.truncations[0].height, FORK_HEIGHT);
+
+ cancel.cancel();
+ drop(tx);
+ handle.await.unwrap();
+ }
+
+ /// A rejected round leaves the store on the replaced branch, and no scan
+ /// reports a truncation twice. So the adapter offers it again, ahead of
+ /// whatever the wallet's next round carries, until a store accepts it.
+ #[tokio::test]
+ async fn should_offer_a_rejected_truncation_again_with_the_next_round() {
+ let wallet_id = [33u8; 32];
+ let (tx, rx) = unbounded_channel::();
+ let (obs_tx, mut obs_rx) = unbounded_channel();
+ let persister = Arc::new(ProbePersister::with_capabilities(
+ obs_tx,
+ crate::changeset::PersistenceCapabilities::CORE_CHAIN_TRUNCATION,
+ ));
+ persister.fail_next(wallet_id);
+ let cancel = CancellationToken::new();
+ let handle = tokio::spawn(run_wallet_event_adapter(
+ test_manager(),
+ Arc::downgrade(&persister),
+ rx,
+ Arc::new(AtomicBool::new(false)),
+ cancel.clone(),
+ ));
+
+ tx.send(truncated_event(wallet_id)).unwrap();
+ let rejected = next_store(&mut obs_rx).await;
+ assert!(rejected.rejected);
+ assert_eq!(rejected.truncations.len(), 1);
+
+ tx.send(block_processed_event(wallet_id, FORK_HEIGHT + 1))
+ .unwrap();
+ let retried = next_store(&mut obs_rx).await;
+ assert!(!retried.rejected);
+ assert_eq!(
+ retried.truncations, rejected.truncations,
+ "the unapplied truncation rides the wallet's next round"
+ );
+ assert_eq!(retried.last_processed_height, Some(FORK_HEIGHT + 1));
+
+ tx.send(block_processed_event(wallet_id, FORK_HEIGHT + 2))
+ .unwrap();
+ let after = next_store(&mut obs_rx).await;
+ assert!(
+ after.truncations.is_empty(),
+ "an accepted truncation is not offered a third time"
+ );
+
+ cancel.cancel();
+ drop(tx);
+ handle.await.unwrap();
+ }
+
+ /// A backend that fails `Transient` without attesting
+ /// `store_transient_is_reissuable` kept the round and retries it itself.
+ /// A second copy from the adapter would land after what that round
+ /// stored behind the truncation, and cut it away.
+ #[tokio::test]
+ async fn should_not_offer_a_truncation_again_to_a_persister_that_kept_the_failed_round() {
+ let wallet_id = [34u8; 32];
+ let (tx, rx) = unbounded_channel::();
+ let (obs_tx, mut obs_rx) = unbounded_channel();
+ let persister = Arc::new(ProbePersister::with_capabilities(
+ obs_tx,
+ crate::changeset::PersistenceCapabilities::CORE_CHAIN_TRUNCATION,
+ ));
+ persister.fail_next_transiently(wallet_id);
+ let cancel = CancellationToken::new();
+ let handle = tokio::spawn(run_wallet_event_adapter(
+ test_manager(),
+ Arc::downgrade(&persister),
+ rx,
+ Arc::new(AtomicBool::new(false)),
+ cancel.clone(),
+ ));
+
+ tx.send(truncated_event(wallet_id)).unwrap();
+ let kept = next_store(&mut obs_rx).await;
+ assert!(kept.rejected);
+ assert_eq!(kept.truncations.len(), 1);
+
+ tx.send(block_processed_event(wallet_id, FORK_HEIGHT + 1))
+ .unwrap();
+ let next = next_store(&mut obs_rx).await;
+ assert!(next.truncations.is_empty());
+
+ cancel.cancel();
+ drop(tx);
+ handle.await.unwrap();
+ }
+
/// The positive case for the same gate: a persister that attests
/// `CORE_SWEEP_REMOVAL` is trusted normally, and the watermark keeps
/// advancing through a sweep-bearing round exactly as it would through
diff --git a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs
index d930daf1ec9..b87e790ec1c 100644
--- a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs
+++ b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs
@@ -105,6 +105,26 @@ impl PersistenceCapabilities {
/// declaration only when `on_persist_dashpay_payments_fn` is actually
/// wired.
pub const DASHPAY_PAYMENTS: Self = Self(1 << 12);
+ /// A stored `CoreChangeSet` whose `truncations` are non-empty is durably
+ /// applied, before the rest of the same changeset and atomically with
+ /// it: each removed transaction and every output it created are gone,
+ /// each kept transaction is stored as unconfirmed with its outputs
+ /// InstantSend-locked, each restored outpoint is unspent, and stored
+ /// `synced_height` / `last_processed_height` above the fork height are
+ /// lowered to it. On the FFI surface truncations travel through their
+ /// own size-negotiated extension callback, like sweeps.
+ ///
+ /// # Unlike `CORE_SWEEP_REMOVAL`, absence does not fault the wallet
+ ///
+ /// A persister without this bit is simply not handed the truncation; the
+ /// rest of the round, its watermark included, is stored as usual. The
+ /// sweep gate freezes the watermark because that makes the next launch
+ /// re-scan and re-emit the sweep. Nothing re-emits a truncation: the
+ /// fork is detected once, against headers that are already replaced, so
+ /// a frozen watermark would cost the host its durable progress and
+ /// recover nothing. Such a store keeps the replaced branch until the
+ /// host rescans.
+ pub const CORE_CHAIN_TRUNCATION: 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
@@ -112,7 +132,7 @@ impl PersistenceCapabilities {
/// 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(
@@ -205,6 +225,10 @@ impl PersistenceCapabilities {
PersistenceCapabilities::DASHPAY_PAYMENTS,
"dashpay_payments",
),
+ (
+ PersistenceCapabilities::CORE_CHAIN_TRUNCATION,
+ "core_chain_truncation",
+ ),
];
KNOWN
@@ -222,7 +246,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::CORE_CHAIN_TRUNCATION.bits()
== 1u64 << PersistenceCapabilities::HIGHEST_DECLARED_BIT,
"HIGHEST_DECLARED_BIT must name the highest declared capability bit"
);
@@ -250,6 +274,10 @@ 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::CORE_CHAIN_TRUNCATION.bits(),
+ 0x2000
+ );
assert_eq!(
PersistenceCapabilities::ASSET_LOCK_RECONCILIATION.bits(),
0x281
From fce09b10ee8898ca8dfd2fa2bb06c93a8f3ab098 Mon Sep 17 00:00:00 2001
From: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Date: Mon, 12 Oct 2026 13:16:32 +0000
Subject: [PATCH 02/28] fix(platform-wallet): return a sent payment to pending
when its block is dropped
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A sent DashPay payment shown `Confirmed` kept that status after a fork
dropped the block that confirmed it (#5380).
A truncation is now evidence for the transactions it removes:
`Confirmed` goes back to `Pending`, and the block that mines the
transaction again confirms it through the ordinary edge. Transactions the
truncation keeps as InstantSend-locked do not move, and a `Failed`
payment stays failed. The verdict rides the same store round as the
truncation.
Not covered: the wallet forgets a removed transaction, so one that is
later double-spent on the new branch reports no sweep and its payment
stays `Pending`.
🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4
---
.../src/changeset/core_bridge.rs | 123 ++++++++++++++++--
1 file changed, 110 insertions(+), 13 deletions(-)
diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs
index b17f7800cf1..c3eb080d42c 100644
--- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs
+++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs
@@ -1487,6 +1487,9 @@ enum SentPaymentEvidence {
/// The transaction reached a context that is final for DashPay display —
/// mined, or InstantSend-locked.
Final,
+ /// A fork dropped the block that held the transaction and no InstantSend
+ /// lock covers it: unconfirmed again, and free to be mined again.
+ Unmined,
}
/// The complete set of legal `Sent` payment transitions, and the only place a
@@ -1498,10 +1501,13 @@ enum SentPaymentEvidence {
/// | `Confirmed` | `Swept` | `Failed` | An IS-locked payment evicted by a chainlocked winner is a dead payment reported as good — the worst of the two. |
/// | `Pending` | `Final` | `Confirmed` | The ordinary confirm: mempool → mined / IS-locked. |
/// | `Failed` | `Final` | `Confirmed` | A chainlock can reinstate a transaction a sweep removed; without this edge the repair is unreachable. |
+/// | `Confirmed` | `Unmined`| `Pending` | The block that confirmed it is gone; the block that mines it again takes the ordinary confirm. |
///
-/// The two remaining pairs — `Failed` + `Swept` and `Confirmed` + `Final` —
-/// return `None`: the entry already carries that verdict, so re-emitting it
-/// would put an unchanged row on the store round for every re-detection.
+/// `Failed` + `Swept` and `Confirmed` + `Final` return `None`: the entry
+/// already carries that verdict, so re-emitting it would put an unchanged row
+/// on the store round for every re-detection. So does `Pending` + `Unmined`.
+/// `Failed` + `Unmined` returns `None` because a truncation does not bring a
+/// swept transaction back.
///
/// Exhaustive on purpose (no `_` arm): a future [`PaymentStatus`] variant must
/// fail to compile here and be given an explicit edge, rather than be silently
@@ -1510,7 +1516,7 @@ fn next_sent_payment_status(
current: PaymentStatus,
evidence: SentPaymentEvidence,
) -> Option {
- use SentPaymentEvidence::{Final, Swept};
+ use SentPaymentEvidence::{Final, Swept, Unmined};
match (current, evidence) {
(PaymentStatus::Pending, Swept) => Some(PaymentStatus::Failed),
(PaymentStatus::Confirmed, Swept) => Some(PaymentStatus::Failed),
@@ -1518,6 +1524,9 @@ fn next_sent_payment_status(
(PaymentStatus::Pending, Final) => Some(PaymentStatus::Confirmed),
(PaymentStatus::Failed, Final) => Some(PaymentStatus::Confirmed),
(PaymentStatus::Confirmed, Final) => None,
+ (PaymentStatus::Confirmed, Unmined) => Some(PaymentStatus::Pending),
+ (PaymentStatus::Pending, Unmined) => None,
+ (PaymentStatus::Failed, Unmined) => None,
}
}
@@ -1561,11 +1570,15 @@ fn sent_payment_evidence(event: &WalletEvent) -> Vec<(String, SentPaymentEvidenc
WalletEvent::BlockProcessed {
inserted, updated, ..
} => finality(inserted.iter().chain(updated.iter())),
- // TODO(#5380): a payment whose confirmation a truncation undid keeps
- // the verdict it already has.
- WalletEvent::SyncHeightAdvanced { .. }
- | WalletEvent::ChainLockProcessed { .. }
- | WalletEvent::ChainTruncated { .. } => Vec::new(),
+ // `unconfirmed_txids` are left out: they stay InstantSend-locked,
+ // which is still final for DashPay display.
+ WalletEvent::ChainTruncated { txids, .. } => txids
+ .iter()
+ .map(|txid| (txid.to_string(), SentPaymentEvidence::Unmined))
+ .collect(),
+ WalletEvent::SyncHeightAdvanced { .. } | WalletEvent::ChainLockProcessed { .. } => {
+ Vec::new()
+ }
}
}
@@ -2934,12 +2947,90 @@ mod sent_payment_verdict_tests {
);
}
+ /// A truncation dropping the block of `removed`, keeping `unconfirmed`
+ /// as InstantSend-locked mempool transactions.
+ fn truncation_of(
+ wallet_id: WalletId,
+ removed: Vec,
+ unconfirmed: Vec,
+ ) -> WalletEvent {
+ WalletEvent::ChainTruncated {
+ wallet_id,
+ height: 1_499_000,
+ txids: removed,
+ unconfirmed_txids: unconfirmed,
+ restored_outpoints: Vec::new(),
+ balance: WalletCoreBalance::default(),
+ account_balances: BTreeMap::new(),
+ }
+ }
+
+ /// A payment shown `Confirmed` on the strength of a block the chain no
+ /// longer has is not confirmed. It is not dead either — the transaction
+ /// can be mined again — so it goes back to `Pending`, and the block that
+ /// mines it again confirms it through the ordinary edge.
+ #[tokio::test]
+ async fn should_return_a_confirmed_sent_payment_to_pending_when_its_block_is_dropped() {
+ let (wallet_manager, wallet_id, txid) =
+ wallet_with_payment(PaymentDirection::Sent, PaymentStatus::Confirmed).await;
+ let event = truncation_of(wallet_id, vec![sent_transaction().txid()], Vec::new());
+
+ let overlay = sent_payment_verdicts(&wallet_manager, &event).await;
+
+ assert_eq!(only_row(&overlay, &txid).status, PaymentStatus::Pending);
+ assert_eq!(
+ stored_status(&wallet_manager, &wallet_id, &txid).await,
+ PaymentStatus::Pending
+ );
+
+ let mined_again =
+ sent_payment_verdicts(&wallet_manager, &chainlocked_reinstatement(wallet_id)).await;
+ assert_eq!(
+ only_row(&mined_again, &txid).status,
+ PaymentStatus::Confirmed
+ );
+ }
+
+ /// An InstantSend lock outlives the block: a kept transaction is still
+ /// final for DashPay display, so its payment does not move.
+ #[tokio::test]
+ async fn should_keep_a_sent_payment_confirmed_when_its_dropped_block_left_it_instant_locked() {
+ let (wallet_manager, wallet_id, txid) =
+ wallet_with_payment(PaymentDirection::Sent, PaymentStatus::Confirmed).await;
+ let event = truncation_of(wallet_id, Vec::new(), vec![sent_transaction().txid()]);
+
+ let overlay = sent_payment_verdicts(&wallet_manager, &event).await;
+
+ assert!(overlay.is_empty());
+ assert_eq!(
+ stored_status(&wallet_manager, &wallet_id, &txid).await,
+ PaymentStatus::Confirmed
+ );
+ }
+
+ /// A sweep's verdict stands through a truncation: the wallet does not
+ /// bring a swept transaction back when the block of its winner goes.
+ #[tokio::test]
+ async fn should_keep_a_failed_sent_payment_failed_when_a_block_is_dropped() {
+ let (wallet_manager, wallet_id, txid) =
+ wallet_with_payment(PaymentDirection::Sent, PaymentStatus::Failed).await;
+ let event = truncation_of(wallet_id, vec![sent_transaction().txid()], Vec::new());
+
+ let overlay = sent_payment_verdicts(&wallet_manager, &event).await;
+
+ assert!(overlay.is_empty());
+ assert_eq!(
+ stored_status(&wallet_manager, &wallet_id, &txid).await,
+ PaymentStatus::Failed
+ );
+ }
+
/// The transition table, enumerated. Written out rather than derived so
/// that adding an edge means editing this list — the point of the table
/// is that every edge was chosen, not inferred.
#[test]
- fn the_transition_table_admits_exactly_the_four_intended_edges() {
- use SentPaymentEvidence::{Final, Swept};
+ fn the_transition_table_admits_exactly_the_five_intended_edges() {
+ use SentPaymentEvidence::{Final, Swept, Unmined};
let expected = [
((PaymentStatus::Pending, Swept), Some(PaymentStatus::Failed)),
(
@@ -2956,6 +3047,12 @@ mod sent_payment_verdict_tests {
Some(PaymentStatus::Confirmed),
),
((PaymentStatus::Confirmed, Final), None),
+ (
+ (PaymentStatus::Confirmed, Unmined),
+ Some(PaymentStatus::Pending),
+ ),
+ ((PaymentStatus::Pending, Unmined), None),
+ ((PaymentStatus::Failed, Unmined), None),
];
for ((from, evidence), to) in expected {
assert_eq!(
@@ -2966,8 +3063,8 @@ mod sent_payment_verdict_tests {
}
assert_eq!(
expected.iter().filter(|(_, to)| to.is_some()).count(),
- 4,
- "four edges move an entry; the other two are no-ops"
+ 5,
+ "five edges move an entry; the other four are no-ops"
);
}
From 3da5fc22328a72fed2ca17509b6c1e0af1d6e45c Mon Sep 17 00:00:00 2001
From: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Date: Mon, 12 Oct 2026 13:16:32 +0000
Subject: [PATCH 03/28] fix(platform-wallet): fence coins a chain truncation
hands back
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A truncation restores the coins its removed transactions spent, but the
broadcast fence those spends had retired stayed down (#5380). The undone
spend is still on the network and is normally mined again, so selecting
the coin for a new send would build a conflicting transaction.
`SpendObservationHandler` now reopens the pending-spend fence on every
restored outpoint; it ends, as always, when the spend is observed.
Cost: when the undone spend is never mined again, nothing reports the
coin spent, and a send that selects it fails with `InputMidBroadcast`
until the wallet is restarted.
🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4
---
.../rs-platform-wallet/src/manager/mod.rs | 72 +++++++++++++++++++
.../src/wallet/core/generation.rs | 22 ++++++
.../src/wallet/core/spend_observer.rs | 19 ++++-
3 files changed, 110 insertions(+), 3 deletions(-)
diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs
index a442f8a48c4..bb990c88718 100644
--- a/packages/rs-platform-wallet/src/manager/mod.rs
+++ b/packages/rs-platform-wallet/src/manager/mod.rs
@@ -1235,6 +1235,78 @@ mod tests {
);
}
+ /// A truncation hands back a coin whose spend was mined once and may be
+ /// mined again, so the fence that spend retired has to stand again until
+ /// the spend is seen a second time.
+ #[tokio::test]
+ async fn should_fence_a_coin_again_when_a_truncation_undoes_its_spend() {
+ use dashcore::hashes::Hash as _;
+
+ let mgr = make_manager();
+ let (wallet_manager, wallet_id, generation, _signer) =
+ crate::test_support::funded_wallet_manager(
+ key_wallet::account::account_type::StandardAccountType::BIP44Account,
+ )
+ .await;
+ let spv = Arc::new(SpvRuntime::new(
+ Arc::clone(&wallet_manager),
+ Arc::new(PlatformEventManager::new(Vec::new())),
+ ));
+ let wallet = Arc::new(PlatformWallet::new(
+ Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")),
+ wallet_id,
+ wallet_manager,
+ Arc::clone(&generation),
+ Arc::new(Notify::new()),
+ Arc::new(NoopPersister) as Arc,
+ Arc::new(crate::broadcaster::SpvBroadcaster::new(spv)),
+ ));
+ mgr.wallets.rcu(|wallets| {
+ let mut next = std::collections::BTreeMap::clone(wallets);
+ next.insert(wallet_id, Arc::clone(&wallet));
+ next
+ });
+ let coin = dashcore::OutPoint {
+ txid: dashcore::Txid::from_slice(&[9u8; 32]).expect("txid"),
+ vout: 0,
+ };
+ let tx = dashcore::Transaction {
+ version: 2,
+ lock_time: 0,
+ input: vec![dashcore::TxIn {
+ previous_output: coin,
+ ..Default::default()
+ }],
+ output: Vec::new(),
+ special_transaction_payload: None,
+ };
+ let spend_observed = crate::test_support::observed_spend_event(wallet_id, &tx);
+ mgr.event_manager.on_wallet_event(&spend_observed);
+ assert!(generation.in_broadcast_conflict(&tx).is_none());
+
+ mgr.event_manager
+ .on_wallet_event(&key_wallet_manager::WalletEvent::ChainTruncated {
+ wallet_id,
+ height: 999,
+ txids: vec![tx.txid()],
+ unconfirmed_txids: Vec::new(),
+ restored_outpoints: vec![coin],
+ balance: key_wallet::WalletCoreBalance::default(),
+ account_balances: std::collections::BTreeMap::new(),
+ });
+ assert_eq!(
+ generation.in_broadcast_conflict(&tx),
+ Some(coin),
+ "the undone spend may be mined again; the coin must not be re-selected"
+ );
+
+ mgr.event_manager.on_wallet_event(&spend_observed);
+ assert!(
+ generation.in_broadcast_conflict(&tx).is_none(),
+ "seeing the spend again is what ends the fence"
+ );
+ }
+
/// `shutdown()` joins every started coordinator through the shared
/// [`ThreadRegistry`], reports each as cleanly joined, and is
/// idempotent — a second call finds nothing running and still reports
diff --git a/packages/rs-platform-wallet/src/wallet/core/generation.rs b/packages/rs-platform-wallet/src/wallet/core/generation.rs
index 5224985f838..0121f341c13 100644
--- a/packages/rs-platform-wallet/src/wallet/core/generation.rs
+++ b/packages/rs-platform-wallet/src/wallet/core/generation.rs
@@ -598,6 +598,28 @@ impl WalletGeneration {
}
}
+ /// Fence every coin in `outpoints` that a chain truncation handed back.
+ ///
+ /// The transaction that spent such a coin was mined once. The wallet has
+ /// dropped its record, but the network still relays it and will normally
+ /// mine it again, so a new spend of the coin would conflict with a
+ /// transaction on the wire. The pending-spend phase therefore opens again
+ /// and ends the way it always does: when the spend is observed. This
+ /// holds for coins this generation never dispatched too — a spend made
+ /// by another instance of the same wallet comes back identically.
+ ///
+ /// The cost: when the undone spend is never mined again, nothing reports
+ /// the coin spent, and a send that selects it fails with the in-broadcast
+ /// conflict until the wallet is restarted.
+ pub(crate) fn fence_restored(&self, outpoints: impl IntoIterator- ) {
+ let mut pinned = self.in_broadcast_lock();
+ for outpoint in outpoints {
+ let fence = pinned.entry(outpoint).or_default();
+ fence.observed_spent = false;
+ fence.pending = true;
+ }
+ }
+
/// End one dispatch's hold on `outpoints` — the [`InBroadcastPin`] release
/// half of [`pin_in_broadcast`](Self::pin_in_broadcast).
///
diff --git a/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs b/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs
index db509798de1..67bf2779ffd 100644
--- a/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs
+++ b/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs
@@ -115,6 +115,20 @@ impl SpendObservationHandler {
impl EventHandler for SpendObservationHandler {
fn on_wallet_event(&self, event: &WalletEvent) {
+ // The one event that undoes observations instead of adding one.
+ if let WalletEvent::ChainTruncated {
+ wallet_id,
+ restored_outpoints,
+ ..
+ } = event
+ {
+ if let Some(wallet) = self.wallets.load().get(wallet_id) {
+ wallet
+ .generation()
+ .fence_restored(restored_outpoints.iter().copied());
+ }
+ return;
+ }
let Some(wallet_id) = observing_wallet(event) else {
return;
};
@@ -185,9 +199,8 @@ pub(crate) fn observed_spends(event: &WalletEvent) -> Vec
{
// without the loser's inputs travelling on the event.
WalletEvent::TransactionsSwept { .. } => Vec::new(),
// A truncation reports coins that became unspent again
- // (`restored_outpoints`), the opposite of a spend.
- // TODO(#5380): a fence already retired by a spend the truncation
- // undid is not re-armed.
+ // (`restored_outpoints`), the opposite of a spend. The handler
+ // fences those instead — see `WalletGeneration::fence_restored`.
WalletEvent::ChainTruncated { .. } => Vec::new(),
}
}
From e6c0f9dfd2161e4521bc4ff0a6d3059b2c817ef6 Mon Sep 17 00:00:00 2001
From: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Date: Mon, 12 Oct 2026 13:16:32 +0000
Subject: [PATCH 04/28] fix(platform-wallet): apply chain truncations in the
SQLite store
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The SQLite backend now attests `CORE_CHAIN_TRUNCATION` and applies a
changeset's truncations first, inside the round's own write transaction
(#5380): removed transactions lose their row, their outputs and their
InstantSend lock; kept ones are stored as unconfirmed, with the record's
context rewritten to match; restored coins are unspent; and both stored
heights are lowered to the fork. No schema change.
A kept transaction whose stored record cannot be read fails the round,
which rolls back whole.
`sqlite_chain_truncation` drives a real wallet through the event adapter
into the store, reopens the database and compares the loaded wallet with
the one that never restarted, for a round per step and for one merged
round. A property test does the same over generated histories of
receives, spends, InstantSend locks and reorgs.
🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4
---
CHANGELOG.md | 10 +-
.../src/sqlite/persister.rs | 3 +-
.../src/sqlite/schema/core_state.rs | 96 ++-
.../tests/sqlite_chain_truncation.rs | 802 ++++++++++++++++++
4 files changed, 907 insertions(+), 4 deletions(-)
create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_chain_truncation.rs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 48c446ba746..64f536218ca 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,8 +26,14 @@
signatures and the encoded bytes are unchanged), and masternode payload
handling to the version-3 ProRegTx / ProUpServTx types.
- **platform-wallet:** a chain reorganization reported by key-wallet
- (`WalletEvent::ChainTruncated`) updates the in-memory balance but is not yet
- mirrored to persisted wallet state
+ (`WalletEvent::ChainTruncated`) now reaches persisted wallet state as
+ `CoreChangeSet::truncations`. The SQLite backend removes the transactions of
+ the dropped blocks and their coins, keeps InstantSend-locked ones as
+ unconfirmed, restores the coins the removed ones spent and lowers the stored
+ sync heights to the fork, so a restart after a reorg loads the same wallet as
+ no restart. A persister attests support with the new
+ `PersistenceCapabilities::CORE_CHAIN_TRUNCATION` bit; one that does not is
+ not handed the truncation and keeps the replaced branch until it rescans
([#5380](https://github.com/dashpay/platform/issues/5380)). Version-3
provider payloads are read through their pre-v3 fields only.
- **Breaking (Rust features):** remove DPP's `core_key_wallet_bip_38` and SDK's
diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs
index 2643e5feb8a..54e724096e7 100644
--- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs
+++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs
@@ -1323,7 +1323,8 @@ impl PlatformWalletPersistence for SqlitePersister {
.union(PersistenceCapabilities::TRACKED_ASSET_LOCKS)
.union(PersistenceCapabilities::TRACKED_MASTERNODES)
.union(PersistenceCapabilities::CORE_SWEEP_REMOVAL)
- .union(PersistenceCapabilities::DASHPAY_PAYMENTS);
+ .union(PersistenceCapabilities::DASHPAY_PAYMENTS)
+ .union(PersistenceCapabilities::CORE_CHAIN_TRUNCATION);
#[cfg(feature = "shielded")]
{
capabilities.union(PersistenceCapabilities::SHIELDED_VIEWING_KEYS)
diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs
index 94b29068de2..bc9e3bae6ca 100644
--- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs
+++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs
@@ -11,7 +11,7 @@ use dashcore::{hashes::Hash, OutPoint, Txid};
use key_wallet::managed_account::transaction_record::TransactionRecord;
use key_wallet::transaction_checking::TransactionContext;
use key_wallet::Utxo;
-use platform_wallet::changeset::changeset::{SweepBatch, UtxoCreditVerdict};
+use platform_wallet::changeset::changeset::{ChainTruncation, SweepBatch, UtxoCreditVerdict};
use platform_wallet::changeset::CoreChangeSet;
use platform_wallet::wallet::platform_wallet::WalletId;
@@ -83,6 +83,11 @@ pub fn apply(
wallet_id: &WalletId,
cs: &CoreChangeSet,
) -> Result<(), WalletStorageError> {
+ // First: the rest of the changeset is the wallet after its last
+ // truncation, so its rows must land on a store already cut back.
+ for truncation in &cs.truncations {
+ apply_truncation(tx, wallet_id, truncation)?;
+ }
if !cs.records.is_empty() {
let mut stmt = tx.prepare_cached(
"INSERT INTO core_transactions \
@@ -433,6 +438,95 @@ pub fn apply(
Ok(())
}
+/// Cut the store back the way the wallet cut itself back when a fork replaced
+/// the chain above `truncation.height`.
+///
+/// Removed transactions lose their row, every output they created and their
+/// InstantSend lock. Kept ones become unconfirmed: the confirmation columns
+/// are cleared and the stored record's context is rewritten to match, so a
+/// reload replays it as the wallet holds it. Restored coins are unspent, and
+/// both stored heights come down to the fork.
+///
+/// Idempotent, and a transaction or coin this store never recorded is a
+/// no-op. Unlike a sweep's release, `restored_outpoints` needs no veto from
+/// stored claims: the removed spender was confirmed, and no block the fork
+/// keeps can hold a second spend of the same coin.
+///
+/// # Errors
+///
+/// A kept transaction whose stored record cannot be decoded, or names another
+/// transaction, fails the round: clearing its columns alone would leave a
+/// record that still claims the dropped block.
+fn apply_truncation(
+ tx: &Transaction<'_>,
+ wallet_id: &WalletId,
+ truncation: &ChainTruncation,
+) -> Result<(), WalletStorageError> {
+ for txid in &truncation.txids {
+ let key = params![wallet_id.as_slice(), AsRef::<[u8]>::as_ref(txid)];
+ tx.execute(
+ "DELETE FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2",
+ key,
+ )?;
+ // `encode_outpoint_txid_occupies_bytes_two_to_thirty_three` pins this prefix.
+ tx.execute(
+ "DELETE FROM core_utxos WHERE wallet_id = ?1 AND substr(outpoint, 2, 32) = ?2",
+ key,
+ )?;
+ tx.execute(
+ "DELETE FROM core_instant_locks WHERE wallet_id = ?1 AND txid = ?2",
+ key,
+ )?;
+ }
+ for txid in &truncation.unconfirmed_txids {
+ // Strict: read before the columns change, so the record is checked
+ // against the block it was stored with.
+ let record = get_tx_record(tx, wallet_id, txid, &LoadCtx::strict())?;
+ tx.execute(
+ "UPDATE core_transactions \
+ SET height = NULL, block_hash = NULL, block_time = NULL, finalized = 0 \
+ WHERE wallet_id = ?1 AND txid = ?2",
+ params![wallet_id.as_slice(), AsRef::<[u8]>::as_ref(txid)],
+ )?;
+ if let Some(mut record) = record {
+ record.update_context(TransactionContext::Mempool);
+ tx.execute(
+ "UPDATE core_transactions SET record_blob = ?3 WHERE wallet_id = ?1 AND txid = ?2",
+ params![
+ wallet_id.as_slice(),
+ AsRef::<[u8]>::as_ref(txid),
+ blob::encode(&record)?
+ ],
+ )?;
+ }
+ }
+ for outpoint in &truncation.restored_outpoints {
+ let key = blob::encode_outpoint(outpoint)?;
+ // A claim that never materialised has no coin to hand back.
+ let dropped = tx.execute(
+ "DELETE FROM core_utxos \
+ WHERE wallet_id = ?1 AND outpoint = ?2 AND is_sweep_placeholder = 1",
+ params![wallet_id.as_slice(), &key[..]],
+ )?;
+ if dropped == 0 {
+ tx.execute(
+ "UPDATE core_utxos SET spent = 0, spent_in_txid = NULL \
+ WHERE wallet_id = ?1 AND outpoint = ?2",
+ params![wallet_id.as_slice(), &key[..]],
+ )?;
+ }
+ }
+ // `MIN` leaves a height that was never stored as it is: NULL.
+ tx.execute(
+ "UPDATE core_sync_state \
+ SET last_processed_height = MIN(last_processed_height, ?2), \
+ synced_height = MIN(synced_height, ?2) \
+ WHERE wallet_id = ?1",
+ params![wallet_id.as_slice(), i64::from(truncation.height)],
+ )?;
+ Ok(())
+}
+
/// The union of every input outpoint claimed by a surviving
/// `core_transactions` row — every row except this round's swept losers,
/// whose deletion the round itself performs.
diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_chain_truncation.rs b/packages/rs-platform-wallet-storage/tests/sqlite_chain_truncation.rs
new file mode 100644
index 00000000000..7de8a577e88
--- /dev/null
+++ b/packages/rs-platform-wallet-storage/tests/sqlite_chain_truncation.rs
@@ -0,0 +1,802 @@
+#![cfg(feature = "sqlite")]
+
+//! A chain reorganization must reach the store: after a restart the wallet
+//! holds the balances, history and scan position it held without one.
+//!
+//! Every case drives a real `WalletManager` through the real wallet-event
+//! adapter into this crate's SQLite persister, then reopens the database and
+//! compares what `load()` hands back with the wallet that never restarted.
+
+mod common;
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::path::PathBuf;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::Arc;
+
+use dashcore::{
+ address::Payload,
+ block::{Header, Version},
+ ephemerealdata::instant_lock::InstantLock,
+ hashes::Hash,
+ Address, Block, BlockHash, CompactTarget, Network, OutPoint, PubkeyHash, Transaction, TxIn,
+ TxMerkleNode, TxOut, Txid,
+};
+use key_wallet::{
+ wallet::{initialization::WalletAccountCreationOptions, ManagedWalletInfo},
+ WalletCoreBalance,
+};
+use platform_wallet::{
+ changeset::{
+ changeset::ChainTruncation, spawn_wallet_event_adapter, AccountRegistrationEntry,
+ CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, WalletMetadataEntry,
+ },
+ key_wallet_manager::{WalletEvent, WalletInterface, WalletManager},
+ wallet::platform_wallet::{PlatformWalletInfo, WalletId},
+};
+use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig};
+use proptest::prelude::*;
+use tokio::sync::{mpsc, RwLock};
+use tokio_util::sync::CancellationToken;
+
+type Manager = Arc>>;
+
+const MNEMONIC: &str =
+ "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
+/// Height of the block that funds the wallet; no case forks below it.
+const BASE: u32 = 100;
+const FUNDING: u64 = 10_000_000;
+const FEE: u64 = 1_000;
+
+/// What a user can tell apart: balances, coins, history and scan position.
+#[derive(Debug, Clone, PartialEq)]
+struct WalletView {
+ balance: WalletCoreBalance,
+ synced_height: u32,
+ /// Compared with `<=`, see [`WalletView::follows`].
+ last_processed_height: u32,
+ /// `(value, is_confirmed, is_instantlocked, height)` per unspent coin.
+ /// The lock is read on an unconfirmed coin only, where it decides which
+ /// balance the coin counts towards.
+ coins: BTreeMap,
+ /// Block height of every recorded transaction, `None` when unconfirmed.
+ history: BTreeMap>,
+}
+
+impl WalletView {
+ /// Whether this restored wallet is the `memory` one.
+ ///
+ /// Everything must be equal except `last_processed_height`, which may
+ /// trail: the wallet reports a block only when it holds something of
+ /// its own, so a store never hears of the blocks in between.
+ fn follows(&self, memory: &WalletView) -> Result<(), String> {
+ let mut restored = self.clone();
+ if restored.last_processed_height <= memory.last_processed_height {
+ restored.last_processed_height = memory.last_processed_height;
+ }
+ if restored == *memory {
+ Ok(())
+ } else {
+ Err(format!(
+ "restored wallet differs from the one that never restarted\n restored: {self:?}\n memory: {memory:?}"
+ ))
+ }
+ }
+}
+
+fn view(info: &ManagedWalletInfo) -> WalletView {
+ WalletView {
+ balance: info.balance,
+ synced_height: info.metadata.synced_height,
+ last_processed_height: info.metadata.last_processed_height,
+ coins: info
+ .accounts
+ .all_funding_accounts()
+ .into_iter()
+ .flat_map(|account| account.utxos.values())
+ .map(|coin| {
+ (
+ coin.outpoint,
+ (
+ coin.value(),
+ coin.is_confirmed,
+ coin.is_instantlocked && !coin.is_confirmed,
+ coin.height,
+ ),
+ )
+ })
+ .collect(),
+ history: info
+ .accounts
+ .all_accounts()
+ .into_iter()
+ .flat_map(|account| account.transactions().values())
+ .map(|record| (record.txid, record.height()))
+ .collect(),
+ }
+}
+
+fn foreign() -> Address {
+ Address::new(
+ Network::Testnet,
+ Payload::PubkeyHash(PubkeyHash::from_byte_array([0xF0; 20])),
+ )
+}
+
+fn transaction(inputs: &[OutPoint], outputs: &[(&Address, u64)]) -> Transaction {
+ Transaction {
+ version: 1,
+ lock_time: 0,
+ input: inputs
+ .iter()
+ .map(|previous_output| TxIn {
+ previous_output: *previous_output,
+ ..Default::default()
+ })
+ .collect(),
+ output: outputs
+ .iter()
+ .map(|(address, value)| TxOut {
+ value: *value,
+ script_pubkey: address.script_pubkey(),
+ })
+ .collect(),
+ special_transaction_payload: None,
+ }
+}
+
+fn instant_lock(tx: &Transaction) -> InstantLock {
+ InstantLock {
+ txid: tx.txid(),
+ inputs: tx.input.iter().map(|input| input.previous_output).collect(),
+ ..Default::default()
+ }
+}
+
+/// One database fed by the wallet-event adapter.
+struct Store {
+ persister: Option>,
+ path: PathBuf,
+ _dir: tempfile::TempDir,
+}
+
+impl Store {
+ fn seeded(wallet_id: WalletId, registrations: &[AccountRegistrationEntry]) -> Self {
+ let (persister, dir, path) = common::fresh_persister();
+ persister
+ .store(
+ wallet_id,
+ PlatformWalletChangeSet {
+ wallet_metadata: Some(WalletMetadataEntry {
+ network: Network::Testnet,
+ wallet_group_id: [0; 32],
+ birth_height: 0,
+ }),
+ account_registrations: registrations.to_vec(),
+ ..Default::default()
+ },
+ )
+ .unwrap();
+ Self {
+ persister: Some(Arc::new(persister)),
+ path,
+ _dir: dir,
+ }
+ }
+
+ fn persister(&self) -> &Arc {
+ self.persister.as_ref().expect("store is open")
+ }
+
+ /// One adapter drain over `events`: a single `store()` round.
+ async fn persist(&self, manager: &Manager, events: Vec) {
+ if events.is_empty() {
+ return;
+ }
+ let (sender, receiver) = mpsc::unbounded_channel();
+ for event in events {
+ sender.send(event).unwrap();
+ }
+ drop(sender);
+ let fault = Arc::new(AtomicBool::new(false));
+ spawn_wallet_event_adapter(
+ Arc::clone(manager),
+ Arc::downgrade(self.persister()),
+ receiver,
+ Arc::clone(&fault),
+ CancellationToken::new(),
+ )
+ .await
+ .unwrap();
+ assert!(!fault.load(Ordering::Relaxed), "the store rejected a round");
+ }
+
+ /// Close the database and read it back the way a relaunching client does.
+ fn restart(&mut self, wallet_id: WalletId) -> ManagedWalletInfo {
+ // The open-path registry refuses a second live persister on one file.
+ drop(self.persister.take());
+ let reopened = SqlitePersister::open(SqlitePersisterConfig::new(&self.path)).unwrap();
+ let info = reopened
+ .load()
+ .unwrap()
+ .wallets
+ .remove(&wallet_id)
+ .expect("the wallet is restored")
+ .wallet_info;
+ self.persister = Some(Arc::new(reopened));
+ info
+ }
+
+ fn transaction_rows(&self, txid: &Txid) -> Vec> {
+ let conn = self.persister().lock_conn_for_test();
+ let mut statement = conn
+ .prepare("SELECT height FROM core_transactions WHERE txid = ?1")
+ .unwrap();
+ let rows = statement
+ .query_map([txid.as_byte_array().as_slice()], |row| row.get(0))
+ .unwrap();
+ rows.map(Result::unwrap).collect()
+ }
+
+ /// Stored `(synced_height, last_processed_height)`.
+ fn heights(&self, wallet_id: WalletId) -> (Option, Option) {
+ self.persister()
+ .lock_conn_for_test()
+ .query_row(
+ "SELECT synced_height, last_processed_height FROM core_sync_state \
+ WHERE wallet_id = ?1",
+ [wallet_id.as_slice()],
+ |row| Ok((row.get(0)?, row.get(1)?)),
+ )
+ .unwrap()
+ }
+}
+
+/// A wallet funded at [`BASE`], its event stream, and a simulated chain tip.
+struct Chain {
+ manager: Manager,
+ wallet_id: WalletId,
+ events: mpsc::UnboundedReceiver,
+ registrations: Vec,
+ ours: Address,
+ funding: OutPoint,
+ tip: u32,
+ /// Distinguishes the blocks of one branch from the ones they replace.
+ branch: u8,
+ /// Seeds the foreign inputs of received payments.
+ nonce: u8,
+}
+
+impl Chain {
+ async fn funded() -> Self {
+ let mut manager = WalletManager::::new(Network::Testnet);
+ let events = manager.take_persistence_receiver().unwrap();
+ let wallet_id = manager
+ .create_wallet_from_mnemonic(MNEMONIC, 0, WalletAccountCreationOptions::Default)
+ .unwrap();
+ let wallet = manager.get_wallet(&wallet_id).unwrap();
+ let xpub = wallet.accounts.standard_bip44_accounts[&0].account_xpub;
+ let registrations = wallet
+ .accounts
+ .all_accounts()
+ .into_iter()
+ .map(|account| AccountRegistrationEntry {
+ account_type: account.account_type,
+ account_xpub: account.account_xpub,
+ })
+ .collect();
+ let ours = manager
+ .get_wallet_info_mut(&wallet_id)
+ .unwrap()
+ .core_wallet
+ .accounts
+ .standard_bip44_accounts
+ .get_mut(&0)
+ .unwrap()
+ .next_receive_address(Some(&xpub), true)
+ .unwrap();
+ let funding = transaction(
+ &[OutPoint::new(Txid::from_byte_array([13; 32]), 0)],
+ &[(&ours, FUNDING)],
+ );
+ let mut chain = Self {
+ manager: Arc::new(RwLock::new(manager)),
+ wallet_id,
+ events,
+ registrations,
+ ours,
+ funding: OutPoint::new(funding.txid(), 0),
+ tip: BASE - 1,
+ branch: 0,
+ nonce: 0,
+ };
+ chain.mine(vec![funding]).await;
+ chain.sync().await;
+ chain
+ }
+
+ fn store(&self) -> Store {
+ Store::seeded(self.wallet_id, &self.registrations)
+ }
+
+ /// Mine `transactions` in the next block of the current branch.
+ async fn mine(&mut self, transactions: Vec) {
+ self.tip += 1;
+ let block = Block {
+ header: Header {
+ version: Version::default(),
+ prev_blockhash: BlockHash::from_byte_array([self.branch; 32]),
+ merkle_root: TxMerkleNode::all_zeros(),
+ time: self.tip,
+ bits: CompactTarget::from_consensus(0x1d00ffff),
+ nonce: 0,
+ },
+ txdata: transactions,
+ };
+ self.manager
+ .write()
+ .await
+ .process_block_for_wallets(
+ &block,
+ block.block_hash(),
+ self.tip,
+ &BTreeSet::from([self.wallet_id]),
+ )
+ .await;
+ }
+
+ /// Commit the scan checkpoint at the tip.
+ async fn sync(&mut self) {
+ self.manager
+ .write()
+ .await
+ .update_wallet_synced_height(&self.wallet_id, self.tip);
+ }
+
+ /// Replace every block above `height` with a new, still empty branch.
+ async fn fork_at(&mut self, height: u32) {
+ self.manager
+ .write()
+ .await
+ .truncate_above(height)
+ .expect("no chain lock is applied in these cases");
+ self.tip = height;
+ self.branch += 1;
+ }
+
+ async fn relay(&mut self, tx: &Transaction, lock: Option) {
+ self.manager
+ .write()
+ .await
+ .process_mempool_transaction(tx, lock)
+ .await;
+ }
+
+ /// A payment from `coin` that returns the change to the wallet.
+ fn spend(&self, coin: OutPoint, value: u64) -> Transaction {
+ transaction(
+ &[coin],
+ &[(&self.ours, value / 2), (&foreign(), value / 2 - FEE)],
+ )
+ }
+
+ /// A payment to the wallet from somebody else.
+ fn receive(&mut self, value: u64) -> Transaction {
+ self.nonce += 1;
+ transaction(
+ &[OutPoint::new(Txid::from_byte_array([self.nonce; 32]), 7)],
+ &[(&self.ours, value)],
+ )
+ }
+
+ fn drain(&mut self) -> Vec {
+ std::iter::from_fn(|| self.events.try_recv().ok()).collect()
+ }
+
+ async fn memory(&self) -> WalletView {
+ let manager = self.manager.read().await;
+ view(
+ &manager
+ .get_wallet_info(&self.wallet_id)
+ .unwrap()
+ .core_wallet,
+ )
+ }
+
+ async fn largest_coin(&self) -> Option<(OutPoint, u64)> {
+ self.memory()
+ .await
+ .coins
+ .into_iter()
+ .map(|(outpoint, (value, ..))| (outpoint, value))
+ .max_by_key(|(outpoint, value)| (*value, *outpoint))
+ }
+}
+
+/// How the rounds of one scenario reach the store.
+#[derive(Debug, Clone, Copy)]
+enum Rounds {
+ /// One `store()` per step, as when the adapter keeps up with the wallet.
+ PerStep,
+ /// Every event of the scenario folded into one `store()`.
+ Merged,
+}
+
+/// A scenario's store plus the events it has not persisted yet.
+struct Session {
+ chain: Chain,
+ store: Store,
+ rounds: Rounds,
+ pending: Vec,
+}
+
+impl Session {
+ async fn open(rounds: Rounds) -> Self {
+ let mut chain = Chain::funded().await;
+ let store = chain.store();
+ // The funding block is history every case starts from.
+ let funded = chain.drain();
+ store.persist(&chain.manager, funded).await;
+ Self {
+ chain,
+ store,
+ rounds,
+ pending: Vec::new(),
+ }
+ }
+
+ /// End of one step: persist it now, or keep it for the single merged round.
+ async fn step(&mut self) {
+ self.pending.extend(self.chain.drain());
+ if matches!(self.rounds, Rounds::PerStep) {
+ self.flush().await;
+ }
+ }
+
+ async fn flush(&mut self) {
+ let events = std::mem::take(&mut self.pending);
+ self.store.persist(&self.chain.manager, events).await;
+ }
+
+ /// Persist what is left, restart, and require the restored wallet to be
+ /// the one that never restarted.
+ async fn assert_restart_matches_memory(&mut self) -> WalletView {
+ self.step().await;
+ self.flush().await;
+ let restored = view(&self.store.restart(self.chain.wallet_id));
+ if let Err(difference) = restored.follows(&self.chain.memory().await) {
+ panic!("{:?}: {difference}", self.rounds);
+ }
+ restored
+ }
+}
+
+#[tokio::test]
+async fn should_restore_a_spent_coin_when_its_spender_is_not_mined_again() {
+ for rounds in [Rounds::PerStep, Rounds::Merged] {
+ let mut session = Session::open(rounds).await;
+ let funding = session.chain.funding;
+ let spend = session.chain.spend(funding, FUNDING);
+ session.chain.mine(vec![spend.clone()]).await;
+ session.chain.sync().await;
+ session.step().await;
+
+ session.chain.fork_at(BASE).await;
+
+ let restored = session.assert_restart_matches_memory().await;
+ assert!(restored.coins.contains_key(&funding), "{rounds:?}");
+ assert!(!restored.history.contains_key(&spend.txid()), "{rounds:?}");
+ assert!(
+ session.store.transaction_rows(&spend.txid()).is_empty(),
+ "{rounds:?}: the dropped transaction must leave the store"
+ );
+ }
+}
+
+#[tokio::test]
+async fn should_store_a_transaction_once_in_its_new_block_when_mined_again() {
+ for rounds in [Rounds::PerStep, Rounds::Merged] {
+ let mut session = Session::open(rounds).await;
+ let funding = session.chain.funding;
+ let spend = session.chain.spend(funding, FUNDING);
+ session.chain.mine(vec![]).await;
+ session.chain.mine(vec![spend.clone()]).await;
+ session.chain.mine(vec![]).await;
+ session.chain.sync().await;
+ session.step().await;
+
+ // The new branch mines it one block earlier, and the restart comes
+ // before the scan is back at the old height.
+ session.chain.fork_at(BASE).await;
+ session.step().await;
+ session.chain.mine(vec![spend.clone()]).await;
+ session.chain.sync().await;
+
+ let restored = session.assert_restart_matches_memory().await;
+ assert_eq!(
+ restored.history.get(&spend.txid()),
+ Some(&Some(BASE + 1)),
+ "{rounds:?}"
+ );
+ assert_eq!(restored.synced_height, BASE + 1, "{rounds:?}");
+ assert_eq!(
+ session.store.transaction_rows(&spend.txid()),
+ vec![Some(BASE + 1)],
+ "{rounds:?}: stored once, in the new block"
+ );
+ }
+}
+
+#[tokio::test]
+async fn should_lower_stored_heights_to_the_fork_before_the_scan_resumes() {
+ for rounds in [Rounds::PerStep, Rounds::Merged] {
+ let mut session = Session::open(rounds).await;
+ for _ in 0..3 {
+ session.chain.mine(vec![]).await;
+ }
+ session.chain.sync().await;
+ session.step().await;
+
+ session.chain.fork_at(BASE).await;
+
+ let restored = session.assert_restart_matches_memory().await;
+ assert_eq!(restored.synced_height, BASE, "{rounds:?}");
+ assert!(restored.last_processed_height <= BASE, "{rounds:?}");
+ }
+}
+
+#[tokio::test]
+async fn should_keep_an_instant_locked_transaction_unconfirmed_when_its_block_is_dropped() {
+ for rounds in [Rounds::PerStep, Rounds::Merged] {
+ let mut session = Session::open(rounds).await;
+ let funding = session.chain.funding;
+ let spend = session.chain.spend(funding, FUNDING);
+ session
+ .chain
+ .relay(&spend, Some(instant_lock(&spend)))
+ .await;
+ session.step().await;
+ session.chain.mine(vec![spend.clone()]).await;
+ session.chain.sync().await;
+ session.step().await;
+
+ session.chain.fork_at(BASE).await;
+
+ let restored = session.assert_restart_matches_memory().await;
+ let change = OutPoint::new(spend.txid(), 0);
+ assert_eq!(
+ restored.coins.get(&change),
+ Some(&(FUNDING / 2, false, true, 0)),
+ "{rounds:?}: its change stays, unconfirmed and InstantSend-locked"
+ );
+ assert!(!restored.coins.contains_key(&funding), "{rounds:?}");
+ assert_eq!(
+ restored.history.get(&spend.txid()),
+ Some(&None),
+ "{rounds:?}"
+ );
+ }
+}
+
+/// A truncation is several writes: rows deleted, records rewritten, coins
+/// released, heights lowered. One that fails part-way must leave none behind.
+#[tokio::test]
+async fn should_leave_the_store_untouched_when_a_truncation_cannot_be_applied() {
+ let mut session = Session::open(Rounds::PerStep).await;
+ let wallet_id = session.chain.wallet_id;
+ let funding = session.chain.funding;
+ let kept = session.chain.spend(funding, FUNDING);
+ let removed = session.chain.receive(250_000);
+ session.chain.relay(&kept, Some(instant_lock(&kept))).await;
+ session
+ .chain
+ .mine(vec![kept.clone(), removed.clone()])
+ .await;
+ session.chain.sync().await;
+ session.step().await;
+ // The kept transaction's record can no longer be rewritten as unconfirmed.
+ session
+ .store
+ .persister()
+ .lock_conn_for_test()
+ .execute(
+ "UPDATE core_transactions SET record_blob = X'00' WHERE txid = ?1",
+ [kept.txid().as_byte_array().as_slice()],
+ )
+ .unwrap();
+
+ session.chain.fork_at(BASE).await;
+ let truncation = session
+ .chain
+ .drain()
+ .into_iter()
+ .find_map(|event| match event {
+ WalletEvent::ChainTruncated {
+ height,
+ txids,
+ unconfirmed_txids,
+ restored_outpoints,
+ ..
+ } => Some(ChainTruncation {
+ height,
+ txids,
+ unconfirmed_txids,
+ restored_outpoints,
+ }),
+ _ => None,
+ })
+ .expect("the fork is reported");
+ assert_eq!(truncation.txids, [removed.txid()]);
+ assert_eq!(truncation.unconfirmed_txids, [kept.txid()]);
+
+ let rejected = session.store.persister().store(
+ wallet_id,
+ PlatformWalletChangeSet {
+ core: Some(CoreChangeSet {
+ truncations: vec![truncation],
+ ..Default::default()
+ }),
+ ..Default::default()
+ },
+ );
+
+ assert!(
+ rejected.is_err(),
+ "an unreadable record must fail the round"
+ );
+ assert_eq!(
+ session.store.transaction_rows(&removed.txid()),
+ vec![Some(BASE + 1)],
+ "the removal made before the failure is rolled back"
+ );
+ assert_eq!(
+ session.store.transaction_rows(&kept.txid()),
+ vec![Some(BASE + 1)]
+ );
+ assert_eq!(
+ session.store.heights(wallet_id),
+ (Some(BASE + 1), Some(BASE + 1))
+ );
+}
+
+/// One step of a generated history.
+#[derive(Debug, Clone)]
+enum Step {
+ /// Mine a block holding whatever is waiting in the mempool.
+ Block,
+ /// Mine a payment to the wallet.
+ Receive,
+ /// Spend the wallet's largest coin, returning change to it.
+ Spend { relayed_first: bool, locked: bool },
+ /// Commit the scan checkpoint at the tip.
+ Sync,
+ /// Replace the last `depth` blocks. What they held goes back to the
+ /// mempool; `remine` mines it in the first block of the new branch.
+ Reorg { depth: u32, remine: bool },
+}
+
+fn step() -> impl Strategy {
+ prop_oneof![
+ 2 => Just(Step::Block),
+ 2 => Just(Step::Receive),
+ 4 => (any::(), any::())
+ .prop_map(|(relayed_first, locked)| Step::Spend { relayed_first, locked }),
+ 2 => Just(Step::Sync),
+ 3 => (1u32..=3, any::()).prop_map(|(depth, remine)| Step::Reorg { depth, remine }),
+ ]
+}
+
+/// The chain as a miner sees it: what each block above [`BASE`] holds and
+/// what waits in the mempool, parents ahead of their children.
+#[derive(Default)]
+struct Miner {
+ blocks: Vec>,
+ mempool: Vec,
+}
+
+impl Miner {
+ async fn mine(&mut self, chain: &mut Chain, mut extra: Vec) {
+ let mut block = std::mem::take(&mut self.mempool);
+ block.append(&mut extra);
+ chain.mine(block.clone()).await;
+ self.blocks.push(block);
+ }
+
+ async fn apply(&mut self, chain: &mut Chain, step: &Step) {
+ match step {
+ Step::Block => self.mine(chain, vec![]).await,
+ Step::Receive => {
+ let payment = chain.receive(250_000);
+ self.mine(chain, vec![payment]).await;
+ }
+ Step::Spend {
+ relayed_first,
+ locked,
+ } => {
+ let Some((coin, value)) = chain.largest_coin().await else {
+ return;
+ };
+ if value < 4 * FEE {
+ return;
+ }
+ let spend = chain.spend(coin, value);
+ let lock = locked.then(|| instant_lock(&spend));
+ if *relayed_first || lock.is_some() {
+ chain.relay(&spend, lock).await;
+ self.mempool.push(spend);
+ } else {
+ self.mine(chain, vec![spend]).await;
+ }
+ }
+ Step::Sync => chain.sync().await,
+ Step::Reorg { depth, remine } => {
+ let depth = (*depth as usize).min(self.blocks.len());
+ if depth == 0 {
+ return;
+ }
+ let mut returned: Vec = self
+ .blocks
+ .split_off(self.blocks.len() - depth)
+ .into_iter()
+ .flatten()
+ .collect();
+ chain.fork_at(chain.tip - depth as u32).await;
+ returned.append(&mut self.mempool);
+ self.mempool = returned;
+ if *remine {
+ self.mine(chain, vec![]).await;
+ }
+ }
+ }
+ }
+}
+
+/// The differential: one wallet, two stores. One store takes a `store()`
+/// round per step, the other a single round holding every event, so the
+/// second exercises the changeset merge the first never needs.
+async fn merged_and_stepwise_stores_agree(steps: Vec) -> Result<(), TestCaseError> {
+ let mut chain = Chain::funded().await;
+ let mut stepwise = chain.store();
+ let mut merged = chain.store();
+ let funded = chain.drain();
+ stepwise.persist(&chain.manager, funded.clone()).await;
+ merged.persist(&chain.manager, funded).await;
+
+ let mut miner = Miner::default();
+ let mut log = Vec::new();
+ for step in &steps {
+ miner.apply(&mut chain, step).await;
+ let events = chain.drain();
+ log.extend(events.iter().cloned());
+ stepwise.persist(&chain.manager, events).await;
+ }
+ merged.persist(&chain.manager, log).await;
+
+ let memory = chain.memory().await;
+ let stepwise = view(&stepwise.restart(chain.wallet_id));
+ let merged = view(&merged.restart(chain.wallet_id));
+ for (rounds, restored) in [
+ ("one round per step", stepwise),
+ ("one merged round", merged),
+ ] {
+ if let Err(difference) = restored.follows(&memory) {
+ return Err(TestCaseError::fail(format!("{rounds}: {difference}")));
+ }
+ }
+ Ok(())
+}
+
+#[test]
+fn should_store_the_same_wallet_from_one_merged_round_as_from_a_round_per_step() {
+ let runtime = tokio::runtime::Builder::new_current_thread()
+ .build()
+ .unwrap();
+ let config = ProptestConfig {
+ cases: 48,
+ failure_persistence: None,
+ ..ProptestConfig::default()
+ };
+ proptest!(config, |(steps in proptest::collection::vec(step(), 1..12))| {
+ runtime.block_on(merged_and_stepwise_stores_agree(steps))?;
+ });
+}
From e9d890e6bba15833b651cfbcc6eef9f6774c66c4 Mon Sep 17 00:00:00 2001
From: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Date: Mon, 12 Oct 2026 13:24:31 +0000
Subject: [PATCH 05/28] fix(platform-wallet): declare CORE_CHAIN_TRUNCATION on
bit 14
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bit 13 is claimed by `CORE_HISTORY_RESTORE` in dashpay/platform#5220,
which is stacked on the same base and not merged yet. Both landing on
bit 13 would make one capability attest the other.
The bit moves to 14 and 13 stays reserved. The name-table test skips the
reserved bit and fails as soon as something names it, so whichever of
the two changes lands second has to drop the skip and keep both bits.
🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4
---
.../src/changeset/persistence_capabilities.rs | 13 ++++++++++---
1 file changed, 10 insertions(+), 3 deletions(-)
diff --git a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs
index b87e790ec1c..31dc589671e 100644
--- a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs
+++ b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs
@@ -124,7 +124,8 @@ impl PersistenceCapabilities {
/// a frozen watermark would cost the host its durable progress and
/// recover nothing. Such a store keeps the replaced branch until the
/// host rescans.
- pub const CORE_CHAIN_TRUNCATION: Self = Self(1 << 13);
+ // Bit 13 is reserved for `CORE_HISTORY_RESTORE` (dashpay/platform#5220).
+ pub const CORE_CHAIN_TRUNCATION: Self = Self(1 << 14);
/// 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
@@ -132,7 +133,7 @@ impl PersistenceCapabilities {
/// 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 = 14;
/// Capabilities required before exporting and funding an invitation voucher.
pub const INVITATION_CREATION: Self = Self(
@@ -276,7 +277,7 @@ mod tests {
assert_eq!(PersistenceCapabilities::DASHPAY_PAYMENTS.bits(), 0x1000);
assert_eq!(
PersistenceCapabilities::CORE_CHAIN_TRUNCATION.bits(),
- 0x2000
+ 0x4000
);
assert_eq!(
PersistenceCapabilities::ASSET_LOCK_RECONCILIATION.bits(),
@@ -311,6 +312,12 @@ mod tests {
// to prevent. Walking the whole width cannot go stale.
for shift in 0..=PersistenceCapabilities::HIGHEST_DECLARED_BIT {
let bit = PersistenceCapabilities::from_bits_retain(1u64 << shift);
+ // Reserved, not declared — see `CORE_CHAIN_TRUNCATION`. Naming
+ // it is what retires this exception.
+ if shift == 13 {
+ assert!(bit.names().is_empty(), "bit 13 is declared; drop this skip");
+ continue;
+ }
assert_eq!(
bit.names().len(),
1,
From d907f3616db5d199156503725a27d0b12a2be3c7 Mon Sep 17 00:00:00 2001
From: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Date: Mon, 12 Oct 2026 13:43:01 +0000
Subject: [PATCH 06/28] feat(platform-wallet): deliver chain truncations to FFI
hosts
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`FFIPersister` had no way to hand a chain truncation to a host, so it
could not attest `CORE_CHAIN_TRUNCATION` and the adapter withheld every
truncation from it (#5380).
`PersistenceCallbacksExtension` gains
`on_persist_wallet_changeset_truncations_fn`, appended behind every
existing slot and read only when the host's `struct_size` covers it, so
hosts built against the shorter struct are unaffected. `store()` fires it
before every other core callback of the round, with one
`ChainTruncationFFI` per truncation in emission order; a non-zero return
fails the round.
The capability is attested for slot present, changeset callback wired,
begin/end wired with `ATOMIC_CHANGESETS`, and the bit (1 << 14) declared,
the same shape as `CORE_SWEEP_REMOVAL`: the truncation travels on its own
call, so only an atomic round applies it together with the rows it cuts
back.
🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4
---
.../src/core_wallet_types.rs | 106 ++++++
.../rs-platform-wallet-ffi/src/manager.rs | 39 +-
.../rs-platform-wallet-ffi/src/persistence.rs | 354 +++++++++++++++++-
3 files changed, 495 insertions(+), 4 deletions(-)
diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs
index 68e3dd8ff8a..78daf5196fe 100644
--- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs
+++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs
@@ -420,6 +420,47 @@ pub struct SweepBatchFFI {
pub winner_mined_height: u32,
}
+/// One chain truncation: a fork replaced the chain above `height` and the
+/// wallet dropped what it recorded from the blocks above it.
+///
+/// Delivered through `PersistenceCallbacksExtension`'s
+/// `on_persist_wallet_changeset_truncations_fn`, for the same layout reason
+/// as [`SweepBatchFFI`]. The entries arrive in the order the wallet emitted
+/// them and **a persister applies them, in sequence, before everything else
+/// the round carries**: the rest of the round describes the wallet after the
+/// last truncation, so a transaction named in `txids` can be stored again,
+/// in its new block, by the changeset callback that follows.
+///
+/// Applying one entry means:
+/// - delete each transaction in `txids` and every UTXO it created;
+/// - keep each transaction in `unconfirmed_txids` as unconfirmed — no block,
+/// outputs unconfirmed and InstantSend-locked, the coins it spent still
+/// spent;
+/// - mark each outpoint in `restored_outpoints` unspent again;
+/// - lower a stored sync height above `height` to `height`.
+///
+/// A transaction or outpoint the persister never stored is a no-op.
+///
+/// # Null at count 0
+///
+/// Each pointer is null when its count is zero. A consumer must check the
+/// pointer before forming a slice from it, as with [`SweepBatchFFI`].
+#[repr(C)]
+pub struct ChainTruncationFFI {
+ /// Height of the last block the fork keeps.
+ pub height: u32,
+ /// Removed transactions, raw 32-byte txids.
+ pub txids: *const [u8; 32],
+ pub txids_count: usize,
+ /// InstantSend-locked transactions kept as unconfirmed, raw 32-byte
+ /// txids.
+ pub unconfirmed_txids: *const [u8; 32],
+ pub unconfirmed_txids_count: usize,
+ /// Coins the removed transactions spent, unspent again.
+ pub restored_outpoints: *const OutPointFFI,
+ pub restored_outpoints_count: usize,
+}
+
// ---------------------------------------------------------------------------
// Conversion
// ---------------------------------------------------------------------------
@@ -777,6 +818,71 @@ pub(crate) fn build_sweep_batches_for_callback(
(batches, storage)
}
+/// Backing storage for one [`ChainTruncationFFI`]'s nested buffers, kept
+/// alive by the caller for the callback window like [`SweepBatchStorage`].
+pub(crate) struct ChainTruncationStorage {
+ txids: Vec<[u8; 32]>,
+ unconfirmed_txids: Vec<[u8; 32]>,
+ restored: Vec,
+}
+
+/// Build the C mirrors of a changeset's truncations for the extension
+/// truncation callback (`on_persist_wallet_changeset_truncations_fn`), in
+/// the wallet's emission order.
+pub(crate) fn build_chain_truncations_for_callback(
+ cs: &platform_wallet::changeset::CoreChangeSet,
+) -> (Vec, Vec) {
+ fn raw_txids(txids: &[dashcore::Txid]) -> Vec<[u8; 32]> {
+ txids
+ .iter()
+ .map(|txid| {
+ let mut raw = [0u8; 32];
+ raw.copy_from_slice(txid.as_ref());
+ raw
+ })
+ .collect()
+ }
+ // Null at count 0: see `ChainTruncationFFI`.
+ fn ptr_or_null(items: &[T]) -> *const T {
+ if items.is_empty() {
+ std::ptr::null()
+ } else {
+ items.as_ptr()
+ }
+ }
+
+ let storage: Vec = cs
+ .truncations
+ .iter()
+ .map(|truncation| ChainTruncationStorage {
+ txids: raw_txids(&truncation.txids),
+ unconfirmed_txids: raw_txids(&truncation.unconfirmed_txids),
+ restored: truncation
+ .restored_outpoints
+ .iter()
+ .map(OutPointFFI::from)
+ .collect(),
+ })
+ .collect();
+
+ let truncations: Vec = cs
+ .truncations
+ .iter()
+ .zip(storage.iter())
+ .map(|(truncation, backing)| ChainTruncationFFI {
+ height: truncation.height,
+ txids: ptr_or_null(&backing.txids),
+ txids_count: backing.txids.len(),
+ unconfirmed_txids: ptr_or_null(&backing.unconfirmed_txids),
+ unconfirmed_txids_count: backing.unconfirmed_txids.len(),
+ restored_outpoints: ptr_or_null(&backing.restored),
+ restored_outpoints_count: backing.restored.len(),
+ })
+ .collect();
+
+ (truncations, storage)
+}
+
/// Returns the account "index" the FFI surfaces in `account_index`.
///
/// For variants with a natural index field (`Standard`, `CoinJoin`,
diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs
index 731fb5c6a32..cc864cb5d33 100644
--- a/packages/rs-platform-wallet-ffi/src/manager.rs
+++ b/packages/rs-platform-wallet-ffi/src/manager.rs
@@ -11,8 +11,9 @@ use crate::persistence::{
FFIPersister, FreeTrackedMasternodesFn, LoadIdentityBalanceBlockTimeFn,
LoadTrackedMasternodesFn, PersistDpnsNameStatesFn, PersistIdentityBalanceBlockTimeFn,
PersistTrackedMasternodesFn, PersistWalletChangesetChainLockHeightFn,
- PersistWalletChangesetSweepsFn, PersistWalletChangesetUtxoVerdictsFn, PersistenceCallbacks,
- PersistenceCallbacksExtension, PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks,
+ PersistWalletChangesetSweepsFn, PersistWalletChangesetTruncationsFn,
+ PersistWalletChangesetUtxoVerdictsFn, PersistenceCallbacks, PersistenceCallbacksExtension,
+ PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks,
PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION,
};
use crate::runtime::runtime;
@@ -277,6 +278,10 @@ unsafe fn persistence_extension_callbacks(
on_load_identity_balance_block_time_fn,
LoadIdentityBalanceBlockTimeFn
),
+ wallet_changeset_truncations: slot!(
+ on_persist_wallet_changeset_truncations_fn,
+ PersistWalletChangesetTruncationsFn
+ ),
}
}
@@ -1308,6 +1313,36 @@ mod tests {
assert!(parsed.load_identity_balance_block_time.is_none());
}
+ /// A host whose `struct_size` stops before the truncations slot keeps
+ /// its earlier slots and never has this one read.
+ #[test]
+ fn should_read_the_truncations_slot_only_from_an_extension_that_covers_it() {
+ unsafe extern "C" fn persist_truncations(
+ _: *mut c_void,
+ _: *const u8,
+ _: *const crate::core_wallet_types::ChainTruncationFFI,
+ _: usize,
+ ) -> i32 {
+ 0
+ }
+ let slot_offset = std::mem::offset_of!(
+ PersistenceCallbacksExtension,
+ on_persist_wallet_changeset_truncations_fn
+ );
+ let mut ext = PersistenceCallbacksExtension {
+ on_persist_wallet_changeset_sweeps_fn: Some(persist_wallet_changeset_sweeps),
+ on_persist_wallet_changeset_truncations_fn: Some(persist_truncations),
+ ..Default::default()
+ };
+ let parsed = unsafe { persistence_extension_callbacks(&ext) };
+ assert!(parsed.wallet_changeset_truncations.is_some());
+
+ ext.struct_size = slot_offset;
+ let parsed = unsafe { persistence_extension_callbacks(&ext) };
+ assert!(parsed.wallet_changeset_sweeps.is_some());
+ assert!(parsed.wallet_changeset_truncations.is_none());
+ }
+
/// A host whose `struct_size` stops right after the chainlock-height
/// slot (built before the credit-verdict slot existed) keeps every
/// earlier slot and simply never has the verdict slot read; a host
diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs
index cb97ca1c04a..cf97e89aaff 100644
--- a/packages/rs-platform-wallet-ffi/src/persistence.rs
+++ b/packages/rs-platform-wallet-ffi/src/persistence.rs
@@ -54,8 +54,9 @@ use crate::contact_persistence::{
};
use crate::core_address_types::{AddressPoolTypeTagFFI, CoreAddressEntryFFI, KeyTypeTagFFI};
use crate::core_wallet_types::{
- build_sweep_batches_for_callback, build_utxo_credit_verdicts_for_callback,
- free_wallet_changeset_ffi, SweepBatchFFI, UtxoCreditVerdictFFI, WalletChangeSetFFI,
+ build_chain_truncations_for_callback, build_sweep_batches_for_callback,
+ build_utxo_credit_verdicts_for_callback, free_wallet_changeset_ffi, ChainTruncationFFI,
+ SweepBatchFFI, UtxoCreditVerdictFFI, WalletChangeSetFFI,
};
use crate::dashpay_payment::{build_payment_persist_entries, DashpayPaymentPersistEntryFFI};
use crate::dpns_name_state_persistence::{
@@ -154,6 +155,12 @@ pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_TRACKED_ASSET_LOCKS: u64 = 1 <<
pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_TRACKED_MASTERNODES: u64 = 1 << 10;
pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_CORE_SWEEP_REMOVAL: u64 = 1 << 11;
pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_DASHPAY_PAYMENTS: u64 = 1 << 12;
+// Bit 13 is reserved for `CORE_HISTORY_RESTORE` (dashpay/platform#5220).
+/// Chain truncations are applied before the rest of their round and
+/// atomically with it. Requires the extension slot
+/// `on_persist_wallet_changeset_truncations_fn`, the changeset callback, the
+/// begin/end pair with `ATOMIC_CHANGESETS`, and the host declaring the bit.
+pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_CORE_CHAIN_TRUNCATION: u64 = 1 << 14;
/// Version of [`PersistenceCallbacksExtension`]. The extension is deliberately
/// separate from [`PersistenceCallbacks`]: existing hosts pass the latter by
@@ -257,6 +264,19 @@ pub type PersistWalletChangesetUtxoVerdictsFn = unsafe extern "C" fn(
verdicts_count: usize,
) -> i32;
+/// Carries a round's chain truncations (see [`ChainTruncationFFI`]). Fired
+/// inside the round's begin/end bracket BEFORE every other callback that
+/// carries core wallet state, because the rest of the round describes the
+/// wallet after the last truncation. Fired only on rounds that carry one. A
+/// non-zero return fails the round like any other per-kind callback: a
+/// truncation silently skipped leaves the replaced branch in the store.
+pub type PersistWalletChangesetTruncationsFn = unsafe extern "C" fn(
+ context: *mut c_void,
+ wallet_id: *const u8,
+ truncations: *const ChainTruncationFFI,
+ truncations_count: usize,
+) -> i32;
+
/// Persist one identity's optional balance freshness stamp in the same
/// transaction as its legacy identity row. A null stamp means no watermark.
pub type PersistIdentityBalanceBlockTimeFn = unsafe extern "C" fn(
@@ -406,6 +426,19 @@ pub struct PersistenceCallbacksExtension {
out_block_time: *mut crate::types::BlockTime,
) -> i32,
>,
+ /// The round's chain truncations (see
+ /// [`PersistWalletChangesetTruncationsFn`]). Appended under the same
+ /// version like the slots above: `struct_size` proves whether a host
+ /// allocated it. A host without it is never handed a truncation and
+ /// cannot attest `CORE_CHAIN_TRUNCATION`.
+ pub on_persist_wallet_changeset_truncations_fn: Option<
+ unsafe extern "C" fn(
+ context: *mut c_void,
+ wallet_id: *const u8,
+ truncations: *const ChainTruncationFFI,
+ truncations_count: usize,
+ ) -> i32,
+ >,
}
impl Default for PersistenceCallbacksExtension {
@@ -423,6 +456,7 @@ impl Default for PersistenceCallbacksExtension {
on_persist_wallet_changeset_utxo_verdicts_fn: None,
on_persist_identity_balance_block_time_fn: None,
on_load_identity_balance_block_time_fn: None,
+ on_persist_wallet_changeset_truncations_fn: None,
}
}
}
@@ -441,6 +475,7 @@ pub struct PersistenceExtensionCallbacks {
pub wallet_changeset_utxo_verdicts: Option,
pub persist_identity_balance_block_time: Option,
pub load_identity_balance_block_time: Option,
+ pub wallet_changeset_truncations: Option,
}
/// Return value by which a persistence callback reports a **retryable**
@@ -1372,6 +1407,10 @@ pub struct FFIPersister {
/// materialises those rows unspent, as every host did before the slot
/// existed.
wallet_changeset_utxo_verdicts_callback: Option,
+ /// `Some` only when the host's extension `struct_size` proved the slot
+ /// was allocated, which is the structural half of
+ /// `CORE_CHAIN_TRUNCATION`.
+ wallet_changeset_truncations_callback: Option,
/// Additive tracked-masternode persistence trio (persist / load /
/// free), likewise extension-negotiated.
tracked_masternodes_callbacks: PersistenceExtensionCallbacks,
@@ -1496,6 +1535,7 @@ impl FFIPersister {
wallet_changeset_chain_lock_height_callback: extensions
.wallet_changeset_chain_lock_height,
wallet_changeset_utxo_verdicts_callback: extensions.wallet_changeset_utxo_verdicts,
+ wallet_changeset_truncations_callback: extensions.wallet_changeset_truncations,
tracked_masternodes_callbacks: extensions,
persist_identity_balance_block_time_callback: extensions
.persist_identity_balance_block_time,
@@ -1616,6 +1656,20 @@ impl FFIPersister {
{
capabilities = capabilities.union(PersistenceCapabilities::CORE_SWEEP_REMOVAL);
}
+ // Same structural proof and same split-transport reasoning as the
+ // sweep bit above: the truncation arrives on its own call, ahead of
+ // the changeset callback whose rows it cuts back, so only a round
+ // that commits or rolls back whole can apply the two together.
+ if self.wallet_changeset_truncations_callback.is_some()
+ && self.callbacks.on_persist_wallet_changeset_fn.is_some()
+ && self.callbacks.on_changeset_begin_fn.is_some()
+ && self.callbacks.on_changeset_end_fn.is_some()
+ && self
+ .declared_capabilities
+ .contains(PersistenceCapabilities::ATOMIC_CHANGESETS)
+ {
+ capabilities = capabilities.union(PersistenceCapabilities::CORE_CHAIN_TRUNCATION);
+ }
#[cfg(feature = "shielded")]
if self.callbacks.on_persist_shielded_viewing_keys_fn.is_some()
&& self.callbacks.on_load_shielded_viewing_keys_fn.is_some()
@@ -1987,6 +2041,34 @@ impl PlatformWalletPersistence for FFIPersister {
// Send core wallet changeset (accounts, transactions, UTXOs).
if let Some(ref core_cs) = changeset.core {
+ // Truncations first: every other callback of this block carries
+ // the wallet as it is after the last of them. A host without
+ // the slot never sees them and cannot attest
+ // `CORE_CHAIN_TRUNCATION`, so the wallet-event adapter withholds
+ // them one layer up.
+ if !core_cs.truncations.is_empty() {
+ if let Some(cb) = self.wallet_changeset_truncations_callback {
+ let (truncations, _truncation_storage) =
+ build_chain_truncations_for_callback(core_cs);
+ let result = unsafe {
+ cb(
+ self.callbacks.context,
+ wallet_id.as_ptr(),
+ truncations.as_ptr(),
+ truncations.len(),
+ )
+ };
+ if result != 0 {
+ eprintln!(
+ "Wallet changeset chain-truncation persistence callback returned \
+ error code {}",
+ result
+ );
+ outcome.record(result);
+ }
+ }
+ }
+
// Fan out gap-limit-extension addresses BEFORE the wallet
// changeset itself: the changeset's UTXOs reference these
// addresses, and the Swift-side `upsertUtxo`'s
@@ -8059,6 +8141,260 @@ mod tests {
.contains(PersistenceCapabilities::CORE_SWEEP_REMOVAL));
}
+ /// The truncation bit mirrors the sweep bit: the slot is the structural
+ /// proof, and because the truncation travels on its own call it also
+ /// needs the changeset callback it cuts back and a round that commits
+ /// or rolls back whole.
+ #[test]
+ fn should_attest_chain_truncation_only_with_the_slot_the_atomic_round_and_the_declaration() {
+ unsafe extern "C" fn noop_truncations(
+ _ctx: *mut c_void,
+ _wallet_id: *const u8,
+ _truncations: *const ChainTruncationFFI,
+ _truncations_count: usize,
+ ) -> i32 {
+ 0
+ }
+ fn attests(
+ callbacks: PersistenceCallbacks,
+ declared: PersistenceCapabilities,
+ truncations: Option,
+ ) -> bool {
+ FFIPersister::new_with_persistence_capabilities_and_extensions(
+ callbacks,
+ declared,
+ PersistenceExtensionCallbacks {
+ wallet_changeset_truncations: truncations,
+ ..Default::default()
+ },
+ )
+ .persistence_capabilities()
+ .contains(PersistenceCapabilities::CORE_CHAIN_TRUNCATION)
+ }
+ fn wired_callbacks() -> PersistenceCallbacks {
+ PersistenceCallbacks {
+ on_persist_wallet_changeset_fn: Some(noop_wallet_changeset),
+ on_changeset_begin_fn: Some(noop_begin),
+ on_changeset_end_fn: Some(noop_end),
+ ..Default::default()
+ }
+ }
+ let declared = PersistenceCapabilities::CORE_CHAIN_TRUNCATION
+ .union(PersistenceCapabilities::ATOMIC_CHANGESETS);
+
+ assert!(
+ !attests(wired_callbacks(), declared, None),
+ "declared without the slot"
+ );
+ assert!(
+ !attests(
+ PersistenceCallbacks::default(),
+ declared,
+ Some(noop_truncations)
+ ),
+ "no changeset callback to cut back"
+ );
+ assert!(
+ !attests(
+ wired_callbacks(),
+ PersistenceCapabilities::ATOMIC_CHANGESETS,
+ Some(noop_truncations)
+ ),
+ "wired but never declared"
+ );
+ assert!(
+ !attests(
+ wired_callbacks(),
+ PersistenceCapabilities::CORE_CHAIN_TRUNCATION,
+ Some(noop_truncations)
+ ),
+ "no atomic round to bracket the two calls"
+ );
+ assert!(
+ !attests(
+ PersistenceCallbacks {
+ on_persist_wallet_changeset_fn: Some(noop_wallet_changeset),
+ ..Default::default()
+ },
+ declared,
+ Some(noop_truncations)
+ ),
+ "atomic declared but begin/end not wired"
+ );
+ assert!(attests(wired_callbacks(), declared, Some(noop_truncations)));
+ }
+
+ /// A round's truncations reach the host before anything else it carries
+ /// about core state, in emission order and with payloads intact: the
+ /// changeset that follows describes the wallet after them.
+ #[test]
+ fn should_deliver_truncations_before_the_rest_of_the_round() {
+ use dashcore::hashes::Hash as _;
+ use platform_wallet::changeset::changeset::{ChainTruncation, SweepBatch};
+ use platform_wallet::changeset::CoreChangeSet;
+
+ #[derive(Default)]
+ struct Sink {
+ events: std::sync::Mutex>,
+ fail_truncations: bool,
+ }
+ unsafe extern "C" fn record_changeset(
+ ctx: *mut c_void,
+ _wallet_id: *const u8,
+ changeset: *const WalletChangeSetFFI,
+ ) -> i32 {
+ let sink = &*(ctx as *const Sink);
+ let changeset = &*changeset;
+ sink.events.lock().unwrap().push(format!(
+ "changeset synced={:?}",
+ changeset.has_chain.then_some(changeset.chain.synced_height)
+ ));
+ 0
+ }
+ unsafe extern "C" fn record_sweeps(
+ ctx: *mut c_void,
+ _wallet_id: *const u8,
+ _sweeps: *const SweepBatchFFI,
+ sweeps_count: usize,
+ ) -> i32 {
+ let sink = &*(ctx as *const Sink);
+ sink.events
+ .lock()
+ .unwrap()
+ .push(format!("sweeps x{sweeps_count}"));
+ 0
+ }
+ unsafe extern "C" fn record_truncations(
+ ctx: *mut c_void,
+ _wallet_id: *const u8,
+ truncations: *const ChainTruncationFFI,
+ truncations_count: usize,
+ ) -> i32 {
+ // Null at count 0, as the struct documents.
+ unsafe fn first_bytes(txids: *const [u8; 32], count: usize) -> Vec {
+ if txids.is_null() {
+ return Vec::new();
+ }
+ slice::from_raw_parts(txids, count)
+ .iter()
+ .map(|txid| txid[0])
+ .collect()
+ }
+ let sink = &*(ctx as *const Sink);
+ if sink.fail_truncations {
+ return 1;
+ }
+ let mut events = sink.events.lock().unwrap();
+ for truncation in slice::from_raw_parts(truncations, truncations_count) {
+ let restored = if truncation.restored_outpoints.is_null() {
+ &[][..]
+ } else {
+ slice::from_raw_parts(
+ truncation.restored_outpoints,
+ truncation.restored_outpoints_count,
+ )
+ };
+ events.push(format!(
+ "truncation height={} removed={:?} unconfirmed={:?} restored={:?}",
+ truncation.height,
+ first_bytes(truncation.txids, truncation.txids_count),
+ first_bytes(
+ truncation.unconfirmed_txids,
+ truncation.unconfirmed_txids_count
+ ),
+ restored
+ .iter()
+ .map(|o| (o.txid[0], o.vout))
+ .collect::>(),
+ ));
+ }
+ 0
+ }
+
+ fn txid(byte: u8) -> dashcore::Txid {
+ dashcore::Txid::from_byte_array([byte; 32])
+ }
+ fn round() -> PlatformWalletChangeSet {
+ PlatformWalletChangeSet {
+ core: Some(CoreChangeSet {
+ truncations: vec![
+ ChainTruncation {
+ height: 900,
+ txids: vec![txid(0x11), txid(0x12)],
+ unconfirmed_txids: vec![txid(0x21)],
+ restored_outpoints: vec![dashcore::OutPoint::new(txid(0x31), 4)],
+ },
+ ChainTruncation {
+ height: 898,
+ txids: Vec::new(),
+ unconfirmed_txids: Vec::new(),
+ restored_outpoints: Vec::new(),
+ },
+ ],
+ synced_height: Some(899),
+ sweeps: vec![SweepBatch {
+ txids: vec![txid(0x41)],
+ superseded_by: txid(0x42),
+ winner_mined_height: None,
+ released_outpoints: Vec::new(),
+ }],
+ ..Default::default()
+ }),
+ ..Default::default()
+ }
+ }
+ fn persister(sink: &Sink, with_slot: bool) -> FFIPersister {
+ FFIPersister::new_with_persistence_capabilities_and_extensions(
+ PersistenceCallbacks {
+ context: sink as *const Sink as *mut c_void,
+ on_persist_wallet_changeset_fn: Some(record_changeset),
+ ..PersistenceCallbacks::default()
+ },
+ PersistenceCapabilities::NONE,
+ PersistenceExtensionCallbacks {
+ wallet_changeset_sweeps: Some(record_sweeps),
+ wallet_changeset_truncations: with_slot.then_some(record_truncations),
+ ..Default::default()
+ },
+ )
+ }
+
+ let sink = Sink::default();
+ persister(&sink, true)
+ .store([1u8; 32], round())
+ .expect("the round must succeed");
+ assert_eq!(
+ sink.events.lock().unwrap().clone(),
+ vec![
+ "truncation height=900 removed=[17, 18] unconfirmed=[33] restored=[(49, 4)]"
+ .to_string(),
+ "truncation height=898 removed=[] unconfirmed=[] restored=[]".to_string(),
+ "changeset synced=Some(899)".to_string(),
+ "sweeps x1".to_string(),
+ ],
+ );
+
+ // A host whose extension never proved the slot is not handed them.
+ let sink = Sink::default();
+ persister(&sink, false)
+ .store([1u8; 32], round())
+ .expect("the round must succeed");
+ assert_eq!(
+ sink.events.lock().unwrap().clone(),
+ vec![
+ "changeset synced=Some(899)".to_string(),
+ "sweeps x1".to_string()
+ ],
+ );
+
+ // A host that could not apply them fails the round.
+ let sink = Sink {
+ fail_truncations: true,
+ ..Sink::default()
+ };
+ assert!(persister(&sink, true).store([1u8; 32], round()).is_err());
+ }
+
/// The delivery contract of the extension transport itself: a
/// sweep-carrying round hands its batches to the extension slot AFTER
/// the changeset callback, within the same round, in emission order and
@@ -8705,6 +9041,16 @@ mod tests {
PersistenceCallbacksExtension,
on_load_identity_balance_block_time_fn
) + std::mem::size_of::>(),
+ std::mem::offset_of!(
+ PersistenceCallbacksExtension,
+ on_persist_wallet_changeset_truncations_fn
+ )
+ );
+ assert_eq!(
+ std::mem::offset_of!(
+ PersistenceCallbacksExtension,
+ on_persist_wallet_changeset_truncations_fn
+ ) + std::mem::size_of:: >(),
std::mem::size_of::()
);
assert_eq!(
@@ -8763,6 +9109,10 @@ mod tests {
PLATFORM_WALLET_PERSISTENCE_CAPABILITY_DASHPAY_PAYMENTS,
PersistenceCapabilities::DASHPAY_PAYMENTS.bits()
);
+ assert_eq!(
+ PLATFORM_WALLET_PERSISTENCE_CAPABILITY_CORE_CHAIN_TRUNCATION,
+ PersistenceCapabilities::CORE_CHAIN_TRUNCATION.bits()
+ );
assert_eq!(
PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ACCOUNT_ADDRESS_POOLS,
PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_FUNDING_INDICES
From d2147e26c57bd7554fec77d925a2054fa7d900c7 Mon Sep 17 00:00:00 2001
From: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Date: Mon, 12 Oct 2026 13:43:01 +0000
Subject: [PATCH 07/28] feat(sdk): bridge chain truncations to the Kotlin
persistence bridge
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The JNI layer wires the new truncation slot to
`NativePersistenceBridge.onWalletChangesetChainTruncated`, one call per
truncation in order, with txids and outpoints packed as flat counted
arrays like the sweep callback (#5380).
The slot is wired only when the concrete bridge overrides the method, so
a subclass that merely declares `CAPABILITY_CORE_CHAIN_TRUNCATION` never
attests it. The descriptor is in the bridge method table, so a drifted
signature fails the startup check instead of the first reorg.
The bridge's default body is a no-op; the handler that applies a
truncation follows separately.
🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4
---
.../dashsdk/ffi/NativePersistenceBridge.kt | 46 +++++++
.../rs-unified-sdk-jni/src/persistence.rs | 116 ++++++++++++++++--
2 files changed, 154 insertions(+), 8 deletions(-)
diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt
index 76759d63ed3..e5333fc561c 100644
--- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt
+++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt
@@ -99,6 +99,13 @@ abstract class NativePersistenceBridge {
* it, so the declaration and the mirror can never drift apart.
*/
const val CAPABILITY_CORE_SWEEP_REMOVAL: Long = 0x800
+
+ /**
+ * `PersistenceCapabilities::CORE_CHAIN_TRUNCATION` (bit 14,
+ * `0x4000`; bit 13 is reserved). The one Kotlin home of this bit,
+ * like [CAPABILITY_CORE_SWEEP_REMOVAL].
+ */
+ const val CAPABILITY_CORE_CHAIN_TRUNCATION: Long = 0x4000
}
/**
@@ -421,6 +428,45 @@ abstract class NativePersistenceBridge {
winnerMinedHeight: Int,
): Int = 0
+ /**
+ * One chain truncation: a fork replaced the chain above [height] and
+ * the wallet dropped what it recorded from the blocks above it.
+ * Descriptor `([BI[BI[BI[BI)I`.
+ *
+ * [txids] packs [txidCount] raw 32-byte txids of removed transactions,
+ * [unconfirmedTxids] packs [unconfirmedTxidCount] txids of
+ * InstantSend-locked transactions kept as unconfirmed, and
+ * [restoredOutpoints] packs [restoredOutpointCount] 36-byte outpoint
+ * keys (raw txid followed by a little-endian vout) of coins the removed
+ * transactions spent.
+ *
+ * Fired before every other core callback of the round, once per
+ * truncation and in order: the rest of the round describes the wallet
+ * after the last one, so a removed transaction can be stored again, in
+ * its new block, by the callbacks that follow. Applying one means
+ * deleting each removed transaction and every UTXO it created, keeping
+ * each unconfirmed one with no block and its outputs unconfirmed and
+ * InstantSend-locked, marking each restored outpoint unspent, and
+ * lowering a stored sync height above [height] to it. A transaction or
+ * outpoint never stored is a no-op.
+ *
+ * The JNI layer wires the slot only when the concrete bridge OVERRIDES
+ * this method, and Rust attests the capability only for slot present
+ * AND [CAPABILITY_CORE_CHAIN_TRUNCATION] declared through
+ * [persistenceCapabilitiesBits]. Without that, native never sends a
+ * truncation, so this default is never reached for a wired slot.
+ */
+ open fun onWalletChangesetChainTruncated(
+ walletId: ByteArray,
+ height: Int,
+ txids: ByteArray,
+ txidCount: Int,
+ unconfirmedTxids: ByteArray,
+ unconfirmedTxidCount: Int,
+ restoredOutpoints: ByteArray,
+ restoredOutpointCount: Int,
+ ): Int = 0
+
/**
* The round's numeric chainlock height, fired on every round whose
* changeset carries a chainlock, after the changeset callback and
diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs
index 95d71c125b3..08ca21f95e1 100644
--- a/packages/rs-unified-sdk-jni/src/persistence.rs
+++ b/packages/rs-unified-sdk-jni/src/persistence.rs
@@ -53,14 +53,14 @@ use jni::sys::jstring;
use jni::JNIEnv;
use platform_wallet_ffi::{
AccountAddressPoolFFI, AccountChangeSetFFI, AccountSpecFFI, AddressBalanceEntryFFI,
- AssetLockEntryFFI, ContactIgnoredSenderFFI, ContactProfileRestoreEntryFFI, ContactRequestFFI,
- ContactRequestRemovalFFI, CoreAddressEntryFFI, DpnsNameStateFFI, IdentityEntryFFI,
- IdentityKeyEntryFFI, IdentityKeyRemovalFFI, IdentityKeyRestoreFFI, IdentityRestoreEntryFFI,
- InvitationEntryFFI, OutPointFFI, PaymentRestoreEntryFFI, PersistenceCallbacks,
- PersistenceCallbacksExtension, PlatformAddressFFI, ProviderSpecialTxRestoreEntryFFI,
- SpentOutPointFFI, SweepBatchFFI, TokenBalanceRemovalFFI, TokenBalanceUpsertFFI,
- TransactionRecordFFI, UnresolvedAssetLockTxRecordFFI, UtxoEntryFFI, UtxoRestoreEntryFFI,
- WalletChangeSetFFI, WalletRestoreEntryFFI,
+ AssetLockEntryFFI, ChainTruncationFFI, ContactIgnoredSenderFFI, ContactProfileRestoreEntryFFI,
+ ContactRequestFFI, ContactRequestRemovalFFI, CoreAddressEntryFFI, DpnsNameStateFFI,
+ IdentityEntryFFI, IdentityKeyEntryFFI, IdentityKeyRemovalFFI, IdentityKeyRestoreFFI,
+ IdentityRestoreEntryFFI, InvitationEntryFFI, OutPointFFI, PaymentRestoreEntryFFI,
+ PersistenceCallbacks, PersistenceCallbacksExtension, PlatformAddressFFI,
+ ProviderSpecialTxRestoreEntryFFI, SpentOutPointFFI, SweepBatchFFI, TokenBalanceRemovalFFI,
+ TokenBalanceUpsertFFI, TransactionRecordFFI, UnresolvedAssetLockTxRecordFFI, UtxoEntryFFI,
+ UtxoRestoreEntryFFI, WalletChangeSetFFI, WalletRestoreEntryFFI,
};
use std::ffi::{c_void, CStr, CString};
use std::os::raw::c_char;
@@ -205,9 +205,19 @@ pub(crate) fn build_vtable(context: *mut c_void) -> PersistenceCallbacks {
/// sync watermark with it, and the round is refused one layer up instead of
/// advancing past a removal that never happened. Wiring the slot for every
/// subclass would make "slot present" prove nothing.
+///
+/// The truncation slot follows the same rule for
+/// `onWalletChangesetChainTruncated` and `CORE_CHAIN_TRUNCATION`; without
+/// the slot the wallet-event adapter withholds the truncation.
pub(crate) fn build_extension(env: &mut JNIEnv, bridge: &JObject) -> PersistenceCallbacksExtension {
let sweeps_overridden = bridge_overrides(env, bridge, "onWalletChangesetTransactionsSwept");
+ let truncations_overridden = bridge_overrides(env, bridge, "onWalletChangesetChainTruncated");
PersistenceCallbacksExtension {
+ on_persist_wallet_changeset_truncations_fn: if truncations_overridden {
+ Some(tramp_persist_wallet_changeset_truncations)
+ } else {
+ None
+ },
on_persist_dpns_name_states_fn: Some(tramp_persist_dpns_name_states),
on_persist_wallet_changeset_sweeps_fn: if sweeps_overridden {
Some(tramp_persist_wallet_changeset_sweeps)
@@ -827,6 +837,77 @@ unsafe fn persist_changeset_sweep_batch(
.i()
}
+/// Extension-callback trampoline for the round's chain truncations, fired
+/// before every other core callback of the round (see
+/// `PersistWalletChangesetTruncationsFn`). One bridge call per truncation,
+/// in order, each in its own local frame like the sweep batches.
+unsafe extern "C" fn tramp_persist_wallet_changeset_truncations(
+ context: *mut c_void,
+ wallet_id: *const u8,
+ truncations: *const ChainTruncationFFI,
+ truncations_count: usize,
+) -> i32 {
+ with_bridge(context, |env, bridge| {
+ let wid = id32(env, wallet_id)?;
+ for truncation in slice_or_empty(truncations, truncations_count) {
+ let code = env.with_local_frame(8, |env| {
+ persist_changeset_truncation(env, bridge, &wid, truncation)
+ })?;
+ if code != 0 {
+ return Ok(code);
+ }
+ }
+ Ok(0)
+ })
+}
+
+/// Descriptor of `NativePersistenceBridge.onWalletChangesetChainTruncated`:
+/// `(walletId, height, txids, txidCount, unconfirmedTxids,
+/// unconfirmedTxidCount, restoredOutpoints, restoredOutpointCount)`. Txids
+/// and outpoints ride as one flat `byte[]` each (32·N and 36·N bytes) plus
+/// a count, the packing the sweep descriptor uses.
+const WALLET_CHANGESET_TRUNCATION_DESCRIPTOR: &str = "([BI[BI[BI[BI)I";
+
+unsafe fn persist_changeset_truncation(
+ env: &mut JNIEnv,
+ bridge: &JObject,
+ wid: &JByteArray,
+ truncation: &ChainTruncationFFI,
+) -> Result {
+ let txids = slice_or_empty(truncation.txids, truncation.txids_count);
+ let txids_arr = env.byte_array_from_slice(txids.as_flattened())?;
+ let unconfirmed = slice_or_empty(
+ truncation.unconfirmed_txids,
+ truncation.unconfirmed_txids_count,
+ );
+ let unconfirmed_arr = env.byte_array_from_slice(unconfirmed.as_flattened())?;
+ let restored = slice_or_empty(
+ truncation.restored_outpoints,
+ truncation.restored_outpoints_count,
+ );
+ let mut packed_restored = Vec::with_capacity(restored.len() * 36);
+ for outpoint in restored {
+ packed_restored.extend_from_slice(&pack_outpoint_key(outpoint));
+ }
+ let restored_arr = env.byte_array_from_slice(&packed_restored)?;
+ env.call_method(
+ bridge,
+ "onWalletChangesetChainTruncated",
+ WALLET_CHANGESET_TRUNCATION_DESCRIPTOR,
+ &[
+ wid.into(),
+ JValue::Int(jint_height(truncation.height)?),
+ (&txids_arr).into(),
+ JValue::Int(txids.len() as i32),
+ (&unconfirmed_arr).into(),
+ JValue::Int(unconfirmed.len() as i32),
+ (&restored_arr).into(),
+ JValue::Int(restored.len() as i32),
+ ],
+ )?
+ .i()
+}
+
/// Deliver the round's numeric chainlock height (see
/// `PersistWalletChangesetChainLockHeightFn`), between the changeset callback
/// and the sweep batches. One scalar, one call — the bincode chainlock blob
@@ -4542,6 +4623,13 @@ const BRIDGE_METHOD_TABLE: &[(&str, &str)] = &[
"onWalletChangesetTransactionsSwept",
WALLET_CHANGESET_SWEEPS_DESCRIPTOR,
),
+ // Fires only on a reorg, so a drifted descriptor would otherwise
+ // surface on the first real one. Bound to the same constant as the
+ // `call_method` site in `persist_changeset_truncation`.
+ (
+ "onWalletChangesetChainTruncated",
+ WALLET_CHANGESET_TRUNCATION_DESCRIPTOR,
+ ),
// Same drift risk as the sweeps descriptor above: this slot fires on
// chainlock-advancing rounds only, so a stale descriptor would surface
// exactly when the first real chainlock crossed. Must track the
@@ -4739,6 +4827,18 @@ mod tests {
assert_eq!(WALLET_CHANGESET_SWEEPS_DESCRIPTOR, "([B[BI[B[BIZI)I");
}
+ #[test]
+ fn should_ship_a_truncation_as_a_height_and_three_counted_flat_arrays() {
+ // walletId, height, then (packed bytes, count) for removed txids,
+ // kept-unconfirmed txids and restored outpoints — must match
+ // `NativePersistenceBridge.onWalletChangesetChainTruncated`.
+ assert_eq!(WALLET_CHANGESET_TRUNCATION_DESCRIPTOR, "([BI[BI[BI[BI)I");
+ assert!(BRIDGE_METHOD_TABLE.contains(&(
+ "onWalletChangesetChainTruncated",
+ WALLET_CHANGESET_TRUNCATION_DESCRIPTOR
+ )));
+ }
+
#[test]
fn vtable_layout_remains_independent_of_capability_declaration() {
let callbacks = build_vtable(ptr::null_mut());
From c9129f36617e8f4a1510bde51f0988afd6d2d2cf Mon Sep 17 00:00:00 2001
From: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Date: Mon, 12 Oct 2026 13:59:28 +0000
Subject: [PATCH 08/28] fix(swift-sdk): apply chain truncations in the
SwiftData store
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The persistence handler wires the truncations extension slot, declares
CORE_CHAIN_TRUNCATION and cuts the mirror back in the round that carries
a truncation: transactions of the dropped blocks and their coins go,
InstantSend-locked ones return to the mempool with their outputs
unconfirmed and locked, the coins the removed ones spent are spendable
again, and the synced height drops to the fork.
A round that removes a transaction can record it again in its new block.
A removed row is therefore hidden from the keyed lookups, handed back to
the writer that would otherwise insert its key, and deleted at the end of
the round only if nothing took it, so no unique key is deleted and
inserted in one save.
Not built or run locally (no Swift toolchain on this host); the new
tests run in the "Swift SDK build + tests" CI job.
Refs: #5380
🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4
---
.../PlatformWalletManager.swift | 7 +
.../PlatformWalletPersistenceHandler.swift | 272 +++++++++++-
.../ChainTruncationPersistTests.swift | 391 ++++++++++++++++++
.../InvitationPersistenceTests.swift | 4 +
4 files changed, 664 insertions(+), 10 deletions(-)
create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ChainTruncationPersistTests.swift
diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift
index 433250e87b8..84439697bce 100644
--- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift
+++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift
@@ -107,6 +107,13 @@ public struct PlatformWalletPersistenceCapabilities: Equatable, Sendable {
/// Rust only honours the declaration when the payments callback is
/// actually wired.
public static let dashpayPayments: UInt64 = 1 << 12
+ /// A round's chain truncations — delivered through the persistence
+ /// extension's size-negotiated truncations callback — are durably
+ /// applied before the rest of their round. Bit 13 is reserved for
+ /// `CORE_HISTORY_RESTORE`. Mirrors
+ /// `PersistenceCapabilities::CORE_CHAIN_TRUNCATION`; Rust only honours
+ /// the declaration when the extension actually carries the callback.
+ public static let coreChainTruncation: UInt64 = 1 << 14
public let version: UInt32
public let bits: UInt64
diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
index f48f1614e63..2466af4f615 100644
--- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
+++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
@@ -297,6 +297,22 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable {
/// counts, by `endChangeset`. Confined to `serialQueue`.
private var roundUtxoCreditTally = UtxoCreditVerdictTally()
+ /// Rows a chain truncation removed in the open round, keyed by `txid`
+ /// and by outpoint, and held back from deletion until `endChangeset`.
+ private struct VacatedRows {
+ var transactions: [Data: PersistentTransaction] = [:]
+ var txos: [Data: PersistentTxo] = [:]
+ }
+
+ /// The round that removes a transaction can record it again in its new
+ /// block, and a delete followed by an insert under one unique key in a
+ /// single save is not a sequence this store is relied on to resolve. A
+ /// removed row is therefore hidden from the keyed lookups, handed to
+ /// the writer that would otherwise insert its key, and deleted by
+ /// `endChangeset` only if nothing took it. Cleared by `beginChangeset`
+ /// and `endChangeset`. Confined to `serialQueue`.
+ private var roundVacated = VacatedRows()
+
/// Breadcrumb backfills that arrived on the serial queue while a
/// changeset round was open. The backfill both mutates
/// `backgroundContext` and saves it, so running it mid-round would
@@ -1487,6 +1503,182 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable {
}
}
+ /// Cut the mirror back the way the wallet cut itself back when a fork
+ /// replaced the chain above each truncation's height — the SwiftData
+ /// counterpart of the SQLite store's `apply_truncation`.
+ ///
+ /// Rust fires this before the round's other core callbacks, so the rest
+ /// of the round describes the wallet after the last truncation. Per
+ /// truncation, in order: a removed transaction and the coins it created
+ /// go for every wallet — chain facts, like a swept loser's; a kept
+ /// transaction — InstantSend-locked, so mined again — returns to the
+ /// mempool with its outputs unconfirmed and locked, and what it spent
+ /// stays spent; this wallet's restored coins are spendable again; the
+ /// synced height drops to the fork and never rises. A restored coin
+ /// needs no veto from stored claims, unlike a sweep's release: its
+ /// spender was confirmed, and no block the fork keeps can hold a second
+ /// spend of it.
+ ///
+ /// Same fail-the-round contract as the sweep callback: a lookup that
+ /// throws returns `false`, so Rust does not treat a truncation that
+ /// never landed as durable.
+ @discardableResult
+ func persistWalletChangesetTruncations(
+ walletId: Data,
+ truncations: UnsafePointer?,
+ count: UInt
+ ) -> Bool {
+ onQueue {
+ let wallet: PersistentWallet
+ switch roundWalletLookup(walletId: walletId, callback: "wallet_changeset_truncations") {
+ case .failed: return false
+ case .absent: return true
+ case .found(let row): wallet = row
+ }
+ guard count > 0, let truncationsPtr = truncations else { return true }
+
+ for index in 0.. 0, let txidsPtr = truncation.txids {
+ for i in 0.. 0,
+ let txidsPtr = truncation.unconfirmed_txids {
+ for i in 0.. 0,
+ let outpointsPtr = truncation.restored_outpoints {
+ for i in 0.. truncation.height {
+ wallet.syncedHeight = truncation.height
+ wallet.lastUpdated = Date()
+ }
+ }
+
+ // Bracketed by changesetBegin/End, which deletes what the round
+ // did not take back; with no round open nothing can.
+ if !inChangeset { deleteVacatedRows() }
+ return true
+ }
+ }
+
+ /// Take a transaction a chain truncation removed, and the coins it
+ /// created, out of the round — see `roundVacated`. The links and the
+ /// pending claims it made go at once, so nothing the rest of the round
+ /// reads still counts it as a spender.
+ ///
+ /// Every row a relationship hands over is registered before it is
+ /// written, like any other first touch of a key (see `roundIndex`).
+ private func vacateTransaction(txid: Data) throws {
+ guard let row = try fetchTransactionRow(
+ txid: txid,
+ prefetching: [\.outputs, \.inputs, \.pendingInputs]
+ ) else { return }
+
+ for output in row.outputs {
+ roundIndex?.txosByOutpoint[output.outpoint] = output
+ output.isSpent = false
+ output.spendingTransaction = nil
+ output.spendingInputIndex = nil
+ output.supersededByTxid = nil
+ roundVacated.txos[output.outpoint] = output
+ }
+ for input in row.inputs {
+ roundIndex?.txosByOutpoint[input.outpoint] = input
+ input.spendingTransaction = nil
+ input.spendingInputIndex = nil
+ input.lastUpdated = Date()
+ }
+ for pending in row.pendingInputs {
+ backgroundContext.delete(pending)
+ }
+ Self.returnToMempool(row)
+ row.involvedAccounts.removeAll()
+ roundVacated.transactions[txid] = row
+ }
+
+ /// Keep a transaction whose block a chain truncation dropped as
+ /// unconfirmed: no block, and its outputs unconfirmed and
+ /// InstantSend-locked. Spend state is not touched.
+ private func unconfirmTransaction(txid: Data) throws {
+ guard let row = try fetchTransactionRow(txid: txid, prefetching: [\.outputs]) else { return }
+ Self.returnToMempool(row)
+ for output in row.outputs {
+ roundIndex?.txosByOutpoint[output.outpoint] = output
+ output.isConfirmed = false
+ output.isInstantLocked = true
+ output.height = 0
+ output.lastUpdated = Date()
+ }
+ }
+
+ private static func returnToMempool(_ row: PersistentTransaction) {
+ row.context = TransactionContextType.mempool.rawValue
+ row.blockHeight = 0
+ row.blockHash = nil
+ row.blockTimestamp = 0
+ row.blockPosition = 0
+ row.hasBlockPosition = false
+ row.lastUpdated = Date()
+ }
+
+ /// Hand back one of this wallet's coins that a removed transaction had
+ /// spent. The link to that transaction is cleared by
+ /// `vacateTransaction`, or by the delete rule when another wallet's
+ /// round removed the shared row first.
+ private func restoreTruncatedCoin(outpoint: Data, walletId: Data) throws {
+ if let txo = try fetchTxoRowChecked(outpoint: outpoint),
+ Self.resolvedWalletId(of: txo) == walletId {
+ txo.isSpent = false
+ txo.supersededByTxid = nil
+ txo.lastUpdated = Date()
+ }
+ for pending in pendingInputRows(outpoint: outpoint)
+ where pending.walletId == walletId && pending.isSweptTombstone {
+ backgroundContext.delete(pending)
+ }
+ }
+
+ /// Delete the rows a truncation removed that the round did not record
+ /// again — see `roundVacated`.
+ private func deleteVacatedRows() {
+ for txo in roundVacated.txos.values where !txo.isDeleted {
+ backgroundContext.delete(txo)
+ }
+ for row in roundVacated.transactions.values where !row.isDeleted {
+ backgroundContext.delete(row)
+ }
+ roundVacated = VacatedRows()
+ }
+
func persistWalletChangesetSweeps(
walletId: Data,
sweeps: UnsafePointer?,
@@ -2363,6 +2555,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable {
txid: Data,
prefetching: [PartialKeyPath]
) throws -> PersistentTransaction? {
+ if roundVacated.transactions[txid] != nil { return nil }
if let known = roundIndex?.transactionsByTxid[txid] {
return known.isDeleted ? nil : known
}
@@ -2390,6 +2583,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable {
/// read both mean "write the row"); the reconcile passes must not,
/// because for them a miss is an insert and a failed read is a stop.
private func fetchTxoRowChecked(outpoint: Data) throws -> PersistentTxo? {
+ if roundVacated.txos[outpoint] != nil { return nil }
if let known = roundIndex?.txosByOutpoint[outpoint] {
return known.isDeleted ? nil : known
}
@@ -2533,6 +2727,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable {
let record: PersistentTransaction
if let existing {
record = existing
+ } else if let vacated = roundVacated.transactions.removeValue(forKey: txidData) {
+ // Removed by a truncation earlier in this round and recorded
+ // again: the row is taken back and rewritten below.
+ record = vacated
} else {
record = PersistentTransaction(
txid: txidData,
@@ -2805,6 +3003,11 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable {
let parentTx: PersistentTransaction
if let existingTx = fetchTransactionRow(txid: txidData) {
parentTx = existingTx
+ } else if let vacated = roundVacated.transactions.removeValue(forKey: txidData) {
+ // Removed by a truncation earlier in this round: taken
+ // back as the stub the branch below would insert.
+ vacated.transactionData = Data()
+ parentTx = vacated
} else {
// Stub row — `transactionData` is left as empty
// `Data()` on purpose. The real upsert (which has the
@@ -2823,18 +3026,28 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable {
return Data(bytes: p, count: Int(utxo.script_pubkey_len))
}()
let addressStr = utxo.address.map { String(cString: $0) } ?? ""
- record = PersistentTxo(
- transaction: parentTx,
- vout: utxo.outpoint.vout,
- amount: utxo.amount,
- address: addressStr,
- scriptPubKey: script,
- height: utxo.height
- )
+ if let vacated = roundVacated.txos.removeValue(forKey: outpoint) {
+ // A coin a truncation removed earlier in this round, created
+ // again: `vacateTransaction` cleared its spend state, and
+ // the rest is rewritten here and below as for a new row.
+ record = vacated
+ record.transaction = parentTx
+ record.address = addressStr
+ record.scriptPubKey = script
+ } else {
+ record = PersistentTxo(
+ transaction: parentTx,
+ vout: utxo.outpoint.vout,
+ amount: utxo.amount,
+ address: addressStr,
+ scriptPubKey: script,
+ height: utxo.height
+ )
+ backgroundContext.insert(record)
+ roundIndex?.txosByOutpoint[outpoint] = record
+ }
record.account = account
record.walletId = resolvedWalletId
- backgroundContext.insert(record)
- roundIndex?.txosByOutpoint[outpoint] = record
redelivered = false
}
@@ -3189,6 +3402,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable {
| PlatformWalletPersistenceCapabilities.trackedMasternodes
| PlatformWalletPersistenceCapabilities.coreSweepRemoval
| PlatformWalletPersistenceCapabilities.dashpayPayments
+ | PlatformWalletPersistenceCapabilities.coreChainTruncation
)
}
@@ -3226,6 +3440,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable {
persistWalletChangesetUtxoVerdictsCallback
extensionCallbacks.on_persist_identity_balance_block_time_fn = persistIdentityBalanceBlockTimeCallback
extensionCallbacks.on_load_identity_balance_block_time_fn = loadIdentityBalanceBlockTimeCallback
+ // Chain truncations: fired first among a round's core callbacks,
+ // and size-negotiated like the sweeps slot.
+ extensionCallbacks.on_persist_wallet_changeset_truncations_fn =
+ persistWalletChangesetTruncationsCallback
return extensionCallbacks
}
@@ -3309,6 +3527,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable {
self.inChangeset = true
self.roundUtxoCreditVerdicts = [:]
self.roundUtxoCreditTally = UtxoCreditVerdictTally()
+ self.roundVacated = VacatedRows()
// Out-of-round writers (heal paths, deferred backfills) stage
// entries too; they are not this round's to reconcile and may
// point at rows a failed save rolled back. Load-time accounting
@@ -3399,6 +3618,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable {
}
self.roundUtxoCreditVerdicts = [:]
self.roundUtxoCreditTally = UtxoCreditVerdictTally()
+ self.roundVacated = VacatedRows()
self.roundIndex = nil
self.accountingDirty.removeAll()
self.roundAdvancedFinalityBoundary = false
@@ -3436,6 +3656,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable {
backgroundContext.rollback()
return false
}
+ // Whatever a truncation removed and the round did not
+ // record again goes now, ahead of every pass that reads
+ // the rows the round leaves behind.
+ deleteVacatedRows()
// The round's one collector pass: after every account slice
// and every sweep, on the boundary the round's own writes
// left on the wallet row — see
@@ -9685,6 +9909,34 @@ private func persistWalletChangesetSweepsCallback(
) ? 0 : 1
}
+/// C shim for the extension's `on_persist_wallet_changeset_truncations_fn`
+/// — the round's chain truncations, fired inside the begin/end bracket
+/// before the round's other core callbacks. Same non-zero-fails-the-round
+/// contract as its siblings: a truncation Rust believes durable but that
+/// never landed would leave the replaced branch in the store.
+private func persistWalletChangesetTruncationsCallback(
+ context: UnsafeMutableRawPointer?,
+ walletIdPtr: UnsafePointer?,
+ truncationsPtr: UnsafePointer?,
+ truncationsCount: UInt
+) -> Int32 {
+ guard let context = context,
+ let walletIdPtr = walletIdPtr else {
+ return 0
+ }
+
+ let handler = Unmanaged
+ .fromOpaque(context)
+ .takeUnretainedValue()
+
+ let walletId = Data(bytes: walletIdPtr, count: 32)
+ return handler.persistWalletChangesetTruncations(
+ walletId: walletId,
+ truncations: truncationsPtr,
+ count: truncationsCount
+ ) ? 0 : 1
+}
+
/// C shim for the extension's
/// `on_persist_wallet_changeset_chain_lock_height_fn` — the round's
/// NUMERIC chainlock height, fired inside the same begin/end bracket
diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ChainTruncationPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ChainTruncationPersistTests.swift
new file mode 100644
index 00000000000..7387f6b030e
--- /dev/null
+++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ChainTruncationPersistTests.swift
@@ -0,0 +1,391 @@
+import XCTest
+import SwiftData
+import DashSDKFFI
+@testable import SwiftDashSDK
+
+/// Coverage for the chain truncations delivered through the persistence
+/// extension's `on_persist_wallet_changeset_truncations_fn`.
+///
+/// A fork that replaces the chain above a height takes back what the
+/// wallet recorded there: transactions of the dropped blocks are removed
+/// or, when InstantSend-locked, kept as unconfirmed; the coins the removed
+/// ones spent are spendable again; the scan resumes from the fork. A
+/// mirror that ignores this hands the replaced branch back at the next
+/// load.
+///
+/// Every fixture starts from the same shape: a coin funded at height 100,
+/// and a spend of it mined at 101 that pays change back, with the wallet
+/// synced to 101.
+@MainActor
+final class ChainTruncationPersistTests: XCTestCase {
+
+ private let walletId = Data(repeating: 0x01, count: 32)
+ private let fundingTxid = Data(repeating: 0x51, count: 32)
+ private let spendTxid = Data(repeating: 0x52, count: 32)
+
+ private func makeHandler() throws -> (PlatformWalletPersistenceHandler, ModelContainer) {
+ let container = try DashModelContainer.createInMemory()
+ let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet)
+ return (handler, container)
+ }
+
+ /// File-backed variant: a restart is a second container over the same
+ /// store, which an in-memory store cannot outlive.
+ private func makeHandler(url: URL) throws -> (PlatformWalletPersistenceHandler, ModelContainer) {
+ let configuration = ModelConfiguration(schema: DashModelContainer.schema, url: url)
+ let container = try ModelContainer(
+ for: DashModelContainer.schema,
+ migrationPlan: DashMigrationPlan.self,
+ configurations: [configuration]
+ )
+ let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet)
+ return (handler, container)
+ }
+
+ /// The coin at `fundingTxid:0` (height 100), spent by `spendTxid` mined
+ /// at 101, which created change at vout 0 and a second output at
+ /// vout 1. The wallet is synced to 101.
+ private func seedSpendMinedAboveTheFork(in container: ModelContainer) throws {
+ let context = ModelContext(container)
+ context.insert(PersistentWallet(walletId: walletId, network: .testnet, syncedHeight: 101))
+
+ let funding = PersistentTransaction(
+ txid: fundingTxid,
+ transactionData: Data(repeating: 0x04, count: 10),
+ context: 2,
+ blockHeight: 100,
+ netAmount: 100_000
+ )
+ let spend = PersistentTransaction(
+ txid: spendTxid,
+ transactionData: Data(repeating: 0x05, count: 10),
+ context: 2,
+ blockHeight: 101,
+ netAmount: -40_000
+ )
+ spend.blockHash = Data(repeating: 0x08, count: 32)
+ context.insert(funding)
+ context.insert(spend)
+
+ let coin = PersistentTxo(
+ transaction: funding, vout: 0, amount: 100_000, address: "yFundAddr", height: 100
+ )
+ coin.walletId = walletId
+ coin.isConfirmed = true
+ coin.isSpent = true
+ coin.spendingTransaction = spend
+ coin.spendingInputIndex = 0
+ context.insert(coin)
+
+ for vout: UInt32 in [0, 1] {
+ let output = PersistentTxo(
+ transaction: spend, vout: vout, amount: 30_000, address: "yChangeAddr", height: 101
+ )
+ output.walletId = walletId
+ output.isConfirmed = true
+ context.insert(output)
+ }
+
+ try context.save()
+ }
+
+ private struct Truncation {
+ var height: UInt32
+ var removed: [Data] = []
+ var unconfirmed: [Data] = []
+ var restored: [(txid: Data, vout: UInt32)] = []
+ }
+
+ private func rawTxids(_ txids: [Data]) -> UnsafeMutablePointer {
+ let buffer = UnsafeMutablePointer.allocate(capacity: max(txids.count, 1))
+ for (i, txid) in txids.enumerated() {
+ var tuple: FFIByteTuple32 = (0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0)
+ Swift.withUnsafeMutableBytes(of: &tuple) { dst in
+ txid.withUnsafeBytes { src in dst.copyMemory(from: src) }
+ }
+ buffer.advanced(by: i).initialize(to: tuple)
+ }
+ return buffer
+ }
+
+ /// The truncations callback alone, inside whatever bracket the caller
+ /// opened. The nested buffers are allocated explicitly and freed after
+ /// the call, as the sweep tests do.
+ @discardableResult
+ private func stageTruncation(
+ _ handler: PlatformWalletPersistenceHandler,
+ _ truncation: Truncation
+ ) -> Bool {
+ let removed = rawTxids(truncation.removed)
+ let unconfirmed = rawTxids(truncation.unconfirmed)
+ let restored = UnsafeMutablePointer.allocate(
+ capacity: max(truncation.restored.count, 1)
+ )
+ for (i, outpoint) in truncation.restored.enumerated() {
+ var entry = OutPointFFI()
+ Swift.withUnsafeMutableBytes(of: &entry.txid) { dst in
+ outpoint.txid.withUnsafeBytes { src in dst.copyMemory(from: src) }
+ }
+ entry.vout = outpoint.vout
+ restored.advanced(by: i).initialize(to: entry)
+ }
+ defer {
+ removed.deinitialize(count: truncation.removed.count)
+ removed.deallocate()
+ unconfirmed.deinitialize(count: truncation.unconfirmed.count)
+ unconfirmed.deallocate()
+ restored.deinitialize(count: truncation.restored.count)
+ restored.deallocate()
+ }
+
+ var entry = ChainTruncationFFI()
+ entry.height = truncation.height
+ entry.txids = UnsafePointer(removed)
+ entry.txids_count = UInt(truncation.removed.count)
+ entry.unconfirmed_txids = UnsafePointer(unconfirmed)
+ entry.unconfirmed_txids_count = UInt(truncation.unconfirmed.count)
+ entry.restored_outpoints = UnsafePointer(restored)
+ entry.restored_outpoints_count = UInt(truncation.restored.count)
+ return withUnsafePointer(to: &entry) { entryPtr in
+ handler.persistWalletChangesetTruncations(
+ walletId: walletId,
+ truncations: entryPtr,
+ count: 1
+ )
+ }
+ }
+
+ /// One committed round carrying a single truncation.
+ @discardableResult
+ private func truncationRound(
+ _ handler: PlatformWalletPersistenceHandler,
+ _ truncation: Truncation,
+ success: Bool = true
+ ) -> Bool {
+ handler.beginChangeset(walletId: walletId)
+ let applied = stageTruncation(handler, truncation)
+ _ = handler.endChangeset(walletId: walletId, success: success && applied)
+ return applied
+ }
+
+ /// Stage `spendTxid` mined at `blockHeight`, spending the funded coin
+ /// and creating output 0 again, together with the synced height — the
+ /// part of a round that follows its truncation. The caller brackets it.
+ private func stageSpendMinedAgain(
+ _ handler: PlatformWalletPersistenceHandler,
+ blockHeight: UInt32,
+ blockHash: Data
+ ) {
+ let name = strdup("Standard { index: 0 }")
+ let address = strdup("yChangeAddr")
+ defer {
+ free(name)
+ free(address)
+ }
+
+ var input = OutPointFFI()
+ Swift.withUnsafeMutableBytes(of: &input.txid) { dst in
+ fundingTxid.withUnsafeBytes { src in dst.copyMemory(from: src) }
+ }
+ input.vout = 0
+
+ var record = TransactionRecordFFI()
+ Swift.withUnsafeMutableBytes(of: &record.txid) { dst in
+ spendTxid.withUnsafeBytes { src in dst.copyMemory(from: src) }
+ }
+ Swift.withUnsafeMutableBytes(of: &record.block_hash) { dst in
+ blockHash.withUnsafeBytes { src in dst.copyMemory(from: src) }
+ }
+ record.context = 2
+ record.block_height = blockHeight
+
+ var utxo = UtxoEntryFFI()
+ Swift.withUnsafeMutableBytes(of: &utxo.outpoint.txid) { dst in
+ spendTxid.withUnsafeBytes { src in dst.copyMemory(from: src) }
+ }
+ utxo.outpoint.vout = 0
+ utxo.amount = 30_000
+ utxo.address = address
+ utxo.height = blockHeight
+ utxo.is_confirmed = true
+
+ withUnsafeMutablePointer(to: &input) { inputPtr in
+ record.input_outpoints = inputPtr
+ record.input_outpoints_count = 1
+ withUnsafeMutablePointer(to: &record) { recordPtr in
+ withUnsafeMutablePointer(to: &utxo) { utxoPtr in
+ var account = AccountChangeSetFFI()
+ account.account_type_name = name
+ account.transactions = recordPtr
+ account.transactions_count = 1
+ account.utxos_added = utxoPtr
+ account.utxos_added_count = 1
+ withUnsafeMutablePointer(to: &account) { accountPtr in
+ var cs = WalletChangeSetFFI()
+ cs.has_chain = true
+ cs.chain.has_synced_height = true
+ cs.chain.synced_height = blockHeight
+ cs.accounts = accountPtr
+ cs.accounts_count = 1
+ withUnsafePointer(to: &cs) { csPtr in
+ _ = handler.persistWalletChangeset(walletId: walletId, changeset: csPtr)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private func transaction(_ container: ModelContainer, txid: Data) -> PersistentTransaction? {
+ let context = ModelContext(container)
+ let descriptor = FetchDescriptor(
+ predicate: #Predicate { $0.txid == txid }
+ )
+ return try? context.fetch(descriptor).first
+ }
+
+ private func txo(_ container: ModelContainer, txid: Data, vout: UInt32) -> PersistentTxo? {
+ let outpoint = PersistentTxo.makeOutpoint(txid: txid, vout: vout)
+ let context = ModelContext(container)
+ let descriptor = FetchDescriptor(
+ predicate: #Predicate { $0.outpoint == outpoint }
+ )
+ return try? context.fetch(descriptor).first
+ }
+
+ private func syncedHeight(_ container: ModelContainer) throws -> UInt32 {
+ let walletId = self.walletId
+ let descriptor = FetchDescriptor(
+ predicate: #Predicate { $0.walletId == walletId }
+ )
+ return try XCTUnwrap(try ModelContext(container).fetch(descriptor).first).syncedHeight
+ }
+
+ func testShouldRemoveATransactionOfADroppedBlockAndRestoreTheCoinItSpent() throws {
+ let (handler, container) = try makeHandler()
+ try seedSpendMinedAboveTheFork(in: container)
+
+ let applied = truncationRound(
+ handler,
+ Truncation(height: 100, removed: [spendTxid], restored: [(txid: fundingTxid, vout: 0)])
+ )
+
+ XCTAssertTrue(applied)
+ XCTAssertNil(transaction(container, txid: spendTxid))
+ XCTAssertNil(txo(container, txid: spendTxid, vout: 0), "the removed transaction's own output is gone")
+ XCTAssertNil(txo(container, txid: spendTxid, vout: 1))
+ let restored = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0))
+ XCTAssertFalse(restored.isSpent, "the coin the removed transaction spent is spendable again")
+ XCTAssertNil(restored.spendingTransaction)
+ XCTAssertNotNil(transaction(container, txid: fundingTxid), "a transaction the fork keeps stays")
+ XCTAssertEqual(try syncedHeight(container), 100)
+ }
+
+ func testShouldKeepAnInstantLockedTransactionUnconfirmedWhenItsBlockIsDropped() throws {
+ let (handler, container) = try makeHandler()
+ try seedSpendMinedAboveTheFork(in: container)
+
+ truncationRound(handler, Truncation(height: 100, unconfirmed: [spendTxid]))
+
+ let kept = try XCTUnwrap(transaction(container, txid: spendTxid))
+ XCTAssertEqual(kept.context, 0, "back in the mempool")
+ XCTAssertEqual(kept.blockHeight, 0)
+ XCTAssertNil(kept.blockHash)
+ let output = try XCTUnwrap(txo(container, txid: spendTxid, vout: 0))
+ XCTAssertFalse(output.isConfirmed)
+ XCTAssertTrue(output.isInstantLocked)
+ XCTAssertEqual(output.height, 0)
+ let coin = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0))
+ XCTAssertTrue(coin.isSpent, "what it spent stays spent")
+ XCTAssertEqual(coin.spendingTransaction?.txid, spendTxid)
+ XCTAssertEqual(try syncedHeight(container), 100)
+ }
+
+ /// The round that removes a transaction also records it in its new
+ /// block: one row per key, carrying the new block, before and after a
+ /// restart.
+ func testShouldStoreATransactionOnceInItsNewBlockWhenTheSameRoundMinesItAgain() throws {
+ let storeURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent("truncation-mined-again-\(UUID().uuidString).store")
+ defer { try? FileManager.default.removeItem(at: storeURL) }
+ let newBlock = Data(repeating: 0x09, count: 32)
+
+ do {
+ let (handler, container) = try makeHandler(url: storeURL)
+ try seedSpendMinedAboveTheFork(in: container)
+
+ // Rust's order: the truncation first, then the round as the
+ // wallet holds it after the fork.
+ handler.beginChangeset(walletId: walletId)
+ XCTAssertTrue(stageTruncation(
+ handler,
+ Truncation(height: 100, removed: [spendTxid], restored: [(txid: fundingTxid, vout: 0)])
+ ))
+ stageSpendMinedAgain(handler, blockHeight: 102, blockHash: newBlock)
+ XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true))
+ }
+
+ let (_, container) = try makeHandler(url: storeURL)
+
+ let context = ModelContext(container)
+ let spendTxid = self.spendTxid
+ let rows = try context.fetch(
+ FetchDescriptor(predicate: #Predicate { $0.txid == spendTxid })
+ )
+ XCTAssertEqual(rows.count, 1, "the transaction is stored once")
+ let mined = try XCTUnwrap(rows.first)
+ XCTAssertEqual(mined.blockHeight, 102)
+ XCTAssertEqual(mined.blockHash, newBlock)
+
+ let output = try XCTUnwrap(
+ txo(container, txid: spendTxid, vout: 0),
+ "the output the new block's record brought back is stored"
+ )
+ XCTAssertEqual(output.height, 102)
+ XCTAssertFalse(output.isSpent)
+ XCTAssertNil(
+ txo(container, txid: spendTxid, vout: 1),
+ "an output the round did not create again is gone"
+ )
+ let coin = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0))
+ XCTAssertTrue(coin.isSpent, "spent again, by the transaction in its new block")
+ XCTAssertEqual(coin.spendingTransaction?.txid, spendTxid)
+ XCTAssertEqual(try syncedHeight(container), 102)
+ }
+
+ func testShouldLeaveASyncedHeightBelowTheForkAlone() throws {
+ let (handler, container) = try makeHandler()
+ try seedSpendMinedAboveTheFork(in: container)
+
+ truncationRound(handler, Truncation(height: 150))
+
+ XCTAssertEqual(try syncedHeight(container), 101)
+ }
+
+ func testShouldRollATruncationBackWithItsRound() throws {
+ let (handler, container) = try makeHandler()
+ try seedSpendMinedAboveTheFork(in: container)
+
+ truncationRound(
+ handler,
+ Truncation(height: 100, removed: [spendTxid], restored: [(txid: fundingTxid, vout: 0)]),
+ success: false
+ )
+
+ XCTAssertNotNil(transaction(container, txid: spendTxid))
+ XCTAssertNotNil(txo(container, txid: spendTxid, vout: 0))
+ let coin = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0))
+ XCTAssertTrue(coin.isSpent)
+ XCTAssertEqual(coin.spendingTransaction?.txid, spendTxid)
+ XCTAssertEqual(try syncedHeight(container), 101)
+ }
+
+ func testShouldTreatATruncationForAMissingWalletAsASuccessfulNoOp() throws {
+ let (handler, _) = try makeHandler()
+
+ XCTAssertTrue(truncationRound(handler, Truncation(height: 100, removed: [spendTxid])))
+ }
+}
diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift
index 4fd3c52c85c..4f9518e4cfc 100644
--- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift
+++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift
@@ -67,6 +67,10 @@ final class InvitationPersistenceTests: XCTestCase {
// `PersistentDashpayPayment` rows, so the sweep's Failed flip
// may ride this store's rounds — genuinely attested.
| PlatformWalletPersistenceCapabilities.dashpayPayments
+ // Chain truncations: the handler wires
+ // `on_persist_wallet_changeset_truncations_fn` and cuts the
+ // mirror back in the round that carries them.
+ | PlatformWalletPersistenceCapabilities.coreChainTruncation
XCTAssertEqual(
capabilities.version,
From fce95af56d91aa6e2a5537dfb38f1a5e2d8135bb Mon Sep 17 00:00:00 2001
From: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Date: Mon, 12 Oct 2026 13:59:28 +0000
Subject: [PATCH 09/28] fix(kotlin-sdk): apply chain truncations in the Room
store
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The persistence handler overrides onWalletChangesetChainTruncated,
declares CORE_CHAIN_TRUNCATION and stages the truncation as the first op
of its round: transactions of the dropped blocks and their coins go,
InstantSend-locked ones return to the mempool with their outputs
unconfirmed and locked, the coins the removed ones spent are spendable
again, and the synced height drops to the fork. Ops replay in order in
the round's one Room transaction, so a transaction the round mines again
is stored once, in its new block.
The changelog entry now names the FFI slot, the Kotlin bridge method and
the Swift and Kotlin hosts.
Not built or run locally (no JVM on this host); the new tests run in the
Kotlin SDK CI job (`:sdk:testDebugUnitTest`).
Refs: #5380
🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4
---
CHANGELOG.md | 14 +-
.../PlatformWalletPersistenceHandler.kt | 74 +++++++-
.../dashsdk/persistence/dao/TransactionDao.kt | 12 ++
.../dashsdk/persistence/dao/TxoDao.kt | 9 +
.../dashsdk/persistence/dao/WalletDao.kt | 15 ++
.../dashsdk/wallet/PlatformWalletManager.kt | 7 +
.../PlatformWalletPersistenceHandlerTest.kt | 168 +++++++++++++++++-
7 files changed, 292 insertions(+), 7 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 64f536218ca..3e29ac635bf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -27,11 +27,15 @@
handling to the version-3 ProRegTx / ProUpServTx types.
- **platform-wallet:** a chain reorganization reported by key-wallet
(`WalletEvent::ChainTruncated`) now reaches persisted wallet state as
- `CoreChangeSet::truncations`. The SQLite backend removes the transactions of
- the dropped blocks and their coins, keeps InstantSend-locked ones as
- unconfirmed, restores the coins the removed ones spent and lowers the stored
- sync heights to the fork, so a restart after a reorg loads the same wallet as
- no restart. A persister attests support with the new
+ `CoreChangeSet::truncations`. The SQLite backend and the Swift and Kotlin
+ persistence handlers remove the transactions of the dropped blocks and their
+ coins, keep InstantSend-locked ones as unconfirmed, restore the coins the
+ removed ones spent and lower the stored sync heights to the fork, so a
+ restart after a reorg loads the same wallet as no restart. FFI hosts receive
+ truncations through the size-negotiated
+ `on_persist_wallet_changeset_truncations_fn` extension callback, Kotlin
+ bridges through `onWalletChangesetChainTruncated`. A persister attests
+ support with the new
`PersistenceCapabilities::CORE_CHAIN_TRUNCATION` bit; one that does not is
not handed the truncation and keeps the replaced branch until it rescans
([#5380](https://github.com/dashpay/platform/issues/5380)). Version-3
diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt
index 2f4ad4d12d5..5f1a5eae853 100644
--- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt
+++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt
@@ -154,7 +154,8 @@ class PlatformWalletPersistenceHandler(
CAPABILITY_WALLET_RESTORE or
CAPABILITY_DPNS_NAME_STATES or
CAPABILITY_TRACKED_ASSET_LOCKS or
- CAPABILITY_CORE_SWEEP_REMOVAL
+ CAPABILITY_CORE_SWEEP_REMOVAL or
+ CAPABILITY_CORE_CHAIN_TRUNCATION
/**
* The single-thread executor created when no [dispatcher] is injected.
@@ -1249,6 +1250,75 @@ class PlatformWalletPersistenceHandler(
return !chainlockOverIsLock
}
+ // ── Chain truncations ─────────────────────────────────────────────
+
+ /** One chain truncation, unpacked from the JNI trampoline's flat arrays at the callback. */
+ private class ChainTruncation(
+ val height: Int,
+ val removedTxids: List,
+ val unconfirmedTxids: List,
+ val restoredOutpoints: List,
+ )
+
+ /**
+ * Stage one chain truncation. Native fires this before the round's
+ * other core callbacks and the round replays its ops in order, so the
+ * truncation lands first and a transaction it removes can be stored
+ * again, in its new block, by the slices that follow. Outside a round
+ * it runs in its own transaction.
+ */
+ override fun onWalletChangesetChainTruncated(
+ walletId: ByteArray,
+ height: Int,
+ txids: ByteArray,
+ txidCount: Int,
+ unconfirmedTxids: ByteArray,
+ unconfirmedTxidCount: Int,
+ restoredOutpoints: ByteArray,
+ restoredOutpointCount: Int,
+ ): Int = guarded {
+ require(height >= 0) { "truncation height must not be negative" }
+ val truncation = ChainTruncation(
+ height = height,
+ removedTxids = unpackFixed(txids, txidCount, 32, "truncated txids"),
+ unconfirmedTxids = unpackFixed(unconfirmedTxids, unconfirmedTxidCount, 32, "unconfirmed txids"),
+ restoredOutpoints = unpackFixed(restoredOutpoints, restoredOutpointCount, 36, "restored outpoints"),
+ )
+ stage(walletId) { db -> applyChainTruncation(db, walletId, truncation) }
+ 0
+ }
+
+ /**
+ * Cut the store back the way the wallet cut itself back when a fork
+ * replaced the chain above [ChainTruncation.height] — the Room mirror
+ * of the SQLite store's `apply_truncation`.
+ *
+ * A removed transaction and the coins it created are chain facts, so
+ * they go for every wallet, like a swept loser's. The restored coins
+ * and the synced height are this wallet's own. A restored coin needs
+ * no veto from stored claims, unlike a sweep's release: its spender was
+ * confirmed, and no block the fork keeps can hold a second spend of it.
+ */
+ private suspend fun applyChainTruncation(db: DashDatabase, walletId: ByteArray, truncation: ChainTruncation) {
+ // Drop stale post-deletion callbacks (can't resurrect a wallet).
+ if (db.walletDao().getByWalletId(walletId) == null) return
+ // Removed: dead coins and dead links first, then the rows; the FK
+ // cascade takes the pending claims the removed transactions made.
+ chunked(truncation.removedTxids) { db.txoDao().deleteByTxids(it) }
+ chunked(truncation.removedTxids) { db.txoDao().detachSpenders(it) }
+ chunked(truncation.removedTxids) { db.transactionDao().deleteByTxids(it) }
+ // Kept: InstantSend-locked, so mined again. No block until then;
+ // the coins they spent stay spent.
+ chunked(truncation.unconfirmedTxids) { db.transactionDao().unconfirmByTxids(it) }
+ chunked(truncation.unconfirmedTxids) { db.txoDao().unconfirmInstantLockedByTxids(it) }
+ // Restored: this wallet's coins the removed transactions spent.
+ chunked(truncation.restoredOutpoints) { db.txoDao().releaseByOutpoints(it, walletId) }
+ chunked(truncation.restoredOutpoints) {
+ db.documentDao().deleteWalletSweptTombstonesByOutpoints(walletId, it)
+ }
+ db.walletDao().lowerSyncedHeight(walletId, truncation.height, System.currentTimeMillis())
+ }
+
// ── Sweeps ────────────────────────────────────────────────────────
/** One sweep batch, unpacked from the JNI trampoline's flat arrays at the callback. */
@@ -4026,6 +4096,8 @@ class PlatformWalletPersistenceHandler(
internal const val CAPABILITY_TRACKED_ASSET_LOCKS: Long = 0x200
internal const val CAPABILITY_CORE_SWEEP_REMOVAL: Long =
NativePersistenceBridge.CAPABILITY_CORE_SWEEP_REMOVAL
+ internal const val CAPABILITY_CORE_CHAIN_TRUNCATION: Long =
+ NativePersistenceBridge.CAPABILITY_CORE_CHAIN_TRUNCATION
private const val TAG = "DashPersistence"
diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt
index c72a5569832..734d27bba88 100644
--- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt
+++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt
@@ -111,6 +111,18 @@ interface TransactionDao {
@Query("DELETE FROM transactions WHERE txid IN (:txids)")
suspend fun deleteByTxids(txids: List)
+ /**
+ * Return [txids] to the mempool: a chain truncation dropped their
+ * block and kept them as unconfirmed. Clears the block columns and
+ * touches nothing else. Chunked by the caller.
+ */
+ @Query(
+ "UPDATE transactions SET context = 0, blockHeight = 0, blockHash = NULL, " +
+ "blockTimestamp = 0, blockPosition = 0, hasBlockPosition = 0 " +
+ "WHERE txid IN (:txids)"
+ )
+ suspend fun unconfirmByTxids(txids: List)
+
/**
* Orphan sweep run after a wallet wipe (Swift `deleteWalletData`'s
* post-delete pass): drop transactions no longer referenced by any
diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt
index ac484917836..64f4c2c6581 100644
--- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt
+++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt
@@ -166,6 +166,15 @@ interface TxoDao {
@Query("DELETE FROM txos WHERE txid IN (:txids)")
suspend fun deleteByTxids(txids: List)
+ /**
+ * Mark every TXO created by one of [txids] unconfirmed and
+ * InstantSend-locked — the outputs of transactions a chain truncation
+ * kept after dropping their block. Spend state is not touched. Chunked
+ * by the caller.
+ */
+ @Query("UPDATE txos SET isConfirmed = 0, isInstantLocked = 1, height = 0 WHERE txid IN (:txids)")
+ suspend fun unconfirmInstantLockedByTxids(txids: List)
+
/**
* Delete the rows at [outpoints] outright — outputs of a transaction
* swept in this round that some loser claimed or some release named.
diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/WalletDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/WalletDao.kt
index 31bf8e81ba5..b7467daa6ed 100644
--- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/WalletDao.kt
+++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/WalletDao.kt
@@ -78,6 +78,21 @@ interface WalletDao {
)
suspend fun advanceChainLockHeight(walletId: ByteArray, height: Int, nowMillis: Long): Int
+ /**
+ * Lower the stored synced height to [height] when it is above it — a
+ * chain truncation at [height]. Never raises it, and a narrow column
+ * write for the same reason as [advanceChainLockHeight].
+ *
+ * @param nowMillis epoch millis for the `lastUpdated` stamp.
+ * @return number of rows updated (0 when the wallet row is gone or its
+ * height is already at or below [height]).
+ */
+ @Query(
+ "UPDATE wallets SET syncedHeight = :height, lastUpdated = :nowMillis " +
+ "WHERE walletId = :walletId AND syncedHeight > :height"
+ )
+ suspend fun lowerSyncedHeight(walletId: ByteArray, height: Int, nowMillis: Long): Int
+
@Delete
suspend fun delete(wallet: WalletEntity)
diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt
index bce8324ef49..49217100b34 100644
--- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt
+++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt
@@ -77,6 +77,13 @@ data class PlatformWalletPersistenceCapabilities(
* is deliberately absent: Android never attests it).
*/
const val CORE_SWEEP_REMOVAL: Long = NativePersistenceBridge.CAPABILITY_CORE_SWEEP_REMOVAL
+ /**
+ * A stored core changeset's chain truncations are durably applied
+ * before the rest of their round. Mirrors
+ * `PersistenceCapabilities::CORE_CHAIN_TRUNCATION`; aliased to the
+ * bridge's declaration like [CORE_SWEEP_REMOVAL].
+ */
+ const val CORE_CHAIN_TRUNCATION: Long = NativePersistenceBridge.CAPABILITY_CORE_CHAIN_TRUNCATION
}
}
diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt
index b7b1bfd63e9..501b2faf713 100644
--- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt
+++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt
@@ -220,7 +220,7 @@ class PlatformWalletPersistenceHandlerTest {
assertEquals(0L, noOpBridge.persistenceCapabilitiesBits())
assertEquals(1, handler.persistenceCapabilitiesVersion())
- assertEquals(0xbbfL, handler.persistenceCapabilitiesBits())
+ assertEquals(0x4bbfL, handler.persistenceCapabilitiesBits())
// Android has no pending-contact-crypto callback, so it must not
// attest that semantic contract.
assertEquals(0L, handler.persistenceCapabilitiesBits() and 0x40L)
@@ -234,6 +234,11 @@ class PlatformWalletPersistenceHandlerTest {
assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.DPNS_NAME_STATES))
assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.TRACKED_ASSET_LOCKS))
assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.CORE_SWEEP_REMOVAL))
+ assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.CORE_CHAIN_TRUNCATION))
+ assertEquals(
+ NativePersistenceBridge.CAPABILITY_CORE_CHAIN_TRUNCATION,
+ PlatformWalletPersistenceCapabilities.CORE_CHAIN_TRUNCATION,
+ )
}
@Test
@@ -6035,6 +6040,167 @@ class PlatformWalletPersistenceHandlerTest {
)
}
+ // ── Chain truncations ─────────────────────────────────────────────
+
+ /**
+ * The truncation slot as the JNI trampoline packs it: each list as one
+ * flat array plus its count.
+ */
+ private fun truncate(
+ h: PlatformWalletPersistenceHandler,
+ height: Int,
+ removed: List = emptyList(),
+ unconfirmed: List = emptyList(),
+ restored: List = emptyList(),
+ ): Int = h.onWalletChangesetChainTruncated(
+ walletId,
+ height,
+ removed.fold(ByteArray(0)) { acc, txid -> acc + txid },
+ removed.size,
+ unconfirmed.fold(ByteArray(0)) { acc, txid -> acc + txid },
+ unconfirmed.size,
+ restored.fold(ByteArray(0)) { acc, outpoint -> acc + outpoint },
+ restored.size,
+ )
+
+ /**
+ * Stage [txid] mined at [height], spending [inputs] and paying output 0
+ * back to the wallet's address. The caller brackets the round.
+ */
+ private fun stageMinedSpend(txid: ByteArray, height: Int, blockHash: ByteArray, vararg inputs: ByteArray) {
+ recordTransaction(
+ handler,
+ walletId, txid, ByteArray(10) { 5 }, 2, height, blockHash,
+ 1_700_000_100, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_050,
+ inputs.fold(ByteArray(0)) { acc, op -> acc + op }, inputs.size,
+ )
+ handler.onWalletChangesetUtxoAdded(
+ walletId, txid, 0, 50_000, "yUtxoAddr", ByteArray(25) { 6 },
+ height, false, true, false, false,
+ )
+ }
+
+ /**
+ * A funded coin at height 100 and a spend of it mined at 101 that pays
+ * change back, with the wallet synced to 101. Returns the coin's and
+ * the change's outpoints.
+ */
+ private suspend fun seedSpendMinedAboveTheFork(spend: ByteArray): Pair {
+ val coin = seedFundedCoin(ByteArray(32) { 0x50 })
+ handler.onChangesetBegin(walletId)
+ stageMinedSpend(spend, 101, ByteArray(32) { 8 }, coin)
+ handler.onChangesetEnd(walletId, success = true)
+ headerRound(handler, synced = 101)
+ assertTrue(db.txoDao().getByOutpoint(coin)!!.isSpent)
+ return coin to makeOutpoint(spend, 0)
+ }
+
+ @Test
+ fun shouldRemoveATransactionOfADroppedBlockAndRestoreTheCoinItSpent() = runTest {
+ val spend = ByteArray(32) { 0x51 }
+ val (coin, change) = seedSpendMinedAboveTheFork(spend)
+
+ handler.onChangesetBegin(walletId)
+ assertEquals(0, truncate(handler, height = 100, removed = listOf(spend), restored = listOf(coin)))
+ assertEquals(0, handler.onChangesetEnd(walletId, success = true))
+
+ assertNull(db.transactionDao().getByTxid(spend))
+ assertNull("the removed transaction's own output is gone", db.txoDao().getByOutpoint(change))
+ val restored = db.txoDao().getByOutpoint(coin)!!
+ assertFalse(restored.isSpent)
+ assertNull(restored.spendingTxid)
+ assertEquals(100, db.walletDao().getByWalletId(walletId)!!.syncedHeight)
+ val reloaded = newHandler().onLoadWalletList().single()
+ assertEquals("the restored coin is spendable after a restart", 1, reloaded.utxos.size)
+ assertEquals(100, reloaded.syncedHeight)
+ }
+
+ @Test
+ fun shouldKeepAnInstantLockedTransactionUnconfirmedWhenItsBlockIsDropped() = runTest {
+ val spend = ByteArray(32) { 0x52 }
+ val (coin, change) = seedSpendMinedAboveTheFork(spend)
+
+ handler.onChangesetBegin(walletId)
+ assertEquals(0, truncate(handler, height = 100, unconfirmed = listOf(spend)))
+ assertEquals(0, handler.onChangesetEnd(walletId, success = true))
+
+ val kept = db.transactionDao().getByTxid(spend)!!
+ assertEquals("back in the mempool", 0, kept.context)
+ assertEquals(0, kept.blockHeight)
+ assertNull(kept.blockHash)
+ val output = db.txoDao().getByOutpoint(change)!!
+ assertFalse(output.isConfirmed)
+ assertTrue(output.isInstantLocked)
+ assertEquals(0, output.height)
+ assertTrue("what it spent stays spent", db.txoDao().getByOutpoint(coin)!!.isSpent)
+ assertEquals(100, db.walletDao().getByWalletId(walletId)!!.syncedHeight)
+ }
+
+ @Test
+ fun shouldStoreATransactionOnceInItsNewBlockWhenTheSameRoundMinesItAgain() = runTest {
+ val spend = ByteArray(32) { 0x53 }
+ val (coin, change) = seedSpendMinedAboveTheFork(spend)
+ val newBlock = ByteArray(32) { 9 }
+
+ // Native order: the truncation first, then the round as the wallet
+ // holds it after the fork. Applied in that order the removal cannot
+ // take the rows the new block wrote.
+ handler.onChangesetBegin(walletId)
+ truncate(handler, height = 100, removed = listOf(spend), restored = listOf(coin))
+ stageMinedSpend(spend, 101, newBlock, coin)
+ handler.onWalletChangesetHeader(walletId, true, 101, false, 0, 0, 0, 0, ByteArray(0))
+ assertEquals(0, handler.onChangesetEnd(walletId, success = true))
+
+ val mined = db.transactionDao().getByTxid(spend)!!
+ assertEquals(101, mined.blockHeight)
+ assertTrue(newBlock.contentEquals(mined.blockHash))
+ assertNotNull(db.txoDao().getByOutpoint(change))
+ val spent = db.txoDao().getByOutpoint(coin)!!
+ assertTrue("spent again, by the transaction in its new block", spent.isSpent)
+ assertTrue(spend.contentEquals(spent.spendingTxid))
+ assertEquals(101, db.walletDao().getByWalletId(walletId)!!.syncedHeight)
+ }
+
+ @Test
+ fun shouldLeaveASyncedHeightBelowTheForkAlone() = runTest {
+ seedFundedCoin(ByteArray(32) { 0x54 })
+ headerRound(handler, synced = 90)
+
+ handler.onChangesetBegin(walletId)
+ truncate(handler, height = 100)
+ handler.onChangesetEnd(walletId, success = true)
+
+ assertEquals(90, db.walletDao().getByWalletId(walletId)!!.syncedHeight)
+ }
+
+ @Test
+ fun shouldRollATruncationBackWithItsRound() = runTest {
+ val spend = ByteArray(32) { 0x55 }
+ val (coin, change) = seedSpendMinedAboveTheFork(spend)
+
+ handler.onChangesetBegin(walletId)
+ truncate(handler, height = 100, removed = listOf(spend), restored = listOf(coin))
+ handler.onChangesetEnd(walletId, success = false)
+
+ assertNotNull(db.transactionDao().getByTxid(spend))
+ assertNotNull(db.txoDao().getByOutpoint(change))
+ assertTrue(db.txoDao().getByOutpoint(coin)!!.isSpent)
+ assertEquals(101, db.walletDao().getByWalletId(walletId)!!.syncedHeight)
+ }
+
+ @Test
+ fun shouldFailTheRoundOnATruncationWhoseArraysDisagreeWithTheirCounts() = runTest {
+ seedFundedCoin(ByteArray(32) { 0x56 })
+
+ handler.onChangesetBegin(walletId)
+ val code = handler.onWalletChangesetChainTruncated(
+ walletId, 100, ByteArray(31), 1, ByteArray(0), 0, ByteArray(0), 0,
+ )
+
+ assertTrue("a packing drift must not be read as an empty truncation", code != 0)
+ handler.onChangesetEnd(walletId, success = false)
+ }
+
// ── Bounded tombstone lifetime ────────────────────────────────────
/** One committed round: synced height + (optionally) chainlock bytes. */
From e38368b6cddaae0733e5e6573d57605611eab9fd Mon Sep 17 00:00:00 2001
From: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Date: Mon, 12 Oct 2026 14:14:13 +0000
Subject: [PATCH 10/28] test(platform-wallet-storage): pin the InstantSend lock
set across a restart
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A fork keeps a transaction only when the wallet holds its InstantSend
lock. The lock is stored on its own when the transaction is detected, so
the SQLite load seeds the lock set for a transaction a block has since
mined: a wallet relaunched before the fork keeps what the one that never
restarted keeps. The new restart-then-fork case fails with that bridge
write disabled.
The restored view now compares the lock flag of confirmed coins too, and
a per-step case covers a fork that drops the block of a double spend
whose loser was swept.
Refs: #5380
🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4
---
.../tests/sqlite_chain_truncation.rs | 81 ++++++++++++++++++-
1 file changed, 77 insertions(+), 4 deletions(-)
diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_chain_truncation.rs b/packages/rs-platform-wallet-storage/tests/sqlite_chain_truncation.rs
index 7de8a577e88..4df81006c5b 100644
--- a/packages/rs-platform-wallet-storage/tests/sqlite_chain_truncation.rs
+++ b/packages/rs-platform-wallet-storage/tests/sqlite_chain_truncation.rs
@@ -23,7 +23,10 @@ use dashcore::{
TxMerkleNode, TxOut, Txid,
};
use key_wallet::{
- wallet::{initialization::WalletAccountCreationOptions, ManagedWalletInfo},
+ wallet::{
+ initialization::WalletAccountCreationOptions,
+ managed_wallet_info::wallet_info_interface::WalletInfoInterface, ManagedWalletInfo,
+ },
WalletCoreBalance,
};
use platform_wallet::{
@@ -56,8 +59,6 @@ struct WalletView {
/// Compared with `<=`, see [`WalletView::follows`].
last_processed_height: u32,
/// `(value, is_confirmed, is_instantlocked, height)` per unspent coin.
- /// The lock is read on an unconfirmed coin only, where it decides which
- /// balance the coin counts towards.
coins: BTreeMap,
/// Block height of every recorded transaction, `None` when unconfirmed.
history: BTreeMap>,
@@ -100,7 +101,7 @@ fn view(info: &ManagedWalletInfo) -> WalletView {
(
coin.value(),
coin.is_confirmed,
- coin.is_instantlocked && !coin.is_confirmed,
+ coin.is_instantlocked,
coin.height,
),
)
@@ -578,6 +579,78 @@ async fn should_keep_an_instant_locked_transaction_unconfirmed_when_its_block_is
}
}
+/// A double spend sweeps the wallet's own unconfirmed payment, then a fork
+/// drops the block that held the winner: the store holds what the wallet
+/// holds, and neither transaction comes back.
+///
+/// The coin both spent is not asserted on its own: whether the wallet takes
+/// it back is key-wallet's decision, and the store follows it.
+// TODO(sweep-of-unstored-loser): run this merged too, and generate the
+// double spend in the property test below, once a payment detected and swept
+// within one `store()` round leaves its coin spent in the store. Today that
+// round stores no spend of the coin, fork or no fork.
+#[tokio::test]
+async fn should_keep_a_swept_payment_out_when_the_block_of_its_double_spend_is_dropped() {
+ let mut session = Session::open(Rounds::PerStep).await;
+ let funding = session.chain.funding;
+ let payment = session.chain.spend(funding, FUNDING);
+ session.chain.relay(&payment, None).await;
+ session.step().await;
+ let winner = transaction(&[funding], &[(&foreign(), FUNDING - FEE)]);
+ session.chain.mine(vec![winner.clone()]).await;
+ session.chain.sync().await;
+ session.step().await;
+
+ session.chain.fork_at(BASE).await;
+
+ let restored = session.assert_restart_matches_memory().await;
+ for gone in [payment.txid(), winner.txid()] {
+ assert!(!restored.history.contains_key(&gone));
+ }
+ assert!(
+ !restored.coins.contains_key(&OutPoint::new(payment.txid(), 0)),
+ "the swept payment's change is not a coin"
+ );
+}
+
+/// The lock is all that tells a fork to keep a transaction, and the block
+/// that mines the transaction replaces the context it arrived in. Stored on
+/// its own it outlives both the block and a restart, so the relaunched
+/// wallet keeps what the one that never restarted keeps.
+#[tokio::test]
+async fn should_keep_an_instant_locked_transaction_when_the_fork_follows_a_restart() {
+ for rounds in [Rounds::PerStep, Rounds::Merged] {
+ let mut session = Session::open(rounds).await;
+ let funding = session.chain.funding;
+ let spend = session.chain.spend(funding, FUNDING);
+ session
+ .chain
+ .relay(&spend, Some(instant_lock(&spend)))
+ .await;
+ session.step().await;
+ session.chain.mine(vec![spend.clone()]).await;
+ session.chain.sync().await;
+ session.step().await;
+ session.flush().await;
+
+ let mut relaunched = session.store.restart(session.chain.wallet_id);
+ relaunched
+ .truncate_above(BASE)
+ .expect("no chain lock is applied in these cases");
+ session.chain.fork_at(BASE).await;
+
+ let relaunched = view(&relaunched);
+ if let Err(difference) = relaunched.follows(&session.chain.memory().await) {
+ panic!("{rounds:?}: {difference}");
+ }
+ assert_eq!(
+ relaunched.history.get(&spend.txid()),
+ Some(&None),
+ "{rounds:?}: kept, as unconfirmed"
+ );
+ }
+}
+
/// A truncation is several writes: rows deleted, records rewritten, coins
/// released, heights lowered. One that fails part-way must leave none behind.
#[tokio::test]
From 05e343bb4cb8a466d987ecc2375286b380a7a7f4 Mon Sep 17 00:00:00 2001
From: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Date: Mon, 12 Oct 2026 14:14:13 +0000
Subject: [PATCH 11/28] docs(platform-wallet): state the InstantSend context in
the truncation contract
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A truncation keeps only InstantSend-locked transactions, so the entry
itself tells an FFI host to store a kept one in the InstantSend context
with its outputs locked. The lock blob does not cross the FFI surface.
Refs: #5380
🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4
---
packages/rs-platform-wallet-ffi/src/core_wallet_types.rs | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs
index 78daf5196fe..859e1971153 100644
--- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs
+++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs
@@ -433,9 +433,11 @@ pub struct SweepBatchFFI {
///
/// Applying one entry means:
/// - delete each transaction in `txids` and every UTXO it created;
-/// - keep each transaction in `unconfirmed_txids` as unconfirmed — no block,
-/// outputs unconfirmed and InstantSend-locked, the coins it spent still
-/// spent;
+/// - keep each transaction in `unconfirmed_txids` as unconfirmed and
+/// InstantSend-locked, which is the only kind a truncation keeps: the
+/// InstantSend context, no block, outputs unconfirmed and
+/// InstantSend-locked, the coins it spent still spent. The entry is all a
+/// host needs for that; the lock itself does not cross this surface;
/// - mark each outpoint in `restored_outpoints` unspent again;
/// - lower a stored sync height above `height` to `height`.
///
From af930d9b9ab58fb69647d3333b08247dd3bb738f Mon Sep 17 00:00:00 2001
From: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Date: Mon, 12 Oct 2026 14:14:13 +0000
Subject: [PATCH 12/28] fix(swift-sdk): store a transaction a truncation keeps
as InstantSend-locked
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A truncation keeps only InstantSend-locked transactions. The kept row
now carries the InstantSend context, the same as a locked transaction no
block holds yet, instead of the plain mempool one, so it still counts as
a settled spender of its inputs.
Not built or run locally; covered by ChainTruncationPersistTests in the
"Swift SDK build + tests" CI job.
Refs: #5380
🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4
---
.../PlatformWalletPersistenceHandler.swift | 21 +++++++++++--------
.../ChainTruncationPersistTests.swift | 6 +++++-
2 files changed, 17 insertions(+), 10 deletions(-)
diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
index 2466af4f615..20cb04216ae 100644
--- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
+++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
@@ -1511,9 +1511,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable {
/// of the round describes the wallet after the last truncation. Per
/// truncation, in order: a removed transaction and the coins it created
/// go for every wallet — chain facts, like a swept loser's; a kept
- /// transaction — InstantSend-locked, so mined again — returns to the
- /// mempool with its outputs unconfirmed and locked, and what it spent
- /// stays spent; this wallet's restored coins are spendable again; the
+ /// transaction — InstantSend-locked, so mined again — is stored the way
+ /// a locked transaction no block holds yet is: InstantSend context, no
+ /// block, outputs unconfirmed and locked, and what it spent stays
+ /// spent; this wallet's restored coins are spendable again; the
/// synced height drops to the fork and never rises. A restored coin
/// needs no veto from stored claims, unlike a sweep's release: its
/// spender was confirmed, and no block the fork keeps can hold a second
@@ -1620,17 +1621,18 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable {
for pending in row.pendingInputs {
backgroundContext.delete(pending)
}
- Self.returnToMempool(row)
+ Self.unconfirm(row, as: .mempool)
row.involvedAccounts.removeAll()
roundVacated.transactions[txid] = row
}
/// Keep a transaction whose block a chain truncation dropped as
- /// unconfirmed: no block, and its outputs unconfirmed and
- /// InstantSend-locked. Spend state is not touched.
+ /// unconfirmed and InstantSend-locked — a truncation keeps no other
+ /// kind: InstantSend context, no block, and its outputs unconfirmed and
+ /// locked. Spend state is not touched.
private func unconfirmTransaction(txid: Data) throws {
guard let row = try fetchTransactionRow(txid: txid, prefetching: [\.outputs]) else { return }
- Self.returnToMempool(row)
+ Self.unconfirm(row, as: .instantSend)
for output in row.outputs {
roundIndex?.txosByOutpoint[output.outpoint] = output
output.isConfirmed = false
@@ -1640,8 +1642,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable {
}
}
- private static func returnToMempool(_ row: PersistentTransaction) {
- row.context = TransactionContextType.mempool.rawValue
+ /// Take `row` out of its block, leaving it unconfirmed in `context`.
+ private static func unconfirm(_ row: PersistentTransaction, as context: TransactionContextType) {
+ row.context = context.rawValue
row.blockHeight = 0
row.blockHash = nil
row.blockTimestamp = 0
diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ChainTruncationPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ChainTruncationPersistTests.swift
index 7387f6b030e..8e47e12db9c 100644
--- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ChainTruncationPersistTests.swift
+++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ChainTruncationPersistTests.swift
@@ -291,7 +291,11 @@ final class ChainTruncationPersistTests: XCTestCase {
truncationRound(handler, Truncation(height: 100, unconfirmed: [spendTxid]))
let kept = try XCTUnwrap(transaction(container, txid: spendTxid))
- XCTAssertEqual(kept.context, 0, "back in the mempool")
+ XCTAssertEqual(
+ kept.context,
+ TransactionContextType.instantSend.rawValue,
+ "unconfirmed, and still InstantSend-locked"
+ )
XCTAssertEqual(kept.blockHeight, 0)
XCTAssertNil(kept.blockHash)
let output = try XCTUnwrap(txo(container, txid: spendTxid, vout: 0))
From 136732232b1e95903528c79e7adce0b20f8fa91a Mon Sep 17 00:00:00 2001
From: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Date: Mon, 12 Oct 2026 14:14:13 +0000
Subject: [PATCH 13/28] fix(kotlin-sdk): store a transaction a truncation keeps
as InstantSend-locked
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A truncation keeps only InstantSend-locked transactions. The kept row
now carries the InstantSend context, the same as a locked transaction no
block holds yet, instead of the plain mempool one.
Not built or run locally; covered by PlatformWalletPersistenceHandlerTest
in the Kotlin SDK CI job (`:sdk:testDebugUnitTest`).
Refs: #5380
🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4
---
.../dashsdk/ffi/NativePersistenceBridge.kt | 8 ++++----
.../persistence/PlatformWalletPersistenceHandler.kt | 5 +++--
.../dashsdk/persistence/dao/TransactionDao.kt | 9 +++++----
.../persistence/PlatformWalletPersistenceHandlerTest.kt | 2 +-
4 files changed, 13 insertions(+), 11 deletions(-)
diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt
index e5333fc561c..26ecf0af5bc 100644
--- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt
+++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt
@@ -445,10 +445,10 @@ abstract class NativePersistenceBridge {
* after the last one, so a removed transaction can be stored again, in
* its new block, by the callbacks that follow. Applying one means
* deleting each removed transaction and every UTXO it created, keeping
- * each unconfirmed one with no block and its outputs unconfirmed and
- * InstantSend-locked, marking each restored outpoint unspent, and
- * lowering a stored sync height above [height] to it. A transaction or
- * outpoint never stored is a no-op.
+ * each unconfirmed one in the InstantSend context with no block and its
+ * outputs unconfirmed and InstantSend-locked, marking each restored
+ * outpoint unspent, and lowering a stored sync height above [height]
+ * to it. A transaction or outpoint never stored is a no-op.
*
* The JNI layer wires the slot only when the concrete bridge OVERRIDES
* this method, and Rust attests the capability only for slot present
diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt
index 5f1a5eae853..5470c612e94 100644
--- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt
+++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt
@@ -1307,8 +1307,9 @@ class PlatformWalletPersistenceHandler(
chunked(truncation.removedTxids) { db.txoDao().deleteByTxids(it) }
chunked(truncation.removedTxids) { db.txoDao().detachSpenders(it) }
chunked(truncation.removedTxids) { db.transactionDao().deleteByTxids(it) }
- // Kept: InstantSend-locked, so mined again. No block until then;
- // the coins they spent stay spent.
+ // Kept: InstantSend-locked, so mined again. Stored until then the
+ // way a locked transaction no block holds yet is; the coins they
+ // spent stay spent.
chunked(truncation.unconfirmedTxids) { db.transactionDao().unconfirmByTxids(it) }
chunked(truncation.unconfirmedTxids) { db.txoDao().unconfirmInstantLockedByTxids(it) }
// Restored: this wallet's coins the removed transactions spent.
diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt
index 734d27bba88..526dc9925b1 100644
--- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt
+++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt
@@ -112,12 +112,13 @@ interface TransactionDao {
suspend fun deleteByTxids(txids: List)
/**
- * Return [txids] to the mempool: a chain truncation dropped their
- * block and kept them as unconfirmed. Clears the block columns and
- * touches nothing else. Chunked by the caller.
+ * Keep [txids] as unconfirmed and InstantSend-locked: a chain
+ * truncation dropped their block, and it keeps no other kind. Sets the
+ * InstantSend context, clears the block columns and touches nothing
+ * else. Chunked by the caller.
*/
@Query(
- "UPDATE transactions SET context = 0, blockHeight = 0, blockHash = NULL, " +
+ "UPDATE transactions SET context = 1, blockHeight = 0, blockHash = NULL, " +
"blockTimestamp = 0, blockPosition = 0, hasBlockPosition = 0 " +
"WHERE txid IN (:txids)"
)
diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt
index 501b2faf713..a660fa01517 100644
--- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt
+++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt
@@ -6125,7 +6125,7 @@ class PlatformWalletPersistenceHandlerTest {
assertEquals(0, handler.onChangesetEnd(walletId, success = true))
val kept = db.transactionDao().getByTxid(spend)!!
- assertEquals("back in the mempool", 0, kept.context)
+ assertEquals("unconfirmed, and still InstantSend-locked", 1, kept.context)
assertEquals(0, kept.blockHeight)
assertNull(kept.blockHash)
val output = db.txoDao().getByOutpoint(change)!!
From 34a16e28d2d73dd1b5310007e557d4e350cd7400 Mon Sep 17 00:00:00 2001
From: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Date: Mon, 12 Oct 2026 14:19:10 +0000
Subject: [PATCH 14/28] style(platform-wallet-storage): format the truncation
tests
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
rustfmt only; no behaviour change.
Refs: #5380
🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4
---
.../tests/sqlite_chain_truncation.rs | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_chain_truncation.rs b/packages/rs-platform-wallet-storage/tests/sqlite_chain_truncation.rs
index 4df81006c5b..9a0f14c9e04 100644
--- a/packages/rs-platform-wallet-storage/tests/sqlite_chain_truncation.rs
+++ b/packages/rs-platform-wallet-storage/tests/sqlite_chain_truncation.rs
@@ -608,7 +608,9 @@ async fn should_keep_a_swept_payment_out_when_the_block_of_its_double_spend_is_d
assert!(!restored.history.contains_key(&gone));
}
assert!(
- !restored.coins.contains_key(&OutPoint::new(payment.txid(), 0)),
+ !restored
+ .coins
+ .contains_key(&OutPoint::new(payment.txid(), 0)),
"the swept payment's change is not a coin"
);
}
From 29b65d71d8d00a25cf7fd88af5a54e772b37ca5e Mon Sep 17 00:00:00 2001
From: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Date: Mon, 12 Oct 2026 16:44:46 +0000
Subject: [PATCH 15/28] fix(platform-wallet-storage): keep a truncation when a
failed buffer is dropped
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A round the SQLite store restores after a busy database is its own to
retry, so the wallet-event adapter stops tracking the truncation in it.
A later round that failed for good dropped the whole buffer, that
truncation included, and nothing offered it again: the store kept the
replaced branch.
The fatal path now leaves the buffer's truncations behind for the next
write. A second copy from a caller that kept its own lands right behind
the first, where applying it again changes nothing.
The CORE_CHAIN_TRUNCATION contract states both halves: a truncation is
owed until it is applied, so a wallet whose truncation cannot be applied
fails every later round, and a backend that is not reissuable must keep
the truncation of a round it holds.
Refs: #5380
🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4
---
.../src/sqlite/persister.rs | 44 +++++-
.../tests/sqlite_chain_truncation.rs | 133 +++++++++++++++---
.../src/changeset/persistence_capabilities.rs | 14 ++
3 files changed, 165 insertions(+), 26 deletions(-)
diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs
index 54e724096e7..dcbdd249fde 100644
--- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs
+++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs
@@ -7,9 +7,9 @@ use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
use rusqlite::{Connection, OptionalExtension};
use dpp::prelude::Identifier;
-use platform_wallet::changeset::changeset::SweepBatch;
+use platform_wallet::changeset::changeset::{ChainTruncation, SweepBatch};
use platform_wallet::changeset::{
- ClientStartState, IdentityChangeSet, PersistenceCapabilities, PersistenceError,
+ ClientStartState, CoreChangeSet, IdentityChangeSet, PersistenceCapabilities, PersistenceError,
PlatformWalletChangeSet, PlatformWalletPersistence,
};
use platform_wallet::wallet::identity::ManagedIdentity;
@@ -1112,12 +1112,12 @@ impl SqlitePersister {
}
/// Classify the failure: transient errors restore the buffer and
- /// surface as `FlushRetryable`; everything else drops the changeset
- /// and returns the original variant.
+ /// surface as `FlushRetryable`; everything else drops the changeset,
+ /// except its chain truncations, and returns the original variant.
fn handle_flush_error(
&self,
wallet_id: &WalletId,
- cs: PendingWrites,
+ mut cs: PendingWrites,
err: WalletStorageError,
) -> Result<(), PersistenceError> {
let field_count = populated_field_count(&cs);
@@ -1161,13 +1161,47 @@ impl SqlitePersister {
source,
}))
} else {
+ // A truncation is reported once, and a round restored after a
+ // transient failure is this store's alone to retry: its caller
+ // no longer holds the truncation in it. So the truncations stay
+ // buffered for the next write, which applies them ahead of
+ // whatever it brings. One that arrives a second time from a
+ // caller that kept its own copy lands right behind the first,
+ // where applying it again changes nothing.
+ let truncations: Vec = cs
+ .segments
+ .iter_mut()
+ .filter_map(|segment| segment.core.as_mut())
+ .flat_map(|core| std::mem::take(&mut core.truncations))
+ .collect();
+ let kept_truncation_count = truncations.len();
tracing::error!(
wallet_id = %hex::encode(wallet_id),
error_kind = kind,
dropped_field_count = field_count,
+ kept_truncation_count,
"flush failed fatally — buffer wiped"
);
drop(cs);
+ if !truncations.is_empty() {
+ let owed = PendingWrites {
+ segments: vec![PlatformWalletChangeSet {
+ core: Some(CoreChangeSet {
+ truncations,
+ ..CoreChangeSet::default()
+ }),
+ ..PlatformWalletChangeSet::default()
+ }],
+ };
+ if let Err(restore_err) = self.buffer.restore(*wallet_id, owed) {
+ tracing::error!(
+ wallet_id = %hex::encode(wallet_id),
+ error_kind = restore_err.error_kind_str(),
+ lost_truncation_count = kept_truncation_count,
+ "chain truncations could not be kept after a fatal flush error — the store keeps the replaced branch"
+ );
+ }
+ }
Err(PersistenceError::from(err))
}
}
diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_chain_truncation.rs b/packages/rs-platform-wallet-storage/tests/sqlite_chain_truncation.rs
index 9a0f14c9e04..031df2ee22d 100644
--- a/packages/rs-platform-wallet-storage/tests/sqlite_chain_truncation.rs
+++ b/packages/rs-platform-wallet-storage/tests/sqlite_chain_truncation.rs
@@ -37,7 +37,7 @@ use platform_wallet::{
key_wallet_manager::{WalletEvent, WalletInterface, WalletManager},
wallet::platform_wallet::{PlatformWalletInfo, WalletId},
};
-use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig};
+use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig, WalletStorageError};
use proptest::prelude::*;
use tokio::sync::{mpsc, RwLock};
use tokio_util::sync::CancellationToken;
@@ -653,6 +653,116 @@ async fn should_keep_an_instant_locked_transaction_when_the_fork_follows_a_resta
}
}
+/// The truncation among `events`, as the changeset carries it.
+fn reported_truncation(events: Vec) -> ChainTruncation {
+ events
+ .into_iter()
+ .find_map(|event| match event {
+ WalletEvent::ChainTruncated {
+ height,
+ txids,
+ unconfirmed_txids,
+ restored_outpoints,
+ ..
+ } => Some(ChainTruncation {
+ height,
+ txids,
+ unconfirmed_txids,
+ restored_outpoints,
+ }),
+ _ => None,
+ })
+ .expect("the fork is reported")
+}
+
+fn core_round(core: CoreChangeSet) -> PlatformWalletChangeSet {
+ PlatformWalletChangeSet {
+ core: Some(core),
+ ..Default::default()
+ }
+}
+
+/// A store that keeps a round after a busy database owns the truncation in
+/// it: nobody else will offer it again. When a later round fails for good
+/// and takes the buffer with it, the truncation must still be applied by
+/// the next round that lands.
+#[tokio::test]
+async fn should_apply_a_truncation_it_kept_after_dropping_a_later_round() {
+ let mut session = Session::open(Rounds::PerStep).await;
+ let wallet_id = session.chain.wallet_id;
+ let spend = session.chain.spend(session.chain.funding, FUNDING);
+ session.chain.mine(vec![spend.clone()]).await;
+ session.chain.sync().await;
+ session.step().await;
+ session.chain.fork_at(BASE).await;
+ let truncation = reported_truncation(session.chain.drain());
+ assert_eq!(truncation.txids, [spend.txid()]);
+ let persister = session.store.persister();
+
+ persister.force_next_flush_to_fail(WalletStorageError::Sqlite(rusqlite::Error::SqliteFailure(
+ rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_BUSY),
+ None,
+ )));
+ let kept = persister.store(
+ wallet_id,
+ core_round(CoreChangeSet {
+ truncations: vec![truncation.clone()],
+ ..Default::default()
+ }),
+ );
+ assert!(kept.is_err(), "the busy database rejects the round");
+ persister.force_next_flush_to_fail(WalletStorageError::Sqlite(
+ rusqlite::Error::InvalidColumnIndex(0),
+ ));
+ let dropped = persister.store(
+ wallet_id,
+ core_round(CoreChangeSet {
+ last_processed_height: Some(BASE),
+ ..Default::default()
+ }),
+ );
+ assert!(dropped.is_err(), "the fatal failure rejects the round");
+ persister
+ .store(
+ wallet_id,
+ core_round(CoreChangeSet {
+ synced_height: Some(BASE),
+ ..Default::default()
+ }),
+ )
+ .expect("the next round lands");
+
+ let follows_the_fork = |when: &str| {
+ assert_eq!(
+ session.store.transaction_rows(&spend.txid()),
+ Vec::>::new(),
+ "{when}: the transaction of the dropped block is gone"
+ );
+ assert_eq!(
+ session.store.heights(wallet_id),
+ (Some(BASE), Some(BASE)),
+ "{when}"
+ );
+ };
+ follows_the_fork("after the round that landed");
+
+ // A caller that kept its own copy offers the truncation once more.
+ persister
+ .store(
+ wallet_id,
+ core_round(CoreChangeSet {
+ truncations: vec![truncation],
+ ..Default::default()
+ }),
+ )
+ .expect("a second delivery is accepted");
+ follows_the_fork("after a second delivery");
+ let restored = view(&session.store.restart(wallet_id));
+ if let Err(difference) = restored.follows(&session.chain.memory().await) {
+ panic!("{difference}");
+ }
+}
+
/// A truncation is several writes: rows deleted, records rewritten, coins
/// released, heights lowered. One that fails part-way must leave none behind.
#[tokio::test]
@@ -681,26 +791,7 @@ async fn should_leave_the_store_untouched_when_a_truncation_cannot_be_applied()
.unwrap();
session.chain.fork_at(BASE).await;
- let truncation = session
- .chain
- .drain()
- .into_iter()
- .find_map(|event| match event {
- WalletEvent::ChainTruncated {
- height,
- txids,
- unconfirmed_txids,
- restored_outpoints,
- ..
- } => Some(ChainTruncation {
- height,
- txids,
- unconfirmed_txids,
- restored_outpoints,
- }),
- _ => None,
- })
- .expect("the fork is reported");
+ let truncation = reported_truncation(session.chain.drain());
assert_eq!(truncation.txids, [removed.txid()]);
assert_eq!(truncation.unconfirmed_txids, [kept.txid()]);
diff --git a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs
index 31dc589671e..7d78642c05b 100644
--- a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs
+++ b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs
@@ -124,6 +124,20 @@ impl PersistenceCapabilities {
/// a frozen watermark would cost the host its durable progress and
/// recover nothing. Such a store keeps the replaced branch until the
/// host rescans.
+ ///
+ /// # A truncation is owed until it is applied
+ ///
+ /// For the same reason a truncation a `store` rejected is offered again
+ /// ahead of the wallet's next round, and so is never skipped: while it
+ /// cannot be applied, every later round of that wallet fails with it.
+ ///
+ /// A backend whose
+ /// [`store_transient_is_reissuable`](super::PlatformWalletPersistence::store_transient_is_reissuable)
+ /// is `false` is not offered a truncation again after a transient
+ /// failure, since it may hold the round for its own retry. Attesting
+ /// this bit therefore obliges such a backend to keep that truncation
+ /// until it has applied it, including when it discards the rest of what
+ /// it held.
// Bit 13 is reserved for `CORE_HISTORY_RESTORE` (dashpay/platform#5220).
pub const CORE_CHAIN_TRUNCATION: Self = Self(1 << 14);
From ecf5e11f7c86d6e7c0b0f28db228f218ca7f7c5f Mon Sep 17 00:00:00 2001
From: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Date: Mon, 12 Oct 2026 16:45:59 +0000
Subject: [PATCH 16/28] fix(platform-wallet): project a transaction a merged
truncation keeps as InstantSend-locked
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A round that folds a block observation and the truncation dropping that
block leaves the kept transaction's records in the plain mempool
context. The FFI projection read the lock from that context alone, so
the row and the coin sent after the truncation callback overwrote the
host's locked state with a mempool sighting, and a transaction the host
had never stored was created unlocked. Delivered as separate rounds the
host ended locked, so the stored state depended on batching.
A mempool record named in the `unconfirmed_txids` of a truncation of the
same round is now projected in the InstantSend context with its outputs
locked. A record mined again keeps its block context, and one no
truncation names is unchanged.
Refs: #5380
🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4
---
.../src/core_wallet_types.rs | 164 +++++++++++++++++-
1 file changed, 158 insertions(+), 6 deletions(-)
diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs
index 859e1971153..c1958ef04bb 100644
--- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs
+++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs
@@ -1,6 +1,8 @@
//! C-compatible types for core wallet changeset FFI.
+use dashcore::Txid;
use platform_wallet::masternode::{provider_payload_fields, MasternodeRecord};
+use std::collections::BTreeSet;
use std::os::raw::c_char;
// ---------------------------------------------------------------------------
@@ -631,6 +633,19 @@ impl WalletChangeSetFFI {
}
}
+ // A truncation keeps only InstantSend-locked transactions, and
+ // merging one into this round left the records it keeps in the
+ // plain mempool context: the lock is not theirs to carry. The host
+ // has stored them as locked by the time these rows arrive — the
+ // truncation callback runs first — so they are projected the same
+ // way. A record a later block mined again is in that block's
+ // context and is not touched.
+ let kept_by_truncation: BTreeSet = cs
+ .truncations
+ .iter()
+ .flat_map(|truncation| truncation.unconfirmed_txids.iter().copied())
+ .collect();
+
let mut ffi_accounts = Vec::with_capacity(by_account.len());
for (account_type, tx_rows, utxo_slices) in by_account {
let type_name = CString::new(format!("{:?}", account_type))
@@ -644,14 +659,16 @@ impl WalletChangeSetFFI {
let mut utxos_added: Vec = Vec::new();
let mut utxos_spent: Vec = Vec::new();
for rec in &utxo_slices {
- utxos_added.extend(record_new_utxos_ffi(rec));
+ utxos_added.extend(record_new_utxos_ffi(rec, &kept_by_truncation));
utxos_spent.extend(record_spent_outpoints_ffi(rec));
}
// Transaction rows for this account (wallet-level,
// folded — see the bucketing comment above).
- let transactions: Vec =
- tx_rows.into_iter().map(tx_record_to_ffi).collect();
+ let transactions: Vec = tx_rows
+ .into_iter()
+ .map(|rec| tx_record_to_ffi(rec, &kept_by_truncation))
+ .collect();
let utxos_added_count = utxos_added.len();
let utxos_spent_count = utxos_spent.len();
@@ -1228,6 +1245,7 @@ pub fn account_type_to_tags(at: &key_wallet::account::AccountType) -> AccountCha
/// further down the stack so the FFI conversion stays self-contained.
fn record_new_utxos_ffi(
rec: &key_wallet::managed_account::transaction_record::TransactionRecord,
+ kept_by_truncation: &BTreeSet,
) -> Vec {
use key_wallet::managed_account::transaction_record::OutputRole;
use key_wallet::transaction_checking::TransactionContext;
@@ -1238,7 +1256,7 @@ fn record_new_utxos_ffi(
rec.context,
TransactionContext::InBlock(_) | TransactionContext::InChainLockedBlock(_)
);
- let is_instant = matches!(rec.context, TransactionContext::InstantSend(_));
+ let is_instant = is_instant_send_locked(rec, kept_by_truncation);
let is_coinbase = rec.transaction.is_coin_base();
rec.output_details
@@ -1589,8 +1607,24 @@ pub(crate) fn masternode_entry_v2_ffi(
}
}
+/// Whether `rec` is unconfirmed and InstantSend-locked: it says so itself,
+/// or it is a mempool record a truncation of the same round kept, which a
+/// truncation does for locked transactions only.
+fn is_instant_send_locked(
+ rec: &key_wallet::managed_account::transaction_record::TransactionRecord,
+ kept_by_truncation: &BTreeSet,
+) -> bool {
+ use key_wallet::transaction_checking::TransactionContext;
+ match rec.context {
+ TransactionContext::InstantSend(_) => true,
+ TransactionContext::Mempool => kept_by_truncation.contains(&rec.txid),
+ TransactionContext::InBlock(_) | TransactionContext::InChainLockedBlock(_) => false,
+ }
+}
+
fn tx_record_to_ffi(
tr: &key_wallet::managed_account::transaction_record::TransactionRecord,
+ kept_by_truncation: &BTreeSet,
) -> TransactionRecordFFI {
use key_wallet::transaction_checking::TransactionContext;
use std::ffi::CString;
@@ -1604,8 +1638,10 @@ fn tx_record_to_ffi(
let (ctx_val, blk_height, blk_hash, blk_ts, blk_position, has_blk_position) = match &tr.context
{
- TransactionContext::Mempool => (0u32, 0u32, [0u8; 32], 0u32, 0u32, false),
- TransactionContext::InstantSend(_is_lock) => {
+ TransactionContext::Mempool if !is_instant_send_locked(tr, kept_by_truncation) => {
+ (0u32, 0u32, [0u8; 32], 0u32, 0u32, false)
+ }
+ TransactionContext::Mempool | TransactionContext::InstantSend(_) => {
// InstantSend has no block info — treat as mempool-level with flag
(1u32, 0u32, [0u8; 32], 0u32, 0u32, false)
}
@@ -2062,6 +2098,122 @@ mod tests {
unsafe { free_wallet_changeset_ffi(&ffi) };
}
+ /// One round sees a transaction mined and then its block dropped. The
+ /// truncation keeps it, which only an InstantSend lock earns, and the
+ /// host has already stored it that way when the truncation callback ran
+ /// first. The row and the coin that follow must say the same, or they
+ /// overwrite the lock with a plain mempool sighting.
+ #[test]
+ fn should_project_a_transaction_a_merged_truncation_keeps_as_instant_send_locked() {
+ use dashcore::hashes::Hash;
+ use dashcore::{Address, BlockHash, Network, OutPoint, ScriptBuf, TxIn, TxOut, Witness};
+ use key_wallet::managed_account::transaction_record::{
+ OutputDetail, OutputRole, TransactionDirection, TransactionRecord,
+ };
+ use key_wallet::transaction_checking::transaction_router::TransactionType;
+ use key_wallet::transaction_checking::{BlockInfo, TransactionContext};
+ use platform_wallet::changeset::changeset::ChainTruncation;
+ use platform_wallet::changeset::Merge;
+
+ let bip44 = AccountType::Standard {
+ index: 0,
+ standard_account_type: StandardAccountType::BIP44Account,
+ };
+ let ours = Address::dummy(Network::Testnet, 1);
+ let tx = dashcore::Transaction {
+ version: 2,
+ lock_time: 0,
+ input: vec![TxIn {
+ previous_output: OutPoint::default(),
+ script_sig: ScriptBuf::new(),
+ sequence: 0xffffffff,
+ witness: Witness::new(),
+ }],
+ output: vec![TxOut {
+ value: 50_000,
+ script_pubkey: ours.script_pubkey(),
+ }],
+ special_transaction_payload: None,
+ };
+ let mined = TransactionRecord::new(
+ tx,
+ bip44,
+ TransactionContext::InBlock(BlockInfo::new(
+ 101,
+ BlockHash::from_byte_array([7; 32]),
+ 1_700_000_000,
+ )),
+ TransactionType::Standard,
+ TransactionDirection::Incoming,
+ vec![],
+ vec![OutputDetail {
+ index: 0,
+ role: OutputRole::Received,
+ address: Some(ours),
+ value: 50_000,
+ }],
+ 50_000,
+ );
+ let txid = mined.txid;
+ // A plain mempool sighting no truncation names, to show the
+ // projection changes nothing for it.
+ let mut unrelated = mined.clone();
+ unrelated.transaction.lock_time = 1;
+ unrelated.txid = unrelated.transaction.txid();
+ unrelated.update_context(TransactionContext::Mempool);
+ let unrelated_txid = unrelated.txid;
+
+ let mut round = CoreChangeSet {
+ records: vec![mined.clone(), unrelated.clone()],
+ account_records: vec![mined, unrelated],
+ ..CoreChangeSet::default()
+ };
+ round.merge(CoreChangeSet {
+ truncations: vec![ChainTruncation {
+ height: 100,
+ txids: vec![],
+ unconfirmed_txids: vec![txid],
+ restored_outpoints: vec![],
+ }],
+ ..CoreChangeSet::default()
+ });
+
+ let ffi = WalletChangeSetFFI::from_changeset(&round);
+ assert_eq!(ffi.accounts_count, 1);
+ let bucket = unsafe { &*ffi.accounts };
+ let rows =
+ unsafe { std::slice::from_raw_parts(bucket.transactions, bucket.transactions_count) };
+ let coins =
+ unsafe { std::slice::from_raw_parts(bucket.utxos_added, bucket.utxos_added_count) };
+ let row_of = |txid: &dashcore::Txid| {
+ rows.iter()
+ .find(|row| row.txid == txid.to_byte_array())
+ .expect("the transaction row is projected")
+ };
+ let coin_of = |txid: &dashcore::Txid| {
+ coins
+ .iter()
+ .find(|coin| coin.outpoint.txid == txid.to_byte_array())
+ .expect("the coin is projected")
+ };
+
+ let row = row_of(&txid);
+ assert_eq!(row.context, 1, "InstantSend, not a plain mempool sighting");
+ assert_eq!(row.block_height, 0, "and in no block");
+ let coin = coin_of(&txid);
+ assert!(coin.is_instantlocked);
+ assert!(!coin.is_confirmed);
+ assert_eq!(coin.height, 0);
+
+ assert_eq!(
+ row_of(&unrelated_txid).context,
+ 0,
+ "a mempool record no truncation names stays one"
+ );
+ assert!(!coin_of(&unrelated_txid).is_instantlocked);
+ unsafe { free_wallet_changeset_ffi(&ffi) };
+ }
+
/// The exact shape behind the provider-restoration P1: a ProReg-like
/// transaction funded by a Standard account whose payload ALSO
/// matches a provider owner-keys account. The provider slice is
From 041da26151e6946d7f22b262eb94e8134f65f163 Mon Sep 17 00:00:00 2001
From: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Date: Mon, 12 Oct 2026 16:49:33 +0000
Subject: [PATCH 17/28] fix(swift-sdk): take only the truncating wallet's part
of a shared transaction
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A transaction row is shared across wallets, and each wallet's round
commits on its own. A second wallet's truncation arriving after the
first wallet had stored the transaction again, in its new block, took
every output of the row with it, the first wallet's fresh coin included,
and returned a kept transaction's outputs to unconfirmed for both.
A truncation now removes the truncating wallet's own outputs, links,
claims and account involvement, and the row only when no other wallet
has a part in it; a kept transaction's outputs are returned to
unconfirmed for that wallet alone. The row's block fields stay shared:
between two wallets' rounds they can show the branch the other wallet
last wrote.
Not built or run locally; covered by ChainTruncationPersistTests in the
"Swift SDK build + tests" CI job.
Refs: #5380
🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4
---
.../PlatformWalletPersistenceHandler.swift | 73 ++++++----
.../ChainTruncationPersistTests.swift | 125 +++++++++++++++++-
2 files changed, 167 insertions(+), 31 deletions(-)
diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
index 20cb04216ae..587fd4cf7cb 100644
--- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
+++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
@@ -1509,16 +1509,25 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable {
///
/// Rust fires this before the round's other core callbacks, so the rest
/// of the round describes the wallet after the last truncation. Per
- /// truncation, in order: a removed transaction and the coins it created
- /// go for every wallet — chain facts, like a swept loser's; a kept
- /// transaction — InstantSend-locked, so mined again — is stored the way
- /// a locked transaction no block holds yet is: InstantSend context, no
- /// block, outputs unconfirmed and locked, and what it spent stays
- /// spent; this wallet's restored coins are spendable again; the
- /// synced height drops to the fork and never rises. A restored coin
- /// needs no veto from stored claims, unlike a sweep's release: its
- /// spender was confirmed, and no block the fork keeps can hold a second
- /// spend of it.
+ /// truncation, in order: this wallet's part in a removed transaction
+ /// goes — the coins it created for it, its accounts' involvement, its
+ /// claims and links — and the row itself once no wallet has a part in
+ /// it; a kept transaction — InstantSend-locked, so mined again — is
+ /// stored the way a locked transaction no block holds yet is:
+ /// InstantSend context, no block, this wallet's outputs unconfirmed and
+ /// locked, and what it spent stays spent; this wallet's restored coins
+ /// are spendable again; the synced height drops to the fork and never
+ /// rises. A restored coin needs no veto from stored claims, unlike a
+ /// sweep's release: its spender was confirmed, and no block the fork
+ /// keeps can hold a second spend of it.
+ ///
+ /// **Everything but the row is this wallet's own.** A transaction row
+ /// is shared across wallets and each wallet's round commits on its own,
+ /// so another wallet may already have stored the transaction again, in
+ /// its new block, by the time this truncation arrives. Its coins are
+ /// not this truncation's to take. The row's block fields are the one
+ /// thing wallets share: between two wallets' rounds they can show the
+ /// branch the other wallet last wrote.
///
/// Same fail-the-round contract as the sweep callback: a lookup that
/// throws returns `false`, so Rust does not treat a truncation that
@@ -1543,13 +1552,13 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable {
do {
if truncation.txids_count > 0, let txidsPtr = truncation.txids {
for i in 0.. 0,
let txidsPtr = truncation.unconfirmed_txids {
for i in 0.. 0,
@@ -1591,20 +1600,29 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable {
}
}
- /// Take a transaction a chain truncation removed, and the coins it
- /// created, out of the round — see `roundVacated`. The links and the
- /// pending claims it made go at once, so nothing the rest of the round
- /// reads still counts it as a spender.
+ /// Take `walletId`'s part in a transaction a chain truncation removed
+ /// out of the round — see `roundVacated` — and the row with it when no
+ /// other wallet has a part in it. The wallet's links and pending claims
+ /// go at once, so nothing the rest of the round reads still counts the
+ /// transaction as a spender of its coins.
///
/// Every row a relationship hands over is registered before it is
/// written, like any other first touch of a key (see `roundIndex`).
- private func vacateTransaction(txid: Data) throws {
+ private func vacateTransaction(txid: Data, walletId: Data) throws {
guard let row = try fetchTransactionRow(
txid: txid,
prefetching: [\.outputs, \.inputs, \.pendingInputs]
) else { return }
- for output in row.outputs {
+ // Decided before anything is detached: a coin whose wallet cannot
+ // be resolved counts as somebody else's.
+ let sharedWithAnotherWallet =
+ row.outputs.contains { Self.resolvedWalletId(of: $0) != walletId }
+ || row.inputs.contains { Self.resolvedWalletId(of: $0) != walletId }
+ || row.pendingInputs.contains { $0.walletId != walletId }
+ || row.involvedAccounts.contains { $0.wallet.walletId != walletId }
+
+ for output in row.outputs where Self.resolvedWalletId(of: output) == walletId {
roundIndex?.txosByOutpoint[output.outpoint] = output
output.isSpent = false
output.spendingTransaction = nil
@@ -1612,28 +1630,30 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable {
output.supersededByTxid = nil
roundVacated.txos[output.outpoint] = output
}
- for input in row.inputs {
+ for input in row.inputs where Self.resolvedWalletId(of: input) == walletId {
roundIndex?.txosByOutpoint[input.outpoint] = input
input.spendingTransaction = nil
input.spendingInputIndex = nil
input.lastUpdated = Date()
}
- for pending in row.pendingInputs {
+ for pending in row.pendingInputs where pending.walletId == walletId {
backgroundContext.delete(pending)
}
+ row.involvedAccounts.removeAll { $0.wallet.walletId == walletId }
+
+ guard !sharedWithAnotherWallet else { return }
Self.unconfirm(row, as: .mempool)
- row.involvedAccounts.removeAll()
roundVacated.transactions[txid] = row
}
/// Keep a transaction whose block a chain truncation dropped as
/// unconfirmed and InstantSend-locked — a truncation keeps no other
- /// kind: InstantSend context, no block, and its outputs unconfirmed and
- /// locked. Spend state is not touched.
- private func unconfirmTransaction(txid: Data) throws {
+ /// kind: InstantSend context, no block, and `walletId`'s outputs
+ /// unconfirmed and locked. Spend state is not touched.
+ private func unconfirmTransaction(txid: Data, walletId: Data) throws {
guard let row = try fetchTransactionRow(txid: txid, prefetching: [\.outputs]) else { return }
Self.unconfirm(row, as: .instantSend)
- for output in row.outputs {
+ for output in row.outputs where Self.resolvedWalletId(of: output) == walletId {
roundIndex?.txosByOutpoint[output.outpoint] = output
output.isConfirmed = false
output.isInstantLocked = true
@@ -1655,8 +1675,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable {
/// Hand back one of this wallet's coins that a removed transaction had
/// spent. The link to that transaction is cleared by
- /// `vacateTransaction`, or by the delete rule when another wallet's
- /// round removed the shared row first.
+ /// `vacateTransaction`.
private func restoreTruncatedCoin(outpoint: Data, walletId: Data) throws {
if let txo = try fetchTxoRowChecked(outpoint: outpoint),
Self.resolvedWalletId(of: txo) == walletId {
diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ChainTruncationPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ChainTruncationPersistTests.swift
index 8e47e12db9c..ef63485b099 100644
--- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ChainTruncationPersistTests.swift
+++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ChainTruncationPersistTests.swift
@@ -117,8 +117,10 @@ final class ChainTruncationPersistTests: XCTestCase {
@discardableResult
private func stageTruncation(
_ handler: PlatformWalletPersistenceHandler,
- _ truncation: Truncation
+ _ truncation: Truncation,
+ wallet: Data? = nil
) -> Bool {
+ let walletId = wallet ?? self.walletId
let removed = rawTxids(truncation.removed)
let unconfirmed = rawTxids(truncation.unconfirmed)
let restored = UnsafeMutablePointer.allocate(
@@ -172,13 +174,16 @@ final class ChainTruncationPersistTests: XCTestCase {
}
/// Stage `spendTxid` mined at `blockHeight`, spending the funded coin
- /// and creating output 0 again, together with the synced height — the
+ /// and creating output `vout` again, together with the synced height — the
/// part of a round that follows its truncation. The caller brackets it.
private func stageSpendMinedAgain(
_ handler: PlatformWalletPersistenceHandler,
blockHeight: UInt32,
- blockHash: Data
+ blockHash: Data,
+ wallet: Data? = nil,
+ vout: UInt32 = 0
) {
+ let walletId = wallet ?? self.walletId
let name = strdup("Standard { index: 0 }")
let address = strdup("yChangeAddr")
defer {
@@ -206,7 +211,7 @@ final class ChainTruncationPersistTests: XCTestCase {
Swift.withUnsafeMutableBytes(of: &utxo.outpoint.txid) { dst in
spendTxid.withUnsafeBytes { src in dst.copyMemory(from: src) }
}
- utxo.outpoint.vout = 0
+ utxo.outpoint.vout = vout
utxo.amount = 30_000
utxo.address = address
utxo.height = blockHeight
@@ -392,4 +397,116 @@ final class ChainTruncationPersistTests: XCTestCase {
XCTAssertTrue(truncationRound(handler, Truncation(height: 100, removed: [spendTxid])))
}
+
+ // MARK: - A transaction two wallets share
+
+ private let otherWalletId = Data(repeating: 0x02, count: 32)
+
+ /// `seedSpendMinedAboveTheFork` with output 1 handed to a second
+ /// wallet: one transaction row, a coin for each wallet.
+ private func seedSpendSharedWithAnotherWallet(in container: ModelContainer) throws {
+ try seedSpendMinedAboveTheFork(in: container)
+ let context = ModelContext(container)
+ context.insert(PersistentWallet(walletId: otherWalletId, network: .testnet, syncedHeight: 101))
+ let outpoint = PersistentTxo.makeOutpoint(txid: spendTxid, vout: 1)
+ let theirs = try XCTUnwrap(
+ try context.fetch(FetchDescriptor(predicate: #Predicate { $0.outpoint == outpoint })).first
+ )
+ theirs.walletId = otherWalletId
+ try context.save()
+ }
+
+ /// One wallet's whole backlog in one round: the truncation, then the
+ /// transaction in its new block with that wallet's own output.
+ private func truncateAndMineAgain(
+ _ handler: PlatformWalletPersistenceHandler,
+ wallet: Data,
+ vout: UInt32,
+ _ truncation: Truncation,
+ blockHash: Data
+ ) {
+ handler.beginChangeset(walletId: wallet)
+ XCTAssertTrue(stageTruncation(handler, truncation, wallet: wallet))
+ stageSpendMinedAgain(handler, blockHeight: 102, blockHash: blockHash, wallet: wallet, vout: vout)
+ XCTAssertTrue(handler.endChangeset(walletId: wallet, success: true))
+ }
+
+ /// Each wallet's round commits on its own, so the second wallet's
+ /// truncation arrives after the first wallet has stored the transaction
+ /// again. It must take only its own part of the old branch.
+ func testShouldKeepAnotherWalletsReMinedOutputWhenItsOwnTruncationArrivesLater() throws {
+ let (handler, container) = try makeHandler()
+ try seedSpendSharedWithAnotherWallet(in: container)
+ let newBlock = Data(repeating: 0x09, count: 32)
+ let removal = Truncation(height: 100, removed: [spendTxid], restored: [(txid: fundingTxid, vout: 0)])
+
+ truncateAndMineAgain(handler, wallet: walletId, vout: 0, removal, blockHash: newBlock)
+ truncateAndMineAgain(
+ handler, wallet: otherWalletId, vout: 1, Truncation(height: 100, removed: [spendTxid]),
+ blockHash: newBlock
+ )
+
+ let first = try XCTUnwrap(
+ txo(container, txid: spendTxid, vout: 0),
+ "the first wallet's coin went missing when the second wallet's truncation arrived"
+ )
+ XCTAssertEqual(first.walletId, walletId)
+ XCTAssertEqual(first.height, 102, "the first wallet's coin lost its new block")
+ let second = try XCTUnwrap(
+ txo(container, txid: spendTxid, vout: 1),
+ "the second wallet's coin went missing"
+ )
+ XCTAssertEqual(second.walletId, otherWalletId)
+ XCTAssertEqual(second.height, 102, "the second wallet's coin lost its new block")
+ XCTAssertEqual(try XCTUnwrap(transaction(container, txid: spendTxid)).blockHeight, 102)
+ }
+
+ /// The same backlog for a transaction the fork keeps: the second
+ /// wallet's truncation must not return the first wallet's coin, already
+ /// confirmed in the new block, to unconfirmed.
+ func testShouldKeepAnotherWalletsReMinedOutputConfirmedWhenItsOwnTruncationKeepsTheTransaction() throws {
+ let (handler, container) = try makeHandler()
+ try seedSpendSharedWithAnotherWallet(in: container)
+ let newBlock = Data(repeating: 0x09, count: 32)
+ let kept = Truncation(height: 100, unconfirmed: [spendTxid])
+
+ truncateAndMineAgain(handler, wallet: walletId, vout: 0, kept, blockHash: newBlock)
+ truncateAndMineAgain(handler, wallet: otherWalletId, vout: 1, kept, blockHash: newBlock)
+
+ let first = try XCTUnwrap(txo(container, txid: spendTxid, vout: 0), "the first wallet's coin went missing")
+ XCTAssertTrue(first.isConfirmed, "the first wallet's coin was returned to unconfirmed by the second wallet's truncation")
+ XCTAssertEqual(first.height, 102, "the first wallet's coin lost its new block")
+ let second = try XCTUnwrap(txo(container, txid: spendTxid, vout: 1), "the second wallet's coin went missing")
+ XCTAssertTrue(second.isConfirmed, "the second wallet's coin stayed unconfirmed after its own round mined it")
+ XCTAssertEqual(second.height, 102, "the second wallet's coin lost its new block")
+ let row = try XCTUnwrap(transaction(container, txid: spendTxid))
+ XCTAssertEqual(row.context, TransactionContextType.inBlock.rawValue)
+ XCTAssertEqual(row.blockHeight, 102)
+ }
+
+ /// A wallet's truncation removes its own part of a shared transaction
+ /// and leaves the row while another wallet still has a coin from it.
+ func testShouldLeaveASharedTransactionWhileAnotherWalletStillHoldsACoinFromIt() throws {
+ let (handler, container) = try makeHandler()
+ try seedSpendSharedWithAnotherWallet(in: container)
+
+ truncationRound(
+ handler,
+ Truncation(height: 100, removed: [spendTxid], restored: [(txid: fundingTxid, vout: 0)])
+ )
+
+ XCTAssertNil(txo(container, txid: spendTxid, vout: 0), "the truncating wallet's own coin is gone")
+ XCTAssertNotNil(
+ txo(container, txid: spendTxid, vout: 1),
+ "the other wallet's coin went missing before its own truncation arrived"
+ )
+ XCTAssertNotNil(transaction(container, txid: spendTxid), "the row stays while a wallet has a part in it")
+
+ handler.beginChangeset(walletId: otherWalletId)
+ XCTAssertTrue(stageTruncation(handler, Truncation(height: 100, removed: [spendTxid]), wallet: otherWalletId))
+ XCTAssertTrue(handler.endChangeset(walletId: otherWalletId, success: true))
+
+ XCTAssertNil(txo(container, txid: spendTxid, vout: 1))
+ XCTAssertNil(transaction(container, txid: spendTxid), "and goes once no wallet does")
+ }
}
From 44ed537b8a53af28a9827b379be90288742a010a Mon Sep 17 00:00:00 2001
From: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Date: Mon, 12 Oct 2026 16:49:33 +0000
Subject: [PATCH 18/28] fix(kotlin-sdk): take only the truncating wallet's part
of a shared transaction
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The truncation deleted transactions and their outputs by txid alone,
while each wallet's round commits on its own. A second wallet's
truncation arriving after the first wallet had stored the transaction
again, in its new block, deleted the first wallet's fresh coin, and
returned a kept transaction's outputs to unconfirmed for both.
A truncation now removes the truncating wallet's own outputs, links,
claims and account involvement, and the row only once nothing references
it; a kept transaction's outputs are returned to unconfirmed for that
wallet alone. The row's block columns stay shared: between two wallets'
rounds they can show the branch the other wallet last wrote.
Not built or run locally; covered by PlatformWalletPersistenceHandlerTest
in the Kotlin SDK CI job (`:sdk:testDebugUnitTest`).
Refs: #5380
🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4
---
.../dashsdk/ffi/NativePersistenceBridge.kt | 12 +-
.../PlatformWalletPersistenceHandler.kt | 31 +++--
.../dashsdk/persistence/dao/DocumentDao.kt | 12 ++
.../dashsdk/persistence/dao/TransactionDao.kt | 27 +++++
.../dashsdk/persistence/dao/TxoDao.kt | 36 +++++-
.../PlatformWalletPersistenceHandlerTest.kt | 109 +++++++++++++++++-
6 files changed, 204 insertions(+), 23 deletions(-)
diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt
index 26ecf0af5bc..8b899a14bb5 100644
--- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt
+++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt
@@ -444,11 +444,13 @@ abstract class NativePersistenceBridge {
* truncation and in order: the rest of the round describes the wallet
* after the last one, so a removed transaction can be stored again, in
* its new block, by the callbacks that follow. Applying one means
- * deleting each removed transaction and every UTXO it created, keeping
- * each unconfirmed one in the InstantSend context with no block and its
- * outputs unconfirmed and InstantSend-locked, marking each restored
- * outpoint unspent, and lowering a stored sync height above [height]
- * to it. A transaction or outpoint never stored is a no-op.
+ * deleting every UTXO a removed transaction created for this wallet,
+ * and the transaction itself unless another wallet of the same store
+ * still has a part in it, keeping each unconfirmed one in the
+ * InstantSend context with no block and this wallet's outputs of it
+ * unconfirmed and InstantSend-locked, marking each restored outpoint
+ * unspent, and lowering a stored sync height above [height] to it. A
+ * transaction or outpoint never stored is a no-op.
*
* The JNI layer wires the slot only when the concrete bridge OVERRIDES
* this method, and Rust attests the capability only for slot present
diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt
index 5470c612e94..400630b500d 100644
--- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt
+++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt
@@ -1293,25 +1293,34 @@ class PlatformWalletPersistenceHandler(
* replaced the chain above [ChainTruncation.height] — the Room mirror
* of the SQLite store's `apply_truncation`.
*
- * A removed transaction and the coins it created are chain facts, so
- * they go for every wallet, like a swept loser's. The restored coins
- * and the synced height are this wallet's own. A restored coin needs
- * no veto from stored claims, unlike a sweep's release: its spender was
- * confirmed, and no block the fork keeps can hold a second spend of it.
+ * Everything but the transaction row is this wallet's own. The row is
+ * shared across wallets and each wallet's round commits on its own, so
+ * another wallet may already have stored a removed transaction again,
+ * in its new block, by the time this truncation arrives: its coins are
+ * not this truncation's to take, and the row goes only once nothing
+ * references it. The row's block columns are the one thing wallets
+ * share; between two wallets' rounds they can show the branch the other
+ * wallet last wrote.
+ *
+ * A restored coin needs no veto from stored claims, unlike a sweep's
+ * release: its spender was confirmed, and no block the fork keeps can
+ * hold a second spend of it.
*/
private suspend fun applyChainTruncation(db: DashDatabase, walletId: ByteArray, truncation: ChainTruncation) {
// Drop stale post-deletion callbacks (can't resurrect a wallet).
if (db.walletDao().getByWalletId(walletId) == null) return
- // Removed: dead coins and dead links first, then the rows; the FK
- // cascade takes the pending claims the removed transactions made.
- chunked(truncation.removedTxids) { db.txoDao().deleteByTxids(it) }
- chunked(truncation.removedTxids) { db.txoDao().detachSpenders(it) }
- chunked(truncation.removedTxids) { db.transactionDao().deleteByTxids(it) }
+ // Removed: this wallet's coins, links, claims and involvement
+ // first, then the rows nothing references any more.
+ chunked(truncation.removedTxids) { db.txoDao().deleteWalletTxosByTxids(it, walletId) }
+ chunked(truncation.removedTxids) { db.txoDao().detachWalletSpenders(it, walletId) }
+ chunked(truncation.removedTxids) { db.documentDao().deleteWalletClaimsBySpenders(walletId, it) }
+ chunked(truncation.removedTxids) { db.transactionDao().deleteWalletInvolvements(it, walletId) }
+ chunked(truncation.removedTxids) { db.transactionDao().deleteUnreferencedByTxids(it) }
// Kept: InstantSend-locked, so mined again. Stored until then the
// way a locked transaction no block holds yet is; the coins they
// spent stay spent.
chunked(truncation.unconfirmedTxids) { db.transactionDao().unconfirmByTxids(it) }
- chunked(truncation.unconfirmedTxids) { db.txoDao().unconfirmInstantLockedByTxids(it) }
+ chunked(truncation.unconfirmedTxids) { db.txoDao().unconfirmInstantLockedByTxids(it, walletId) }
// Restored: this wallet's coins the removed transactions spent.
chunked(truncation.restoredOutpoints) { db.txoDao().releaseByOutpoints(it, walletId) }
chunked(truncation.restoredOutpoints) {
diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/DocumentDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/DocumentDao.kt
index 649df5271c4..b1619bb8c7d 100644
--- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/DocumentDao.kt
+++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/DocumentDao.kt
@@ -254,6 +254,18 @@ interface DocumentDao {
)
suspend fun deleteWalletSweptTombstonesByOutpoints(walletId: ByteArray, outpoints: List)
+ /**
+ * Delete [walletId]'s ordinary claims made by one of [spendingTxids] —
+ * its spend-before-funding rows for transactions a chain truncation
+ * removed. Tombstones are not claims of the removed transaction and
+ * stay. Chunked by the caller.
+ */
+ @Query(
+ "DELETE FROM pending_inputs WHERE walletId = :walletId AND isSweptTombstone = 0 " +
+ "AND spendingTransactionTxid IN (:spendingTxids)",
+ )
+ suspend fun deleteWalletClaimsBySpenders(walletId: ByteArray, spendingTxids: List)
+
/** Bulk insert of freshly minted tombstones; Room binds one row at a time. */
@Insert
suspend fun insertPendingInputs(rows: List)
diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt
index 526dc9925b1..d368a4e8e9d 100644
--- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt
+++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt
@@ -111,6 +111,33 @@ interface TransactionDao {
@Query("DELETE FROM transactions WHERE txid IN (:txids)")
suspend fun deleteByTxids(txids: List)
+ /**
+ * Drop [walletId]'s accounts' involvement in [txids] — its part in
+ * transactions a chain truncation removed. Chunked by the caller.
+ */
+ @Query(
+ "DELETE FROM transaction_account_involvements WHERE transactionTxid IN (:txids) " +
+ "AND accountId IN (SELECT id FROM accounts WHERE walletId = :walletId)"
+ )
+ suspend fun deleteWalletInvolvements(txids: List, walletId: ByteArray)
+
+ /**
+ * Delete those of [txids] nothing references any more — the same test
+ * as [deleteOrphanTransactions], limited to the transactions one chain
+ * truncation removed. A row another wallet still has a coin, a spend,
+ * a claim or an involvement in stays. Chunked by the caller.
+ */
+ @Query(
+ "DELETE FROM transactions WHERE txid IN (:txids) " +
+ "AND txid NOT IN (SELECT txid FROM txos WHERE txid IS NOT NULL) " +
+ "AND txid NOT IN (SELECT spendingTxid FROM txos WHERE spendingTxid IS NOT NULL) " +
+ "AND txid NOT IN " +
+ "(SELECT spendingTransactionTxid FROM pending_inputs " +
+ "WHERE spendingTransactionTxid IS NOT NULL) " +
+ "AND txid NOT IN (SELECT transactionTxid FROM transaction_account_involvements)"
+ )
+ suspend fun deleteUnreferencedByTxids(txids: List)
+
/**
* Keep [txids] as unconfirmed and InstantSend-locked: a chain
* truncation dropped their block, and it keeps no other kind. Sets the
diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt
index 64f4c2c6581..ccc105d1a1c 100644
--- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt
+++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt
@@ -167,13 +167,37 @@ interface TxoDao {
suspend fun deleteByTxids(txids: List)
/**
- * Mark every TXO created by one of [txids] unconfirmed and
- * InstantSend-locked — the outputs of transactions a chain truncation
- * kept after dropping their block. Spend state is not touched. Chunked
- * by the caller.
+ * Delete [walletId]'s TXOs created by one of [txids] — its own coins
+ * from transactions a chain truncation removed. Another wallet's coins
+ * from the same transactions are not this wallet's truncation to take:
+ * that wallet's round may already have stored them again, in their new
+ * block. Chunked by the caller.
*/
- @Query("UPDATE txos SET isConfirmed = 0, isInstantLocked = 1, height = 0 WHERE txid IN (:txids)")
- suspend fun unconfirmInstantLockedByTxids(txids: List)
+ @Query("DELETE FROM txos WHERE txid IN (:txids) AND walletId = :walletId")
+ suspend fun deleteWalletTxosByTxids(txids: List, walletId: ByteArray)
+
+ /**
+ * Drop [walletId]'s links to one of [spendingTxids] — the wallet-scoped
+ * form of [detachSpenders], for a chain truncation. Chunked by the
+ * caller.
+ */
+ @Query(
+ "UPDATE txos SET spendingTxid = NULL, spendingInputIndex = NULL " +
+ "WHERE spendingTxid IN (:spendingTxids) AND walletId = :walletId",
+ )
+ suspend fun detachWalletSpenders(spendingTxids: List, walletId: ByteArray)
+
+ /**
+ * Mark [walletId]'s TXOs created by one of [txids] unconfirmed and
+ * InstantSend-locked — its outputs of transactions a chain truncation
+ * kept after dropping their block. Spend state is not touched, nor are
+ * another wallet's outputs. Chunked by the caller.
+ */
+ @Query(
+ "UPDATE txos SET isConfirmed = 0, isInstantLocked = 1, height = 0 " +
+ "WHERE txid IN (:txids) AND walletId = :walletId",
+ )
+ suspend fun unconfirmInstantLockedByTxids(txids: List, walletId: ByteArray)
/**
* Delete the rows at [outpoints] outright — outputs of a transaction
diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt
index a660fa01517..661d4878acc 100644
--- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt
+++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt
@@ -6052,8 +6052,9 @@ class PlatformWalletPersistenceHandlerTest {
removed: List = emptyList(),
unconfirmed: List = emptyList(),
restored: List = emptyList(),
+ wallet: ByteArray = walletId,
): Int = h.onWalletChangesetChainTruncated(
- walletId,
+ wallet,
height,
removed.fold(ByteArray(0)) { acc, txid -> acc + txid },
removed.size,
@@ -6201,6 +6202,112 @@ class PlatformWalletPersistenceHandlerTest {
handler.onChangesetEnd(walletId, success = false)
}
+ // A transaction two wallets share: one row, a coin for each.
+
+ private val sharingWalletA = ByteArray(32) { 0x61 }
+ private val sharingWalletB = ByteArray(32) { 0x62 }
+ private val sharedTxid = ByteArray(32) { 0x63 }
+
+ /** Stage [sharedTxid] mined at [height] with [wallet]'s own output [vout]. The caller brackets the round. */
+ private fun stageSharedOutput(wallet: ByteArray, vout: Int, address: String, height: Int, blockHash: ByteArray) {
+ recordTransaction(
+ handler,
+ wallet, sharedTxid, ByteArray(10) { 5 }, 2, height, blockHash,
+ 1_700_000_100, 0, "Standard", 0, 50_000, 0, false, "", 1_700_000_050,
+ ByteArray(0), 0,
+ )
+ handler.onWalletChangesetUtxoAdded(
+ wallet, sharedTxid, vout, 50_000, address, ByteArray(25) { 6 },
+ height, false, true, false, false,
+ )
+ }
+
+ /** Two wallets, and [sharedTxid] mined at 101 paying output 0 to A and output 1 to B. */
+ private suspend fun seedTransactionSharedByTwoWallets() {
+ seedWalletWithAddress(sharingWalletA, "yWalletA", xpubFill = 30)
+ seedWalletWithAddress(sharingWalletB, "yWalletB", xpubFill = 31)
+ for ((wallet, vout, address) in sharedOutputs) {
+ handler.onChangesetBegin(wallet)
+ stageSharedOutput(wallet, vout, address, 101, ByteArray(32) { 8 })
+ assertEquals(0, handler.onChangesetEnd(wallet, success = true))
+ }
+ }
+
+ private val sharedOutputs = listOf(Triple(sharingWalletA, 0, "yWalletA"), Triple(sharingWalletB, 1, "yWalletB"))
+
+ /**
+ * Each wallet's whole backlog in a round of its own, A's first: the
+ * truncation, then the transaction in its new block with that wallet's
+ * output.
+ */
+ private fun truncateAndMineAgainForEachWallet(removed: List, unconfirmed: List) {
+ for ((wallet, vout, address) in sharedOutputs) {
+ handler.onChangesetBegin(wallet)
+ assertEquals(0, truncate(handler, 100, removed, unconfirmed, wallet = wallet))
+ stageSharedOutput(wallet, vout, address, 102, ByteArray(32) { 9 })
+ assertEquals(0, handler.onChangesetEnd(wallet, success = true))
+ }
+ }
+
+ @Test
+ fun shouldKeepAnotherWalletsReMinedOutputWhenItsOwnTruncationArrivesLater() = runTest {
+ seedTransactionSharedByTwoWallets()
+
+ truncateAndMineAgainForEachWallet(removed = listOf(sharedTxid), unconfirmed = emptyList())
+
+ val first = db.txoDao().getByOutpoint(makeOutpoint(sharedTxid, 0))
+ assertNotNull("wallet A's coin went missing when wallet B's truncation arrived", first)
+ assertEquals("wallet A's coin lost its new block", 102, first!!.height)
+ val second = db.txoDao().getByOutpoint(makeOutpoint(sharedTxid, 1))
+ assertNotNull("wallet B's coin went missing", second)
+ assertEquals("wallet B's coin lost its new block", 102, second!!.height)
+ assertEquals(102, db.transactionDao().getByTxid(sharedTxid)!!.blockHeight)
+ }
+
+ @Test
+ fun shouldKeepAnotherWalletsReMinedOutputConfirmedWhenItsOwnTruncationKeepsTheTransaction() = runTest {
+ seedTransactionSharedByTwoWallets()
+
+ truncateAndMineAgainForEachWallet(removed = emptyList(), unconfirmed = listOf(sharedTxid))
+
+ val first = db.txoDao().getByOutpoint(makeOutpoint(sharedTxid, 0))
+ assertNotNull("wallet A's coin went missing", first)
+ assertTrue(
+ "wallet A's coin was returned to unconfirmed by wallet B's truncation",
+ first!!.isConfirmed,
+ )
+ assertEquals("wallet A's coin lost its new block", 102, first.height)
+ val second = db.txoDao().getByOutpoint(makeOutpoint(sharedTxid, 1))
+ assertNotNull("wallet B's coin went missing", second)
+ assertTrue("wallet B's coin stayed unconfirmed after its own round mined it", second!!.isConfirmed)
+ val row = db.transactionDao().getByTxid(sharedTxid)!!
+ assertEquals(2, row.context)
+ assertEquals(102, row.blockHeight)
+ }
+
+ @Test
+ fun shouldLeaveASharedTransactionWhileAnotherWalletStillHoldsACoinFromIt() = runTest {
+ seedTransactionSharedByTwoWallets()
+
+ handler.onChangesetBegin(sharingWalletA)
+ assertEquals(0, truncate(handler, 100, removed = listOf(sharedTxid), wallet = sharingWalletA))
+ assertEquals(0, handler.onChangesetEnd(sharingWalletA, success = true))
+
+ assertNull("the truncating wallet's own coin is gone", db.txoDao().getByOutpoint(makeOutpoint(sharedTxid, 0)))
+ assertNotNull(
+ "wallet B's coin went missing before its own truncation arrived",
+ db.txoDao().getByOutpoint(makeOutpoint(sharedTxid, 1)),
+ )
+ assertNotNull("the row stays while a wallet has a part in it", db.transactionDao().getByTxid(sharedTxid))
+
+ handler.onChangesetBegin(sharingWalletB)
+ assertEquals(0, truncate(handler, 100, removed = listOf(sharedTxid), wallet = sharingWalletB))
+ assertEquals(0, handler.onChangesetEnd(sharingWalletB, success = true))
+
+ assertNull(db.txoDao().getByOutpoint(makeOutpoint(sharedTxid, 1)))
+ assertNull("and goes once no wallet does", db.transactionDao().getByTxid(sharedTxid))
+ }
+
// ── Bounded tombstone lifetime ────────────────────────────────────
/** One committed round: synced height + (optionally) chainlock bytes. */
From 8b63a1e4c8de829ad73d67574cae54af3b173fe8 Mon Sep 17 00:00:00 2001
From: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Date: Mon, 12 Oct 2026 16:49:34 +0000
Subject: [PATCH 19/28] test(platform-wallet-storage): pin the safe side of a
merged sweep and truncation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A sweep and the fork that drops its winner, folded into one round, can
leave the store withholding a coin the wallet holds again. The new case
runs that shape through the adapter and SQLite, reloads, and requires
that the store holds no coin the wallet does not; round by round it must
match exactly.
The `truncations` doc now says when the two differ: only when the
wallet had recorded the winner. The FFI contract states that a removal
covers the truncating wallet's part of a transaction.
Refs: #5380
🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4
---
.../src/core_wallet_types.rs | 5 +-
.../tests/sqlite_chain_truncation.rs | 53 +++++++++++++++++++
.../src/changeset/changeset.rs | 8 +--
3 files changed, 62 insertions(+), 4 deletions(-)
diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs
index c1958ef04bb..69687ed3b19 100644
--- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs
+++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs
@@ -434,7 +434,10 @@ pub struct SweepBatchFFI {
/// in its new block, by the changeset callback that follows.
///
/// Applying one entry means:
-/// - delete each transaction in `txids` and every UTXO it created;
+/// - delete every UTXO a transaction in `txids` created for this wallet,
+/// and the transaction itself unless another wallet of the same store
+/// still has a part in it. Rounds are per wallet, so that wallet may
+/// already have stored the transaction again, in its new block;
/// - keep each transaction in `unconfirmed_txids` as unconfirmed and
/// InstantSend-locked, which is the only kind a truncation keeps: the
/// InstantSend context, no block, outputs unconfirmed and
diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_chain_truncation.rs b/packages/rs-platform-wallet-storage/tests/sqlite_chain_truncation.rs
index 031df2ee22d..5c62ba5e8a7 100644
--- a/packages/rs-platform-wallet-storage/tests/sqlite_chain_truncation.rs
+++ b/packages/rs-platform-wallet-storage/tests/sqlite_chain_truncation.rs
@@ -763,6 +763,59 @@ async fn should_apply_a_truncation_it_kept_after_dropping_a_later_round() {
}
}
+/// A second spend of the wallet's own sweeps a payment the store already
+/// holds; then the winner's block and the block that funded the coin are
+/// both dropped, and only the funding transaction is mined again. The
+/// wallet holds the coin once more. Round by round the store follows it.
+/// With the sweep and the fork folded into one round the sweep still holds
+/// the coin for a winner that is gone, so the store may withhold it — and
+/// must never hold a coin the wallet does not.
+#[tokio::test]
+async fn should_hand_out_no_coin_the_wallet_lacks_when_a_sweep_and_a_fork_share_a_round() {
+ for rounds in [Rounds::PerStep, Rounds::Merged] {
+ let mut session = Session::open(rounds).await;
+ let funding = session.chain.receive(250_000);
+ let coin = OutPoint::new(funding.txid(), 0);
+ session.chain.mine(vec![funding.clone()]).await;
+ let payment = session.chain.spend(coin, 250_000);
+ session.chain.relay(&payment, None).await;
+ session.step().await;
+ session.flush().await;
+
+ let winner = transaction(
+ &[coin],
+ &[(&session.chain.ours, 100_000), (&foreign(), 100_000)],
+ );
+ session.chain.mine(vec![winner]).await;
+ session.step().await;
+ session.chain.fork_at(BASE).await;
+ session.step().await;
+ session.chain.mine(vec![funding.clone()]).await;
+ session.chain.sync().await;
+ session.step().await;
+ session.flush().await;
+
+ let memory = session.chain.memory().await;
+ assert!(
+ memory.coins.contains_key(&coin),
+ "{rounds:?}: the wallet holds the coin again"
+ );
+ let restored = view(&session.store.restart(session.chain.wallet_id));
+ for (outpoint, held) in &restored.coins {
+ assert_eq!(
+ memory.coins.get(outpoint),
+ Some(held),
+ "{rounds:?}: the store holds a coin the wallet does not"
+ );
+ }
+ if matches!(rounds, Rounds::PerStep) {
+ if let Err(difference) = restored.follows(&memory) {
+ panic!("{rounds:?}: {difference}");
+ }
+ }
+ }
+}
+
/// A truncation is several writes: rows deleted, records rewritten, coins
/// released, heights lowered. One that fails part-way must leave none behind.
#[tokio::test]
diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs
index 4a6bf323ad2..ae9857c5d8c 100644
--- a/packages/rs-platform-wallet/src/changeset/changeset.rs
+++ b/packages/rs-platform-wallet/src/changeset/changeset.rs
@@ -330,9 +330,11 @@ pub struct CoreChangeSet {
/// winner and funding transaction were both in a dropped block, a store
/// applying the merged changeset keeps that coin spent if the funding
/// transaction is mined again and neither the winner nor any other
- /// spender of it is. Applied as separate rounds the coin is free. The
- /// error is on the safe side: the coin is withheld, never handed out
- /// twice.
+ /// spender of it is. Applied as separate rounds the store follows the
+ /// wallet, which holds the coin again when the winner was a transaction
+ /// it had recorded; a winner it never recorded leaves the coin spent in
+ /// the wallet too, and the two agree. The error is on the safe side: the
+ /// coin is withheld, never handed out twice.
///
/// `serde(default)` for the same backward-compatible reading as
/// [`Self::sweeps`].
From d582cff7f2fe3434bb97b0962c02c2f6ff6353c8 Mon Sep 17 00:00:00 2001
From: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Date: Mon, 12 Oct 2026 16:51:32 +0000
Subject: [PATCH 20/28] fix(swift-sdk): read an involved account's wallet
through an Optional cast
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`vacateTransaction` decides whose part of a shared transaction a
truncation takes by the wallet of each involved account. That
relationship is fault-loaded, so it is read the way
`resolvedWalletId(of:)` and `participatingWalletIds` read it: an
inconsistent store yields no wallet, which counts as another wallet's
part, instead of a crash.
Refs: #5380
🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4
---
.../PlatformWalletPersistenceHandler.swift | 9 +++++++--
.../SwiftDashSDKTests/ChainTruncationPersistTests.swift | 5 ++++-
2 files changed, 11 insertions(+), 3 deletions(-)
diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
index 587fd4cf7cb..83092cc7ac9 100644
--- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
+++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
@@ -1614,13 +1614,18 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable {
prefetching: [\.outputs, \.inputs, \.pendingInputs]
) else { return }
+ // Read through an Optional cast, as `resolvedWalletId(of:)` does.
+ let walletOf: (PersistentAccount) -> Data? = { account in
+ let wallet: PersistentWallet? = account.wallet
+ return wallet?.walletId
+ }
// Decided before anything is detached: a coin whose wallet cannot
// be resolved counts as somebody else's.
let sharedWithAnotherWallet =
row.outputs.contains { Self.resolvedWalletId(of: $0) != walletId }
|| row.inputs.contains { Self.resolvedWalletId(of: $0) != walletId }
|| row.pendingInputs.contains { $0.walletId != walletId }
- || row.involvedAccounts.contains { $0.wallet.walletId != walletId }
+ || row.involvedAccounts.contains { walletOf($0) != walletId }
for output in row.outputs where Self.resolvedWalletId(of: output) == walletId {
roundIndex?.txosByOutpoint[output.outpoint] = output
@@ -1639,7 +1644,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable {
for pending in row.pendingInputs where pending.walletId == walletId {
backgroundContext.delete(pending)
}
- row.involvedAccounts.removeAll { $0.wallet.walletId == walletId }
+ row.involvedAccounts.removeAll { walletOf($0) == walletId }
guard !sharedWithAnotherWallet else { return }
Self.unconfirm(row, as: .mempool)
diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ChainTruncationPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ChainTruncationPersistTests.swift
index ef63485b099..f668f875a5c 100644
--- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ChainTruncationPersistTests.swift
+++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ChainTruncationPersistTests.swift
@@ -474,7 +474,10 @@ final class ChainTruncationPersistTests: XCTestCase {
truncateAndMineAgain(handler, wallet: otherWalletId, vout: 1, kept, blockHash: newBlock)
let first = try XCTUnwrap(txo(container, txid: spendTxid, vout: 0), "the first wallet's coin went missing")
- XCTAssertTrue(first.isConfirmed, "the first wallet's coin was returned to unconfirmed by the second wallet's truncation")
+ XCTAssertTrue(
+ first.isConfirmed,
+ "the first wallet's coin was returned to unconfirmed by the second wallet's truncation"
+ )
XCTAssertEqual(first.height, 102, "the first wallet's coin lost its new block")
let second = try XCTUnwrap(txo(container, txid: spendTxid, vout: 1), "the second wallet's coin went missing")
XCTAssertTrue(second.isConfirmed, "the second wallet's coin stayed unconfirmed after its own round mined it")
From 5889000c971766eaa742ce2c52fd57d48b4e721e Mon Sep 17 00:00:00 2001
From: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Date: Mon, 12 Oct 2026 16:59:26 +0000
Subject: [PATCH 21/28] fix(platform-wallet-storage): keep each owed truncation
once
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The wallet-event adapter keeps its own copy of a truncation a store
rejected for good and offers it again with the next round. The SQLite
buffer keeps that truncation too, so a truncation that could never be
applied gained a copy with every round it failed, and each flush
replayed all of them.
Only truncations survive a dropped buffer, and they only ever take away,
so applying one again behind the others changes nothing. A repeat is
dropped when the buffer is cut down to them.
Refs: #5380
🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4
---
.../src/sqlite/persister.rs | 68 ++++++++++++++++++-
1 file changed, 65 insertions(+), 3 deletions(-)
diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs
index dcbdd249fde..33060bcae02 100644
--- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs
+++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs
@@ -1167,13 +1167,21 @@ impl SqlitePersister {
// buffered for the next write, which applies them ahead of
// whatever it brings. One that arrives a second time from a
// caller that kept its own copy lands right behind the first,
- // where applying it again changes nothing.
- let truncations: Vec = cs
+ // where applying it again changes nothing. Nothing but
+ // truncations is kept here, and they only ever take away, so a
+ // repeat is dropped: a truncation that can never be applied
+ // would otherwise add a copy with every round it fails.
+ let mut truncations: Vec = Vec::new();
+ for truncation in cs
.segments
.iter_mut()
.filter_map(|segment| segment.core.as_mut())
.flat_map(|core| std::mem::take(&mut core.truncations))
- .collect();
+ {
+ if !truncations.contains(&truncation) {
+ truncations.push(truncation);
+ }
+ }
let kept_truncation_count = truncations.len();
tracing::error!(
wallet_id = %hex::encode(wallet_id),
@@ -2463,6 +2471,60 @@ fn current_schema_version(conn: &Connection) -> Result, WalletStorag
mod tests {
use super::*;
+ /// A caller that kept its own copy of a rejected truncation offers it
+ /// again with every round. While that truncation cannot be applied each
+ /// round fails, and what the buffer keeps must not grow with them.
+ #[test]
+ fn should_keep_one_copy_of_a_truncation_offered_again_after_a_fatal_flush() {
+ let dir = tempfile::tempdir().expect("temp dir");
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700))
+ .expect("secure tempdir permissions");
+ }
+ let persister =
+ SqlitePersister::open(SqlitePersisterConfig::new(dir.path().join("wallet.db")))
+ .expect("open persister");
+ let wallet_id: WalletId = [7; 32];
+ let at = |height| ChainTruncation {
+ height,
+ txids: Vec::new(),
+ unconfirmed_txids: Vec::new(),
+ restored_outpoints: Vec::new(),
+ };
+ // The second fork is reported while the first is still owed.
+ let offers = [vec![at(100)], vec![at(100), at(90)], vec![at(100), at(90)]];
+
+ for truncations in offers {
+ persister.force_next_flush_to_fail(WalletStorageError::Sqlite(
+ rusqlite::Error::InvalidColumnIndex(0),
+ ));
+ let rejected = persister.store(
+ wallet_id,
+ PlatformWalletChangeSet {
+ core: Some(CoreChangeSet {
+ truncations,
+ ..CoreChangeSet::default()
+ }),
+ ..PlatformWalletChangeSet::default()
+ },
+ );
+ assert!(rejected.is_err(), "the fatal failure rejects the round");
+ }
+
+ let kept: Vec = persister
+ .buffer
+ .take_for_flush(&wallet_id)
+ .expect("buffer lock")
+ .into_iter()
+ .flat_map(|pending| pending.segments)
+ .filter_map(|segment| segment.core)
+ .flat_map(|core| core.truncations)
+ .collect();
+ assert_eq!(kept, vec![at(100), at(90)]);
+ }
+
/// Every table in the migrated schema is accounted for: rehydrated by
/// `load()`, reachable through a dedicated production API, listed as
/// unimplemented, or schema infrastructure. A table in none of those
From d1a4c0fc4331d2d86dc2e5c6fe422c87243abf9d Mon Sep 17 00:00:00 2001
From: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Date: Mon, 12 Oct 2026 18:35:56 +0000
Subject: [PATCH 22/28] fix(platform-wallet): offer a payment a rejected
truncation returned to pending again
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A fork returns a confirmed sent payment to pending, and the row rode
only the round that carried the truncation. When the store rejected that
round the truncation was offered again and the payment row was not, so
after the retry the store kept a confirmed payment for a transaction no
block holds, and a restart loaded it as confirmed.
The adapter now sets those rows aside on any store failure and lays them
under the wallet's next round, where a verdict reached since wins. Only
rows left pending are kept: a swept or confirmed payment of a rejected
round is re-derived by the scan the frozen watermark forces at the next
start, a return to pending is not. Like the truncation list, the rows
are held in memory only.
Refs: #5380
🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4
---
.../src/changeset/core_bridge.rs | 206 +++++++++++++++++-
1 file changed, 205 insertions(+), 1 deletion(-)
diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs
index c3eb080d42c..247a338dd81 100644
--- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs
+++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs
@@ -140,6 +140,11 @@ struct AdapterFaultState {
/// wallet's next round until a store accepts them. Session-scoped: a
/// restart before that round loses them.
unapplied_truncations: HashMap>,
+ /// Sent-payment rows a truncation returned to pending in a round a
+ /// `store()` rejected, per wallet. That verdict is decided once, with
+ /// the fork, so the rows are laid under that wallet's next round until
+ /// a store accepts them. Session-scoped, like the truncations.
+ unstored_reversals: HashMap,
}
impl AdapterFaultState {
@@ -733,9 +738,14 @@ fn commit_wallet(
let WalletBatch {
mut core,
asset_locks,
- payments,
+ mut payments,
} = wallet_batch;
{
+ // Under this round's own rows: a verdict reached since is the newer.
+ if let Some(mut owed) = fault.unstored_reversals.remove(&wallet_id) {
+ merge_payment_overlays(&mut owed, payments);
+ payments = owed;
+ }
// A truncation this wallet's store rejected goes first: everything
// folded since describes the wallet after it.
if let Some(unapplied) = fault.unapplied_truncations.remove(&wallet_id) {
@@ -885,6 +895,7 @@ fn commit_wallet
(
.as_ref()
.map(|core| core.truncations.clone())
.unwrap_or_default();
+ let offered_reversals = returned_to_pending(cs.dashpay_payments_overlay.as_ref());
let store_result = persister.store(wallet_id, cs);
if let Err(error) = &store_result {
// A backend that kept the failed round for its own retry still
@@ -897,6 +908,13 @@ fn commit_wallet
(
.unapplied_truncations
.insert(wallet_id, offered_truncations);
}
+ // Whole rows, so a copy the backend also kept changes nothing:
+ // these are set aside on every failure.
+ if !offered_reversals.is_empty() {
+ fault
+ .unstored_reversals
+ .insert(wallet_id, offered_reversals);
+ }
}
// Recorded only once the store has RETURNED, and whether it accepted
// or rejected — both are answers. A wallet whose store panicked never
@@ -1620,6 +1638,12 @@ fn sent_payment_evidence(event: &WalletEvent) -> Vec<(String, SentPaymentEvidenc
/// the sweep from the frozen watermark, which re-derives the verdict. A
/// rollback ledger would only defend a divergence that cannot outlive the
/// session that caused it.
+///
+/// The exception is a payment a truncation returned to pending: no scan
+/// reports the fork again, so nothing re-derives that verdict. Those rows are
+/// offered again with the wallet's next round, as the truncation is (see
+/// [`returned_to_pending`]). The list is held in memory only; a restart
+/// before a store accepts them leaves the stored payment confirmed.
pub(crate) async fn sent_payment_verdicts(
wallet_manager: &Arc>>,
event: &WalletEvent,
@@ -1700,6 +1724,23 @@ pub(crate) async fn sent_payment_verdicts(
overlay
}
+/// The rows of `overlay` a truncation returned to pending. `Pending` is the
+/// status only that verdict writes (see [`next_sent_payment_status`]), so the
+/// status alone picks them out.
+fn returned_to_pending(overlay: Option<&PaymentOverlay>) -> PaymentOverlay {
+ let mut rows = PaymentOverlay::default();
+ for (owner, payments) in overlay.into_iter().flatten() {
+ for (txid, entry) in payments {
+ if entry.status == PaymentStatus::Pending {
+ rows.entry(*owner)
+ .or_default()
+ .insert(txid.clone(), entry.clone());
+ }
+ }
+ }
+ rows
+}
+
/// The status `entry` moves to under `evidence`, or `None` if it does not
/// move. Received entries never move: their status is settled at the moment
/// they are recorded from an on-chain sighting, and a sweep of an unrelated
@@ -7295,6 +7336,169 @@ mod tests {
assert_eq!(diag.faulted, 0, "a payments-capable host is not a fault");
}
+ /// A fork is reported once, so the payment it returns to pending is
+ /// decided once. When the store rejects that round the truncation is
+ /// offered again; the payment row has to come with it, or the store
+ /// keeps a confirmed payment for a transaction no block holds.
+ #[test]
+ fn should_offer_the_payment_a_rejected_truncation_returned_to_pending_again() {
+ use crate::changeset::PersistenceCapabilities;
+ use crate::wallet::identity::PaymentStatus;
+ let wallet_id = [0x35u8; 32];
+ let (obs_tx, mut obs_rx) = unbounded_channel();
+ let persister = ProbePersister::with_capabilities(
+ obs_tx,
+ PersistenceCapabilities::CORE_CHAIN_TRUNCATION
+ .union(PersistenceCapabilities::DASHPAY_PAYMENTS),
+ );
+ persister.fail_next(wallet_id);
+ let sync_fault = AtomicBool::new(false);
+ let mut fault = AdapterFaultState::default();
+ let freeze_logged = AtomicBool::new(false);
+ let truncation = super::ChainTruncation {
+ height: 600,
+ txids: Vec::new(),
+ unconfirmed_txids: Vec::new(),
+ restored_outpoints: Vec::new(),
+ };
+
+ let mut rejected_round = BTreeMap::new();
+ rejected_round.insert(
+ wallet_id,
+ WalletBatch {
+ core: CoreChangeSet {
+ truncations: vec![truncation.clone()],
+ ..CoreChangeSet::default()
+ },
+ asset_locks: AssetLockChangeSet::default(),
+ payments: one_verdict_overlay(PaymentStatus::Pending),
+ },
+ );
+ commit_batch(
+ &persister,
+ rejected_round,
+ 1,
+ &mut fault,
+ &sync_fault,
+ &freeze_logged,
+ &mut Vec::new(),
+ );
+ assert!(
+ obs_rx
+ .try_recv()
+ .expect("the round reaches store()")
+ .rejected
+ );
+
+ commit_batch(
+ &persister,
+ one_wallet_batch(wallet_id, watermark_with_rows(700, 700)),
+ 2,
+ &mut fault,
+ &sync_fault,
+ &freeze_logged,
+ &mut Vec::new(),
+ );
+ let retried = obs_rx.try_recv().expect("the next round reaches store()");
+ assert_eq!(retried.truncations, vec![truncation]);
+ assert_eq!(
+ retried.dashpay_payments,
+ Some(one_verdict_overlay(PaymentStatus::Pending)),
+ "the payment the truncation returned to pending rides the retry"
+ );
+
+ commit_batch(
+ &persister,
+ one_wallet_batch(wallet_id, watermark_with_rows(701, 701)),
+ 3,
+ &mut fault,
+ &sync_fault,
+ &freeze_logged,
+ &mut Vec::new(),
+ );
+ let after = obs_rx.try_recv().expect("the third round reaches store()");
+ assert_eq!(
+ after.dashpay_payments, None,
+ "a row a store accepted is not offered again"
+ );
+ }
+
+ /// What the store sees on the round after one it rejected, when the
+ /// rejected round carried `rejected` and the next one carries `next`.
+ fn payments_stored_after_a_rejected_round(
+ rejected: super::PaymentOverlay,
+ next: super::PaymentOverlay,
+ ) -> Option {
+ let wallet_id = [0x36u8; 32];
+ let (obs_tx, mut obs_rx) = unbounded_channel();
+ let persister = ProbePersister::with_capabilities(
+ obs_tx,
+ crate::changeset::PersistenceCapabilities::DASHPAY_PAYMENTS,
+ );
+ persister.fail_next(wallet_id);
+ let sync_fault = AtomicBool::new(false);
+ let mut fault = AdapterFaultState::default();
+ let freeze_logged = AtomicBool::new(false);
+
+ for (seq, payments) in [(1, rejected), (2, next)] {
+ let mut batch = BTreeMap::new();
+ batch.insert(
+ wallet_id,
+ WalletBatch {
+ core: watermark_with_rows(700 + seq as u32, 700 + seq as u32),
+ asset_locks: AssetLockChangeSet::default(),
+ payments,
+ },
+ );
+ commit_batch(
+ &persister,
+ batch,
+ seq,
+ &mut fault,
+ &sync_fault,
+ &freeze_logged,
+ &mut Vec::new(),
+ );
+ }
+ assert!(obs_rx.try_recv().expect("the first round").rejected);
+ obs_rx
+ .try_recv()
+ .expect("the second round")
+ .dashpay_payments
+ }
+
+ /// A swept or confirmed payment of a rejected round is re-derived by the
+ /// scan the frozen watermark forces at the next start, so it is not
+ /// carried: only the verdict nothing reports twice is.
+ #[test]
+ fn should_not_offer_a_verdict_the_next_scan_re_derives_again() {
+ use crate::wallet::identity::PaymentStatus;
+ for status in [PaymentStatus::Failed, PaymentStatus::Confirmed] {
+ assert_eq!(
+ payments_stored_after_a_rejected_round(
+ one_verdict_overlay(status),
+ super::PaymentOverlay::default(),
+ ),
+ None,
+ "{status:?}"
+ );
+ }
+ }
+
+ /// The payment was mined again before the retry: the round that says so
+ /// is the newer word on the row.
+ #[test]
+ fn should_store_the_newer_verdict_over_a_carried_return_to_pending() {
+ use crate::wallet::identity::PaymentStatus;
+ assert_eq!(
+ payments_stored_after_a_rejected_round(
+ one_verdict_overlay(PaymentStatus::Pending),
+ one_verdict_overlay(PaymentStatus::Confirmed),
+ ),
+ Some(one_verdict_overlay(PaymentStatus::Confirmed)),
+ );
+ }
+
/// A persister that never attested `DASHPAY_PAYMENTS` cannot apply the
/// overlay, so handing it one would let the round return `Ok` while the
/// verdict was silently dropped. Withhold it instead — and unlike a
From 3d6aeca70d7d3f88d12be10e50669af1d68219bc Mon Sep 17 00:00:00 2001
From: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Date: Mon, 12 Oct 2026 18:35:56 +0000
Subject: [PATCH 23/28] docs(platform-wallet): state why a truncation is
retried after an unvouched failure
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A store failure whose outcome the backend could not vouch for still sets
the truncation aside for the next round. The comment at that point and
the CORE_CHAIN_TRUNCATION contract now say why that is sound: over a
round that landed in part, the truncation removes only what that round
stored above the fork, which the rejection already counts as unstored,
and the lowered height makes the next start scan it back in.
Refs: #5380
🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4
---
packages/rs-platform-wallet/src/changeset/core_bridge.rs | 9 +++++++++
.../src/changeset/persistence_capabilities.rs | 5 +++++
2 files changed, 14 insertions(+)
diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs
index 247a338dd81..707e99ad42d 100644
--- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs
+++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs
@@ -901,6 +901,15 @@ fn commit_wallet(
// A backend that kept the failed round for its own retry still
// holds the truncation. A second copy would land after what that
// round stores behind it, and cut that away.
+ //
+ // Any other failure sets the truncation aside, a round whose
+ // outcome the backend could not vouch for included. Applied over
+ // a round that did land in part, it takes away only what that
+ // round stored above the fork — rows this rejection already
+ // counts as unstored — and it lowers the stored height to the
+ // fork, where the frozen watermark keeps it, so the next start
+ // scans them back in. Withholding it would leave the replaced
+ // branch in the store for good.
let kept_by_backend =
error.is_transient() && !persister.store_transient_is_reissuable();
if !offered_truncations.is_empty() && !kept_by_backend {
diff --git a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs
index 7d78642c05b..669d9947a25 100644
--- a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs
+++ b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs
@@ -130,6 +130,11 @@ impl PersistenceCapabilities {
/// For the same reason a truncation a `store` rejected is offered again
/// ahead of the wallet's next round, and so is never skipped: while it
/// cannot be applied, every later round of that wallet fails with it.
+ /// That includes a rejection whose outcome the backend could not vouch
+ /// for. Over a round that landed in part, the truncation takes away only
+ /// what that round stored above the fork, which the rejection already
+ /// counts as unstored, and the next start scans it back in from the
+ /// height the truncation lowered.
///
/// A backend whose
/// [`store_transient_is_reissuable`](super::PlatformWalletPersistence::store_transient_is_reissuable)
From 22d050f409f77dd16c8554df3f4678db903380ec Mon Sep 17 00:00:00 2001
From: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Date: Mon, 12 Oct 2026 20:13:19 +0000
Subject: [PATCH 24/28] fix(platform-wallet): fence a restored coin where the
wallet is truncated
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The fence on a coin a chain truncation hands back was installed by the
spend-observation handler on the `ChainTruncated` event. A fork recovery
truncates the wallets between two runs of the SPV client: the event
monitor of the stopped run is gone and the next one subscribes only
afterwards, so no handler ever received the event and the fence never
stood. The restored coin was selectable at once, and a send could spend
it again while the undone spend was still on the wire.
`PlatformWalletInfo::truncate_above` now fences the restored coins
itself, under the wallet-manager write lock, so no build can select one
before its fence stands. Every truncation reaches key-wallet through
that method, so the handler's arm is removed.
The test truncates a real manager and delivers no event; the previous
one called the handler directly and could not see the gap.
Refs: #5380
🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4
---
.../rs-platform-wallet/src/manager/mod.rs | 89 ++++++++++---------
.../src/wallet/core/spend_observer.rs | 19 +---
.../src/wallet/platform_wallet_traits.rs | 11 ++-
3 files changed, 61 insertions(+), 58 deletions(-)
diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs
index bb990c88718..f733fb5372a 100644
--- a/packages/rs-platform-wallet/src/manager/mod.rs
+++ b/packages/rs-platform-wallet/src/manager/mod.rs
@@ -1236,41 +1236,29 @@ mod tests {
}
/// A truncation hands back a coin whose spend was mined once and may be
- /// mined again, so the fence that spend retired has to stand again until
- /// the spend is seen a second time.
+ /// mined again, so the coin has to be fenced until the spend is seen a
+ /// second time — and from the moment the wallet lock is released, not
+ /// from the delivery of an event: a fork recovery truncates between two
+ /// runs of the SPV client, when nothing listens for one.
#[tokio::test]
async fn should_fence_a_coin_again_when_a_truncation_undoes_its_spend() {
use dashcore::hashes::Hash as _;
+ use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface;
+ use key_wallet_manager::WalletInterface;
- let mgr = make_manager();
+ const FORK_HEIGHT: u32 = 4;
let (wallet_manager, wallet_id, generation, _signer) =
crate::test_support::funded_wallet_manager(
key_wallet::account::account_type::StandardAccountType::BIP44Account,
)
.await;
- let spv = Arc::new(SpvRuntime::new(
- Arc::clone(&wallet_manager),
- Arc::new(PlatformEventManager::new(Vec::new())),
- ));
- let wallet = Arc::new(PlatformWallet::new(
- Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")),
- wallet_id,
- wallet_manager,
- Arc::clone(&generation),
- Arc::new(Notify::new()),
- Arc::new(NoopPersister) as Arc,
- Arc::new(crate::broadcaster::SpvBroadcaster::new(spv)),
- ));
- mgr.wallets.rcu(|wallets| {
- let mut next = std::collections::BTreeMap::clone(wallets);
- next.insert(wallet_id, Arc::clone(&wallet));
- next
- });
- let coin = dashcore::OutPoint {
- txid: dashcore::Txid::from_slice(&[9u8; 32]).expect("txid"),
- vout: 0,
+ let coin = {
+ let wm = wallet_manager.read().await;
+ let info = wm.get_wallet_info(&wallet_id).expect("the funded wallet");
+ let utxos = info.core_wallet.utxos();
+ utxos.first().expect("the funded coin").outpoint
};
- let tx = dashcore::Transaction {
+ let spend = dashcore::Transaction {
version: 2,
lock_time: 0,
input: vec![dashcore::TxIn {
@@ -1280,29 +1268,48 @@ mod tests {
output: Vec::new(),
special_transaction_payload: None,
};
- let spend_observed = crate::test_support::observed_spend_event(wallet_id, &tx);
- mgr.event_manager.on_wallet_event(&spend_observed);
- assert!(generation.in_broadcast_conflict(&tx).is_none());
+ let block = dashcore::Block {
+ header: dashcore::block::Header {
+ version: dashcore::block::Version::default(),
+ prev_blockhash: dashcore::BlockHash::all_zeros(),
+ merkle_root: dashcore::TxMerkleNode::all_zeros(),
+ time: 0,
+ bits: dashcore::CompactTarget::from_consensus(0x1d00ffff),
+ nonce: 0,
+ },
+ txdata: vec![spend.clone()],
+ };
+ wallet_manager
+ .write()
+ .await
+ .process_block_for_wallets(
+ &block,
+ block.block_hash(),
+ FORK_HEIGHT + 1,
+ &std::collections::BTreeSet::from([wallet_id]),
+ )
+ .await;
+ assert!(
+ generation.in_broadcast_conflict(&spend).is_none(),
+ "a coin the wallet holds as spent needs no fence"
+ );
+
+ // No event is delivered to any handler from here on.
+ wallet_manager
+ .write()
+ .await
+ .truncate_above(FORK_HEIGHT)
+ .expect("no chain lock is applied above the fork");
- mgr.event_manager
- .on_wallet_event(&key_wallet_manager::WalletEvent::ChainTruncated {
- wallet_id,
- height: 999,
- txids: vec![tx.txid()],
- unconfirmed_txids: Vec::new(),
- restored_outpoints: vec![coin],
- balance: key_wallet::WalletCoreBalance::default(),
- account_balances: std::collections::BTreeMap::new(),
- });
assert_eq!(
- generation.in_broadcast_conflict(&tx),
+ generation.in_broadcast_conflict(&spend),
Some(coin),
"the undone spend may be mined again; the coin must not be re-selected"
);
- mgr.event_manager.on_wallet_event(&spend_observed);
+ generation.observe_spent([coin]);
assert!(
- generation.in_broadcast_conflict(&tx).is_none(),
+ generation.in_broadcast_conflict(&spend).is_none(),
"seeing the spend again is what ends the fence"
);
}
diff --git a/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs b/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs
index 67bf2779ffd..fc8d50aa973 100644
--- a/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs
+++ b/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs
@@ -115,20 +115,6 @@ impl SpendObservationHandler {
impl EventHandler for SpendObservationHandler {
fn on_wallet_event(&self, event: &WalletEvent) {
- // The one event that undoes observations instead of adding one.
- if let WalletEvent::ChainTruncated {
- wallet_id,
- restored_outpoints,
- ..
- } = event
- {
- if let Some(wallet) = self.wallets.load().get(wallet_id) {
- wallet
- .generation()
- .fence_restored(restored_outpoints.iter().copied());
- }
- return;
- }
let Some(wallet_id) = observing_wallet(event) else {
return;
};
@@ -199,8 +185,9 @@ pub(crate) fn observed_spends(event: &WalletEvent) -> Vec {
// without the loser's inputs travelling on the event.
WalletEvent::TransactionsSwept { .. } => Vec::new(),
// A truncation reports coins that became unspent again
- // (`restored_outpoints`), the opposite of a spend. The handler
- // fences those instead — see `WalletGeneration::fence_restored`.
+ // (`restored_outpoints`), the opposite of a spend. Those are fenced
+ // where the wallet is truncated — see
+ // `WalletGeneration::fence_restored`.
WalletEvent::ChainTruncated { .. } => Vec::new(),
}
}
diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs
index d5fdd12b64e..e0935138f88 100644
--- a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs
+++ b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs
@@ -162,7 +162,16 @@ impl WalletInfoInterface for PlatformWalletInfo {
}
fn truncate_above(&mut self, height: CoreBlockHeight) -> Result {
- self.core_wallet.truncate_above(height)
+ let truncation = self.core_wallet.truncate_above(height)?;
+ // Fenced here, not on the `ChainTruncated` event: the caller holds
+ // the wallet-manager write lock, so no build can select a restored
+ // coin before its fence stands, and a fork recovery truncates
+ // between two runs of the SPV client, when no handler is listening.
+ // Lock order: wallet manager, then the fence mutex — a leaf, which
+ // nothing holds while it takes another lock.
+ self.generation
+ .fence_restored(truncation.restored_outpoints.iter().copied());
+ Ok(truncation)
}
fn matured_coinbase_records(
From e9b2aaed5aeee56d317bbb83bd0cb8d8d78f97a6 Mon Sep 17 00:00:00 2001
From: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Date: Mon, 12 Oct 2026 21:50:41 +0000
Subject: [PATCH 25/28] fix(platform-wallet): give up a carried payment
reversal the store refuses for good
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A sent-payment row a rejected truncation round returned to pending is
set aside and laid under the wallet's next round. Its identity can be
removed while it waits. The store then refuses the row for good, the
round rolls back whole, and the row was set aside again: every later
round of that wallet failed, the truncation with it, until a restart.
A round that already carried such rows and is refused with a failure
that is not transient now gives them up, so the next round goes through
and applies the truncation. A transient failure still keeps them. The
cost is that two permanent refusals in a row leave the stored payment
confirmed, as it was before the rows were carried at all.
The storage test pins the store contract this relies on: a payment row
of an absent identity rejects the whole round, truncation included.
Refs: #5380
🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4
---
.../tests/sqlite_chain_truncation.rs | 43 +++++++
.../src/changeset/core_bridge.rs | 113 +++++++++++++++++-
2 files changed, 153 insertions(+), 3 deletions(-)
diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_chain_truncation.rs b/packages/rs-platform-wallet-storage/tests/sqlite_chain_truncation.rs
index 5c62ba5e8a7..65f2c1a5d9d 100644
--- a/packages/rs-platform-wallet-storage/tests/sqlite_chain_truncation.rs
+++ b/packages/rs-platform-wallet-storage/tests/sqlite_chain_truncation.rs
@@ -682,6 +682,49 @@ fn core_round(core: CoreChangeSet) -> PlatformWalletChangeSet {
}
}
+/// A payment row needs its identity in the store. One whose identity is
+/// gone is refused for good, and the round it rides in rolls back whole, the
+/// truncation included — which is why the wallet-event adapter gives such a
+/// row up instead of offering it with every round.
+#[tokio::test]
+async fn should_reject_the_whole_round_for_a_payment_row_of_an_absent_identity() {
+ use platform_wallet::changeset::PersistenceErrorKind;
+ use platform_wallet::wallet::identity::PaymentEntry;
+
+ let mut session = Session::open(Rounds::PerStep).await;
+ let wallet_id = session.chain.wallet_id;
+ let spend = session.chain.spend(session.chain.funding, FUNDING);
+ session.chain.mine(vec![spend.clone()]).await;
+ session.chain.sync().await;
+ session.step().await;
+ session.chain.fork_at(BASE).await;
+ let truncation = reported_truncation(session.chain.drain());
+ let absent = dpp::prelude::Identifier::from([0xAB; 32]);
+ let reversal = PaymentEntry::new_sent(dpp::prelude::Identifier::from([0xCD; 32]), 1, None);
+
+ let mut round = core_round(CoreChangeSet {
+ truncations: vec![truncation],
+ synced_height: Some(BASE),
+ ..Default::default()
+ });
+ round.dashpay_payments_overlay = Some(std::collections::BTreeMap::from([(
+ absent,
+ std::collections::BTreeMap::from([(spend.txid().to_string(), reversal)]),
+ )]));
+ let refused = session
+ .store
+ .persister()
+ .store(wallet_id, round)
+ .expect_err("the store refuses the round");
+
+ assert_eq!(refused.kind(), Some(PersistenceErrorKind::Constraint));
+ assert_eq!(
+ session.store.transaction_rows(&spend.txid()).len(),
+ 1,
+ "the truncation rolled back with the row"
+ );
+}
+
/// A store that keeps a round after a busy database owns the truncation in
/// it: nobody else will offer it again. When a later round fails for good
/// and takes the buffer with it, the truncation must still be applied by
diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs
index 707e99ad42d..6dee6d67483 100644
--- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs
+++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs
@@ -742,6 +742,7 @@ fn commit_wallet(
} = wallet_batch;
{
// Under this round's own rows: a verdict reached since is the newer.
+ let carries_reversals = fault.unstored_reversals.contains_key(&wallet_id);
if let Some(mut owed) = fault.unstored_reversals.remove(&wallet_id) {
merge_payment_overlays(&mut owed, payments);
payments = owed;
@@ -918,8 +919,11 @@ fn commit_wallet
(
.insert(wallet_id, offered_truncations);
}
// Whole rows, so a copy the backend also kept changes nothing:
- // these are set aside on every failure.
- if !offered_reversals.is_empty() {
+ // these are set aside on every failure but one. A round that
+ // already carried such rows and is refused for good gives them
+ // up: the store may never take them — their identity removed,
+ // say — and they would fail every later round of the wallet.
+ if !offered_reversals.is_empty() && (error.is_transient() || !carries_reversals) {
fault
.unstored_reversals
.insert(wallet_id, offered_reversals);
@@ -1652,7 +1656,8 @@ fn sent_payment_evidence(event: &WalletEvent) -> Vec<(String, SentPaymentEvidenc
/// reports the fork again, so nothing re-derives that verdict. Those rows are
/// offered again with the wallet's next round, as the truncation is (see
/// [`returned_to_pending`]). The list is held in memory only; a restart
-/// before a store accepts them leaves the stored payment confirmed.
+/// before a store accepts them leaves the stored payment confirmed, and so
+/// does a second refusal that is not transient.
pub(crate) async fn sent_payment_verdicts(
wallet_manager: &Arc>>,
event: &WalletEvent,
@@ -7476,6 +7481,108 @@ mod tests {
.dashpay_payments
}
+ /// What the store sees over three rounds when the first is refused for
+ /// good and carries a truncation with the payment it returned to
+ /// pending, and the second is refused the way `second` says.
+ fn rounds_after_a_refused_reversal(
+ second: fn(&ProbePersister, WalletId),
+ ) -> Vec {
+ use crate::changeset::PersistenceCapabilities;
+ use crate::wallet::identity::PaymentStatus;
+ let wallet_id = [0x37u8; 32];
+ let (obs_tx, mut obs_rx) = unbounded_channel();
+ let persister = ProbePersister::with_capabilities(
+ obs_tx,
+ PersistenceCapabilities::CORE_CHAIN_TRUNCATION
+ .union(PersistenceCapabilities::DASHPAY_PAYMENTS),
+ );
+ let sync_fault = AtomicBool::new(false);
+ let mut fault = AdapterFaultState::default();
+ let freeze_logged = AtomicBool::new(false);
+
+ let mut stored = Vec::new();
+ for seq in 0..3usize {
+ let mut core = watermark_with_rows(700 + seq as u32, 700 + seq as u32);
+ let mut payments = super::PaymentOverlay::default();
+ match seq {
+ 0 => {
+ core.truncations = vec![super::ChainTruncation {
+ height: 600,
+ txids: Vec::new(),
+ unconfirmed_txids: Vec::new(),
+ restored_outpoints: Vec::new(),
+ }];
+ payments = one_verdict_overlay(PaymentStatus::Pending);
+ persister.fail_next(wallet_id);
+ }
+ 1 => second(&persister, wallet_id),
+ _ => {}
+ }
+ let mut batch = BTreeMap::new();
+ batch.insert(
+ wallet_id,
+ WalletBatch {
+ core,
+ asset_locks: AssetLockChangeSet::default(),
+ payments,
+ },
+ );
+ commit_batch(
+ &persister,
+ batch,
+ seq,
+ &mut fault,
+ &sync_fault,
+ &freeze_logged,
+ &mut Vec::new(),
+ );
+ stored.push(obs_rx.try_recv().expect("the round reaches store()"));
+ }
+ assert!(stored[0].rejected && stored[1].rejected);
+ assert_eq!(
+ stored[1].dashpay_payments,
+ Some(one_verdict_overlay(PaymentStatus::Pending)),
+ "the second round carries the row the first one could not store"
+ );
+ stored
+ }
+
+ /// A store can refuse a carried row for good — its identity was removed
+ /// while the row waited. Offered again with every round, the row would
+ /// take each of them down, the truncation with it.
+ #[test]
+ fn should_stop_offering_a_carried_reversal_the_store_refuses_for_good() {
+ let stored = rounds_after_a_refused_reversal(|persister, wallet_id| {
+ persister.fail_next(wallet_id);
+ });
+
+ let third = &stored[2];
+ assert!(!third.rejected, "the round after the refusal goes through");
+ assert_eq!(
+ third.dashpay_payments, None,
+ "a row refused for good is not offered a third time"
+ );
+ assert_eq!(
+ third.truncations, stored[0].truncations,
+ "and the truncation it rode with is applied"
+ );
+ }
+
+ /// A store that is merely busy has said nothing about the row, so it is
+ /// offered until a store answers.
+ #[test]
+ fn should_keep_offering_a_carried_reversal_through_a_transient_failure() {
+ use crate::wallet::identity::PaymentStatus;
+ let stored = rounds_after_a_refused_reversal(|persister, wallet_id| {
+ persister.fail_next_transiently(wallet_id);
+ });
+
+ assert_eq!(
+ stored[2].dashpay_payments,
+ Some(one_verdict_overlay(PaymentStatus::Pending)),
+ );
+ }
+
/// A swept or confirmed payment of a rejected round is re-derived by the
/// scan the frozen watermark forces at the next start, so it is not
/// carried: only the verdict nothing reports twice is.
From a20fdff39885f17793c8bcd44210672f6edac650 Mon Sep 17 00:00:00 2001
From: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Date: Tue, 13 Oct 2026 10:50:50 +0000
Subject: [PATCH 26/28] fix(platform-wallet): keep a round's own payment
reversal when a carried one is given up
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A round that is refused for good gives up the sent-payment reversals it
carried from an earlier rejected round. It gave up every reversal it
held, so one the same round reported itself — from a second fork — went
with them after a single refusal. Nothing reports that fork again, and
the stored payment stayed confirmed.
The rows a round reports itself are now taken before the carried ones
are laid under them. A failure that is not transient sets only those
aside; a transient one still keeps all of them.
Refs: #5380
🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4
---
.../src/changeset/core_bridge.rs | 92 +++++++++++++++++--
1 file changed, 82 insertions(+), 10 deletions(-)
diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs
index 6dee6d67483..58b4c4a69b8 100644
--- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs
+++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs
@@ -143,7 +143,8 @@ struct AdapterFaultState {
/// Sent-payment rows a truncation returned to pending in a round a
/// `store()` rejected, per wallet. That verdict is decided once, with
/// the fork, so the rows are laid under that wallet's next round until
- /// a store accepts them. Session-scoped, like the truncations.
+ /// a store accepts them or refuses them for good on a retry.
+ /// Session-scoped, like the truncations.
unstored_reversals: HashMap,
}
@@ -742,7 +743,7 @@ fn commit_wallet(
} = wallet_batch;
{
// Under this round's own rows: a verdict reached since is the newer.
- let carries_reversals = fault.unstored_reversals.contains_key(&wallet_id);
+ let fresh_reversals = returned_to_pending(Some(&payments));
if let Some(mut owed) = fault.unstored_reversals.remove(&wallet_id) {
merge_payment_overlays(&mut owed, payments);
payments = owed;
@@ -919,14 +920,19 @@ fn commit_wallet
(
.insert(wallet_id, offered_truncations);
}
// Whole rows, so a copy the backend also kept changes nothing:
- // these are set aside on every failure but one. A round that
- // already carried such rows and is refused for good gives them
- // up: the store may never take them — their identity removed,
- // say — and they would fail every later round of the wallet.
- if !offered_reversals.is_empty() && (error.is_transient() || !carries_reversals) {
- fault
- .unstored_reversals
- .insert(wallet_id, offered_reversals);
+ // these are set aside on every failure. One that is not transient
+ // keeps only the rows this round reported itself: a row carried
+ // from an earlier round may never be taken — its identity
+ // removed, say — and would fail every later round of the wallet.
+ if !offered_reversals.is_empty() {
+ let owed = if error.is_transient() {
+ offered_reversals
+ } else {
+ fresh_reversals
+ };
+ if !owed.is_empty() {
+ fault.unstored_reversals.insert(wallet_id, owed);
+ }
}
}
// Recorded only once the store has RETURNED, and whether it accepted
@@ -7568,6 +7574,72 @@ mod tests {
);
}
+ /// The round that gives a carried row up can hold a reversal of its
+ /// own, from a second fork. That one has been refused once, not twice,
+ /// and nothing reports its fork again either.
+ #[test]
+ fn should_keep_a_fresh_reversal_when_a_carried_one_is_given_up() {
+ use crate::wallet::identity::{PaymentEntry, PaymentStatus};
+ let wallet_id = [0x38u8; 32];
+ let (obs_tx, mut obs_rx) = unbounded_channel();
+ let persister = ProbePersister::with_capabilities(
+ obs_tx,
+ crate::changeset::PersistenceCapabilities::DASHPAY_PAYMENTS,
+ );
+ let sync_fault = AtomicBool::new(false);
+ let mut fault = AdapterFaultState::default();
+ let freeze_logged = AtomicBool::new(false);
+ let fresh = super::PaymentOverlay::from([(
+ Identifier::from([0xCC; 32]),
+ BTreeMap::from([(
+ "cafe".to_string(),
+ PaymentEntry::new_sent(Identifier::from([0xDD; 32]), 70_000, None),
+ )]),
+ )]);
+
+ let rounds = [
+ (one_verdict_overlay(PaymentStatus::Pending), true),
+ (fresh.clone(), true),
+ (super::PaymentOverlay::default(), false),
+ ];
+ let mut stored = Vec::new();
+ for (seq, (payments, refused)) in rounds.into_iter().enumerate() {
+ if refused {
+ persister.fail_next(wallet_id);
+ }
+ let mut batch = BTreeMap::new();
+ batch.insert(
+ wallet_id,
+ WalletBatch {
+ core: watermark_with_rows(700 + seq as u32, 700 + seq as u32),
+ asset_locks: AssetLockChangeSet::default(),
+ payments,
+ },
+ );
+ commit_batch(
+ &persister,
+ batch,
+ seq,
+ &mut fault,
+ &sync_fault,
+ &freeze_logged,
+ &mut Vec::new(),
+ );
+ stored.push(obs_rx.try_recv().expect("the round reaches store()"));
+ }
+
+ assert_eq!(
+ stored[1].dashpay_payments.as_ref().map(BTreeMap::len),
+ Some(2),
+ "the refused round held the carried row and its own"
+ );
+ assert_eq!(
+ stored[2].dashpay_payments,
+ Some(fresh),
+ "its own row is offered again, the carried one is not"
+ );
+ }
+
/// A store that is merely busy has said nothing about the row, so it is
/// offered until a store answers.
#[test]
From 7286da481acc732b131aedf1ceb66c96d638f300 Mon Sep 17 00:00:00 2001
From: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Date: Wed, 28 Oct 2026 16:13:55 +0000
Subject: [PATCH 27/28] refactor(platform-wallet): let the changeset decide a
kept record's lock state
A truncation keeps only InstantSend-locked transactions, and both the
wallet and `CoreChangeSet::rewind_to` demote the kept records to
`Mempool`: key-wallet holds the lock of a mined transaction only as a
txid, so there is no lock payload to put back into the context, and a
made-up one would be stored and later read back as a real lock.
The FFI conversion used to restate that rule itself
(`kept_by_truncation` / `is_instant_send_locked`) to project such a
record and its outputs as InstantSend-locked. The rule now lives on its
owner as `CoreChangeSet::is_unconfirmed_instant_send_locked`, and the
FFI only marshals the bool it returns into the row context and the
coins' `is_instantlocked` flag. Projection output is unchanged.
Co-Authored-By: Claude Opus 5.5
---
.../src/core_wallet_types.rs | 55 +++++----------
.../src/changeset/changeset.rs | 67 +++++++++++++++++++
2 files changed, 82 insertions(+), 40 deletions(-)
diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs
index 073b2e7686a..05583280b98 100644
--- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs
+++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs
@@ -1,8 +1,6 @@
//! C-compatible types for core wallet changeset FFI.
-use dashcore::Txid;
use platform_wallet::masternode::{provider_payload_fields, MasternodeRecord};
-use std::collections::BTreeSet;
use std::os::raw::c_char;
// ---------------------------------------------------------------------------
@@ -680,19 +678,6 @@ impl WalletChangeSetFFI {
}
}
- // A truncation keeps only InstantSend-locked transactions, and
- // merging one into this round left the records it keeps in the
- // plain mempool context: the lock is not theirs to carry. The host
- // has stored them as locked by the time these rows arrive — the
- // truncation callback runs first — so they are projected the same
- // way. A record a later block mined again is in that block's
- // context and is not touched.
- let kept_by_truncation: BTreeSet = cs
- .truncations
- .iter()
- .flat_map(|truncation| truncation.unconfirmed_txids.iter().copied())
- .collect();
-
let mut ffi_accounts = Vec::with_capacity(by_account.len());
for (account_type, tx_rows, utxo_slices) in by_account {
let type_name = CString::new(format!("{:?}", account_type))
@@ -706,7 +691,12 @@ impl WalletChangeSetFFI {
let mut utxos_added: Vec = Vec::new();
let mut utxos_spent: Vec = Vec::new();
for rec in &utxo_slices {
- utxos_added.extend(record_new_utxos_ffi(rec, &kept_by_truncation));
+ // The lock state is the changeset's to say: a record's
+ // context alone does not carry it.
+ utxos_added.extend(record_new_utxos_ffi(
+ rec,
+ cs.is_unconfirmed_instant_send_locked(rec),
+ ));
utxos_spent.extend(record_spent_outpoints_ffi(rec));
}
@@ -714,7 +704,7 @@ impl WalletChangeSetFFI {
// folded — see the bucketing comment above).
let transactions: Vec = tx_rows
.into_iter()
- .map(|rec| tx_record_to_ffi(rec, &kept_by_truncation))
+ .map(|rec| tx_record_to_ffi(rec, cs.is_unconfirmed_instant_send_locked(rec)))
.collect();
let utxos_added_count = utxos_added.len();
@@ -1290,9 +1280,11 @@ pub fn account_type_to_tags(at: &key_wallet::account::AccountType) -> AccountCha
/// entries. Mirrors `derive_new_utxos` in
/// `platform_wallet::changeset::core_bridge` but stops one layer
/// further down the stack so the FFI conversion stays self-contained.
+/// `is_instant` is the record's lock state as the changeset reports it
+/// (`CoreChangeSet::is_unconfirmed_instant_send_locked`).
fn record_new_utxos_ffi(
rec: &key_wallet::managed_account::transaction_record::TransactionRecord,
- kept_by_truncation: &BTreeSet,
+ is_instant: bool,
) -> Vec {
use key_wallet::managed_account::transaction_record::OutputRole;
use key_wallet::transaction_checking::TransactionContext;
@@ -1303,7 +1295,6 @@ fn record_new_utxos_ffi(
rec.context,
TransactionContext::InBlock(_) | TransactionContext::InChainLockedBlock(_)
);
- let is_instant = is_instant_send_locked(rec, kept_by_truncation);
let is_coinbase = rec.transaction.is_coin_base();
rec.output_details
@@ -1654,24 +1645,9 @@ pub(crate) fn masternode_entry_v2_ffi(
}
}
-/// Whether `rec` is unconfirmed and InstantSend-locked: it says so itself,
-/// or it is a mempool record a truncation of the same round kept, which a
-/// truncation does for locked transactions only.
-fn is_instant_send_locked(
- rec: &key_wallet::managed_account::transaction_record::TransactionRecord,
- kept_by_truncation: &BTreeSet,
-) -> bool {
- use key_wallet::transaction_checking::TransactionContext;
- match rec.context {
- TransactionContext::InstantSend(_) => true,
- TransactionContext::Mempool => kept_by_truncation.contains(&rec.txid),
- TransactionContext::InBlock(_) | TransactionContext::InChainLockedBlock(_) => false,
- }
-}
-
fn tx_record_to_ffi(
tr: &key_wallet::managed_account::transaction_record::TransactionRecord,
- kept_by_truncation: &BTreeSet,
+ is_instant: bool,
) -> TransactionRecordFFI {
use key_wallet::transaction_checking::TransactionContext;
use std::ffi::CString;
@@ -1685,12 +1661,11 @@ fn tx_record_to_ffi(
let (ctx_val, blk_height, blk_hash, blk_ts, blk_position, has_blk_position) = match &tr.context
{
- TransactionContext::Mempool if !is_instant_send_locked(tr, kept_by_truncation) => {
- (0u32, 0u32, [0u8; 32], 0u32, 0u32, false)
- }
+ // Unconfirmed: whether it is locked is the caller's, read from the
+ // changeset (`CoreChangeSet::is_unconfirmed_instant_send_locked`).
TransactionContext::Mempool | TransactionContext::InstantSend(_) => {
- // InstantSend has no block info — treat as mempool-level with flag
- (1u32, 0u32, [0u8; 32], 0u32, 0u32, false)
+ // InstantSend has no block info — mempool-level with the flag.
+ (u32::from(is_instant), 0u32, [0u8; 32], 0u32, 0u32, false)
}
TransactionContext::InBlock(bi) => {
let mut h = [0u8; 32];
diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs
index a9333102665..c89db1ea9f0 100644
--- a/packages/rs-platform-wallet/src/changeset/changeset.rs
+++ b/packages/rs-platform-wallet/src/changeset/changeset.rs
@@ -378,6 +378,8 @@ pub struct ChainTruncation {
/// InstantSend-locked transactions recorded in blocks above `height`.
/// The network mines them again, so they are kept as unconfirmed: the
/// coins they spent stay spent and their outputs stay InstantSend-locked.
+ /// Their records read `Mempool`; see
+ /// [`CoreChangeSet::is_unconfirmed_instant_send_locked`].
pub unconfirmed_txids: Vec,
/// Coins the removed transactions spent, unspent again.
pub restored_outpoints: Vec,
@@ -742,9 +744,35 @@ fn context_rank(context: &key_wallet::transaction_checking::TransactionContext)
}
impl CoreChangeSet {
+ /// Whether `record`, one of this changeset's records, is unconfirmed
+ /// and InstantSend-locked in the wallet this changeset describes.
+ ///
+ /// A record's own `InstantSend` context says so. So does a `Mempool`
+ /// record that a truncation in this changeset kept: a truncation keeps
+ /// only InstantSend-locked transactions, and the wallet holds the lock
+ /// of a transaction it has seen mined only as a txid, with no lock to
+ /// put back into the record's context (see [`Self::rewind_to`]). A
+ /// projection that writes the lock state of a record and its outputs
+ /// reads it here, not from the context alone.
+ pub fn is_unconfirmed_instant_send_locked(&self, record: &TransactionRecord) -> bool {
+ match record.context {
+ TransactionContext::InstantSend(_) => true,
+ TransactionContext::Mempool => self
+ .truncations
+ .iter()
+ .any(|truncation| truncation.unconfirmed_txids.contains(&record.txid)),
+ TransactionContext::InBlock(_) | TransactionContext::InChainLockedBlock(_) => false,
+ }
+ }
+
/// Rewrite this changeset as `truncation` left the wallet, so that what
/// it holds can be applied after that truncation instead of before it —
/// see [`Self::truncations`].
+ ///
+ /// A kept record is demoted to `Mempool`, as the wallet demotes its
+ /// own: its lock payload is not held anywhere, and a made-up one would
+ /// be stored and later read back as a real lock. Its lock state is read
+ /// through [`Self::is_unconfirmed_instant_send_locked`].
fn rewind_to(&mut self, truncation: &ChainTruncation) {
let removed: HashSet<&Txid> = truncation.txids.iter().collect();
let demoted: HashSet<&Txid> = truncation.unconfirmed_txids.iter().collect();
@@ -4283,4 +4311,43 @@ mod chain_truncation_merge_tests {
assert_eq!(cs.instant_locks_for_non_final_records.len(), 1);
assert_eq!(cs.synced_height, Some(FORK));
}
+
+ #[test]
+ fn should_read_a_transaction_a_merged_truncation_keeps_as_instant_send_locked() {
+ let mut cs = block_with(2, FORK + 1, 9);
+ cs.records.push(record(4, TransactionContext::Mempool));
+ cs.merge(truncation(FORK, &[], &[2], &[]));
+
+ let kept = cs.records.iter().find(|r| r.txid == txid(2)).unwrap();
+ assert!(
+ cs.is_unconfirmed_instant_send_locked(kept),
+ "a truncation keeps only InstantSend-locked transactions"
+ );
+ let unrelated = cs.records.iter().find(|r| r.txid == txid(4)).unwrap();
+ assert!(
+ !cs.is_unconfirmed_instant_send_locked(unrelated),
+ "a mempool record no truncation names is not locked"
+ );
+ }
+
+ #[test]
+ fn should_read_an_instant_send_context_as_locked_and_a_block_as_not() {
+ let cs = CoreChangeSet::default();
+
+ assert!(cs.is_unconfirmed_instant_send_locked(&record(
+ 1,
+ TransactionContext::InstantSend(InstantLock::default())
+ )));
+ assert!(!cs.is_unconfirmed_instant_send_locked(&record(1, in_block(FORK))));
+ assert!(!cs.is_unconfirmed_instant_send_locked(&record(1, TransactionContext::Mempool)));
+ }
+
+ #[test]
+ fn should_not_read_a_kept_transaction_mined_again_as_unconfirmed() {
+ let mut cs = block_with(2, FORK + 1, 9);
+ cs.merge(truncation(FORK, &[], &[2], &[]));
+ cs.merge(block_with(2, FORK + 1, 9));
+
+ assert!(!cs.is_unconfirmed_instant_send_locked(&cs.records[0]));
+ }
}
From dd0acccd17ee9a7e4843c76db49b34106cb101ab Mon Sep 17 00:00:00 2001
From: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Date: Thu, 29 Oct 2026 09:07:03 +0000
Subject: [PATCH 28/28] test(kotlin-sdk): expect the chain-truncation
capability at bit 15
CORE_CHAIN_TRUNCATION is 0x8000 (bit 15), so the handler advertises
0x8bbf. The expectation said 0x4bbf (bit 14, reserved for history
restoration) and failed the Kotlin SDK test job.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01WunYYjpfLux1ByK7fGF7SG
---
.../dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt
index 9e8bb319fa1..5044317ba6f 100644
--- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt
+++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt
@@ -220,7 +220,7 @@ class PlatformWalletPersistenceHandlerTest {
assertEquals(0L, noOpBridge.persistenceCapabilitiesBits())
assertEquals(1, handler.persistenceCapabilitiesVersion())
- assertEquals(0x4bbfL, handler.persistenceCapabilitiesBits())
+ assertEquals(0x8bbfL, handler.persistenceCapabilitiesBits())
// Android has no pending-contact-crypto callback, so it must not
// attest that semantic contract.
assertEquals(0L, handler.persistenceCapabilitiesBits() and 0x40L)