fix(platform-wallet): act on swept transactions at the persistence seam - #4406
Closed
romchornyi wants to merge 111 commits into
Closed
romchornyi wants to merge 111 commits into
romchornyi wants to merge 111 commits into
Conversation
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Issue being fixed or feature implemented
Bumps
rust-dashcorefrom173ffac0to639e70e0(tip ofdev), which brings indashpay/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 walletbus: 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 onWalletEvent:BalanceUpdateHandlerroutes it like any other balance-bearing variant; a sweep is theone event that can lower the balance, and its snapshot is post-removal.
TransactionInstantLockedis:a swept transaction can never confirm, so a matching
Pendingsent payment moves toFailed— the state machine's previously unwritten terminal — whileConfirmedis neverdemoted and a chainlocked reinstatement (whose record re-arrives confirmed) advances
Failedback toConfirmed.build_core_changesetprojects it into the newCoreChangeSet.sweeps(orderedSweepBatches — losers, winner, released outpoints), counted byis_empty_no_recordsso asweep-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 persistenceextension's size-negotiated
on_persist_wallet_changeset_sweeps_fn(see the ABI findingbelow for why they must not ride
WalletChangeSetFFIitself), fired right after thechangeset 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, viatramp_persist_wallet_changeset_sweepsinrs-unified-sdk-jni), andcore_state::apply_sweep(SQLite) delete the transaction row; the outputs it createdcascade with it.
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_utxosrow keyed by outpoint (
spent_in_txid), and Swift/Kotlin detach the pending-input row fromthe doomed loser and repoint it at the winner (
isSweptTombstone/supersededByTxid) sothe 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 — italready detached from that relationship the first time. Both mobile backends therefore run
a second lookup by the scalar
spendingTxidthe tombstone was repointed to (Kotlin'sDocumentDao.sweptTombstonesTargetingwith an in-memory partition against the releasedset; the scalar reconciliation in Swift's
applySweptTransaction) and carry it the rest ofthe chain: deleted if a later sweep finally releases it, repointed at the new winner if
not. SQLite never had this defect —
apply_sweepalways re-derives a loser's inputs fromits own
core_transactionsblob and matchescore_utxosby outpoint alone, so aplaceholder 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
sweepsat all — Rust would thentreat the round as durable and clear it, letting the removed transaction return after
restart. Added
PersistenceCapabilities::CORE_SWEEP_REMOVAL(bit 10): the FFI persisteronly attests it when the host is structurally sweep-capable and explicitly declared the
bit (Swift's
makePersistenceCapabilities(), Kotlin'spersistenceCapabilitiesBits()), andthe wallet-event adapter (
core_bridge::commit_batch) now treatsstore()succeeding on asweep-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 existingfail-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_counttoWalletChangeSetFFIwas safe in only one direction: the struct crossesthe 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_countand could dereferencecs.sweepsbeyond the end of the olderproducer'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— theexisting size-tagged transport — as
on_persist_wallet_changeset_sweeps_fn, appendedunder extension version 1 and read only when the host's declared
struct_sizeproves theslot exists.
CORE_SWEEP_REMOVAL's structural half is now that slot rather than thelegacy 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 toW'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.
applySweptTransactionnow runs the wallet-scopedscalar 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'stramp_persist_wallet_changesetbuilt each batch's arrays in the trampoline's own localframe; 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'sspent_in_txid) so arestore-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
isSpentwriter ismonotonic 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
removedchannel, with achainlocked reinstatement re-inserting via reconstruction) and fails the matching
Pendingsent 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 andnothing else, survives
is_empty_no_records, and dedupes across a merged round.cargo test -p platform-wallet --lib— 686 passed, including thesweep_without_declared_capability_freezes_the_wallet_despite_a_successful_store/sweep_with_declared_capability_does_not_freezeadapter-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_tombstonesconfirming SQLite's
(wallet_id, …)-keyed design needed no fix for either finding.platform-wallet-ffiunit tests —cargo test -p platform-wallet-ffi --lib, 276 passed —including
a_legacy_sized_extension_refuses_the_sweeps_slot_but_keeps_dpns(alegacy-declared
struct_sizemust make Rust refuse the sweeps slot rather than read it),core_sweep_removal_requires_the_extension_slot_and_the_declaration, the extensionappend-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 transactionstays, 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
testSharedWinnerDeletedByAnotherWalletsCallbackStillReconcilesThisWalletsTombstonesmulti-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
SwiftDashSDKTestssuite on the iPhone 17 simulator — 360 passed.PlatformWalletPersistenceHandlerTest:sweptTransactionIsDeletedAndReleasesItsSpendClaim,sweptTransactionRollsBackWithItsRound(the deletion is staged in the round's bufferedtransaction, so a failed round must not take the rows with it), the chained-sweep pair,
and the new multi-wallet
sharedWinnerDeletedByAnotherWalletsCallbackStillReconcilesThisWalletsTombstonesconfirmation 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 thisclass. (No Room schema change in any round: no new columns, no migration.)
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-targetsandcargo fmt --all -- --checkclean.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
WalletChangeSetFFIkeeps its released layout — an earlier revision of this PR appended thesweep fields to it, which review found unsafe in the new-callback-on-old-library direction,
so the payload moved to
PersistenceCallbacksExtension's size-negotiatedon_persist_wallet_changeset_sweeps_fninstead (appended under extension version 1; olderextensions fail closed by declared
struct_size, so this is not a C ABI break either).NativePersistenceBridgegains anopen funwhose inherited body consults the subclass'sown declared capability bits: a subclass that declares
CORE_SWEEP_REMOVALwithoutoverriding 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_REMOVALis 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:
For repository code-owners and collaborators only
Summary by CodeRabbit
New Features
CORE_SWEEP_REMOVALpersistence capability so a backend must explicitly attest sweep-removal support before its sync watermark is trusted to advance through a sweep.Bug Fixes
Tests