Skip to content

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

Closed
romchornyi wants to merge 111 commits into
v4.2-devfrom
chore/bump-rust-dashcore-dev-961
Closed

romchornyi wants to merge 111 commits into
v4.2-devfrom
chore/bump-rust-dashcore-dev-961

Conversation

@romchornyi

@romchornyi romchornyi commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Issue being fixed or feature implemented

Bumps rust-dashcore from 173ffac0 to 639e70e0 (tip of dev), which brings in
dashpay/rust-dashcore#961 — a never-broadcast transaction no longer credits 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. Every field on our persistence seam was additive, so without
handling it the mirror keeps the dead rows, hands them back at the next load, and
re-creates the balance the wallet just corrected — the same bug #961 fixes, one layer up.

What was done?

Event routing (platform-wallet) — three consumers matched exhaustively on WalletEvent:

  • BalanceUpdateHandler 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.
  • The DashPay payment hooks route it by txid, the same way TransactionInstantLocked is:
    a swept transaction can never confirm, so a matching Pending sent payment moves to
    Failed — the state machine's previously unwritten terminal — while Confirmed is never
    demoted and a chainlocked reinstatement (whose record re-arrives confirmed) advances
    Failed back to Confirmed.
  • build_core_changeset projects it into the new CoreChangeSet.sweeps (ordered
    SweepBatches — losers, winner, released outpoints), counted by is_empty_no_records so a
    sweep-only round survives the filter that decides whether the persister is called at all.

Persistence seam — the round's SweepBatches cross the FFI through the persistence
extension's size-negotiated on_persist_wallet_changeset_sweeps_fn (see the ABI finding
below for why they must not ride WalletChangeSetFFI itself), fired right after the
changeset callback in the same round and applied batch by batch and in order (a later batch
can keep a coin spent that an earlier one freed), after the additive part of the round,
since the transaction that won the inputs usually rides in the same changeset:

  • PlatformWalletPersistenceHandler.persistWalletChangesetSweeps / applySweptTransaction
    (Swift), onWalletChangesetTransactionsSwept (Kotlin, via
    tramp_persist_wallet_changeset_sweeps in rs-unified-sdk-jni), and
    core_state::apply_sweep (SQLite) delete the transaction row; the outputs it created
    cascade with it.
  • The coins it claimed to spend are held first, then the released set frees exactly the
    outpoints upstream named. A coin whose funding TXO hasn't materialized yet (the loser was
    persisted before its own funding output was observed) has no row to hold, so a held-but-
    unfunded input gets a durable placeholder of its own instead: SQLite writes a core_utxos
    row keyed by outpoint (spent_in_txid), and Swift/Kotlin detach the pending-input row from
    the doomed loser and repoint it at the winner (isSweptTombstone / supersededByTxid) so
    the claim survives both the loser's cascade-delete and the funding TXO's own later arrival.

