Skip to content

fix(platform-wallet): couple a sweep's payment flips to their own persistence round - #4442

Draft
romchornyi wants to merge 96 commits into
v4.2-devfrom
fix/sweep-payment-flip-rounds
Draft

fix(platform-wallet): couple a sweep's payment flips to their own persistence round#4442
romchornyi wants to merge 96 commits into
v4.2-devfrom
fix/sweep-payment-flip-rounds

Conversation

@romchornyi

Copy link
Copy Markdown
Contributor

Issue being fixed or feature implemented

Extracted from #4406 to shrink the surface a reviewer has to hold at once. This is the payment half of the swept-transaction work: what happens to a DashPay sent payment when the transaction that carried it loses a double-spend race, and what happens when a chainlocked reinstatement brings it back.

The code here is unchanged from #4406 — it is the same commits, moved. Every finding listed below was raised and resolved there, on the linked threads, and each fix arrives with the regression test that pins it. Nothing is open against this block: the two findings still live on #4406 (the balance-handler try_read snapshot and the JNI winner-array allocation) belong to the remainder and stayed there.

Based on chore/bump-rust-dashcore-dev-961 (#4406), so the diff shows only the payments block. It rebases onto v4.2-dev once #4406 merges.

What was done?

A sweep's payment consequence rides the sweep's own store round, rather than being persisted separately and hoping for a retry.

The flip. When a sweep removes the transaction that carried a Sent payment, the entry flips Pending → Failed. That flip is staged as a dashpay_payments_overlay row on the same PlatformWalletChangeSet as the sweep, so a rejected store() discards both together, the wallet faults, and the replayed sweep recomputes the flip. The alternative — persisting it on its own round — loses it exactly once, permanently: a sweep never re-emits once its round is durable.

The reinstatement. A chainlocked reinstatement corrects Failed → Confirmed, and it too rides the reinstating record's own round, for the same reason in reverse: that correction is one-shot, since a record that arrived already chainlocked gets no later detection to retry from.

Ordering. Three mechanisms compose so no writer can overwrite a newer verdict:

  • a reinstating record in the same fold retracts the sweep-derived overlay row before it can reach any store
  • the commit stage re-validates each staged row against live memory under a read lock held across the store it orders, so an older sweep round cannot overwrite a confirmation that landed first
  • the rejected-round rollback reverts only a still-standing write of the status it wrote

Evidence classes. resolve_sent_payment_by_txid now takes what the caller's evidence can speak for, intersected with the shared transition table: a live signal may apply Failed → Confirmed (a live event for a dead txid is authoritative reinstatement), a reconciler snapshot may not (its read can predate a racing sweep's verdict).

Capability. Staging is gated on ROUND_COUPLED_PAYMENT_FLIPS = DASHPAY_PAYMENTS | ATOMIC_CHANGESETS. The payments bit alone attests per-callback durability, which a host whose callbacks commit independently truthfully provides — but on such a host the record and watermark can commit before the payments write, stranding a one-shot reinstatement beside a durably Failed payment. A host failing the gate keeps the in-memory flip with nothing round-coupled: funds-safe, since payment entries are display metadata, and the funds-critical half of the sweep still gates on CORE_SWEEP_REMOVAL.

Findings resolved here, with the test that pins each

Finding Fix Regression
Payment flip persisted separately from the sweep could be lost once, unrecoverably flip rides the sweep's round swept_payment_flip_rides_the_sweeps_round_and_rolls_back_on_rejection
Same-fold reinstatement left a stale Failed overlay on the round (r3805302098 family) retraction keyed on the fold's own projected records a_reinstating_record_in_the_same_fold_retracts_the_payment_flip
Cross-drain race: an older sweep round could overwrite a confirmation that landed first store-time re-validation under a held read lock a_confirmation_landing_before_the_sweeps_store_retracts_its_stale_failed_row
Unconditional rollback clobbered a concurrently confirmed entry rollback reverts only its own still-standing write rollback_does_not_clobber_a_concurrently_confirmed_entry
Stale reconcile evidence could terminally Confirm a swept payment evidence classes threaded into the resolver a_stale_reconcile_snapshot_cannot_confirm_a_swept_payment
One-shot reinstatement lost when its persist failed reinstatement confirmation rides the record's round a_chainlocked_reinstatement_rides_the_records_round_and_survives_rejection
Payments capability treated as sufficient without an atomic round ROUND_COUPLED_PAYMENT_FLIPS composite an_atomicity_blind_backend_is_not_handed_payment_flips_on_the_round
A payments-blind backend was handed round-coupled overlays staging gated on attestation a_payments_blind_backend_is_not_handed_the_sweeps_flip_on_the_round

How Has This Been Tested?

Every regression above was verified failing without its fix — each was revert-tested individually, not merely observed passing.

Gates on this branch: platform-wallet 697, platform-wallet-ffi 277, platform-wallet-storage all suites including 24/24 sweep, cargo clippy --workspace --all-targets clean, cargo fmt --all -- --check clean.

The extraction itself was verified lossless: with this branch's commit applied on top of the removal commit on #4406, git diff against #4406's pre-extraction head is empty.

No Swift or Kotlin code is touched, so those suites are unaffected.

Breaking Changes

None. ROUND_COUPLED_PAYMENT_FLIPS is a new composite over existing bits; no bit value changes and no FFI signature changes. A host that attests DASHPAY_PAYMENTS without ATOMIC_CHANGESETS stops receiving round-coupled overlays and keeps the in-memory flip — a deliberate narrowing, and funds-safe.

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

Brings in dashpay/rust-dashcore#961, which stops a never-broadcast
transaction from crediting money that does not exist, plus the seven
commits ahead of the previous pin.

#961 adds `WalletEvent::TransactionsSwept`, the first subtractive event
on the wallet bus: it names transactions the wallet removed because a
later, final transaction provably beat them to their inputs. Three
consumers matched exhaustively on `WalletEvent` and now handle it.

- The balance handler routes it like any other balance-bearing variant.
  A sweep is the one event that can lower the balance, and its snapshot
  is post-removal like every other; dropping it would leave the
  corrected-away amount on screen until some later event happened to
  arrive.
- The DashPay payment hooks ignore it: it carries txids, not records.
  A sent payment whose transaction was swept stays `Pending` — the hooks
  only advance a payment forward, and inventing a failure transition is
  a change to the payment state machine, not to event routing.
- The core bridge projects it into a new `CoreChangeSet.swept_txids`,
  the only subtractive field on that type, and `is_empty_no_records`
  counts it — that filter decides whether the persister is called at
  all, so a sweep-only round has to survive it on the strength of the
  txids alone.

Nothing consumes `swept_txids` yet; the persistence seam follows.
The persistence seam had no way to say "this row is gone". Every field
on the changeset was additive, so a swept transaction — a recorded spend
that a later, final transaction beat to one of its inputs, and that can
therefore never confirm — stayed on disk after Rust dropped it, came
back at the next load, and re-created the balance the wallet had just
corrected. That is the bug rust-dashcore#961 fixes, reappearing one
layer up on every consumer that mirrors state.

`WalletChangeSetFFI` gains `swept_txids`, wallet-scoped rather than
per-account: the upstream event is wallet-scoped and the persister
deletes by txid, so the row it deletes carries its own account link.

Both persisters apply it the same way, after the additive part of the
round — the transaction that beat the swept one to its inputs usually
rides along in the same changeset, so by the time the removal runs its
claim is already recorded:

- the transaction row goes, and the outputs it created go with it (a
  cascade on both sides — SwiftData `PersistentTransaction.outputs`, the
  Room `txos.txid` foreign key);
- the coins it claimed to *spend* are released first. The relationship
  only nils the link and would leave `isSpent` set, i.e. a coin marked
  spent by a transaction that no longer exists — invisible to the wallet
  and to the restore set, the same lost-funds shape as the phantom
  balance, inverted. On Android the release has to run before the
  delete: once the FK nulls `spendingTxid` there is nothing left to find
  those rows by.

Transaction rows are keyed by txid alone and shared across wallets by
design, and a sweep is a statement about the transaction rather than
about one wallet's view of it, so neither persister narrows the delete
to the emitting wallet.
… throws

