Skip to content

fix(platform-wallet): act on swept transactions at the persistence seam - #4560

Open
romchornyi wants to merge 4 commits into
v4.2-devfrom
split/4406-3-producer
Open

fix(platform-wallet): act on swept transactions at the persistence seam#4560
romchornyi wants to merge 4 commits into
v4.2-devfrom
split/4406-3-producer

Conversation

@romchornyi

Copy link
Copy Markdown
Contributor

Stacked on #4559. Review only this PR's own diff; its base is split/4406-2-storage.
Third of the five PRs #4406 was split into: seam → storage → producer → Swift → Kotlin.
This is the heart of #4406, and the smallest it can be.

Issue being fixed or feature implemented

Nothing yet emits a sweep. This PR bumps the rust-dashcore pin and projects the TransactionsSwept event the bump brings with it, so the seam (#4558) and the store (#4559) finally carry the removal a losing double-spend requires.

The pin bump and the arms are one commit by construction. WalletEvent is not #[non_exhaustive] and platform has four exhaustive matches over it, so new-pin code cannot compile without the arms — and arms that did nothing would be worse than none, because upstream's removal is unconditional (wallet_checker.rs): the wallet drops the losing rows in memory, and a store that keeps them replays them at the next load.

What was done?

The pin

4db5c367 → rust-dashcore dev (21aaafed).

Worth knowing why it moves this far: v4.2-dev was pinned to a curated rebase line (chore/sync-fixes-payload-seam) that deliberately omits the whole sweep chain (#961/#962/#966/#969/#975) but carries #991's set_payload_finalizer, which masternode/update_service.rs now requires. Our previous pin had the reverse. No revision carrying both existed, so this takes dev, which carries everything.

dev also carries rust-dashcore#981, which collapses BIP-39 parsing onto one auto-detecting path. Platform's four hand-rolled "try every wordlist" helpers become that function and the call sites drop their Language argument (13 files). Unrelated to sweeps; it rides here only because the sweep chain and the payload-finalization seam this branch's base already depends on both sit above it on dev.

The producer

  • TransactionsSwept → one SweepBatch (core_bridge.rs); is_empty_no_records counts sweeps, so a sweep-only round still reaches the persister.
  • Compile-forced arms in balance_handler.rs (routes the post-removal balance snapshot — a sweep is the one event that can lower a balance) and payment_handler.rs (deliberate no-ops; the payment coupling is fix(platform-wallet): couple a sweep's payment flips to their own persistence round #4442).
  • spend_observer.rs gains sweep arms that report no observed spend: a sweep's released outpoints are coins that came back free, and the inputs it kept spent are precisely the ones it does not name, so the held set cannot be derived from the event at all.

The gate — what makes every intermediate host state safe

A backend that has not attested CORE_SWEEP_REMOVAL is not known to have applied the round's subtractive half, so its watermark is stripped before the store and the wallet faults exactly as on a rejection. Order is load-bearing: reporting the height durable first and faulting after cannot retract a height a legacy backend already committed. Such a host freezes its sync watermark on the first sweep it meets instead of diverging — fail-closed, funds-safe, and unfrozen the moment its persister ships (#4406's Swift and Kotlin PRs).

Reinstatement

A record arriving after a sweep of the same txid retracts that txid from the folded sweep, since persisters write records before replaying sweeps and would otherwise delete a row the wallet has brought back. The asset-lock half mirrors it: a sweep removes the tracked entry its funding transaction created, and AssetLockChangeSet::merge cancels a folded tombstone against a reinstating upsert (and vice versa), so no store sees an upsert/tombstone pair whose outcome depends on which it applies first.

How Has This Been Tested?

cargo test -p platform-wallet -p platform-wallet-ffi -p platform-wallet-storage — 928 + 310 + 138 pass, plus every integration suite in those crates.

Tests travelling with the change (core_bridge.rs): sweep_without_declared_capability_freezes_the_wallet_despite_a_successful_store pins the gate; sweep_names_the_dead_transactions_and_nothing_else, sweep_reaches_the_persister, merged_sweeps_stay_separate_and_ordered; transactions_swept_removes_the_tracked_asset_lock_it_funded and a_reinstating_reconstruction_folded_after_a_sweep_cancels_its_tombstone; transactions_swept_does_not_drive_payment_hooks pins the handler no-op.

Breaking Changes

None for platform's own API.

Release-timing constraint, not a merge constraint: do not cut a swift-sdk or kotlin-sdk release from a base that contains this PR but not its Swift/Kotlin counterparts. A mobile host at that base freezes its sync watermark on the first sweep it meets — funds-safe, but a user-visible stall. Between merges on v4.2-dev nothing auto-ships.

The pin bump also carries rust-dashcore#981's breaking mnemonic API; the platform-side adaptation is included here and is mechanical.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Team

Run ID: 39db2657-33f8-4a2a-919a-6f3a1dcd0b80

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

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 Aug 31, 2026

Copy link
Copy Markdown
Collaborator

🔍 Review in progress — actively reviewing now (commit 66a7c74)
Stage: Phase-1 GLM Flash review → Sol gate verification
ETA: complete ~10:53 UTC (median 57m across 30 recent reviews)
Running 4m · Last checked: 2026-09-07 10:00 UTC

@romchornyi
romchornyi force-pushed the split/4406-2-storage branch from b7f2e47 to 4861a82 Compare August 31, 2026 16:47
@romchornyi
romchornyi force-pushed the split/4406-3-producer branch from 8a655b8 to cffc998 Compare August 31, 2026 16:47
@romchornyi

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Review rate limited.

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.

@thepastaclaw

Copy link
Copy Markdown
Collaborator

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Review rate limited.

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.

llbartekll
llbartekll previously approved these changes Sep 3, 2026

@llbartekll llbartekll 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.

Approving — the producer logic is sound and well covered. One verification request below that I'd like done before the stack lands; it's not an objection to the code.

Verified while reviewing

Three things in the description I checked rather than took on trust, all of which held up:

The pin bump loses nothing. The SHA-level compare looks alarming — 4db5c367...21aaafed is ahead 22, behind 10, diverged, i.e. ten commits reachable from the old pin are not reachable from the new one, including 4db5c367 itself (#991, the payload-finalization seam this branch's base requires). That's purely an artifact of the curated line having been rebased. Every one of the ten exists on dev under a different SHA:

missing from new pin same commit on dev
4db5c367 #991 payload-finalization seam 5f2de2e0
33030acf #980 non-English BIP-39 parse paths b66db390
e8928f8b #947 QRInfo masternode-sync recovery 1a6fb3bf
3acfdb33 #960 / 9a928518 #964 dash-spv tick fixes 54b7f7e5 / e9ef99c5
#963, #970, #965, #967 (seeds, bench) all present

So "dev carries everything" is accurate. 21aaafed is also a single-parent squash-merge on dev rather than a branch tip that can be rebased away, which makes the pin stable.

The mnemonic adaptation is complete. Swept the workspace for missed call sites. The two remaining Language references are both unaffected: wasm-sdk/src/wallet/key_derivation.rs uses the bip39 crate directly (not key_wallet, note SimplifiedChinese vs ChineseSimplified), and rs-unified-sdk-jni uses FFILanguage on the generation path, where a wordlist is still required. All eight workspace deps and twelve Cargo.lock entries moved consistently; no stale pins.

The partial-reinstatement release is safe. The case I went looking for: a batch removes losers A and B, A's untaken input Y lands in released_outpoints, then A returns chainlocked in the same fold. CoreChangeSet::merge retracts A from txids but deliberately keeps the release set, so on the face of it Y gets freed while a surviving record spends it. #4559 closes this — core_state.rs builds claimed_by_survivors from records no batch sweeps and filters them out of released, then surviving_stored_input_claims re-checks against the unpruned on-disk history, with a_released_coin_a_surviving_record_reclaims_stays_spent pinning it.

Worth stating explicitly somewhere, because the two halves are coupled: the store's veto only works because the merge retracted A from txids first. Had the retraction not happened, A would be in swept_txids, its record would be excluded from the survivor set, and Y would be released. The Swift and Kotlin persisters in PRs 4 and 5 need the same veto — the merge-level retraction alone does not protect them.

The one request: run the Rust checks manually

No Rust CI ran on this PR. tests.yml is gated to master, v*-dev and ci/*, and this targets split/4406-2-storage, so there's been no cargo build, no cargo test, no clippy. (CodeRabbit skipped for the same reason, and the two @coderabbitai review retries hit the rate limit.)

That matters more here than it would on a normal PR: the bump touches 97 files upstream across 22 commits, the whole workspace depends on dashcore transitively, and the Mnemonic::from_phrase signature change is proof that breaking changes ride along. cargo test -p platform-wallet -p platform-wallet-ffi -p platform-wallet-storage covers three crates of roughly thirty — it wouldn't catch a second breaking change landing in rs-drive, rs-dpp, dapi or rs-sdk.

Could you run cargo check --workspace and cargo clippy --workspace on this branch (or dispatch Tests manually — workflow_dispatch is in the triggers) and confirm? If the plan is that full CI catches this on the final PR into v4.2-dev, saying so is enough and this is moot. I'd just rather not discover a compile break at PR 5 of 5 and have to walk it back down the stack.

What's good

  • The gate ordering is the load-bearing part and it's right: strip synced_height before store(), fault after. a_coalesced_sweep_and_watermark_never_commits_the_height pins exactly the shape that would otherwise lose data silently, and checking the capability separately from the store() result — rather than trusting an Ok from a host that never saw core.sweeps — is the correct read of the size-negotiated FFI slot.
  • Documenting Merge as "associative but NOT commutative" at the fold, with both order-dependent behaviours named, is the kind of thing that stops a future parallelization from quietly corrupting spend decisions. a_later_sweep_that_keeps_a_coin_spent_outlives_an_earlier_release is a real regression test for the union-the-release-sets mistake, not a restatement of the implementation.
  • AssetLockChangeSet::merge now holds an actual invariant — never an upsert and a tombstone for the same outpoint — instead of depending on stores applying upserts first. Strictly better contract than the one it replaces.
  • The no-op arms explain why nothing happens and where the consequence actually lands, and transactions_swept_does_not_drive_payment_hooks pins the no-op so a later "fix" can't quietly route it back here.

Non-blocking nits

  • Stale doc comment, packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs:25-32 — still says callers "must walk the language list themselves" and that key_wallet::Mnemonic "only exposes language-tagged constructors", which is precisely what #981 removed. It now contradicts the inline comment three lines below it.
  • Four identical copies of parse_mnemonic_any_language (platform-wallet-ffi/derivation.rs, identity_keys_from_mnemonic.rs, manager/wallet_lifecycle.rs, rs-sdk-ffi/signer_simple.rs) are now one-line wrappers around the same call with the same error string. Post-#981 there's nothing left to share but the &'static str narrowing — good moment to collapse or inline them.
  • Vestigial block in commit_wallet — the { … } wrapping the body is a leftover from the loop it was extracted from; dropping it would de-indent the new code by one level.
  • DASHPAY_PAYMENTS in transactions_swept_removes_the_tracked_asset_lock_it_funded is declared with a comment about the flip's overlay being "staged for a payment-durable backend", but nothing in this diff writes dashpay_payments_overlay — that's #4442. The bit is harmless; the comment describes behaviour that isn't here yet.
  • last_processed_height isn't stripped alongside synced_height in the sweep gate. That matches the existing #4069 guard exactly, so it's consistent — but the justification given ("a height that claims blocks are scanned while the removal never landed") reads as applying to both watermarks. Deliberate?
  • core_bridge.rs:715 — "retried against (hopefully, by then) a capable backend" is optimistic in-session, since the persister doesn't change under a running adapter. Recovery is really "next launch after the host ships its persister", which is what the PR description says.

Bumps the rust-dashcore pin to dev and projects the `TransactionsSwept`
event the bump brings with it. The two halves are one commit by
construction: `WalletEvent` is not `#[non_exhaustive]` and platform has
four exhaustive matches over it, so new-pin code cannot compile without
the arms — and arms that did nothing would be worse than none, because
upstream's removal is unconditional. The wallet drops the losing rows in
memory; a store that keeps them replays them at the next load and
re-creates the phantom balance the upstream fix exists to kill.

The projection is one `SweepBatch` per event, and a sweep-only round is
counted in `is_empty_no_records` so a round carrying nothing but a sweep
still reaches the persister.

The gate is what makes every intermediate host state safe. A backend
that has not attested `CORE_SWEEP_REMOVAL` is not known to have applied
the round's subtractive half, so its watermark is stripped BEFORE the
store and the wallet faults exactly as it would on a rejection —
reporting the height durable first and faulting after cannot retract a
height a legacy backend already committed. Such a host freezes its sync
watermark on the first sweep it meets instead of diverging: fail-closed,
funds-safe, and unfrozen the moment its persister ships.

A record arriving after a sweep of the same txid retracts that txid from
the folded sweep, since persisters write records before replaying sweeps
and would otherwise delete a row the wallet has brought back. The
asset-lock half mirrors it: a sweep removes the tracked entry its
funding transaction created, and `AssetLockChangeSet::merge` now cancels
a folded tombstone against a reinstating upsert (and vice versa), so no
store ever sees an upsert/tombstone pair for one outpoint whose outcome
depends on which it applies first.

The pin also carries rust-dashcore#981, which collapses BIP-39 parsing
onto one auto-detecting path. Platform's four hand-rolled
"try every wordlist" helpers are now that function, and the call sites
drop their `Language` argument. It is unrelated to sweeps and rides here
only because the sweep chain and the payload-finalization seam this
branch's base already depends on both sit above it on dev.

`spend_observer`'s two projections gain sweep arms that report no
observed spend: a sweep's released outpoints are coins that came back
free, and the inputs it kept spent are precisely the ones it does not
name, so the held set cannot be derived from the event at all.
…ouched

`cargo fmt --check --all` is a CI gate and the collapsed
`Mnemonic::from_phrase` calls left two of them wrapped.
Review nits, all documentation.

`parse_mnemonic_any_language`'s doc still said `key_wallet::Mnemonic`
"only exposes language-tagged constructors" and that callers "must walk
the language list themselves" — precisely what rust-dashcore#981
removed, and it contradicted the inline comment three lines below. The
wrapper is kept: 20 call sites narrow upstream's error to the
`&'static str` they report, and that narrowing is now what the doc says
it does.

The sweep gate's recovery note read as if a capable backend might appear
mid-session. It cannot: the persister does not change under a running
adapter, so a host without the slot stays frozen until it ships one and
relaunches. Freezing is the point.

`last_processed_height` is now documented as deliberately NOT stripped
beside `synced_height`, matching the #4069 guard: `synced_height` is the
durable "scanned AND persisted" claim that must not outrun an unapplied
removal, while `last_processed_height` is the adapter's own progress
marker whose retention makes nothing safer.

And the asset-lock test's `DASHPAY_PAYMENTS` attestation no longer
describes an overlay this PR writes — nothing here stages
`dashpay_payments_overlay`; the bit is declared so the fixture still
describes a fully capable backend once #4442 lands.

Not taken: de-indenting the vestigial block in `commit_wallet`. It spans
152 lines, so removing it would bury the reviewable diff under a
whitespace-only change and force another rebase of the four PRs stacked
above this one.
…reason

CI lints these crates with `-D warnings`, so clippy's seven-argument
threshold is an error, and the #4370 merge gave `commit_wallet` an
eighth: the `settled` set the panic arm in `run_wallet_event_adapter`
reads back to decide which wallets have an unknown outcome. Every
parameter is a distinct piece of drain state this function reads and
writes, and the borrow split is what keeps them separately mutable —
bundling them would rename the same eight.
Base automatically changed from split/4406-2-storage to v4.2-dev September 7, 2026 19:04
@romchornyi
romchornyi dismissed llbartekll’s stale review September 7, 2026 19:04

The base branch was changed.

@github-actions github-actions Bot added this to the v4.2.0 milestone Sep 7, 2026
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.

4 participants