Skip to content

fix(swift-sdk): fixed mempool tx categorization after restart#3777

Draft
ZocoLini wants to merge 1 commit into
v3.1-devfrom
fix/tx-categorization
Draft

fix(swift-sdk): fixed mempool tx categorization after restart#3777
ZocoLini wants to merge 1 commit into
v3.1-devfrom
fix/tx-categorization

Conversation

@ZocoLini
Copy link
Copy Markdown
Collaborator

@ZocoLini ZocoLini commented Jun 1, 2026

After a mid-flight restart between mempool sighting and block confirmation, a self-send tx was misclassified as Incoming with netAmount = +sum_of_outputs. The persister flipped PersistentTxo.isSpent = true on the mempool sighting, and loadWalletList filters by isSpent == false, so the input TXO was dropped from the restored set — the catch-up classifier on the next launch saw an "unknown" input and emitted Incoming.

Fix: gate the isSpent write on the spending tx being in a block (context >= inBlock). Mempool sightings still link the spendingTransaction relationship but keep the row in the unspent set. The next upsert with a confirmed context flips isSpent then.

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

  • Bug Fixes
    • Fixed transaction spend-state tracking so mempool-only (unconfirmed) spends are no longer incorrectly marked as confirmed.
    • Improved confirmation detection to reliably distinguish unconfirmed spending transactions from those confirmed in a block.
    • Enhanced pending transaction resolution and linkage handling to keep wallet UTXO and spent/unspent status more accurate and stable during context changes.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jun 1, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d30d1b2e-2523-49cf-a4af-56d86a5c8354

📥 Commits

Reviewing files that changed from the base of the PR and between e3c20ea and f9c28f8.

📒 Files selected for processing (1)
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift

📝 Walkthrough

Walkthrough

This PR updates UTXO spend-state persistence in PlatformWalletPersistenceHandler to gate isSpent flags on spending transaction confirmation status. A new spendIsInBlock helper treats only in-block (or later) contexts as confirmed; that rule is applied in input resolution, pending-input upsert, and mark-as-spent paths so mempool-only sightings won't incorrectly set isSpent.

Changes

Transaction spend-state confirmation gating

Layer / File(s) Summary
Confirmation status helper
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
New private spendIsInBlock(_:) helper checks whether a PersistentTransaction has confirmed context (in-block or chain-locked) to gate downstream spend-state writes.
Input resolution spend-state gating
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
resolveInputOutpoint derives expectedIsSpent from the spending transaction's confirmed-context rule, includes isSpent mismatches in linkage-change detection, and sets txo.isSpent to the expected value instead of forcing true.
Pending-input resolution and mark-as-spent update
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
upsertUtxo pending-input path now selects/resolves the spending transaction relationship first (or fetches by txid) and sets record.isSpent via spendIsInBlock; markUtxoSpent removes the unconditional txo.isSpent = true and conditionally resolves spendingTransaction to set txo.isSpent based on spendIsInBlock, otherwise conservatively false.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • shumkov
  • QuantumExplorer

Poem

🐰 A rabbit’s ledger, neat and bright,
Mempool shadows wait till light;
Only blocks may mark what's spent,
No hasty flips or false intent.
Hop—confirm before you write!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly addresses the main problem and fix: correcting mempool transaction categorization after restart by fixing isSpent flag handling.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/tx-categorization

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@ZocoLini ZocoLini marked this pull request as ready for review June 1, 2026 17:59
@thepastaclaw
Copy link
Copy Markdown
Collaborator

thepastaclaw commented Jun 1, 2026

✅ Review complete (commit e3c20ea)

@ZocoLini ZocoLini force-pushed the fix/tx-categorization branch from e3c20ea to f9c28f8 Compare June 1, 2026 18:01
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- Around line 660-675: The current logic unconditionally derives txo.isSpent
from the incoming spendingTransaction and will clear a previously confirmed
spend when a later unconfirmed conflicting mempool sighting arrives; change the
update so that if the existing txo.spendingTransaction is confirmed (use
Self.spendIsInBlock on txo.spendingTransaction) and the incoming
spendingTransaction is unconfirmed (expectedIsSpent == false) and has a
different txid than txo.spendingTransaction, then skip changing txo.isSpent and
do not overwrite the canonical spending link; only allow the update when the
incoming spender is confirmed, or when the incoming txid equals the existing one
(same-tx downgrade/upgrade) or when an explicit reorg downgrade path is taken.
Apply the same guard to the other similar blocks around the indicated regions
(lines ~843-872 and ~901-922) referencing the same symbols: txo,
spendingTransaction, spendingTxid, inputIndex, and Self.spendIsInBlock.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: aa0944c8-4597-4813-ad8c-c8517d9f145c

