Skip to content

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

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

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

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, reducing false positives and state flips.

@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: a310cd11-6609-4617-bb3c-8ebf2037d509

📥 Commits

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

📒 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 gates UTXO spent-state writes on whether the spending transaction is in-block (or later). It adds a spendIsInBlock helper and applies that rule across input resolution, pending-input upsert, and mark-as-spent paths so mempool-only spend sightings don't flip 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 prefers an existing spending-transaction relationship (or fetches by txid) and sets record.isSpent via spendIsInBlock; markUtxoSpent removes the unconditional txo.isSpent = true and conditionally sets txo.isSpent based on the resolved spending transaction's confirmation, otherwise leaves it false in this flush.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • QuantumExplorer
  • shumkov

Poem

🐰 I nibble ledgers in the night,
I wait for blocks before I write;
Mempool whispers, soft and fleet,
But only chains make spent complete.

🚥 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 'fixed mempool tx categorization after restart' accurately describes the main fix—correcting transaction categorization issues that occur after a restart, specifically for mempool transactions.
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 95b219d)

@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: "0e27e6ca0f0f7b0d1bf94fc952b590c9ab38c8902f54934be88cb6f2a7683d7c"
)

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.

@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

@ZocoLini ZocoLini force-pushed the fix/tx-categorization branch from f9c28f8 to 95b219d Compare June 3, 2026 22:52
@ZocoLini ZocoLini marked this pull request as ready for review June 5, 2026 14:00
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's gate on isSpent writes is intentionally scoped to block-confirmed contexts so mempool/IS-locked spends can be re-identified by the catch-up classifier on restart — this is explicitly stated in the doc comment and the linkage comment. Codex's blocking flag on InstantSend exclusion contradicts this documented design and is dropped as a false positive. The remaining valid feedback is a real doc-comment typo (unterminated backtick) plus low-priority suggestions around test coverage and clarifying the IS exclusion rationale.

💬 3 nitpick(s)

1 additional finding(s) omitted (not in diff).

Comment on lines +630 to +631
/// `true` if the spending tx has reached a confirmed context. Used
/// to gate `isSpent writes so a mempool-sighting alone — which is
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: Unterminated backtick in spendIsInBlock doc comment

The opening backtick on `isSpent is never closed before writes, which leaves the rest of the sentence rendered as inline code in Xcode quick-help and DocC output.

Suggested change
/// `true` if the spending tx has reached a confirmed context. Used
/// to gate `isSpent writes so a mempool-sighting alone — which is
/// `true` if the spending tx has reached a confirmed context. 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.

source: ['claude']

Comment on lines +630 to +640
/// `true` if the spending tx has reached a confirmed context. 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: Call out InstantSend exclusion in spendIsInBlock doc

TransactionContextType.instantSend (raw value 1) is intentionally excluded by the >= inBlock gate, but the doc only motivates the mempool/RBF case. Since IS-locked funds are treated as confirmed for balance display, a future contributor could reasonably read this as an oversight and widen the gate to >= instantSend, which would re-introduce the restart-misclassification bug for IS-locked self-sends. A single line in the comment noting that IS-locked-but-not-yet-mined spends are deliberately treated like mempool here — so the catch-up classifier can re-identify the TXO after restart, and the next inBlock upsert flips it — would lock that intent in.

source: ['claude', 'codex']

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