Two findings.

**A released input could be one the winner consumed.** Upstream is explicit
that a sweep frees only the loser's *extra* inputs — "a loser spending A+B
against a winner spending only A must leave A marked and free B" — and the
winner does not have to be wallet-relevant: `test_an_irrelevant_winner_
still_sweeps_its_loser` covers a winner that spends our funding output and
pays entirely to outside addresses, so no record for it ever reaches the
persister. Both persisters released every claim the loser held, so after a
restart that consumed coin came back in the unspent restore set with no
winner record left to re-spend it.

The changeset now carries the pairing: `CoreChangeSet.swept_transactions`
(and `SweptTransactionFFI`) name the removed transaction *and* the
transaction that settled its inputs. That is enough to tell the two kinds
apart without shipping the winner's input list:

- a wallet-relevant winner has re-pointed the shared inputs at itself
  earlier in the same round, so releasing whatever still points at the loser
  releases exactly the loser's extras;
- a winner absent from the store is the irrelevant case, where nothing
  distinguishes them — so the claims stand. The wallet holds no UTXO for
  either kind either, and upstream documents a rescan as the recovery path
  for the freed ones. Keeping a coin out of the restore set is recoverable;
  handing back one the chain has already spent is not.

**A failed fetch read as "no such transaction".** `try?` collapsed a
SwiftData failure into the same no-op as a successful miss, and the round
still reported success — Rust would clear the sweep while the row it named
survived to be replayed at the next load. The lookups throw now, and
`persistWalletChangeset` returns a failure the C shim forwards, so the round
rolls back.

Tests: the irrelevant-winner scenario end to end on both persisters, plus
the A/B split, on top of the existing deletion coverage.
The previous round paired each loser with its winner but still leaned on
the winner's record to keep the shared input spent, and that only works
when such a record exists.

It usually does not look like the tests said it did. Upstream sweeps only
*unconfirmed* records (`!record.is_confirmed()`), and both mirrors flip
`isSpent` solely for a spender that reached a block — so a real swept
loser holds its inputs by link alone, `isSpent == false`. Deleting the
loser nils the link, and every coin it named, the winner's included, fell
straight back into the restore query (`isSpent == false`). The earlier
tests hid this by seeding the loser at `InBlock` with `isSpent = true`, a
state upstream never sweeps.

So the branch that cannot prove anything now holds rather than releases:

- winner present in the store — it is wallet-relevant, its record has
  already re-pointed the inputs it took at itself, so what still points at
  the loser is the loser's own and stays spendable;
- winner absent — it pays only to outside addresses and is never recorded.
  Nothing separates the coin it consumed from the loser's extras, so all
  of them are marked spent with no spender named, keeping them out of the
  restore set. The wallet holds no UTXO for either kind either.

Handing back a coin the chain has already spent is the one outcome that
cannot be undone from here, which is why the uncertainty resolves that
way — and the hold is not permanent: the wallet is authoritative about
which coins are free, and the utxo-added path now clears a mark that has
no spender behind it, so a rescan re-delivering a coin releases it.

Tests now model the unconfirmed loser upstream actually sweeps, and cover
the release path, the hold, and the re-delivery that lifts it, on both
persisters.
Inferring the split from the winner's row was wrong twice over, and the
second way is not fixable downstream: the block path emits
`TransactionsSwept` per winning transaction *before* the `BlockProcessed`
that carries the winner's record, and `run_wallet_event_adapter` ends its
non-waiting drain as soon as `try_recv` sees an empty channel. So a sweep
can commit a whole round before a wallet-relevant winner is even queued.
For a loser spending A+B against a winner taking only A, both mobile
handlers then held A and B; the winner's later record re-pointed A and
never touched B, stranding a genuinely unspent coin outside cold-start
restoration for good.