📥 Commits

Reviewing files that changed from the base of the PR and between 009661b and e3c20ea.

📒 Files selected for processing (1)
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 1, 2026

✅ DashSDKFFI.xcframework built for this PR.

SwiftPM (host the zip at a stable URL, then use):

.binaryTarget(
  name: "DashSDKFFI",
  url: "https://your.cdn.example/DashSDKFFI.xcframework.zip",
  checksum: "b7487f6f0a59d47773eb5a9b4539d6bcce339d0b0063d87caff4b6c4a0498437"
)

Xcode manual integration:

  • Download 'DashSDKFFI.xcframework' artifact from the run link above.
  • Drag it into your app target (Frameworks, Libraries & Embedded Content) and set Embed & Sign.
  • If using the Swift wrapper package, point its binaryTarget to the xcframework location or add the package and place the xcframework at the expected path.

@ZocoLini ZocoLini requested a review from llbartekll June 1, 2026 18:10
Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

The PR correctly identifies and partially fixes the mempool self-send restart-misclassification bug. However, spendIsInBlock is too strict: it excludes instantSend, which this codebase already treats as confirmed (CoreBalance.confirmed doc string says "in a block or InstantSend-locked"). Combined with loadWalletList's isSpent == false filter, this regresses InstantSend spends across a restart, re-feeding the spent UTXO to Rust as unspent. A second, narrower concern: markUtxoSpent now flips isSpent back to false whenever the spending tx can't be resolved, including the empty/zero spending_txid guard path, which previously was an authoritative spend signal.

