fix(swift-sdk): fixed mempool tx categorization after restart#3777
fix(swift-sdk): fixed mempool tx categorization after restart#3777ZocoLini wants to merge 1 commit into
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughThis PR gates UTXO spent-state writes on whether the spending transaction is in-block (or later). It adds a ChangesTransaction spend-state confirmation gating
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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 |
|
✅ Review complete (commit 95b219d) |
e3c20ea to
f9c28f8
Compare
There was a problem hiding this comment.
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
📒 Files selected for processing (1)
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
|
✅ 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:
|
thepastaclaw
left a comment
There was a problem hiding this comment.
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.
|
I will be taking a look into pasta claw complains to be able to preperly answer them |
f9c28f8 to
95b219d
Compare
thepastaclaw
left a comment
There was a problem hiding this comment.
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).
| /// `true` if the spending tx has reached a confirmed context. Used | ||
| /// to gate `isSpent writes so a mempool-sighting alone — which is |
There was a problem hiding this comment.
💬 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.
| /// `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']
| /// `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 | ||
| } |
There was a problem hiding this comment.
💬 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']
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:
For repository code-owners and collaborators only
Summary by CodeRabbit
Summary by CodeRabbit