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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,57 @@ class PlatformWalletPersistenceHandlerTest {
assertEquals(123_456, db.walletDao().getByWalletId(walletId)!!.syncedHeight)
}

/**
* dashpay/platform#4069 (Kotlin half of signature C): the
* `syncedHeight` watermark is written by [onWalletChangesetHeader]
* into the SAME buffered transaction as the TXO/tx rows of its
* changeset. A round that rolls back (`success = false`) must
* therefore NOT advance the persisted watermark — otherwise the
* durable watermark could outrun the rows it implies, exactly the
* "empty-and-scanned after restart" corruption in #4069. Pins the

Copy link
Copy Markdown
Member

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.

* rollback path for the core header specifically (the sibling
* `changesetRollbackDiscardsBufferedWrites` only covers the
* platform sync-state write).
*/
@Test
fun walletChangesetHeaderDoesNotAdvanceSyncedHeightOnRollback() = runTest {
handler.onPersistWalletMetadata(walletId, testnet, groupId, 0)
// Establish a committed baseline watermark.
handler.onChangesetBegin(walletId)
handler.onWalletChangesetHeader(
walletId = walletId,
hasSyncedHeight = true,
syncedHeight = 1_000,
hasBalance = false,
confirmedDelta = 0,
unconfirmedDelta = 0,
immatureDelta = 0,
lockedDelta = 0,
lastAppliedChainLockBytes = ByteArray(0),
)
handler.onChangesetEnd(walletId, success = true)
assertEquals(1_000, db.walletDao().getByWalletId(walletId)!!.syncedHeight)

// A later round tries to advance the watermark but rolls back.
handler.onChangesetBegin(walletId)
handler.onWalletChangesetHeader(
walletId = walletId,
hasSyncedHeight = true,
syncedHeight = 2_000,
hasBalance = false,
confirmedDelta = 0,
unconfirmedDelta = 0,
immatureDelta = 0,
lockedDelta = 0,
lastAppliedChainLockBytes = ByteArray(0),
)
handler.onChangesetEnd(walletId, success = false)

// Watermark stays at the last committed value — never the
// rolled-back 2_000.
assertEquals(1_000, db.walletDao().getByWalletId(walletId)!!.syncedHeight)
}

@Test
fun walletChangesetAddsAccountUtxoAndTransaction() = runTest {
handler.onPersistWalletMetadata(walletId, testnet, groupId, 0)
Expand Down
281 changes: 279 additions & 2 deletions packages/rs-platform-wallet-ffi/src/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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()),
}
}
}
Expand All @@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 parking_lot::Mutex (persistence.rs:18, 668), which — unlike std::sync::Mutex — does not poison on unwind. store() acquires it (703), calls begin_round() which sets in_round=true (708), runs the ~900-line per-kind FFI callback sequence, and only clears in_round via end_round() at line 1618. If any code in that window unwinds (an allocation failure, a CString::new on interior-NUL data, or a future panic in per-kind marshalling), the guard's Drop releases the mutex but leaves in_round=true. Every subsequent store() on this Arc<FFIPersister> — shared by the core-changeset bridge, platform-address sync, shielded sync, and DashPay tasks — then hits begin_round(), sees in_round==true, and is rejected as 'round already open' for the rest of the process; the persister never self-heals, so a single transient unwind degrades into a whole-session outage requiring a wallet restart.

Worth noting: because round_lock fully serializes rounds, only one thread is ever inside the begin→end window at a time, so begin_round() can realistically only observe in_round==true as the residue of a prior unwind — the 'concurrent nested begin' the guard nominally defends against cannot occur under the lock (a same-thread reentrant host callback would deadlock on the non-reentrant mutex before reaching begin_round). In other words the guard's only reachable trigger is the stale-unwind case, which it then refuses to recover from.

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 ?/unwrap/expect early returns in the window today (all failure paths set round_success=false and fall through to end_round), so this is primarily defense against a future edit or a hard panic. A small RAII drop-guard that resets in_round on Drop (or a begin_round that treats an already-open state as a recoverable stale round — logging at error! and resetting, since a prior unwind means that host transaction is already gone) would still serialize rounds and reject genuine reentrancy while letting the persister recover on the next round after a transient unwind.

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
Expand All @@ -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;
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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");
}
}
Loading
Loading