🔴 1 blocking | 🟡 1 suggestion(s) | 💬 1 nitpick(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/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:639-641: InstantSend-locked spends regress to unspent across a restart
  `TransactionContextType` ordering is `mempool=0, instantSend=1, inBlock=2, inChainLockedBlock=3`, so `tx.context >= inBlock.rawValue` excludes `instantSend`. The stated reason for the new gate — "reversible by RBF or mempool eviction" — does not apply to InstantSend: by Dash design it cannot be reorged out and cannot be replaced. Treating it the same as mempool produces two concrete regressions:

  1. While the spending tx is `instantSend` but not yet in a block, the input TXO keeps `isSpent == false`, so the wallet UI/queries that read SwiftData see the row as still spendable.
  2. Across a restart in that window, `loadWalletList` (line 2948) filters TXOs by `isSpent == false` and hands the row back to Rust as a live UTXO. Nothing in the restore path rebuilds ordinary spending transactions into Rust's in-memory graph, so the spent input is silently re-credited until a later block update arrives, which can overstate spendable funds and enable a duplicate-spend attempt.

  This contradicts the project's own contract — `ManagedCoreWallet.CoreBalance.confirmed`'s doc literally says "Confirmed balance (in a block or InstantSend-locked)" — so the persistence layer should follow the same definition.
- [SUGGESTION] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:915-921: `markUtxoSpent` can now clear a previously-set `isSpent`
  Before this PR, `markUtxoSpent` was an authoritative spend signal: once the TXO was located, `isSpent` was set to `true` unconditionally. After this change the tail-write is `txo.isSpent = spendingTx.map(Self.spendIsInBlock) ?? false`, which can flip `isSpent` from `true` back to `false` in three paths:

  1. The guard at line 902 leaves `spendingTx == nil` when the FFI emits an empty/all-zero `spending_txid`. The fact that this guard exists implies Rust can emit that case — and the legacy semantic there was "mark as spent anyway".
  2. The spending tx hasn't been persisted yet this flush. The comment promises self-healing on the next flush, but until then the TXO observably toggles back to spendable.
  3. A TXO already confirmed-spent in an earlier flush is re-emitted here with the spending tx's record evicted from this `backgroundContext` — `isSpent` goes `true → false`.

  The PR's stated intent ("don't permanently flip `isSpent` from a mempool-only sighting") only requires the negative case for mempool. An upgrade-only rule — never clear `isSpent` here, only set `false → true` when the spending tx is confirmed — preserves the fix while keeping the existing authoritative-signal semantics. Same shape should be applied at the deferred-pending drain (line 872) for consistency.

Comment on lines +639 to +641
private static func spendIsInBlock(_ tx: PersistentTransaction) -> Bool {
tx.context >= TransactionContextType.inBlock.rawValue
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: InstantSend-locked spends regress to unspent across a restart

TransactionContextType ordering is mempool=0, instantSend=1, inBlock=2, inChainLockedBlock=3, so tx.context >= inBlock.rawValue excludes instantSend. The stated reason for the new gate — "reversible by RBF or mempool eviction" — does not apply to InstantSend: by Dash design it cannot be reorged out and cannot be replaced. Treating it the same as mempool produces two concrete regressions:

  1. While the spending tx is instantSend but not yet in a block, the input TXO keeps isSpent == false, so the wallet UI/queries that read SwiftData see the row as still spendable.
  2. Across a restart in that window, loadWalletList (line 2948) filters TXOs by isSpent == false and hands the row back to Rust as a live UTXO. Nothing in the restore path rebuilds ordinary spending transactions into Rust's in-memory graph, so the spent input is silently re-credited until a later block update arrives, which can overstate spendable funds and enable a duplicate-spend attempt.

This contradicts the project's own contract — ManagedCoreWallet.CoreBalance.confirmed's doc literally says "Confirmed balance (in a block or InstantSend-locked)" — so the persistence layer should follow the same definition.

Suggested change
private static func spendIsInBlock(_ tx: PersistentTransaction) -> Bool {
tx.context >= TransactionContextType.inBlock.rawValue
}
private static func spendIsInBlock(_ tx: PersistentTransaction) -> Bool {
tx.context >= TransactionContextType.instantSend.rawValue
}

source: ['claude', 'codex']

Comment on lines +915 to +921
// Gate the `isSpent` flip on the spending tx being in a
// block — same rule as `resolveInputOutpoint`. When the
// spending tx isn't found yet, conservatively assume not
// confirmed; the next upsert round with the spending tx
// landed will run `resolveInputOutpoint` and flip the
// flag if appropriate.
txo.isSpent = spendingTx.map(Self.spendIsInBlock) ?? false
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: markUtxoSpent can now clear a previously-set isSpent

Before this PR, markUtxoSpent was an authoritative spend signal: once the TXO was located, isSpent was set to true unconditionally. After this change the tail-write is txo.isSpent = spendingTx.map(Self.spendIsInBlock) ?? false, which can flip isSpent from true back to false in three paths:

  1. The guard at line 902 leaves spendingTx == nil when the FFI emits an empty/all-zero spending_txid. The fact that this guard exists implies Rust can emit that case — and the legacy semantic there was "mark as spent anyway".
  2. The spending tx hasn't been persisted yet this flush. The comment promises self-healing on the next flush, but until then the TXO observably toggles back to spendable.
  3. A TXO already confirmed-spent in an earlier flush is re-emitted here with the spending tx's record evicted from this backgroundContextisSpent goes true → false.

The PR's stated intent ("don't permanently flip isSpent from a mempool-only sighting") only requires the negative case for mempool. An upgrade-only rule — never clear isSpent here, only set false → true when the spending tx is confirmed — preserves the fix while keeping the existing authoritative-signal semantics. Same shape should be applied at the deferred-pending drain (line 872) for consistency.

source: ['claude']

Comment on lines +630 to +641
/// `true` if the spending tx has reached a confirmed context
/// (`inBlock` or `inChainLockedBlock`). Used to gate `isSpent`
/// writes so a mempool-sighting alone — which is reversible by
/// RBF or mempool eviction — doesn't permanently flip the input
/// TXO out of the unspent set. The TXO becomes truly spent only
/// when the spending tx lands in a block; until then the
/// persisted state reflects "still spendable from this row's POV",
/// and the catch-up classifier on the next launch reloads the
/// row and recognises it as ours when the block arrives.
private static func spendIsInBlock(_ tx: PersistentTransaction) -> Bool {
tx.context >= TransactionContextType.inBlock.rawValue
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: No regression test for the restart misclassification

The fix targets a subtle ordering bug (self-send mempool sighting → restart → loadWalletList drops the input TXO because isSpent==true → catch-up classifier misclassifies as Incoming) that's easy to silently regress later. A regression test that (1) upserts a tx in mempool context spending a persisted TXO, (2) asserts PersistentTxo.isSpent == false with spendingTransaction linked, (3) re-runs the same predicate loadWalletList uses, (4) re-upserts the tx with inBlock context, and (5) asserts isSpent == true would lock the contract in place.

source: ['claude']

@ZocoLini ZocoLini marked this pull request as draft June 1, 2026 23:27
@ZocoLini
Copy link
Copy Markdown
Collaborator Author

ZocoLini commented Jun 1, 2026

I will be taking a look into pasta claw complains to be able to preperly answer them

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.

2 participants