Seam hardening (shipped in this PR, review-driven):

  • Chained sweeps before funding. A held-but-unfunded pending-input tombstone (above) is
    keyed to that sweep's winner. If the winner is itself swept later, the mobile backends'
    staged-row lookup (spendingTransactionTxid = :loserTxid) can no longer find it — it
    already detached from that relationship the first time. Both mobile backends therefore run
    a second lookup by the scalar spendingTxid the tombstone was repointed to (Kotlin's
    DocumentDao.sweptTombstonesTargeting with an in-memory partition against the released
    set; the scalar reconciliation in Swift's applySweptTransaction) and carry it the rest of
    the chain: deleted if a later sweep finally releases it, repointed at the new winner if
    not. SQLite never had this defect — apply_sweep always re-derives a loser's inputs from
    its own core_transactions blob and matches core_utxos by outpoint alone, so a
    placeholder is chain-safe without any relationship to detach from in the first place.

  • Sweep-support capability negotiation. A persister predating sweep support processes
    the rest of a round, returns success, and never sees sweeps at all — Rust would then
    treat the round as durable and clear it, letting the removed transaction return after
    restart. Added PersistenceCapabilities::CORE_SWEEP_REMOVAL (bit 10): the FFI persister
    only attests it when the host is structurally sweep-capable and explicitly declared the
    bit (Swift's makePersistenceCapabilities(), Kotlin's persistenceCapabilitiesBits()), and
    the wallet-event adapter (core_bridge::commit_batch) now treats store() succeeding on a
    sweep-bearing round as durable only when the backend attests it — otherwise it freezes that
    wallet's sync watermark exactly like a store() rejection (kotlin-sdk/platform-wallet: duplicated unspent TXO rows after SPV rescan following unclean shutdown (inflated balance) #4069's existing
    fail-closed guard), so a removal is never reported durable to a backend that cannot apply
    it. All three in-tree backends (SQLite, Swift, Kotlin) now attest the bit.

  • Sweep transport off the unversioned changeset struct. Appending sweeps /
    sweeps_count to WalletChangeSetFFI was safe in only one direction: the struct crosses
    the C ABI by bare pointer with no size or version field, so the current Swift callback
    installed against the previous native library (nothing prevents that pairing — the
    callback signature and manager-create entry points are unchanged) would read
    cs.sweeps_count and could dereference cs.sweeps beyond the end of the older
    producer's allocation: undefined behavior on an ordinary changeset round, which the
    capability bit (semantics, not memory layout) cannot make safe. The struct is restored to
    its released layout and the batches now ride PersistenceCallbacksExtension — the
    existing size-tagged transport — as on_persist_wallet_changeset_sweeps_fn, appended
    under extension version 1 and read only when the host's declared struct_size proves the
    slot exists. CORE_SWEEP_REMOVAL's structural half is now that slot rather than the
    legacy changeset pointer, whose unchanged signature proves nothing. Both cross-version
    pairings are safe: an old host is simply never handed sweeps (and its watermark freezes
    per the previous bullet), and a new host on an old library reads only the unchanged
    struct prefix.

  • Detached tombstones survive the shared winner row's deletion (Swift). A first sweep
    detaches unresolved pending inputs from multiple wallets and repoints them at winner W by
    scalar spendingTxid; when W's own record arrives, resolveInputOutpoint's
    (outpoint, spendingTxid) duplicate guard sees those tombstones and attaches nothing to
    W's row, so a later sweep of W lets the first wallet's callback delete the shared row
    with another wallet's tombstones still naming it. That second wallet's callback used to
    hit the missing-row early return and never apply its own release decision — a released
    coin would resurrect spent under the obsolete W once funded, and a held tombstone could
    never follow a further chained sweep. applySweptTransaction now runs the wallet-scoped
    scalar tombstone reconciliation regardless of whether the shared row still exists. Kotlin
    never had the early return (its tombstone queries key on the scalar column and run
    unconditionally) and SQLite's tables are (wallet_id, …)-keyed with no shared rows;
    both are pinned by multi-wallet chained-sweep-before-funding confirmation tests.

  • JNI local-reference frames. The sweep-batch loop in rs-unified-sdk-jni's
    tramp_persist_wallet_changeset built each batch's arrays in the trampoline's own local
    frame; since the batch count is unbounded, a large enough changeset could exhaust ART's
    local-reference table. Each batch's construction and callback invocation now run inside
    their own with_local_frame, matching the per-account loop just above it.

  • One hold/release model on all three backends. The mobile drains give a sweep
    tombstone priority over the newest-wins pick (records precede sweeps in a round, so the
    winner's own pending row can coexist with the tombstone and must not delete it); every
    hold names its winner (supersededByTxid, mirroring SQLite's spent_in_txid) so a
    restore-rescan re-delivering the funding output cannot resurrect a provably consumed
    coin, while pre-stamp rows keep the old re-delivery backstop; releases apply by outpoint
    on every backend (reaching claims that drained onto the TXO with no relationship left to
    follow) and clear the stamp in the same statement; and every isSpent writer is
    monotonic under a stamp, so the winner's own IS-locked arrival cannot flip a durable hold
    back into the restore set. SQLite additionally applies a batch's released outpoints even
    when the swept txid has no row (record loss must not swallow a release), and its
    co-swept-parent skip is scoped to parents whose row is actually on hand to delete.

  • Sweeps cascade beyond the transaction tables. A sweep now drops the tracked asset
    locks its losers funded (through the changeset's existing removed channel, with a
    chainlocked reinstatement re-inserting via reconstruction) and fails the matching
    Pending sent DashPay payments, as described under event routing above.

How Has This Been Tested?

  • swept_transaction_projection_tests (core_bridge.rs): the arm names the dead txids and
    nothing else, survives is_empty_no_records, and dedupes across a merged round.
    cargo test -p platform-wallet --lib — 686 passed, including the
    sweep_without_declared_capability_freezes_the_wallet_despite_a_successful_store /
    sweep_with_declared_capability_does_not_freeze adapter-loop tests for the capability gate.
  • sqlite_transaction_sweeps.rs: cargo test -p platform-wallet-storage — all green,
    including the chained-sweep-before-funding tests and the new
    a_multi_wallet_chained_sweep_before_funding_reconciles_each_wallets_own_tombstones
    confirming SQLite's (wallet_id, …)-keyed design needed no fix for either finding.
  • platform-wallet-ffi unit tests — cargo test -p platform-wallet-ffi --lib, 276 passed —
    including a_legacy_sized_extension_refuses_the_sweeps_slot_but_keeps_dpns (a
    legacy-declared struct_size must make Rust refuse the sweeps slot rather than read it),
    core_sweep_removal_requires_the_extension_slot_and_the_declaration, the extension
    append-only layout pins, and
    store_delivers_sweeps_through_the_extension_slot_after_the_changeset (in-order,
    after-the-changeset delivery; a slot-less host still succeeds with sweeps undelivered).
  • SweptTransactionPersistTests.swift: the row and its outputs go, the funding transaction
    stays, the claimed coin becomes spendable again, an unknown txid is a no-op, a tombstone
    survives (or correctly moves through) a second sweep, and the new
    testSharedWinnerDeletedByAnotherWalletsCallbackStillReconcilesThisWalletsTombstones
    multi-wallet chained-sweep-before-funding regression, plus the review-round pins: the
    coexisting winner-row drain, the stamped-hold re-delivery pair, the by-outpoint release
    reaching a drained claim, and the record-pass/spent-emit downgrade guards pinned
    independently. Full SwiftDashSDKTests suite on the iPhone 17 simulator — 360 passed.
  • PlatformWalletPersistenceHandlerTest:
    sweptTransactionIsDeletedAndReleasesItsSpendClaim,
    sweptTransactionRollsBackWithItsRound (the deletion is staged in the round's buffered
    transaction, so a failed round must not take the rows with it), the chained-sweep pair,
    and the new multi-wallet
    sharedWinnerDeletedByAnotherWalletsCallbackStillReconcilesThisWalletsTombstones
    confirmation test, plus the review-round pins (coexisting winner-row drain, stamped-hold
    re-delivery and its pre-stamp backstop, released-marker clearing, the spent-emit
    downgrade guard, the two-wallet released-pending deadlock, and the capability-guarded
    sweep-slot default). :sdk:testDebugUnitTest — 329 passed across the suite, 99 in this
    class. (No Room schema change in any round: no new columns, no migration.)
  • Every new regression test was confirmed to fail without its corresponding fix (temporarily
    reverted, run, restored) before being counted above. The Kotlin/SQLite multi-wallet tests
    are confirmations of designs that needed no fix, so they have no revert to fail against.
  • cargo check --workspace --all-targets and cargo fmt --all -- --check clean.

Not exercised on a device or against live sync: no wallet was driven into an actual
double-spend to watch the sweep arrive end to end.

Breaking Changes

WalletChangeSetFFI keeps its released layout — an earlier revision of this PR appended the
sweep fields to it, which review found unsafe in the new-callback-on-old-library direction,
so the payload moved to PersistenceCallbacksExtension's size-negotiated
on_persist_wallet_changeset_sweeps_fn instead (appended under extension version 1; older
extensions fail closed by declared struct_size, so this is not a C ABI break either).
NativePersistenceBridge gains an open fun whose inherited body consults the subclass's
own declared capability bits: a subclass that declares CORE_SWEEP_REMOVAL without
overriding the slot fails the round (declared removals must never be silently swallowed
under an advancing watermark), while a non-attesting subclass keeps a benign success (its
watermark is stripped Rust-side anyway).

The behavioral story stands: a backend that does not both wire the extension's sweeps slot
and declare CORE_SWEEP_REMOVAL is deliberately treated as not supporting sweep removal —
the wallet-event adapter freezes that wallet's durable sync watermark on every sweep-bearing
round rather than trust a store() success that never carried the removal (see the
"Sweep-support capability negotiation" bullet above). This is intentional fail-closed
behavior, not a regression — silently losing the removal was the bug — but any out-of-tree
persister that implements sweep removal must supply the extension callback and add the bit to
its declared capabilities to avoid a spurious watermark freeze.

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

Summary by CodeRabbit

  • New Features

    • Added support for tracking transaction sweeps, superseding transactions, and released outpoints.
    • Wallet persistence now removes swept transactions and outputs while preserving valid spend claims.
    • Sweep updates synchronize across supported SDKs and refresh wallet balances.
    • Added a versioned CORE_SWEEP_REMOVAL persistence capability so a backend must explicitly attest sweep-removal support before its sync watermark is trusted to advance through a sweep.
  • Bug Fixes

    • Prevented swept transactions from generating payment records or hooks.
    • Improved handling of released inputs, unknown transactions, and unrelated transaction data.
    • Added reliable rollback when sweep persistence fails.
    • Fixed a chained-sweep case where a pending-input tombstone from an earlier sweep could survive stale after its winner was itself swept.
  • Tests

    • Expanded coverage for cleanup, ordering, rollback, balance updates, and cross-platform persistence.
    • Added chained-sweep-before-funding and capability-negotiation regression coverage across Rust, Swift, and Kotlin.

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

5 participants