Skip to content

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

Merged
QuantumExplorer merged 1 commit into
v3.1-devfrom
fix/tx-categorization
Jun 12, 2026
Merged

fix(swift-sdk): fixed mempool tx categorization after restart#3777
QuantumExplorer merged 1 commit into
v3.1-devfrom
fix/tx-categorization

Conversation

@ZocoLini

@ZocoLini ZocoLini commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator

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 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 UTXO spent/unspent status more accurate and stable during context changes, reducing false positives and state flips.

@coderabbitai

coderabbitai Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Gates UTXO spent-state writes on whether the spending transaction is in-block (or later). Adds spendIsInBlock and applies it in input resolution, pending-input upsert, and mark-as-spent paths so mempool-only 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 unchanged 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, eager and fleet,
But only chains make spent complete;
I hop on confirmations, steady and bright.

🚥 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 PR title clearly identifies the main fix: addressing incorrect mempool transaction categorization after a restart in the Swift SDK.
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

thepastaclaw commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator

✅ Review complete (commit d20ddee)

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

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

github-actions Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

✅ 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: "e44fd4b120e798325af07d5e9c97fb7a567acc18869dc0754b3ca8ab9da145cb"
)

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

@thepastaclaw thepastaclaw left a comment

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.

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

ZocoLini commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator Author

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

@thepastaclaw thepastaclaw left a comment

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.

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

@ZocoLini ZocoLini force-pushed the fix/tx-categorization branch from 95b219d to d20ddee Compare June 9, 2026 08:32

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift (1)

659-675: ⚠️ Potential issue | 🟠 Major

Preserve an existing confirmed spender against later unconfirmed conflicts.

These paths still derive isSpent / spendingTransaction from the incoming or selected spender only. If a TXO is already linked to confirmed spend A and a later mempool-only conflicting spend B is processed, this code can overwrite the canonical link with B and clear isSpent back to false. Because loadWalletList() still rebuilds the restore set from isSpent == false at Line 2952, a restart can rehydrate a confirmed-spent output as live again.

Only allow the overwrite when the incoming spender is confirmed or matches the existing txid; if the existing linked spender is confirmed and the new spender is a different unconfirmed tx, keep the current link/flag.

Also applies to: 843-873, 901-925

🤖 Prompt for 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.

In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`
around lines 659 - 675, The code currently overwrites txo.isSpent and
txo.spendingTransaction with any incoming spender, which allows a confirmed
existing spender to be replaced by a later unconfirmed conflicting spender;
change the logic in the block that computes linkageChanged (and the subsequent
mutation around spendIsInBlock(spendingTransaction), txo.spendingTransaction,
spendingTxid, and isSpent) so that you only overwrite the existing link if the
incoming spendingTransaction is confirmed (Self.spendIsInBlock(...) == true) or
the incoming spendingTxid equals the already-linked
txo.spendingTransaction?.txid; if the existing txo.spendingTransaction is
confirmed and the incoming one is unconfirmed and has a different txid, do not
change txo.isSpent or txo.spendingTransaction (preserve the canonical confirmed
spender); apply the same guard to the other similar blocks noted (around the
other ranges) and ensure loadWalletList rebuilding logic remains consistent with
isSpent semantics.
🤖 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.

Duplicate comments:
In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- Around line 659-675: The code currently overwrites txo.isSpent and
txo.spendingTransaction with any incoming spender, which allows a confirmed
existing spender to be replaced by a later unconfirmed conflicting spender;
change the logic in the block that computes linkageChanged (and the subsequent
mutation around spendIsInBlock(spendingTransaction), txo.spendingTransaction,
spendingTxid, and isSpent) so that you only overwrite the existing link if the
incoming spendingTransaction is confirmed (Self.spendIsInBlock(...) == true) or
the incoming spendingTxid equals the already-linked
txo.spendingTransaction?.txid; if the existing txo.spendingTransaction is
confirmed and the incoming one is unconfirmed and has a different txid, do not
change txo.isSpent or txo.spendingTransaction (preserve the canonical confirmed
spender); apply the same guard to the other similar blocks noted (around the
other ranges) and ensure loadWalletList rebuilding logic remains consistent with
isSpent semantics.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 960f2a02-4a55-4968-b6e0-97d394741117

📥 Commits

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

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

@thepastaclaw thepastaclaw left a comment

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.

Code Review

Latest delta (95b219d→d20ddee3) is a single-character doc fix (closing backtick on isSpent). The prior 'backtick typo' finding is FIXED; the prior 'document/clarify InstantSend exclusion' and 'add restart regression test' findings are STILL VALID against the head — they are carried forward below. The de novo concern surfaced this round is the symmetric isSpent = expectedIsSpent write in resolveInputOutpoint, which can now flip a previously-true isSpent to false if a stale/conflicting mempool spender for the same outpoint is observed after a confirmed spender — this is a behavioral regression introduced by this PR (pre-PR code unconditionally wrote true). It does not justify blocking on its own because it depends on an unusual conflict/reorg emit sequence, but it is the most important new observation in the cumulative state. Recommending COMMENT.

🟡 3 suggestion(s) | 💬 1 nitpick(s)

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

🤖 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`:
- [SUGGESTION] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:667-675: New write path can flip `isSpent` from true back to false on a stale/conflicting mempool spender
  The pre-PR code in `resolveInputOutpoint` unconditionally set `txo.isSpent = true`. The new code computes `expectedIsSpent = spendIsInBlock(spendingTransaction)` and writes it whenever `linkageChanged` is true. This is the intended fix when an unconfirmed spender arrives first — but it also means: if a TXO is already linked to a confirmed spender A (`isSpent == true`), and a later stale or conflicting mempool observation B for the same outpoint flows through this path, then `expectedIsSpent` is false, `linkageChanged` is true, and the code clears `isSpent` and reassigns `spendingTransaction` to B. Because `loadWalletList()` restores rows where `isSpent == false`, that TXO becomes re-eligible as spendable, leading to balance overcount and a possible user-driven double-spend attempt. The same shape exists in the pending-input drain path at line 871–872. Consider only allowing the spender to be replaced (and `isSpent` lowered) when the existing linked spender is also unconfirmed, the same txid is changing context, or an explicit reorg downgrade signal is present. Pre-PR behavior masked this because `true` was always rewritten; this PR makes the downward flip newly reachable.