Upstream already draws the line and now reports it (rust-dashcore#961's
`release_spent_marks`, exposed by dashpay/rust-dashcore#962): the pin moves
to 51eafd8c and `WalletEvent::TransactionsSwept.released_outpoints` names
the inputs no surviving transaction spends. That set flows through
`CoreChangeSet.swept_released_outpoints` and `WalletChangeSetFFI` to all
three persisters, which now apply it verbatim — an outpoint it names goes
back to spendable, every other input the removed transaction claimed stays
spent, and neither depends on when the winner's record shows up or whether
it exists at all.

Also fixes the second blocker: the canonical SQLite persister ignored
`swept_transactions` entirely, so a sweep-only round flushed successfully
while the dead row stayed in `core_transactions`, its outputs in
`core_utxos`, and its inputs untouched — leaving an InstantSend loser
answerable through `get_core_tx_record`, which sent-payment reconciliation
reads as final and would use to advance a dead DashPay payment to
`Confirmed`. `core_state::apply` now applies sweeps in the same
transaction as the rest of the round.

The Swift and Kotlin backstop stays: a coin marked spent with no spender
on record is cleared when the wallet re-delivers it as a UTXO, so a rescan
still recovers anything an older row was left holding.
…aimed

`releaseByOutpoint` matched on the outpoint alone, so it cleared whatever
spend claim the row happened to hold. A round can carry both a release and
a later transaction that legitimately spends the freed coin — merging folds
several events together, and every record is written before sweeps are
processed — so by the time the release ran the coin could already be
claimed again. Clearing that claim put a spent coin back in the restore
set, which is the failure the sweep handling exists to prevent.

Restrict the update to rows with `spendingTxid IS NULL`. Paired with the
existing hold-then-release order that is exactly the right set: holding
detaches the rows this round's removals still claim, so only those qualify,
while a row a live transaction claims keeps it.

Swift never had this: `applySweptTransaction` walks
`PersistentTransaction.inputs`, the inverse of `spendingTransaction`, so it
only ever touches rows still pointing at the removed transaction. Keying
the Kotlin query on the outpoint is what lost that property.
…persister

`swept_transactions` became a non-empty part of `CoreChangeSet`, but
`core_state::apply` never read it. A sweep-only changeset was therefore
accepted and flushed successfully while the dead row stayed in
`core_transactions`, the outputs it created stayed in `core_utxos`, and
its input state was untouched — the subtractive guarantee simply did not
hold for this first-party backend. It also left an InstantSend loser
answerable through `get_core_tx_record`, which sent-payment reconciliation
treats as final and can use to advance a dead DashPay payment to
`Confirmed`.

Apply each sweep in the same transaction as the rest of the round, after
the additive writes: delete the removed transaction and the UTXOs it
created, then resolve the coins it claimed to spend from
`swept_released_outpoints` — an outpoint named there goes back to
spendable, every other input it claimed stays spent because the
transaction that beat it took them.

Each input is written outright rather than only when it changes, since a
coin the sweep did not free must end the round out of the unspent query
even when nothing had marked it spent yet: upstream sweeps only
unconfirmed records, whose spends this schema does not mark.
… claim

Two defects in the SQLite sweep, both found in review.

The release was applied unconditionally. A round can carry both a release
and a later transaction that legitimately spends the freed coin — merging
folds several events together, and every record is written before sweeps
are processed — so the coin could already be claimed again by the time the
sweep ran, and setting `spent = 0` handed a consumed coin back to the
unspent query.

The mobile mirrors settle this by looking at who currently claims the row,
but `core_utxos` never records that: `spent_in_txid` stays null on every
write path. The changeset carries the answer instead — a record in this
round that is not itself being swept and spends a released outpoint is the
live claim — so the release now defers to it. This is the SQLite half of
the same defect fixed on the Kotlin side by `spendingTxid IS NULL`.

Second, a swept transaction's InstantLock row survived it. A chainlocked
winner may evict an InstantSend-locked loser, so a swept transaction can
own a row in `core_instant_locks`, and nothing ties that table to
`core_transactions` — no foreign key, no trigger. Delete it in the same
transaction.

Both regressions are covered, and both tests were confirmed to fail
without their fix.
A release is only true of the wallet the sweep that made it saw — it is not
a property of the whole drain. The adapter folds every event buffered in one
pass into a single changeset, so two sweeps that disagree were being
reconciled by unioning their release sets, and the earlier answer won.

The shape that breaks: a sweep frees B, a later transaction spends B, and a
final winner consumes B while sweeping that spender. The second sweep frees
nothing, precisely because its winner took B. Unioned, B stays in the
release set; the spender is in `swept_transactions`, so SQLite excludes it
from `claimed_by_survivors` and the mobile handlers detach its claim before
applying the same global set. All three backends then persist a coin the
chain consumed as spendable.

Replace `swept_transactions` + `swept_released_outpoints` with
`sweeps: Vec<SweepBatch>`, each carrying its own removals, winner and
release set, merged by appending rather than folding. Every backend applies
them in sequence, so a later batch corrects the one before it — which is
what the wallet itself did.

The FFI mirrors the nesting (`SweepBatchFFI`), and JNI now makes one bridge
call per batch, so the Kotlin handler's signature is unchanged and its
existing hold-then-release gives the ordering for free.

Regression coverage on all three backends plus the merge itself, each
confirmed to fail against the folded set.
Ordering the sweep batches fixed them relative to each other, but records
still sit in their own vector and every persister writes all of them before
replaying any sweep. So a transaction removed by a buffered sweep and then
recorded again in the same round was deleted anyway, along with its outputs,
while the in-memory wallet had kept it.

Reachable through IS-lock precedence, which the pinned wallet permits: an
unconfirmed transaction is swept when an IS-locked conflict arrives, then
comes back chainlocked and sweeps that conflict in turn. One drain then
holds records for both plus removals for both.

Merging now drops a reinstated txid from any sweep already buffered — the
record is the newer fact — and drops the batch entirely once nothing is left
to remove. The batch's release set goes with it: it described a wallet in
which that transaction was gone, and leaving those coins spent is the
recoverable direction, since the wallet re-delivers a genuinely free one as
a UTXO while a coin handed back that the chain consumed cannot be taken away
again.

Also fixes the Swift test helper, which stored `baseAddress` from
`withUnsafeMutableBufferPointer` in the FFI structs and used it after those
closures returned — a dangling pointer the FFI consumer then read. The
buffers are allocated explicitly and freed after the call.
`released_outpoints` is the aggregate for every loser in the batch, so
clearing it on reinstatement discarded coins freed by the losers that are
still going: a winner sweeping X and Y, where only Y also spends C, releases
C — and X returning chainlocked left the batch keeping Y but losing C, so
replaying it marked C spent though no final winner took it.

Keep the set. Entries belonging to the reinstated transaction are inert on
every backend: each scopes its release to the remaining losers' own inputs,
or withholds any outpoint a surviving record claims — and the reinstating
record is exactly such a claim.
A wallet-relevant loser can be persisted before one of its own funding
outputs is materialized: the mobile handlers stage that spend as a
pending-input row, and SQLite simply has no `core_utxos` row for the
outpoint yet. When a later, unresolved-elsewhere winner sweeps that loser
and does not release the input, every backend tried to update a row that
did not exist — a no-op — then deleted the loser, which was the only
place the claim lived. A pending-input row is cascade-owned by the
transaction that created it, so it went with the loser too. Once the
funding transaction was finally observed, even after a restart, its
ordinary UTXO upsert had nothing telling it the coin was already spoken
for, and inserted it back as spendable.

Give the claim somewhere durable to live before deleting the loser.
SQLite's `core_utxos.spent_in_txid` column already existed for exactly
this and was never populated on any write path; `apply_sweep` now writes
it for a held input with no existing row (a placeholder row the real
funding upsert fills in later) and for one that does exist, and
`execute_upsert_utxo`'s ON CONFLICT clause refuses to clear `spent` while
it's set. Swift and Kotlin get the mobile-appropriate version: a held
pending input is detached from its doomed loser (so the cascade-delete
no longer reaches it) and repointed at the winner, flagged so the
funding TXO's own later upsert forces `isSpent` unconditionally and
stamps a new `supersededByTxid` column rather than waiting on the
winner's own row to resolve. That column is deliberately not the same
"no spender on record" state a plain held coin gets — clearing `isSpent`
when the wallet re-delivers a coin as a UTXO stays gated on no spender
*and* no superseding txid, so the existing recovery path for an
unresolved sweep is untouched.

Regression coverage on all three backends: seed the pending spend, sweep
it holding the input, drop and reopen the store/persister, then let the
funding UTXO arrive — the coin must not become spendable. Each was
confirmed to fail without its half of the fix. Kotlin's schema move
(`txos.supersededByTxid`, `pending_inputs.isSweptTombstone`) ships as
Room migration v10→v11 with exported-schema and migration-path coverage.
…e validates

`MIGRATION_10_11` adds `isSweptTombstone` as `INTEGER NOT NULL DEFAULT 0` —
SQLite requires a default on a NOT NULL `ADD COLUMN` — but the entity did not
declare one, so the exported schema carried none. Room compares defaults when
it validates a migrated database against the entity, so an upgraded install
would have failed to open where a fresh one was fine.

Declare it the way every other migration-added flag in this schema already
does (`paymentChannelBroken`, `contactHidden`), and re-export v11.

Not caught by the suite: the migration test that would have is an androidTest
and needs a device, which this machine has none of.
A held-but-unfunded pending input's tombstone (from the previous commit)
is keyed to the sweep that wrote it: the row detaches from its doomed
loser and is repointed at that sweep's winner. If that winner is itself
swept later, the mobile backends' own repoint query only matches pending
rows still attached to the loser via `spendingTransactionTxid` — exactly
the relationship the first tombstoning already cleared. A second sweep
of the winner therefore neither deletes the tombstone when its outpoint
is finally released nor repoints it to the new winner when it isn't, and
the funding TXO's later arrival resurrects a coin the final sweep either
freed or attributed to a transaction that no longer has a row.

Kotlin's `DocumentDao` gains `deleteReleasedSweptTombstones` /
`retargetSweptTombstones`, matched by scalar `spendingTxid` +
`isSweptTombstone` rather than the relationship column, and
`onWalletChangesetTransactionsSwept` runs them alongside the existing
repoint for every loser in the batch. Swift's `applySweptTransaction`
gets a second `PersistentPendingInput` lookup by the same scalar key,
since a detached tombstone no longer appears in `row.pendingInputs`.

SQLite needed no fix: `apply_sweep` always re-derives a loser's inputs
from its own `core_transactions` blob rather than from any state a prior
sweep left behind, and resolves `core_utxos` by outpoint alone — so a
placeholder written by one sweep is found and correctly repointed or
released by the next regardless of chain length. Two new tests confirm
this rather than changing any SQLite code.

Regression coverage on all three backends: L spends P, W spends P and Q
and sweeps L while P's funding TXO is unknown, then X spends Q and
sweeps W. Both the release-P and hold-P variants are covered, each
confirmed to fail without its half of the fix.
`WalletChangeSetFFI` has no size or version header, so a callback compiled
against the pre-sweep struct layout — an old C consumer, or a Kotlin
`NativePersistenceBridge` subclass that never overrode
`onWalletChangesetTransactionsSwept` — reads the unchanged prefix and
returns success without ever seeing `sweeps`. `store()` coming back `Ok`
in that case proves nothing about whether the removal actually happened;
the wallet-event adapter was trusting it anyway, clearing the round and
letting a swept loser return at the next `load()`.

Add `PersistenceCapabilities::CORE_SWEEP_REMOVAL`, the same fail-closed
contract mechanism already used for invitations, asset-lock
reconciliation and the rest: a bit a backend must explicitly attest, not
one inferred from schema presence or a generic successful write. The FFI
persister's structural half requires `on_persist_wallet_changeset_fn` to
be wired (the only callback that ever carries `sweeps`) — necessary but
not sufficient, since that pointer's signature didn't change — and the
semantic half comes only from the host's own declared-capabilities value,
which an unrecompiled binary has no way to have set for a bit it predates.

The gate itself lives in `core_bridge::commit_batch`, the single choke
point every core changeset — including a sweep-only round — passes
through before reaching the persister: a `store()` that succeeds on a
sweep-bearing round is treated as durable only when the backend attests
the bit; otherwise the round is faulted exactly like a `store()`
rejection, via the fail-closed watermark-freeze guard #4069
already added for this class of problem. That means "fail closed" here
is neither refusing to register the wallet nor refusing to start sync —
both are all-or-nothing and would break every wallet on a backend that is
otherwise fine, including SQLite before this same commit adds the bit to
it. Freezing only the affected wallet's durable sync watermark keeps the
guarantee local to the actual gap: the round's non-sweep data still
lands, nothing is ever reported durable that the backend cannot apply,
and the host-visible hard-fault signal from #4069 surfaces the problem
instead of hiding it.

All three in-tree backends now attest the bit: SQLite (`apply_sweep`
resolves chained sweeps by outpoint with no extra state, per the previous
commit), and Swift/Kotlin (both fixed by the previous two commits).

`NativePersistenceBridge.onWalletChangesetTransactionsSwept`'s default
body is documented as the exact shape this bit exists to catch: a
subclass overriding it must also add the bit to its own
`persistenceCapabilitiesBits()`.
Every iteration of the sweep-batch loop in `tramp_persist_wallet_changeset`
built `byte_array_cls`, `empty`, `txids_arr`, `winners`, and `released_arr`
directly in the trampoline's own local frame, same as the account loop
just above it used to. The nested `with_local_frame` calls only cover the
temporary per-element byte-array references; the five per-batch locals
piled up in the outer frame across every batch. The number of ordered
sweep batches in one changeset is not bounded by this ABI, so a large
enough one could exhaust ART's local-reference table before the callback
returns.

Factor the per-batch body into `persist_changeset_sweep_batch` and run it
inside its own `with_local_frame`, matching `persist_changeset_account`'s
existing pattern for the per-account loop.
The capability gate faulted the wallet only after `store()` returned, which
protects later rounds but not the one that carried the sweep. The adapter
folds whatever is buffered, so a `TransactionsSwept` and a following
`SyncHeightAdvanced` land in a single changeset — and `synced_height` sits in
the unchanged prefix a pre-sweep persister does read and commit. Faulting
afterwards cannot retract a watermark the backend has already made durable:
on the next launch the wallet believes those blocks are scanned, never
re-matches them, and the removal is lost for good.

Strip `synced_height` before the changeset is handed over. `offered_height`
is captured earlier, so the round is still diagnosed as a withheld advance
rather than as one that carried no watermark at all.

The existing negative test waits for the sweep's store before sending its
watermark, so it never exercised the folded path; the new one buffers both
events before the adapter starts, which makes the coalescing deterministic
rather than racy. It was confirmed to fail without the fix, observing
Some(900) where None is required.
`swept_txids` and `claimed_by_survivors` depend on the whole changeset, not
on any one batch, but both were rebuilt for every sweep batch with the write
transaction open. The adapter folds up to a full drain into one store, so
that re-hashed every swept txid and every surviving record input once per
sweep. Build them once; only the per-batch release set stays inside the loop,
since that is the part a later batch is meant to be able to correct.
`findWalletRecord` swallows the error with `try?`, so a thrown SwiftData
fetch was indistinguishable from a successful "no such wallet" and the
callback returned success without applying the sweep. Rust then discarded
the subtractive event; the round withholds its own watermark, but a later
successful callback can persist a newer height beyond the removal that never
landed, and the swept transaction returns after restart.

Split out a throwing `fetchWalletRecord` and use it here: a successful empty
result stays a no-op, a failure fails the round. `applySweptTransaction`
already handles its own lookups this way.
…ound

The invariant sets serve only the sweep loop, but were built for every
changeset — hashing every input of every record, with the write transaction
open, for a loop that does not run on the ordinary path. Return early when
the round carries no sweeps.

Also bounds the capability test's drain loop. Its comment claimed the loop
stops when the channel goes quiet, but the adapter and the probe both keep
the sender alive, so a regression that stopped the watermark would hang the
test until the outer CI timeout instead of failing on its assertion.
…llet

A transaction row is shared across every wallet that touches it — the same
loser can spend a coin from wallet A and a coin from wallet B in one
transaction — but upstream computes `released_outpoints` separately per
wallet (`CheckTransactionsResult::per_wallet_released_outpoints`). The
mobile persistence handlers did not respect that: the first wallet's sweep
callback applied its own released set to every input on the shared row,
including inputs it did not own, then deleted the row outright. A second
wallet's callback for the same loser found the row already gone and became
a silent no-op, so its own coin's release-or-hold decision was never
applied — a coin one wallet was told came free could stay wrongly marked
spent forever, or vice versa, depending on which wallet's callback happened
to run first.

Split the operation by what is actually global versus per-wallet. Deleting
the loser's row and cascading away the outputs it created is correct to do
once — the loser is dead for every wallet. The spend decision on each input
is per-wallet: a callback now only touches (holds, releases, or tombstones)
the inputs and pending-inputs it owns, and deletes the shared row only once
no other wallet's input still references it. Whichever wallet's callback is
the last to run performs the delete, so processing order stops mattering; a
wallet whose callback never arrives leaves a dead row behind with every
other wallet's inputs already correctly decided, cleaned up by a
re-emitted sweep.

Swift's `applySweptTransaction` now takes the calling `walletId` and
resolves ownership through `resolvedWalletId(of:)` rather than a raw
`PersistentTxo.walletId` compare — that column is empty on rows migrated
from an older schema, and comparing it raw would silently leave those coins
undecided forever. Kotlin's `TxoDao`/`DocumentDao` gain a `walletId` filter
on `holdSpentWithoutSpender`, `releaseByOutpoint`, and the tombstone
queries, plus `hasOtherWalletSpender`/`hasOtherWalletPendingInput`
existence checks that gate the delete; `TxoEntity.walletId` and
`PendingInputEntity.walletId` have no equivalent migration gap (both were
present in the schema from the start), so a direct compare is safe there.
No Room entity changed, so no migration is needed.

SQLite needed no fix: `core_transactions` and `core_utxos` are keyed by
`(wallet_id, txid)` / `(wallet_id, outpoint)`, so two wallets persisting the
same loser txid each get their own row — there is nothing here for one
wallet's `apply_sweep` call to leak into another's. A new test confirms
this rather than changing any SQLite code.

Regression coverage on both mobile backends: a loser spends one coin from
each of two wallets, a winner takes only one, and both wallets' callbacks
are driven in both orders — each new test confirmed to fail on the
ordering that used to lose data before the fix.
…allback

commit_batch calls store() once per wallet, and each of those commits
independently. The previous round's fix scoped a swept loser's per-input
decisions to the owning wallet and deferred the shared row's physical
delete until no other wallet's claim remained — but deletion was still the
ONLY thing that excluded the row and its outputs from restoration. If
wallet A's callback committed first, that store() call returned success
while the loser and its outputs stayed fully live and enumerable; if
wallet B's callback was then rejected, or the process stopped before it
ever arrived, the row stayed acknowledged-but-resurrectable indefinitely.
After a restart, the retained loser's outputs could be enumerated as live
funds and its involvedAccounts membership could still be handed back
through restore-to-Rust paths, recreating the exact balance the sweep
existed to remove.

Split what is globally true from what is per-wallet, and make the global
half durable in every callback rather than only the last one:

- PersistentTransaction (Swift) / TransactionEntity (Kotlin, new
  isGloballySwept column) gain a durable flag set unconditionally,
  idempotently, in every callback that observes a row's sweep.
- The row's own outputs are deleted unconditionally in every such
  callback too (Swift: `row.outputs`; Kotlin: TxoDao.deleteOwnOutputs) —
  they are nobody's coin regardless of which wallet's callback runs.
- Every restore/enumeration path that can reach a PersistentTransaction /
  TransactionEntity row now excludes flagged rows: Swift's
  walletOwnsTransaction (the sole gate for walletCoreTxids),
  coreTxRecord, buildUnresolvedAssetLockTxRecordBuffer, and
  buildProviderSpecialTxRestoreBuffer; Kotlin's onGetCoreTxRecord,
  getProviderSpecialTransactionsByWallet, and
  buildUnresolvedAssetLockTxRecordData. Both backends' upsert paths
  (upsertTransaction/upsertUtxo, onWalletChangesetTransaction/
  onWalletChangesetUtxoAdded) now bail on an already-flagged row instead
  of resurrecting it, as defense-in-depth against a stale re-emission.
- The physical row delete is demoted to housekeeping: it still runs once
  no other wallet's claim remains, but correctness no longer depends on
  it, and the doc comments say so.

SQLite needed no change: core_transactions / core_utxos are keyed by
(wallet_id, txid) / (wallet_id, outpoint), so there is no shared row for
a second wallet's callback to hold back in the first place — confirmed,
not fixed, by a new durability test in sqlite_transaction_sweeps.rs.

Kotlin's new column ships with @ColumnInfo(defaultValue = "0"),
MIGRATION_11_12, a re-exported v12 schema (diffed against v11: only
transactions.isGloballySwept added), and a migration test. The
migration's own androidTest cannot run in this environment (no emulator).

Regression coverage on both mobile backends: a loser shared by two
wallets, with an output of its own, where only ONE wallet's callback ever
commits and the other's never arrives — the phantom output and the row's
enumerability are gone from that single callback alone, and stay gone
across a simulated restart. Both new tests confirmed to fail without this
fix (reverted, ran, restored).
testAMissingWalletIsASuccessfulNoOp's own doc comment admits it does not
distinguish persistWalletChangeset's do/catch around fetchWalletRecord
from the old try? it replaced: a successful fetch that finds no wallet
row reads identically either way, so reverting that fix would not make
the test fail.

Add a genuinely throwing fetch instead of a mock: a file-backed store is
truncated on disk, out from under the still-open container, before any
context reads or writes through it. SwiftData's row cache is scoped to
the persistent store coordinator rather than to any one ModelContext, so
corrupting the file after a seed helper's throwaway context had already
touched it left the wallet row served from that shared cache and never
hit disk at all in an earlier attempt — corrupting before any read ever
happens is what makes fetchWalletRecord's fetch the first real I/O this
store performs, landing on the truncated file directly.

Confirmed to fail without the fix: with fetchWalletRecord's do/catch
temporarily reverted to try?, the corrupted fetch still throws, but the
error is swallowed to nil and persistWalletChangeset reports success.
…fault

The entity carries `@ColumnInfo(defaultValue = "0")` and `MIGRATION_11_12`
adds the column as `NOT NULL DEFAULT 0`, but the exported schema was written
before the annotation landed and recorded no default. `MigrationTestHelper`
builds the "before" database from that JSON, so the migration test would have
validated against a shape neither the migration nor the entity produces.

Regenerated it; the diff is `isGloballySwept` gaining its default, plus the
identity hash that follows from it.
dashpay/rust-dashcore#962 landed on `dev` as f4b907c3. The branch was pinned
to its PR head while it was in review; point it at the merge commit so this
PR no longer depends on an unmerged branch.
…ives it

A sweep is upstream's word as of the callback that observed it, not a
permanent verdict: CoreChangeSet::merge documents a reachable sequence
where an unconfirmed transaction swept by an IS-locked conflict later
returns chainlocked and sweeps that conflict in turn. When both events
land in one changeset the merge already strips the sweep before it
reaches the persister, but across two separate persistence rounds the
merge-level fix can't help — the first round's sweep already durably
tombstoned the row (`isGloballySwept = true`), and the second round's
plain record for the same txid used to be silently discarded by
`upsertTransaction`'s unconditional bail, taking `upsertUtxo`'s guard
down with it since it also reads the same flag.

Treat a later live record for an `isGloballySwept` txid as upstream's
newer word: clear the tombstone and let the ordinary upsert path
(context/blockHeight/involvedAccounts/input reconciliation) apply
normally. The row's physically deleted outputs come back only if the
reinstating round also carries fresh `utxos_added` entries for them,
the same way any transaction's outputs ordinarily arrive alongside its
record — that part is not this method's to fake if Rust doesn't
re-emit them.

Adds a cross-round regression test: sweep in round 1 with a second
wallet's claim keeping the shared row physically present, then a
separate round 2 delivering the reinstating record and its output,
asserting both are live and survive a simulated restart. Confirmed to
fail without the fix (reverted the guard, reran — 3 assertion failures
on the tombstone, block height, and output; restored and reran green).
…vives it

Kotlin port of the Swift fix in this round: onWalletChangesetTransaction
bailed unconditionally when a row was isGloballySwept, permanently
rejecting a later record that reinstates a txid a sweep previously
tombstoned. CoreChangeSet::merge documents that a wallet's sweep state
is not monotonic (IS-lock precedence: a chainlocked return beats the
IS-locked conflict that swept it), and while the merge strips a sweep
reversed within one changeset, two separate persistence rounds get no
such help — the second round's plain record used to be discarded, and
onWalletChangesetUtxoAdded's own isGloballySwept guard kept rejecting
its output on the strength of a tombstone nothing could ever clear.

Removing the bail is sufficient on its own: the @upsert below always
builds a fresh TransactionEntity without naming isGloballySwept, so it
defaults to false and Room's full-row replace overwrites the stored
true unconditionally.

That alone was not enough on Android, though. persist_changeset_account
in rs-unified-sdk-jni called utxos_added before transactions per
account — backwards from the Swift bridge's order — so a reinstated
transaction's own fresh output would hit onWalletChangesetUtxoAdded's
guard before onWalletChangesetTransaction ever got a chance to clear
the tombstone it depends on. Reordered in the companion Rust commit.

Adds a cross-round regression test mirroring the Swift one: sweep in
round 1 with a second wallet's claim keeping the shared row physically
present, then a separate round 2 delivering the reinstating record and
its output (in the corrected transaction-before-utxo order), asserting
both are live and survive a simulated restart. Confirmed to fail
without the fix (reverted the guard, reran via
:sdk:testDebugUnitTest --tests, one assertion failure; restored and
reran green — 91/91).

No Room schema change: isGloballySwept already exists with its default
and migration from an earlier commit on this branch.
Companion to the Swift/Kotlin sweep-reinstatement fixes: persist_changeset_
account called onWalletChangesetUtxoAdded/onWalletChangesetUtxoSpent
before onWalletChangesetTransaction for the same account, backwards
from the Swift bridge's applyAccountChangeset order (transactions,
then utxos_added, then utxos_spent).

