fix(platform-wallet): couple a sweep's payment flips to their own persistence round - #4442
fix(platform-wallet): couple a sweep's payment flips to their own persistence round#4442romchornyi wants to merge 96 commits into
Conversation
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.
…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>
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
|
⛔ Blockers found — Opus deferred (commit 51b9bb9) |
thepastaclaw
left a comment
There was a problem hiding this comment.
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.
| 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, | ||
| ]; |
There was a problem hiding this comment.
🔴 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']
There was a problem hiding this comment.
Resolved in 51b9bb9 — Order 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.
| use crate::wallet::identity::types::dashpay::payment::PaymentStatus; | ||
| matches!( | ||
| (from, to), | ||
| (PaymentStatus::Pending, _) | (PaymentStatus::Failed, PaymentStatus::Confirmed) | ||
| ) |
There was a problem hiding this comment.
🟡 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.
| 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']
There was a problem hiding this comment.
Resolved in 51b9bb9 — Enumerate 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
left a comment
There was a problem hiding this comment.
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.
| payment_rollbacks | ||
| .entry(wallet_id) | ||
| .or_default() | ||
| .extend(flips.rollback); |
There was a problem hiding this comment.
🔴 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']
…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.
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.
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.
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_readsnapshot 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 ontov4.2-devonce #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
Sentpayment, the entry flipsPending → Failed. That flip is staged as adashpay_payments_overlayrow on the samePlatformWalletChangeSetas the sweep, so a rejectedstore()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:
Evidence classes.
resolve_sent_payment_by_txidnow takes what the caller's evidence can speak for, intersected with the shared transition table: a live signal may applyFailed → 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 durablyFailedpayment. 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 onCORE_SWEEP_REMOVAL.Findings resolved here, with the test that pins each
swept_payment_flip_rides_the_sweeps_round_and_rolls_back_on_rejectionFailedoverlay on the round (r3805302098 family)a_reinstating_record_in_the_same_fold_retracts_the_payment_flipa_confirmation_landing_before_the_sweeps_store_retracts_its_stale_failed_rowrollback_does_not_clobber_a_concurrently_confirmed_entryConfirma swept paymenta_stale_reconcile_snapshot_cannot_confirm_a_swept_paymenta_chainlocked_reinstatement_rides_the_records_round_and_survives_rejectionROUND_COUPLED_PAYMENT_FLIPScompositean_atomicity_blind_backend_is_not_handed_payment_flips_on_the_rounda_payments_blind_backend_is_not_handed_the_sweeps_flip_on_the_roundHow 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-wallet697,platform-wallet-ffi277,platform-wallet-storageall suites including 24/24 sweep,cargo clippy --workspace --all-targetsclean,cargo fmt --all -- --checkclean.The extraction itself was verified lossless: with this branch's commit applied on top of the removal commit on #4406,
git diffagainst #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_FLIPSis a new composite over existing bits; no bit value changes and no FFI signature changes. A host that attestsDASHPAY_PAYMENTSwithoutATOMIC_CHANGESETSstops receiving round-coupled overlays and keeps the in-memory flip — a deliberate narrowing, and funds-safe.Checklist: