-
Notifications
You must be signed in to change notification settings - Fork 59
fix(platform-wallet): freeze sync watermark on persistence fault — TXO loss/duplication (#4069) #4071
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(platform-wallet): freeze sync watermark on persistence fault — TXO loss/duplication (#4069) #4071
Changes from all commits
c5a0358
0556e58
4313d84
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,7 +15,7 @@ use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoIn | |
| use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; | ||
| use key_wallet::wallet::Wallet; | ||
| use key_wallet::AddressInfo; | ||
| use parking_lot::RwLock; | ||
| use parking_lot::{Mutex, RwLock}; | ||
| use std::str::FromStr; | ||
|
|
||
| use crate::types::{FFINetwork, Network}; | ||
|
|
@@ -610,17 +610,70 @@ impl Default for PersistenceCallbacks { | |
| } | ||
| } | ||
|
|
||
| /// Defensive state machine for the begin→end FFI callback round, guarded | ||
| /// by [`FFIPersister::round_lock`]. `in_round` is set when a round opens | ||
| /// and cleared once it closes, so a nested begin (or an `end` with no | ||
| /// matching `begin`) is detectable and rejected — as an error, never a | ||
| /// panic — instead of silently corrupting the client's single in-flight | ||
| /// transaction state. | ||
| #[derive(Default)] | ||
| struct RoundGuardState { | ||
| in_round: bool, | ||
| } | ||
|
|
||
| impl RoundGuardState { | ||
| /// Open a round. Rejects (does not panic) if one is already open — | ||
| /// a nested begin, or an unclean round left open by a prior call | ||
| /// that unwound between its begin and end. | ||
| fn begin_round(&mut self) -> Result<(), PersistenceError> { | ||
| if self.in_round { | ||
| return Err(PersistenceError::backend( | ||
| "FFIPersister: changeset round already open (nested begin); \ | ||
| refusing to start a new round", | ||
| )); | ||
| } | ||
| self.in_round = true; | ||
| Ok(()) | ||
| } | ||
|
|
||
| /// Close the current round. Rejects (does not panic) if no round is | ||
| /// open — an unmatched end. | ||
| fn end_round(&mut self) -> Result<(), PersistenceError> { | ||
| if !self.in_round { | ||
| return Err(PersistenceError::backend( | ||
| "FFIPersister: changeset round is not open (unmatched end)", | ||
| )); | ||
| } | ||
| self.in_round = false; | ||
| Ok(()) | ||
| } | ||
| } | ||
|
|
||
| /// In-memory persister that accumulates changesets and notifies via callbacks. | ||
| pub struct FFIPersister { | ||
| callbacks: PersistenceCallbacks, | ||
| pending: RwLock<BTreeMap<WalletId, PlatformWalletChangeSet>>, | ||
| /// Serializes the ENTIRE begin→per-kind→end callback round of | ||
| /// [`Self::store`]. Every round producer (the core-changeset bridge, | ||
| /// platform-address sync, shielded sync, spawned DashPay tasks) shares | ||
| /// one `Arc<FFIPersister>` and calls `store()` concurrently; the host | ||
| /// client keeps a single in-flight-round transaction state (Kotlin: one | ||
| /// per-wallet buffer; Swift: one global `inChangeset` flag), so two | ||
| /// overlapping rounds would let one round's writes land in — or roll | ||
| /// back with — the other round's transaction. That drops core TXO / | ||
| /// spent-marker rows while both `store()` calls still return `Ok`, | ||
| /// bypassing the durable-watermark fault latch and recreating | ||
| /// dashpay/platform#4069. Holding this lock for the whole round makes | ||
| /// each round atomic with respect to every other round. | ||
| round_lock: Mutex<RoundGuardState>, | ||
| } | ||
|
|
||
| impl FFIPersister { | ||
| pub fn new(callbacks: PersistenceCallbacks) -> Self { | ||
| Self { | ||
| callbacks, | ||
| pending: RwLock::new(BTreeMap::new()), | ||
| round_lock: Mutex::new(RoundGuardState::default()), | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -637,6 +690,23 @@ impl PlatformWalletPersistence for FFIPersister { | |
| wallet_id: WalletId, | ||
| changeset: PlatformWalletChangeSet, | ||
| ) -> Result<(), PersistenceError> { | ||
| // Serialize the ENTIRE begin→per-kind→end round against every | ||
| // other round producer (see `round_lock`'s field doc and | ||
| // dashpay/platform#4069). The lock is a synchronous | ||
| // `parking_lot::Mutex`, NOT a `tokio::sync::Mutex`: `store()` | ||
| // is a synchronous trait method invoked directly (blocking) from | ||
| // both async tasks and blocking FFI entry points, so an async | ||
| // mutex cannot be `.await`ed here and `blocking_lock()` panics | ||
| // inside a runtime. Serialization — not async yielding — is the | ||
| // requirement; callers already block for the round's duration, so | ||
| // the sync mutex only adds waiting under genuine round contention. | ||
| let mut round = self.round_lock.lock(); | ||
|
|
||
| // Open the round on the Rust side (rejects a nested begin / an | ||
| // unclean round left open by a prior unwind — error, never | ||
| // panic). Matched 1:1 with the `round.end_round()` below. | ||
| round.begin_round()?; | ||
|
Comment on lines
+703
to
+708
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: A panic between begin_round and end_round permanently wedges the shared FFIPersister for the process The new round_lock is a Worth noting: because round_lock fully serializes rounds, only one thread is ever inside the begin→end window at a time, so This is fail-closed rather than corrupting — a rejected store propagates up, the core_bridge adapter faults the wallet and freezes its watermark, so the worst case is a rescan on next launch, not lost or inflated funds — which is why it is a suggestion. It is also a genuine regression relative to the pre-PR code, which had no round lock and therefore could not wedge. The agents verified there are no source: ['claude'] |
||
|
|
||
| // Bracket the whole per-kind callback sequence with a | ||
| // begin/end pair so clients (Swift, etc.) can treat the | ||
| // round as a single atomic transaction: begin opens a | ||
|
|
@@ -648,7 +718,19 @@ impl PlatformWalletPersistence for FFIPersister { | |
| if let Some(cb) = self.callbacks.on_changeset_begin_fn { | ||
| let result = unsafe { cb(self.callbacks.context, wallet_id.as_ptr()) }; | ||
| if result != 0 { | ||
| eprintln!("Changeset-begin callback returned error code {}", result); | ||
| // A nonzero begin means the client could NOT open its | ||
| // transaction. Proceeding would run every per-kind | ||
| // callback against no batch and then fire an unmatched | ||
| // `end`. Treat it as fatal: close the Rust-side round | ||
| // (so `in_round` doesn't wedge) and fail now, before any | ||
| // per-kind write. (Unlike the previous advisory-log | ||
| // behavior, the round is aborted so no state advances | ||
| // against an unopened batch.) | ||
| let _ = round.end_round(); | ||
| return Err(PersistenceError::backend(format!( | ||
| "changeset-begin callback returned error code {result}; \ | ||
| round aborted before any write" | ||
| ))); | ||
| } | ||
| } | ||
| let mut round_success = true; | ||
|
|
@@ -1526,6 +1608,15 @@ impl PlatformWalletPersistence for FFIPersister { | |
| } | ||
| } | ||
|
|
||
| // Close the round: its `end` callback has fired (committing or | ||
| // rolling back the client transaction), or there was no end | ||
| // callback wired. Clear the state-machine flag now — BEFORE any | ||
| // early return below — so a rejected round doesn't wedge the | ||
| // persister into permanent "round already open" rejection on the | ||
| // next `store()`. (`end_round` only errors on an unmatched end, | ||
| // which cannot happen here since `begin_round` succeeded above.) | ||
| round.end_round()?; | ||
|
|
||
| if !round_success { | ||
| return Err(PersistenceError::backend( | ||
| "one or more persistence callbacks failed; changeset was rolled back", | ||
|
|
@@ -5087,4 +5178,190 @@ mod tests { | |
|
|
||
| unsafe { free_contact_requests_ffi(rows.as_mut_ptr(), rows.len()) }; | ||
| } | ||
|
|
||
| // ── Round serialization + defensive state machine (dashpay/platform#4069) ── | ||
|
|
||
| use std::os::raw::c_void as TestCVoid; | ||
| use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; | ||
| use std::sync::Arc; | ||
|
|
||
| /// Shared context for the begin/end probe callbacks. Records the | ||
| /// chronological boundary log and flags any interleave (a begin while | ||
| /// another round is already open, or an end that doesn't close the | ||
| /// round it should). | ||
| struct RoundProbe { | ||
| /// `true` = begin fired, `false` = end fired, in call order. | ||
| events: parking_lot::Mutex<Vec<bool>>, | ||
| /// Live round depth: must only ever toggle 0↔1. Anything else | ||
| /// means two rounds overlapped. | ||
| depth: AtomicUsize, | ||
| /// Latched if `depth` ever leaves the {0,1} set. | ||
| interleaved: AtomicBool, | ||
| } | ||
|
|
||
| impl RoundProbe { | ||
| fn new() -> Arc<Self> { | ||
| Arc::new(Self { | ||
| events: parking_lot::Mutex::new(Vec::new()), | ||
| depth: AtomicUsize::new(0), | ||
| interleaved: AtomicBool::new(false), | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| extern "C" fn probe_begin(ctx: *mut TestCVoid, _wallet_id: *const u8) -> i32 { | ||
| let probe = unsafe { &*(ctx as *const RoundProbe) }; | ||
| // Entering a round: depth must transition 0 -> 1. | ||
| if probe.depth.fetch_add(1, Ordering::SeqCst) != 0 { | ||
| probe.interleaved.store(true, Ordering::SeqCst); | ||
| } | ||
| probe.events.lock().push(true); | ||
| // Widen the interleave window so an UNSERIALIZED persister is | ||
| // caught deterministically: without the round lock, the sibling | ||
| // thread's begin lands inside this sleep. | ||
| std::thread::sleep(std::time::Duration::from_millis(15)); | ||
| 0 | ||
| } | ||
|
|
||
| extern "C" fn probe_end(ctx: *mut TestCVoid, _wallet_id: *const u8, _success: bool) -> i32 { | ||
| let probe = unsafe { &*(ctx as *const RoundProbe) }; | ||
| probe.events.lock().push(false); | ||
| // Leaving a round: depth must transition 1 -> 0. | ||
| if probe.depth.fetch_sub(1, Ordering::SeqCst) != 1 { | ||
| probe.interleaved.store(true, Ordering::SeqCst); | ||
| } | ||
| 0 | ||
| } | ||
|
|
||
| /// dashpay/platform#4069 (P1 from QuantumExplorer's review): two | ||
| /// concurrent `store()` rounds through the SAME `FFIPersister` must be | ||
| /// fully serialized — no begin fires while another round's begin→end | ||
| /// bracket is still open. Without the global round lock the probe's | ||
| /// `begin` sleep lets the sibling thread's begin interleave, tripping | ||
| /// `interleaved`. | ||
| #[test] | ||
| fn concurrent_store_rounds_are_serialized() { | ||
| let probe = RoundProbe::new(); | ||
| let callbacks = PersistenceCallbacks { | ||
| context: Arc::as_ptr(&probe) as *mut TestCVoid, | ||
| on_changeset_begin_fn: Some(probe_begin), | ||
| on_changeset_end_fn: Some(probe_end), | ||
| ..PersistenceCallbacks::default() | ||
| }; | ||
| let persister = Arc::new(FFIPersister::new(callbacks)); | ||
|
|
||
| const THREADS: u8 = 2; | ||
| const ROUNDS_PER_THREAD: usize = 10; | ||
| let mut handles = Vec::new(); | ||
| for t in 0..THREADS { | ||
| let p = Arc::clone(&persister); | ||
| handles.push(std::thread::spawn(move || { | ||
| for _ in 0..ROUNDS_PER_THREAD { | ||
| // An empty changeset still fires begin + end (they | ||
| // bracket every round unconditionally). | ||
| p.store([t; 32], PlatformWalletChangeSet::default()) | ||
| .expect("empty changeset round must succeed"); | ||
| } | ||
| })); | ||
| } | ||
| for h in handles { | ||
| h.join().expect("store thread panicked"); | ||
| } | ||
|
|
||
| assert!( | ||
| !probe.interleaved.load(Ordering::SeqCst), | ||
| "begin/end rounds interleaved — the global round lock did not \ | ||
| serialize concurrent store() calls" | ||
| ); | ||
|
|
||
| let events = probe.events.lock(); | ||
| let expected = THREADS as usize * ROUNDS_PER_THREAD * 2; | ||
| assert_eq!( | ||
| events.len(), | ||
| expected, | ||
| "each round must fire exactly one begin + one end" | ||
| ); | ||
| // Every begin must be immediately followed by its own end. | ||
| let mut i = 0; | ||
| while i < events.len() { | ||
| assert!(events[i], "expected a begin at position {i}"); | ||
| assert!(!events[i + 1], "expected an end at position {}", i + 1); | ||
| i += 2; | ||
| } | ||
| drop(events); | ||
|
|
||
| // Keep the probe alive until no thread can touch the context | ||
| // pointer any more. | ||
| drop(persister); | ||
| drop(probe); | ||
| } | ||
|
|
||
| /// A nonzero `begin` return is fatal: the client failed to open its | ||
| /// transaction, so `store()` must abort before any per-kind write and | ||
| /// leave the round CLOSED (so the next `store()` isn't wedged). | ||
| #[test] | ||
| fn nonzero_begin_aborts_the_round() { | ||
| extern "C" fn failing_begin(_ctx: *mut TestCVoid, _wallet_id: *const u8) -> i32 { | ||
| 7 | ||
| } | ||
| let callbacks = PersistenceCallbacks { | ||
| on_changeset_begin_fn: Some(failing_begin), | ||
| ..PersistenceCallbacks::default() | ||
| }; | ||
| let persister = FFIPersister::new(callbacks); | ||
| let err = persister | ||
| .store([1u8; 32], PlatformWalletChangeSet::default()) | ||
| .expect_err("a nonzero begin must fail the round"); | ||
| assert!( | ||
| err.to_string().contains("changeset-begin callback returned error code 7"), | ||
| "unexpected error: {err}" | ||
| ); | ||
| // The round must be closed again: a follow-up store() with a | ||
| // healthy (absent) begin succeeds — proving `in_round` was reset. | ||
| let healthy = PersistenceCallbacks::default(); | ||
| let persister2 = FFIPersister::new(healthy); | ||
| persister2 | ||
| .store([1u8; 32], PlatformWalletChangeSet::default()) | ||
| .expect("a healthy round must succeed"); | ||
| // And the failing persister itself is not wedged: repeated calls | ||
| // keep returning the same begin error, never a "round already | ||
| // open" rejection. | ||
| let err2 = persister | ||
| .store([1u8; 32], PlatformWalletChangeSet::default()) | ||
| .expect_err("second call must also fail on begin, not on a stuck round"); | ||
| assert!( | ||
| err2.to_string().contains("changeset-begin"), | ||
| "expected a fresh begin error, got a wedged-round error: {err2}" | ||
| ); | ||
| } | ||
|
|
||
| /// The round state machine rejects a nested begin and an unmatched end | ||
| /// as errors (never panics), and a normal begin→end pair round-trips. | ||
| #[test] | ||
| fn round_guard_state_machine_rejects_nesting_and_unmatched_end() { | ||
| let mut state = RoundGuardState::default(); | ||
| // Fresh: begin opens the round. | ||
| state.begin_round().expect("first begin must open the round"); | ||
| // Nested begin is rejected (error, not panic). | ||
| let nested = state | ||
| .begin_round() | ||
| .expect_err("a nested begin must be rejected"); | ||
| assert!( | ||
| nested.to_string().contains("nested begin"), | ||
| "unexpected nested-begin error: {nested}" | ||
| ); | ||
| // End closes it. | ||
| state.end_round().expect("end must close the open round"); | ||
| // A second end is unmatched → rejected. | ||
| let unmatched = state | ||
| .end_round() | ||
| .expect_err("an unmatched end must be rejected"); | ||
| assert!( | ||
| unmatched.to_string().contains("unmatched end"), | ||
| "unexpected unmatched-end error: {unmatched}" | ||
| ); | ||
| // Fully cycled back to a usable state. | ||
| state.begin_round().expect("state must be reusable after a clean cycle"); | ||
| state.end_round().expect("end must close the reopened round"); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test validates the pre-existing single-threaded rollback behavior; no production Kotlin code changes in this PR, so the same test would pass on the base commit. It does not exercise RecvError::Lagged, a rejected store followed by a watermark, the stateful fault latch, or overlapping JNI store rounds. Please add adapter-level lag and store-error tests plus a concurrent FFI round test.