That ordering is load-bearing now, not just cosmetic. A reinstating
record clears a row's isGloballySwept tombstone in
onWalletChangesetTransaction; onWalletChangesetUtxoAdded bails when its
parent is still isGloballySwept. With utxos_added running first, a
reinstated transaction's own fresh output would hit that guard one
callback before the record that was supposed to clear it, and the
Kotlin-side fix alone could not make reinstatement work on Android.
Reordered to match Swift so the tombstone is already cleared by the
time the UTXO arrives.

Also adds a confirmation test to platform-wallet-storage's SQLite
backend: its core_transactions/core_utxos rows are keyed
(wallet_id, txid), so a sweep's DELETE is unconditional and per-wallet
— there is no shared row for a second wallet's claim to hold onto and
no tombstone flag to begin with. A later round's plain record for the
same (wallet_id, txid) is just an ordinary INSERT ... ON CONFLICT DO
UPDATE into empty space, verified here across a sweep, a reinstating
record in a separate `apply` call, and a restart. This is a
confirmation, not a fix — SQLite was already unaffected by this
round's blocking finding.
…e descriptor table

The trampoline resolves onWalletChangesetTransactionsSwept against
NativePersistenceBridge with descriptor ([B[[B[[B[[B)I at the
persist_changeset_sweep_batch call site, but the method was missing
from BRIDGE_METHOD_TABLE. That table exists so
nativeVerifyPersistenceBridgeDescriptors can resolve every JNI method
up front; without this entry a drifted descriptor for the sweep path
passed the smoke check regardless, and would only have surfaced during
a live sweep — where the round fails and the wallet's watermark
freezes, per the withhold-the-watermark contract from an earlier round
on this branch.
…_commits_the_height's wait

This test awaited obs_rx.recv() with no timeout. Both the adapter and
ProbePersister hold their own sender, so a regression that stops the
folded round from reaching store() would hang the test instead of
failing its assertion. The neighbouring capability tests in this same
file already use a bounded receive; this one was missed when it was
added.
jeanpierreroma and others added 12 commits August 19, 2026 12:56
…it orders

The store-time payment revalidation took the manager read lock as soon
as ANY wallet in the folded batch staged an overlay row, then committed
the entire multi-wallet batch under it — synchronous SQLite/FFI stores
for unrelated wallets ran while every manager writer was blocked, and
the guard stayed held even when revalidation dropped every staged row.
The persistence trait explicitly permits inline I/O and marks calls
under the manager lock latency-sensitive.

Split the per-wallet unit out of commit_batch (behavior identical;
commit_batch now loops over it) and scope the hold per wallet: a wallet
with no staged rows commits outside any guard, a wallet whose rows all
retract commits after the guard is released, and only a wallet with
surviving rows stores under it.

The narrowing does not weaken the ordering that makes the revalidation
sound, because the mutual-exclusion argument is per store: the confirm
path advances memory and persists under one continuous manager WRITE
hold, and each overlay-carrying store still runs inside a read hold that
began before its own rows were re-validated — either the confirm ran
before that hold (the re-validation sees Confirmed and drops the row) or
it runs after that store (its Confirmed round lands later, the allowed
transition). The guard that previously covered other wallets' stores
ordered nothing: those rounds carry no payment rows, and rows of a later
wallet are re-validated under that wallet's own subsequent hold.
…stating record's round

A chainlocked reinstatement can be a one-shot: the record re-arrives
already final, so no further detection follows it, and the reconcile
pass is Pending-only by construction — its snapshot evidence can predate
a racing sweep's verdict. The hooks' live confirm persists on its own
round, so a rejection there had nothing left to retry against: memory
rolled back to Failed per record_dashpay_payment's contract, the caller
only logged, and the adapter round still persisted the reinstated core
record — a durable Failed for a transaction that survived and is final.

The adapter now owns the correction. When a drain folds a record the
shared finality gate accepts for a Sent entry currently Failed,
confirm_reinstated_sent_payments_for_store flips the entry in memory and
stages the Confirmed row onto the SAME store round as the reinstated
record, giving it the round's fail-closed machinery: a rejected round
rolls the in-memory flip back to Failed (the durable state), keeps the
watermark back, and the re-scan re-emits the chainlocked record, which
recomputes the flip — the same durability contract the sweep's own
Failed flip already gets. A durable retry queue was rejected as the same
fix with extra machinery: the queue row itself would have to ride a
round to survive the very rejection it exists to record.

The undo ledger now carries the status each flip wrote
(PaymentFlipUndo), and the rejected-round rollback reverts only a
still-standing write of that status — the sweep direction's guard is
unchanged, the reinstatement direction gets the mirrored one. The
same-fold retraction touches only sweep-derived Failed rows (a Confirmed
reinstatement row asserts exactly what the reinstating record says), and
the commit-time revalidation keeps a row while the live entry still
holds the status the row asserts — for a Confirmed row that is always,
Confirmed being terminal, unless the entry vanished. The hooks' own
confirm path remains as the low-latency duplicate: whichever writer runs
first flips memory under the manager write lock, the other no-ops, and
both rounds write the same terminal row. A read-locked fast path skips
the flip's write lock for the common record-bearing event with no Failed
entries; this drain task is the only Failed writer, so the fast path
cannot miss a concurrent flip. Payments-blind backends still get the
in-memory flip with nothing staged, unchanged.

The reconcile-time insert of a reconstructed payment does NOT need this
treatment: record_dashpay_payment removes an inserted entry when its
store rejects, and the reconciler withholds the digest stamp for that
contact window, so the next recurring pass re-enumerates and retries —
its retry driver exists, unlike the one-shot reinstatement's.

Covered end to end through the real adapter loop: the reinstating
record's round carries the record and one Confirmed overlay row, a
rejected round rolls memory back to Failed, and the replayed record's
round carries the correction again. Revert-tested: with the drain wiring
removed, the ride leg fails with no overlay row on the record's round.
… table

The bit gates the payment-overlay path but was never added to `KNOWN`,
so `names()` returned nothing for it. A host debugging why its overlay
rows never landed would see every other capability listed and no trace
of the one that withheld them — the bit was invisible in exactly the
situation it exists to explain.

The guard is the general form rather than one more assertion: every
declarable bit must resolve to exactly one name, so the next capability
cannot repeat this. It fails against the missing entry.
dashpay/rust-dashcore#969 merged as 5877d15f, so the pin moves off the
#966 merge commit onto it. The revision replaces the conflict sweep's
per-generation rescan of the whole retained history with a
parent-to-children index built once and a queue traversal that visits
each record exactly once — O(records + edges) instead of O(depth ×
history), which a peer could drive with a deep chain of unconfirmed
wallet-relevant transactions followed by a finalized replacement for
the root input.

Skip semantics are unchanged: confirmed and InstantSend-locked records
are still never followed, the winner is never a candidate, and an
IS-locked initial loser still has its descendants walked.

All eight workspace pins and Cargo.lock move together; no API changed.
… flips

The wallet-event adapter staged sweep-failed flips and one-shot
reinstatement confirmations onto the triggering record's store round
whenever the persister attested DASHPAY_PAYMENTS. That bit only proves
the payments callback is wired and declared: on a host whose callbacks
commit independently, the Core record and watermark can become durable
while the process stops before the payments write — and a chainlocked
reinstatement never re-emits, leaving the reinstatement durably
recorded beside a payment durably Failed.

Gate the staging on the new ROUND_COUPLED_PAYMENT_FLIPS composite
(DASHPAY_PAYMENTS | ATOMIC_CHANGESETS), following the existing
operation-composite shape (INVITATION_CREATION and friends) rather
than folding atomicity into the bit itself: the bit's contract is
per-callback durability, which a non-atomic host truthfully provides,
and on the FFI surface the composite's atomic half is already
structurally enforced — ATOMIC_CHANGESETS is only attested when the
begin/end pair is wired AND declared. A host failing the stricter gate
degrades exactly like a payments-blind one: the in-memory flip still
happens with nothing round-coupled, which is funds-safe since payment
entries are display metadata and the funds-critical half still gates
on CORE_SWEEP_REMOVAL. SQLite and the Swift handler already attest
both bits; Android's payments slot is unwired either way.
…eep tombstones

The held-but-absent placeholder apply_sweep writes for a swept incoming
payment's foreign inputs was permanent: no funding upsert ever overwrites
it and no release ever names it, so anyone repeatedly double-spending
payments at a wallet could grow core_utxos without limit (the KNOWN
EXPOSURE block, #4406). Creation cannot be gated —
nothing on the record or at the upstream sweep site can prove an input
foreign (dashpay/rust-dashcore#968: the proposed attested-ours set is
empty by construction) — so bound the row's lifetime instead, mirroring
key-wallet's prune_finalized_observed_spends doctrine for the same shape:

- stamp each tombstone with the round's best-known processed height
  (core_utxos.held_since_height, V006), re-stamping on chained-sweep
  re-point, clearing on materialisation;
- persist the chainlock height the changeset already carried and the
  store previously dropped (core_sync_state.chainlock_height, monotonic
  max);
- after any height-advancing round, collect never-materialised held rows
  (height IS NULL, spent = 1) once min(chainlock, synced) clears their
  stamp by a 2-block margin — the InstantSend-path winner customarily
  mines one block after the stamp, and beyond the margin BIP158 filters
  matching input prevout scripts guarantee any delivery path that ever
  classifies the funding output also delivers the winner's spend. Like
  upstream, a no-op until a chainlock has been persisted. Unstamped
  legacy rows are back-filled with the current height first, so they
  wait a full margin from first sight.

Also stop releasing a never-materialised claim in place: the zero-value
spent = 0 leftover read as a phantom spendable coin through
list_unspent_utxos. A released unmaterialised row is deleted outright —
the funding upsert recreates the real row if the coin ever classifies —
and the collector sweeps up pre-existing leftovers.
The pending_inputs row onWalletChangesetTransactionsSwept repurposes as a
durable claim (isSweptTombstone) never drains when its outpoint is a
foreign input of a swept incoming payment — no funding TXO ever arrives —
so it was permanent junk an attacker could grow one row per input by
repeatedly double-spending payments at the wallet: the Room half of the
same exposure the SQLite store's core_utxos placeholder carried
(#4406). Ownership cannot be proven at creation
(dashpay/rust-dashcore#968), so bound the row's lifetime instead,
mirroring the SQLite store's collect_finalized_tombstones:

- v13 adds pending_inputs.heldSinceHeight (nullable, additive), stamped
  with the wallet's synced height when a sweep flags a tombstone and
  re-stamped when a chained sweep re-points it;
- onWalletChangesetHeader collects tombstones once the synced height
  clears their stamp by a 2-block margin, back-filling unstamped
  (pre-migration) rows with the current height first, and only after a
  chainlock has been applied — the chainlock's own height is
  bincode-opaque on this side of the FFI, so the boundary is the synced
  height, the filter-coverage half of the upstream doctrine.

A genuine claim is untouched: its funding TXO's arrival drains the hold
onto the TxoEntity and deletes the pending rows, leaving nothing for the
collector to see.
…ollection margin

Advancing the boundary to 105 let the collector reap the stamp-100
tombstone before the second sweep ran, so the test was exercising the
insert path's re-creation rather than the UPDATE's re-stamp CASE. One
block of progress keeps the row alive through the chained sweep and pins
the genuine re-point + re-stamp behavior.
The PersistentPendingInput row applySweptTransaction repurposes as a
durable claim (isSweptTombstone) never drains when its outpoint is a
foreign input of a swept incoming payment — no funding TXO ever arrives —
so it was permanent junk an attacker could grow one row per input by
repeatedly double-spending payments at the wallet: the SwiftData half of
the same exposure the SQLite store's core_utxos placeholder carried
(#4406). Ownership cannot be proven at creation
(dashpay/rust-dashcore#968), so bound the row's lifetime instead,
mirroring the SQLite store's collect_finalized_tombstones:

- heldSinceHeight (optional, lightweight-migrated) stamps a tombstone
  with the wallet's synced height when a sweep flags it and re-stamps it
  when a chained sweep re-points it;
- persistWalletChangeset collects tombstones once the synced height
  clears their stamp by a 2-block margin, back-filling unstamped
  (pre-property) rows with the current height first, and only after a
  chainlock has been applied — the chainlock's own height is
  bincode-opaque on this side of the FFI, so the boundary is the synced
  height, the filter-coverage half of the upstream doctrine.

A genuine claim is untouched: its funding TXO's arrival drains the hold
onto the PersistentTxo and deletes the pending rows, leaving nothing for
the collector to see.
…into its own PR

Remove the bit-11 behavioral block so the funds-critical sweep core —
pin bump, producer arm, watermark gate, SQLite/Swift/Kotlin persisters —
can converge on its own: SweptPaymentFlips and the evidence-classed
resolver in payments.rs, the adapter's flip staging with its same-fold
retraction, rollback ledger, cross-drain re-validation
(commit_batch_with_payment_revalidation) and
WalletBatch::payments_overlay, and the ROUND_COUPLED_PAYMENT_FLIPS
composite. The block returns unchanged as a stacked follow-up PR that
carries its review findings together with their regression tests.

What stays is the seam the extracted PR plugs back into: capability
bits 10 and 11 with their FFI derivations and gate tests, the
commit_batch/commit_wallet factoring, and the payment_handler no-op
arms with their pinning test — a sweep still must not drive the
payment hooks, whichever PR the flip lands in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sistence round

Reattach the bit-11 behavioral block extracted from the sweep core PR,
byte-identical to how it was reviewed there: SweptPaymentFlips /
PaymentFlipUndo and the evidence-classed resolve_sent_payment_by_txid
with the shared sent-status transition table in payments.rs; the
wallet-event adapter's flip staging with same-fold retraction
(retract_reinstated_payment_flips), the rollback ledger and
rejected-wallet replay, cross-drain re-validation under the manager
read lock (commit_batch_with_payment_revalidation /
retract_superseded_payment_flips) and WalletBatch::payments_overlay;
the adapter-owned reinstatement confirmation riding the reinstating
record's round; and the ROUND_COUPLED_PAYMENT_FLIPS composite
(DASHPAY_PAYMENTS | ATOMIC_CHANGESETS) that gates all staging.

A backend failing the composite degrades to a payments-blind host: the
in-memory flip still happens with nothing round-coupled — funds-safe,
since payment entries are display metadata and the funds-critical half
gates on CORE_SWEEP_REMOVAL in the base PR.

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

coderabbitai Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

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: Pro Plus

Run ID: 7bf47ec0-5086-4564-972b-045826a1a5e8

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 21, 2026

Copy link
Copy Markdown
Collaborator

⛔ Blockers found — Opus deferred (commit 51b9bb9)
Canonical validated blockers: 1

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

Preliminary review — Codex only

The round-coupled persistence and rollback paths are well tested, but the live confirmation path still makes an invalid temporal assumption: independently spawned hooks can apply an older confirmation after a newer sweep. The transition table should also enumerate its legal edges explicitly instead of allowing every destination from Pending.
Source: Codex reviewer evidence (codex-general, codex-rust-quality, codex-security-auditor, and codex-ffi-engineer); final verifier: Claude Agent SDK. Orchestration-only, not reviewer evidence: openclaw-agent/cliproxy/gpt-5.6-sol.

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — security-auditor (completed), gpt-5.6-sol — rust-quality (completed), gpt-5.6-sol — ffi-engineer (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 1 blocking | 🟡 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/src/wallet/identity/network/payments.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/payments.rs:1009-1012: Order live confirmation hooks against newer sweeps
  `LIVE_CONFIRM_EVIDENCE` treats the payment's current state when a hook executes as proof that the hook's event postdates the sweep, but each wallet event is cloned into an independently spawned task in `DashPayPaymentHandler::on_wallet_event`, so execution order does not preserve emission order. Upstream explicitly permits a chainlocked transaction to evict an earlier InstantSend-locked conflicting transaction. If that earlier confirmation task is delayed until after the newer sweep stores `Failed`, this set authorizes the stale `Failed -> Confirmed` write; if it runs just before the adapter stages the sweep, its `Pending -> Confirmed` write makes the terminal-state check skip the newer failure. Either interleaving can leave the dead payment durably `Confirmed`, and later sweeps cannot repair it because `Confirmed` is terminal. Route sent-payment verdicts through the ordered persistence adapter or carry an event sequence/generation that is checked under the manager lock. Add a regression that parks a pre-sweep confirmation hook until the newer sweep has been emitted.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/payments.rs:1368-1372: Enumerate the payment state-machine transitions explicitly
  The documented state machine permits `Pending -> Confirmed`, `Pending -> Failed`, and `Failed -> Confirmed`, but `(PaymentStatus::Pending, _)` also accepts `Pending -> Pending` and will silently accept every future `PaymentStatus` variant. Because this function is the shared transition authority, enumerate the legal edges so extending the enum forces an explicit state-machine review.

Comment on lines +1009 to +1012
const LIVE_CONFIRM_EVIDENCE: &[crate::wallet::identity::types::dashpay::payment::PaymentStatus] = &[
crate::wallet::identity::types::dashpay::payment::PaymentStatus::Pending,
crate::wallet::identity::types::dashpay::payment::PaymentStatus::Failed,
];

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.

🔴 Blocking: Order live confirmation hooks against newer sweeps

LIVE_CONFIRM_EVIDENCE treats the payment's current state when a hook executes as proof that the hook's event postdates the sweep, but each wallet event is cloned into an independently spawned task in DashPayPaymentHandler::on_wallet_event, so execution order does not preserve emission order. Upstream explicitly permits a chainlocked transaction to evict an earlier InstantSend-locked conflicting transaction. If that earlier confirmation task is delayed until after the newer sweep stores Failed, this set authorizes the stale Failed -> Confirmed write; if it runs just before the adapter stages the sweep, its Pending -> Confirmed write makes the terminal-state check skip the newer failure. Either interleaving can leave the dead payment durably Confirmed, and later sweeps cannot repair it because Confirmed is terminal. Route sent-payment verdicts through the ordered persistence adapter or carry an event sequence/generation that is checked under the manager lock. Add a regression that parks a pre-sweep confirmation hook until the newer sweep has been emitted.

source: ['codex']

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.

Resolved in 51b9bb9Order live confirmation hooks against newer sweeps no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +1368 to +1372
use crate::wallet::identity::types::dashpay::payment::PaymentStatus;
matches!(
(from, to),
(PaymentStatus::Pending, _) | (PaymentStatus::Failed, PaymentStatus::Confirmed)
)

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: Enumerate the payment state-machine transitions explicitly

The documented state machine permits Pending -> Confirmed, Pending -> Failed, and Failed -> Confirmed, but (PaymentStatus::Pending, _) also accepts Pending -> Pending and will silently accept every future PaymentStatus variant. Because this function is the shared transition authority, enumerate the legal edges so extending the enum forces an explicit state-machine review.

Suggested change
use crate::wallet::identity::types::dashpay::payment::PaymentStatus;
matches!(
(from, to),
(PaymentStatus::Pending, _) | (PaymentStatus::Failed, PaymentStatus::Confirmed)
)
use crate::wallet::identity::types::dashpay::payment::PaymentStatus;
matches!(
(from, to),
(PaymentStatus::Pending, PaymentStatus::Confirmed)
| (PaymentStatus::Pending, PaymentStatus::Failed)
| (PaymentStatus::Failed, PaymentStatus::Confirmed)
)

source: ['codex']

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.

Resolved in 51b9bb9Enumerate the payment state-machine transitions explicitly no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

…n the wallet-event adapter

The DashPay payment hooks confirmed sent payments from independently
spawned tasks off dash-spv's bounded, lossy event broadcast, so
execution order did not preserve emission order. Upstream permits a
chainlocked transaction to evict an IS-locked conflict, so a pre-sweep
confirmation task delayed past the newer sweep flipped the durable
Failed verdict back to Confirmed (LIVE_CONFIRM_EVIDENCE admitted
Failed), and one that ran just before the sweep staged made the
terminal-state check skip the failure — either way a dead payment ended
durably Confirmed with no later sweep re-emission to repair it.

Route every sent-payment verdict through the adapter's single ordered
drain of the lossless persistence channel instead:

- confirm_final_sent_payments_for_store generalizes the
  reinstatement-only confirm to ALL finality evidence (final records,
  and TransactionInstantLocked by txid), staging Pending/Failed ->
  Confirmed on the event's own store round; the hooks now only record
  incoming payments (idempotent inserts with no state machine to race).
- flip_swept_sent_payments_for_store gains Confirmed -> Failed: with
  writers ordered, every Confirmed visible at sweep-fold time was
  written from evidence the sweep postdates, and upstream never sweeps
  a currently final record, so the newer sweep verdict must win.
- sent_status_transition_allowed enumerates its legal edges explicitly
  (review suggestion): the (Pending, _) wildcard admitted Pending ->
  Pending and would silently admit any future PaymentStatus variant.
- The commit-stage re-validation
  (commit_batch_with_payment_revalidation /
  retract_superseded_payment_flips) is removed as redundant: its only
  purpose was dropping staged rows an unordered hook had outrun, and no
  such writer remains — the reconcile pass, the one off-adapter
  confirmer, persists memory-and-store atomically under the manager
  write lock with Pending-only evidence, so staged Failed/Confirmed
  rows cannot be superseded between fold and store.

Regressions cover both reviewed interleavings by parking a pre-sweep
IS-lock confirmation hook until the newer sweep is durable and then
releasing it, plus the same-fold [IS-lock, Swept] case committing only
the newer verdict, and an exhaustive transition-table test.

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

Preliminary review — Codex only

The ordered-adapter change and explicit transition table resolve both prior findings. One blocking fold-journal defect remains: payment overlays coalesce repeated verdicts, while their rollback ledger retains every intermediate write, breaking both rejected-round rollback and same-fold reinstatement. Source: Codex reviewer backend — gpt-5.6-sol; final verifier backend — grok-4.5 via Claude Agent SDK; openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and not reviewer evidence.

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — rust-quality (completed), gpt-5.6-sol — ffi-engineer (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 1 blocking

🤖 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`:
- [BLOCKING] packages/rs-platform-wallet/src/changeset/core_bridge.rs:1029-1032: Coalesce repeated flips before recording the rollback
  `payments_overlay` folds repeated writes with last-write-wins semantics, but `payment_rollbacks` appends every intermediate undo. In a rejected buffered `[TransactionInstantLocked(X), TransactionsSwept(X)]` round starting from durably `Pending`, memory ends at `Failed` and the ledger contains `Pending → Confirmed` followed by `Confirmed → Failed`; the forward guarded rollback skips the first undo and only restores `Confirmed`, rather than the durable `Pending` state. The same mismatch breaks a `[finality(X), sweep(X), reinstating record(X)]` fold: retraction removes the final `Failed` row and undo, restoring memory to `Confirmed`, but it does not restore the overwritten `Confirmed` overlay, so the subsequent confirmation no-ops and the reinstating record can commit without its payment correction. Coalesce rollback state per `(owner, txid)`, retaining the first pre-round entry and final staged status, and make same-fold retraction update that coalesced state consistently. Add rejected-store and three-event reinstatement regressions.

Comment on lines +1029 to +1032
payment_rollbacks
.entry(wallet_id)
.or_default()
.extend(flips.rollback);

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.

🔴 Blocking: Coalesce repeated flips before recording the rollback

payments_overlay folds repeated writes with last-write-wins semantics, but payment_rollbacks appends every intermediate undo. In a rejected buffered [TransactionInstantLocked(X), TransactionsSwept(X)] round starting from durably Pending, memory ends at Failed and the ledger contains Pending → Confirmed followed by Confirmed → Failed; the forward guarded rollback skips the first undo and only restores Confirmed, rather than the durable Pending state. The same mismatch breaks a [finality(X), sweep(X), reinstating record(X)] fold: retraction removes the final Failed row and undo, restoring memory to Confirmed, but it does not restore the overwritten Confirmed overlay, so the subsequent confirmation no-ops and the reinstating record can commit without its payment correction. Coalesce rollback state per (owner, txid), retaining the first pre-round entry and final staged status, and make same-fold retraction update that coalesced state consistently. Add rejected-store and three-event reinstatement regressions.

source: ['codex']

@PastaPastaPasta
PastaPastaPasta marked this pull request as draft August 22, 2026 15:42
…al entry per (owner, txid)

The payments overlay folded repeated verdicts last-write-wins while the
rollback ledger appended every intermediate undo, so a round that
flipped the same entry twice (Pending -> Confirmed -> Failed) kept two
undos for one staged row. A rejected round's forward guarded replay then
skipped the overwritten Confirmed undo and restored only Confirmed --
never the durable Pending -- and a same-fold reinstatement retracted
only the sweep's undo, returning memory to the in-round Confirmed, where
the ordered confirm no-opped and the reinstated record committed with no
payment correction at all.

Make the two structurally incapable of disagreeing: each flip is now a
single previous/updated record, coalesced into a drain-scoped round
journal keyed per (owner, txid) exactly like the overlay -- the first
flip pins the durable pre-round entry, the last flip sets the staged
one, and there is no intermediate history to unwind. The store overlay
is materialized from the journal just before commit, the rejected-round
rollback restores previous while the staged status stands, and the
same-fold retraction drops the journal entry whole and restores the
pre-round state so the reinstating record's confirm genuinely re-stages
Confirmed. Regressions cover the rejected [IS-lock(X), Swept(X)] round
and the three-event [IS-lock(X), Swept(X), reinstating record(X)] fold,
both through the real adapter loop.
romchornyi pushed a commit that referenced this pull request Sep 3, 2026
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.
romchornyi pushed a commit that referenced this pull request Sep 3, 2026
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.
@romchornyi
romchornyi changed the base branch from chore/bump-rust-dashcore-dev-961 to v4.2-dev September 8, 2026 10:36
@github-actions github-actions Bot added this to the v4.2.0 milestone Sep 8, 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.

3 participants