- [SUGGESTION] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:630-640: [Carried forward] Document the deliberate InstantSend exclusion in `spendIsInBlock`
  `TransactionContextType` is `mempool=0, instantSend=1, inBlock=2, inChainLockedBlock=3` (KeyWallet/TransactionContext.swift:4-8). The threshold `tx.context >= inBlock.rawValue` therefore groups `instantSend` with `mempool` as 'don't flip `isSpent`'. The justification given in the comment is reversibility via RBF or mempool eviction — but an InstantSend-locked tx is precisely the case that is NOT reversible by RBF or eviction. Two consequences:

  * For the bug being fixed (restart classifier needs the input TXO back as `isSpent == false`), lumping InstantSend with mempool is actually safe and arguably required, since the catch-up classifier still needs the input handed back until the spending tx lands in a block.
  * For UI surfaces that read `isSpent == false` as 'spendable', an InstantSend-locked spend will appear spendable until the block confirms — a spendable-balance overcount window.

  A future maintainer reading only the comment's RBF/eviction justification could reasonably widen the predicate to `>= instantSend.rawValue` and silently reintroduce the restart-classifier bug. Either widen the comment to explicitly call out InstantSend (intentional, and why), or — if the intent really is 'is this spend safely final' — change the threshold to `> mempool`. Recommend the doc-clarification approach so the restart-classifier contract stays intact.
- [SUGGESTION] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:628-936: [Carried forward] No regression test for the restart + mempool sighting + classifier sequence
  The PR replaces three previously unconditional `isSpent = true` writes with a context-gated path. The behavior change is meaningful in at least four flows: (1) mempool-only sighting then restart then block (the bug being fixed), (2) confirmed sighting in-order, (3) pending-row resolution in `upsertUtxo` when spending tx is mempool vs in-block, (4) `markUtxoSpent` with a missing spending-tx record (now leaves `isSpent` untouched instead of writing `true`). All four are realistic; (4) in particular silently changes a previously-loud signal into a no-op. There is no Swift test in `packages/swift-sdk` exercising the persist→mempool-sighting→restart→block sequence (no `testSelfSendClassificationSurvivesMidFlightRestart` or equivalent). Given the bug being fixed was a production-observed `netAmount` direction flip with inflated value, a regression test asserting `isSpent == true` and correct classification after that sequence would meaningfully harden the fix.

@QuantumExplorer QuantumExplorer merged commit 21e8f22 into v3.1-dev Jun 12, 2026
17 checks passed
@QuantumExplorer QuantumExplorer deleted the fix/tx-categorization branch June 12, 2026 03:09
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.

3 participants