Skip to content

fix(platform-wallet): freeze sync watermark on persistence fault — TXO loss/duplication (#4069)#4071

Merged
QuantumExplorer merged 3 commits into
dashpay:feat/kotlin-sdk-and-example-appfrom
bfoss765:fix/kotlin-sdk-txo-persistence
Jul 10, 2026
Merged

fix(platform-wallet): freeze sync watermark on persistence fault — TXO loss/duplication (#4069)#4071
QuantumExplorer merged 3 commits into
dashpay:feat/kotlin-sdk-and-example-appfrom
bfoss765:fix/kotlin-sdk-txo-persistence

Conversation

@bfoss765

@bfoss765 bfoss765 commented Jul 9, 2026

Copy link
Copy Markdown

Fixes the root cause behind #4069 (all three reported signatures).

Root cause

key-wallet-manager publishes wallet persistence events on a bounded tokio broadcast ring (capacity 1000) with fire-and-forget sends. During historical SPV catch-up the producer outruns the JNI→Room consumer, recv() returns Lagged(n), and the dropped events — exactly the TXO/record/spent-marker payloads — are silently lost. SyncHeightAdvanced is the only event whose height reaches the persisted watermark (WalletEntity.syncedHeight), so the watermark advances past blocks whose rows never hit storage:

  • store-nothing: fresh scan floods the ring → all record events dropped → zero rows persisted, watermark = tip → wallet rehydrates empty-and-"fully scanned" (funds invisible, unrecoverable without wallet re-creation)
  • inflated balance: dropped spend events → consumed TXOs rehydrate as spendable
  • watermark vs rows: nothing gates the watermark on record persistence across changesets (within one changeset the Kotlin handler is already atomic — verified)

Also verified NOT the cause: outpoint encoding is consistently wire-order end-to-end, and Room uses a true @Upsert on the outpoint PK.

Fix (workspace-local, rs-platform-wallet/src/changeset/core_bridge.rs)

Once a persistence fault occurs in a session — broadcast Lagged or a rejected store()freeze_synced_height_if_faulted strips synced_height from every subsequent changeset (record/UTXO deltas still persist), pinning the durable watermark at the last fully-committed height. On next launch the wallet restores the lower watermark, the SPV scan resumes over the gap, and the idempotent outpoint-keyed upserts restore missing rows / re-mark lost spends — self-healing instead of silent corruption. Fault logs raised to error!.

The bounded capacity itself lives in the pinned key-wallet-manager crate; bumping it and/or moving persistence to a backpressured channel is the recommended upstream follow-up (noted in code comments).

Tests

cc @QuantumExplorer

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Prevented synchronization progress from advancing when wallet persistence encounters an error or misses events.
    • Ensured failed or rolled-back changesets do not permanently update the stored synchronization height.
    • Preserved other wallet progress information so affected data can be reprocessed safely on the next scan.

… fault (dashpay#4069)

The wallet-event → persister bridge in `core_bridge.rs` drains a bounded
(capacity 1000) `tokio::broadcast` ring published by the upstream
`WalletManager` via fire-and-forget `let _ = event_sender.send(..)`.
During a historical SPV catch-up the manager processes blocks far faster
than the single-threaded, JNI + Room persister can drain them, so the
ring overflows and `recv()` returns `RecvError::Lagged(n)` — the dropped
events are exactly the `TransactionDetected` / `BlockProcessed` records
carrying new UTXOs and spent-outpoint markers. Meanwhile the separate
`SyncHeightAdvanced` event (the ONLY event whose height reaches the host
`syncedHeight`) keeps flowing and advances the persisted watermark past
blocks whose rows never reached disk.

This is the single root cause behind all three dashpay#4069 signatures:
- B (store-nothing): a fresh scan floods the ring, records are dropped,
  yet the watermark advances → next launch rehydrates empty-and-scanned.
- A (inflated balance): dropped `BlockProcessed` spends leave consumed
  outputs unmarked, so they rehydrate as spendable.
- C (watermark outruns rows): `synced_height` is persisted in a changeset
  independent of the record-bearing ones, with no atomicity between them.

Fix (workspace-side, in `platform-wallet`): once persistence has faulted
this session — a broadcast lag OR a rejected `store()` — never advance the
durable watermark again. `freeze_synced_height_if_faulted` strips only
`synced_height` from every subsequent changeset (records/UTXO deltas still
persist), pinning the watermark at the last fully-committed height. On the
next launch the wallet restores that lower watermark and the SPV scan
resumes from it, re-emitting the dropped blocks; the persister's upserts
are idempotent on the outpoint key, so re-applying them restores the
missing rows and re-marks the lost spends — turning the integrator's
manual "clear + recreate wallet" recovery into automatic self-healing.
The `Lagged` / `store()`-error logs are raised to `error!`.

Requires a native rebuild (`build_android.sh`) to take effect — the change
is in the Rust library, not Kotlin. Kotlin-side the watermark is already
atomic with its rows *within* a changeset; added a regression test pinning
that a rolled-back changeset does not advance `WalletEntity.syncedHeight`.

Tests: 2 new Rust unit tests over `freeze_synced_height_if_faulted`
(platform-wallet: 400 passed); 1 new Kotlin test
(PlatformWalletPersistenceHandlerTest: 35 passed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 99d39949-589f-4037-a9f7-cc7c104f17e3

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The wallet event adapter now freezes durable sync-height advancement after persistence failures or dropped events. Tests cover watermark stripping, preservation of other changeset fields, and rollback behavior in Kotlin persistence.

Changes

Persistence watermark handling

Layer / File(s) Summary
Freeze watermark after persistence faults
packages/rs-platform-wallet/src/changeset/core_bridge.rs
The adapter tracks persistence failures and receiver lag, clears synced_height from later changesets, logs the fault, and tests that other fields remain unchanged.
Verify rollback preserves the committed watermark
packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt
A regression test confirms that a rolled-back wallet changeset does not advance the persisted syncedHeight.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested reviewers: shumkov, llbartekll, lklimek

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: freezing the sync watermark after persistence faults to prevent TXO loss and duplication.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@thepastaclaw

thepastaclaw commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

✅ Review complete (commit 4313d84)

@thepastaclaw thepastaclaw left a comment

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.

Code Review

Source: reviewers: claude opus general (ok), codex gpt-5.5 general (failed), claude opus security-auditor (ok), codex gpt-5.5 security-auditor (failed), claude opus ffi-engineer (ok), codex gpt-5.5 ffi-engineer (failed). Verifier: claude opus.

Focused, well-documented fix for a persistence-vs-watermark race in the wallet event adapter. Freezing durable synced_height after any broadcast lag or store() rejection is correctly narrow — only synced_height is stripped, records/UTXOs/last_processed_height stay intact. Two non-blocking suggestions: the fault flag is task-global (freezes all wallets when one faults) and the async fault-then-freeze loop lacks integration coverage.

🟡 2 suggestion(s)

1 additional finding(s) omitted (not in diff).

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/changeset/core_bridge.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/changeset/core_bridge.rs:114-155: Task-global fault flag freezes every wallet's watermark on a single wallet's fault
  `persistence_faulted` is a single `bool` in the shared adapter task, and every event for every wallet drains from the same broadcast subscriber. When wallet A's `store()` fails, wallet B's `synced_height` also stops advancing for the rest of the session even though wallet B's rows are still landing atomically. This is safe (worst case is a redundant rescan on next launch), which is why it's not blocking — but a `HashMap<WalletId, bool>` on the adapter would confine the freeze to the wallet that actually lost rows. A `Lagged` event has no wallet id, so that path would still have to fault every known wallet — the win is limited to the `store()`-rejection path. Worth considering if multi-wallet users are common on the JNI target where the throughput mismatch that motivated this fix is severe.
- [SUGGESTION] packages/rs-platform-wallet/src/changeset/core_bridge.rs:116-178: No integration coverage for the async fault-then-freeze loop
  The new Rust tests exercise `freeze_synced_height_if_faulted` in isolation and the Kotlin test pins the atomic-rollback invariant. Nothing exercises the loop itself: that a `RecvError::Lagged` actually flips `persistence_faulted`, and that a subsequent `SyncHeightAdvanced` is stripped and dropped via `is_empty_no_records`. A small test with a mock `PlatformWalletPersistence` that (a) returns `Err` on the first `store()` and asserts a following watermark-only event is not delivered, and (b) drives a real `broadcast::Sender` past its capacity to synthesize `Lagged` and asserts the same, would lock in the wiring — not just the helper. The loop is exactly where the earlier `warn!`-and-continue behavior lived and where a future refactor is most likely to silently reintroduce the bug.

Comment on lines 114 to 155
@@ -78,22 +123,34 @@ where
// state (today only `TransactionInstantLocked`,
// which checks finality before recording the IS
// lock), grab a brief read lock on the manager.
let core = build_core_changeset(&wallet_manager, &event).await;
let mut core = build_core_changeset(&wallet_manager, &event).await;
// Hold the durable watermark at the last
// fully-persisted height once persistence has
// faulted (see the guard doc above). Records/UTXOs
// in this changeset are still persisted — only the
// height advance is suppressed.
freeze_synced_height_if_faulted(&mut core, persistence_faulted);
if core.is_empty_no_records() {
// SyncHeightAdvanced for an unknown wallet,
// empty BlockProcessed, etc. — nothing to
// persist. Skip the round-trip.
// empty BlockProcessed, a watermark-only event
// stripped by the fault guard above, etc. —
// nothing to persist. Skip the round-trip.
continue;
}
let cs = PlatformWalletChangeSet {
core: Some(core),
..PlatformWalletChangeSet::default()
};
if let Err(e) = persister.store(wallet_id, cs) {
tracing::warn!(
// A rejected changeset means these rows are not
// on disk. Fault the watermark so it can't
// outrun them; the next scan re-emits and the
// idempotent upserts recover the state.
persistence_faulted = true;
tracing::error!(
wallet_id = %hex::encode(wallet_id),
error = %e,
"Persister rejected core changeset; state will be re-emitted on next sync round"
"Persister rejected core changeset; freezing sync watermark so the next scan re-persists the missing rows (dashpay/platform#4069)"
);
}

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: Task-global fault flag freezes every wallet's watermark on a single wallet's fault

persistence_faulted is a single bool in the shared adapter task, and every event for every wallet drains from the same broadcast subscriber. When wallet A's store() fails, wallet B's synced_height also stops advancing for the rest of the session even though wallet B's rows are still landing atomically. This is safe (worst case is a redundant rescan on next launch), which is why it's not blocking — but a HashMap<WalletId, bool> on the adapter would confine the freeze to the wallet that actually lost rows. A Lagged event has no wallet id, so that path would still have to fault every known wallet — the win is limited to the store()-rejection path. Worth considering if multi-wallet users are common on the JNI target where the throughput mismatch that motivated this fix is severe.

source: ['claude']

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.

✅ Fixed in 4313d843: AdapterFaultState now scopes rejected store() rounds to the affected wallet while keeping wallet-id-less broadcast lag global. fault_state_scopes_store_rejection_per_wallet_and_lag_globally covers the split.

@bfoss765

Copy link
Copy Markdown
Author

On-device validation: PASSED (Galaxy S22, testnet, Android wallet parity harness from dashpay/dash-wallet#1507).

With this fix in the native library: full wallet re-creation → complete chain scan → discovered TXOs persisted correctly → balance parity vs dashj confirmed → verification re-passed after app restart (the exact scenario that previously left the wallet empty-and-scanned or inflated — see #4069's three reproductions). Followed by the first successful end-to-end L1→shielded transfer through the SDK pipeline (asset lock → IS lock → Halo2 proof → Type 18 → credits minted).

The integrating app's evidence gate now opens and stays open across restarts. Recommend prioritizing review — this unblocks SDK-consuming wallets generally, not just this integration.

@thepastaclaw

Copy link
Copy Markdown
Collaborator

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/rs-platform-wallet/src/changeset/core_bridge.rs (1)

144-168: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Exercise both fault transitions through the adapter loop.

The tests only validate the helper. Add async tests proving that a rejected store and RecvError::Lagged activate the guard before subsequent watermark events, while record-bearing changesets still persist.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/rs-platform-wallet/src/changeset/core_bridge.rs` around lines 144 -
168, The adapter-loop tests currently cover only the helper; add async
integration tests around the adapter loop that trigger both a rejected
`persister.store` and `RecvError::Lagged`, asserting each sets the persistence
fault before later watermark events are processed. Also verify record-bearing
changesets continue to be persisted after the guard activates, using the
existing adapter loop and test persister/event symbols.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/rs-platform-wallet/src/changeset/core_bridge.rs`:
- Around line 144-168: The adapter-loop tests currently cover only the helper;
add async integration tests around the adapter loop that trigger both a rejected
`persister.store` and `RecvError::Lagged`, asserting each sets the persistence
fault before later watermark events are processed. Also verify record-bearing
changesets continue to be persisted after the guard activates, using the
existing adapter loop and test persister/event symbols.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 92b701d0-0904-4331-bd7d-698db2011c7e

📥 Commits

Reviewing files that changed from the base of the PR and between 6afdb0b and c5a0358.

📒 Files selected for processing (2)
  • packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt
  • packages/rs-platform-wallet/src/changeset/core_bridge.rs

@QuantumExplorer QuantumExplorer left a comment

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.

Codex review of c5a0358: request changes.

The watermark freeze is directionally correct, but it relies on FFIPersister.store returning Ok only after one isolated atomic callback round. That invariant is currently false. FFIPersister has no lock across begin, per-kind callbacks, and end, while core persistence, platform-address sync, shielded sync, and spawned DashPay work share it concurrently. Kotlin keeps one unsynchronized buffer per wallet and Swift keeps one global inChangeset transaction state. Interleaved rounds can therefore drop or roll back core TXO or spent-marker writes while both stores return Ok; the new fault latch remains false and a later SyncHeightAdvanced can still commit. This recreates #4069.

Before merge, serialize the entire FFI callback round with one global lock, treat a nonzero begin as fatal, reject nested begins and unmatched ends, and add a forced concurrent-store regression. Also subscribe to WalletEvents before publishing the manager: subscription currently occurs inside the spawned task, so pre-poll events are lost without Lagged. Finally, the capacity-1000 fire-and-forget channel remains lossy; the patch is fail-closed after detected lag but does not guarantee the claimed one-restart self-healing under repeatable overload. Use a backpressured or atomic persistence path, or narrow the claim and surface a hard sync fault.

The current tests do not exercise concurrent FFI rounds, actual Lagged handling, a rejected store followed by a watermark, startup timing, or two-launch recovery. This is client wallet persistence only and has no consensus impact.

core: Some(core),
..PlatformWalletChangeSet::default()
};
if let Err(e) = persister.store(wallet_id, cs) {

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.

P1: an Ok result here is not currently evidence that this core changeset committed atomically. FFIPersister permits overlapping begin-to-end callback rounds from core, platform-address, shielded, and DashPay writers. Kotlin can replace the open wallet buffer and Swift can commit or roll back another round, while both Rust calls return Ok. The fault flag then remains false and the later SyncHeightAdvanced is persisted past missing TXO or spend rows. Please serialize the complete FFI callback round globally and add a forced-interleaving regression.

// corruption is unrecoverable without deleting + recreating the
// wallet.
//
// Fix: once persistence has faulted this session (a broadcast

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 prevents a detected lag from advancing the durable cursor, but it does not guarantee the claimed self-healing on the next launch. The capacity-1000 producer remains fire-and-forget and ordinary transaction records are not restored into the in-memory map, so the same historical replay can exceed consumer throughput and lag again on every restart. Please use a lossless or backpressured persistence path, stop and rewind with throttling, or narrow this to a fail-closed mitigation and surface a hard sync fault.

* 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.

bfoss765 and others added 2 commits July 10, 2026 07:48
…er one lock (dashpay#4069)

FFIPersister::store ran begin → per-kind callbacks → end with no lock,
while the core-changeset bridge, platform-address sync, shielded sync,
and spawned DashPay tasks all share one Arc<FFIPersister> and call
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 could let one round's
writes land in — or roll back with — the other round's transaction,
dropping core TXO / spent-marker rows while both store() calls still
returned Ok. That silently bypassed the durable-watermark fault latch
and recreated dashpay#4069.

- Add a per-persister parking_lot::Mutex<RoundGuardState> held for the
  entire begin→end round, making each round atomic wrt every other.
  Sync (not async) mutex because store() is a synchronous trait method
  called blocking from both async tasks and blocking FFI entry points.
- Treat a nonzero on_changeset_begin return as fatal: abort the round
  before any per-kind write instead of logging and continuing.
- Add a defensive state machine (RoundGuardState::begin_round /
  end_round) that rejects a nested begin or an unmatched end as an
  error, never a panic, and never wedges the persister after a rejected
  round.
- Tests: forced concurrent-store regression (probe begin/end callbacks
  assert no interleave), nonzero-begin abort + no-wedge, and the
  nested-begin / unmatched-end state-machine rejection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ing, hard sync-fault signal (dashpay#4069)

Follow-ups to the watermark-freeze guard from QuantumExplorer's review:

- Subscribe-before-publish: the wallet-event broadcast receiver is now
  created synchronously in PlatformWalletManager::new, BEFORE the manager
  is wrapped in the shared Arc<RwLock> and handed to producers, then
  moved into the adapter task. Previously the task subscribed itself, so
  every event emitted between manager publication and the task's first
  poll was lost with no Lagged marker (a broadcast::Receiver only sees
  messages sent after its subscribe()).

- Per-wallet fault scoping (CodeRabbit suggestion): AdapterFaultState
  replaces the task-global bool. A store() rejection freezes ONLY the
  named wallet; a broadcast Lagged (no wallet id) freezes every wallet
  via a single global latch that also covers wallet ids first seen
  later. A sibling wallet whose rows are still landing atomically keeps
  advancing.

- Hard sync-fault signal: the adapter raises an Arc<AtomicBool> the host
  can poll via PlatformWalletManager::sync_fault_detected the first time
  it freezes a durable watermark, plus an error-level log, so integrators
  can surface "verification failed / rescan pending" instead of silently
  re-freezing on the next launch.

- Narrowed the self-healing claim in the guard doc: a restart resumes
  from the last durable watermark, but the capacity-1000 fire-and-forget
  producer means repeated overload keeps freezing — this is a fail-closed
  safety mitigation (funds never lost/inflated), not a guaranteed
  one-restart self-heal.

- Extracted the loop into run_wallet_event_adapter and added async
  integration tests exercising the loop itself: real RecvError::Lagged →
  fault → subsequent watermark stripped; rejected store() → watermark
  stripped; record-bearing changesets still persist after the guard
  activates; and subscribe-before-publish timing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bfoss765

Copy link
Copy Markdown
Author

@QuantumExplorer thanks for the review — all findings addressed. Pushed as 0556e58 (FFI persister lock) and 4313d84 (adapter follow-ups) on fix/kotlin-sdk-txo-persistence.

1. Serialize the entire FFI callback round under one lock

The P1 was correct: FFIPersister::store had no lock across begin → per-kind → end, so overlapping rounds from the core bridge / platform-address / shielded / DashPay writers could roll back or clobber each other's client transaction while both store() calls returned Ok, bypassing the fault latch.

  • New per-persister lock held for the whole round: round_lock: Mutex<RoundGuardState>persistence.rs:668, acquired at the top of store() and held through end.
  • Nonzero begin is now fatal — the round aborts before any per-kind write instead of logging and continuing (persistence.rs:730).
  • Defensive state machine RoundGuardState::begin_round / end_round (persistence.rs:628, :641; used at :708, :1618) rejects a nested begin / unmatched end as an error, never a panic, and never wedges the persister after a rejected round.

One deliberate deviation from the ask: the lock is a synchronous parking_lot::Mutex, not an async mutex. store() is a synchronous trait method invoked blocking from both async tasks and blocking FFI entry points, so a tokio::sync::Mutex can't be .awaited there and blocking_lock() panics inside a runtime. Serialization — not async yielding — is the requirement, and callers already block for the round's duration, so the sync mutex only adds waiting under genuine contention. Rationale is in the code comment at the lock site; happy to revisit if you'd prefer the trait go async.

Regression test: concurrent_store_rounds_are_serialized (persistence.rs:5243) drives two threads through the same persister with a probe begin/end callback that latches any interleave; it fails without the lock.

2. Subscribe to WalletEvents before publishing the manager

The receiver is now created synchronously in PlatformWalletManager::new before the manager is wrapped in the shared Arc<RwLock> and handed to producers (manager/mod.rs:131), then moved into the task. spawn_wallet_event_adapter takes the Receiver as a parameter instead of subscribing inside the task (core_bridge.rs:111). Test events_emitted_before_task_poll_are_received (core_bridge.rs:870) pins that an event emitted before the task polls is still delivered.

3. Lossy channel — narrowed claim + hard sync-fault signal

  • (a) The guard doc is rewritten to the narrower claim: a restart resumes from the last durable watermark, but the capacity-1000 fire-and-forget producer means repeated overload keeps freezing. It's a fail-closed safety mitigation (the watermark never outruns its rows → funds never silently lost or inflated), not a guaranteed one-restart self-heal (run_wallet_event_adapter doc, core_bridge.rs:197).
  • (b) Hard fault signal: the adapter raises an Arc<AtomicBool> the host polls via PlatformWalletManager::sync_fault_detected (manager/mod.rs:236) the first time it freezes a watermark, plus an error-level log. No pendingIdentityKeys FFI precedent exists on this branch, so this is the Rust-side flag + accessor + log per the fallback; wiring it through a dedicated FFI symbol can follow once a host wants to render it.

4. Per-wallet fault scoping (CodeRabbit)

AdapterFaultState (core_bridge.rs:63) replaces the task-global bool: store()-rejection faults only the named wallet (fault_wallet, :78); Lagged has no wallet id so it faults everyone via a global latch that also covers wallet ids first seen later (fault_all, :85); is_faulted (:72) is global || per_wallet[id].

5. Tests the review demanded

  • (a) concurrent-store serialization — persistence.rs:5243
  • (b) real RecvError::Lagged through the loop → fault → subsequent watermark stripped — lagged_broadcast_freezes_and_strips_subsequent_watermark (core_bridge.rs:726)
  • (c) rejected store() then watermark-only → not delivered — rejected_store_freezes_wallet_and_strips_watermark (core_bridge.rs:781)
  • (d) record-bearing changesets still persist after the guard activates — record_bearing_changesets_persist_after_guard_activates (core_bridge.rs:829)
  • (e) subscribe-before-publish — events_emitted_before_task_poll_are_received (core_bridge.rs:870)
  • (f) nested-begin / unmatched-end rejection — round_guard_state_machine_rejects_nesting_and_unmatched_end and nonzero_begin_aborts_the_round (persistence.rs:5341, :5303)

cargo test -p platform-wallet and -p platform-wallet-ffi both green; cargo check -p platform-wallet-ffi --features shielded clean. Ready for re-review.

@QuantumExplorer
QuantumExplorer merged commit cac6927 into dashpay:feat/kotlin-sdk-and-example-app Jul 10, 2026
3 checks passed
bezibalazs added a commit that referenced this pull request Jul 10, 2026
"Rust workspace tests / Tests (macOS)" failed at step 7 (cargo fmt --check):
cac6927 landed 6 unformatted spots in persistence.rs / core_bridge.rs.
Reflow only, no code change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@thepastaclaw thepastaclaw left a comment

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.

Code Review

Source: reviewers: claude opus general (ok), codex gpt-5.6-sol general (failed: revoked refresh token), claude opus security-auditor (ok), codex gpt-5.6-sol security-auditor (failed: revoked refresh token), claude opus rust-quality (ok), codex gpt-5.6-sol rust-quality (failed: revoked refresh token), claude opus ffi-engineer (ok), codex gpt-5.6-sol ffi-engineer (failed: revoked refresh token). Verifier: claude opus.

Prior-finding reconciliation (c5a03580 -> 4313d843):

  • FIXED: the task-global store-fault latch is now scoped per wallet, while wallet-id-less broadcast lag remains global.
  • FIXED: four async adapter-loop tests now cover real lag, rejected store, post-fault record persistence, and subscribe-before-publish delivery.

Carried-forward prior findings: none.

New findings in the latest delta: one non-blocking suggestion. A panic after begin_round() can leave the non-poisoning FFI round state latched open, causing every later store in the process to fail closed until restart. The cumulative PR otherwise closes the startup-subscription and concurrent FFI-round persistence gaps without introducing a blocking defect.

Validation: git diff --check c5a03580..4313d843; 7/7 focused platform-wallet core-bridge tests; 3/3 focused platform-wallet-ffi round-state/concurrency tests.

🟡 1 suggestion(s)

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet-ffi/src/persistence.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/persistence.rs:703-708: 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.

Comment on lines +703 to +708
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()?;

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']

shumkov added a commit that referenced this pull request Jul 15, 2026
…nd-example-app

Brings in DIP-13/15 DashPay invitations (#4041). Conflict resolution:
- persistence.rs: kept the round_lock/RoundGuardState round serializer (a
  superset of v4.1-dev's store_round) + grafted #4041's persists_durably()
  invitation-durability attestation. Dropped v4.1-dev's duplicate
  store_round round test (RoundProbe collided with #4071's).
- asset_lock/build.rs: kept both ensure_identity_topup_account and #4041's
  persist_asset_lock_account_pools; tightened broadcast_funded_asset_lock +
  invitation create_invitation to <ExtendedPubKeySigner> to match #4106's
  asset-lock signer tightening (extended the UnreachableSigner test double).
- rs-unified-sdk-jni: left on_persist_invitations_fn None so Android's
  persists_durably() stays fail-closed (the invitation flow refuses to run
  without durable funding-index persistence).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants