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 updates UTXO spend-state persistence in 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 e3c20ea) |
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: "b7487f6f0a59d47773eb5a9b4539d6bcce339d0b0063d87caff4b6c4a0498437"
)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.
| private static func spendIsInBlock(_ tx: PersistentTransaction) -> Bool { | ||
| tx.context >= TransactionContextType.inBlock.rawValue | ||
| } |
There was a problem hiding this comment.
🔴 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:
- While the spending tx is
instantSendbut not yet in a block, the input TXO keepsisSpent == false, so the wallet UI/queries that read SwiftData see the row as still spendable. - Across a restart in that window,
loadWalletList(line 2948) filters TXOs byisSpent == falseand 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.
| 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']
| // 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 |
There was a problem hiding this comment.
🟡 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:
- The guard at line 902 leaves
spendingTx == nilwhen the FFI emits an empty/all-zerospending_txid. The fact that this guard exists implies Rust can emit that case — and the legacy semantic there was "mark as spent anyway". - 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.
- A TXO already confirmed-spent in an earlier flush is re-emitted here with the spending tx's record evicted from this
backgroundContext—isSpentgoestrue → 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']
| /// `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 | ||
| } |
There was a problem hiding this comment.
💬 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']
|
I will be taking a look into pasta claw complains to be able to preperly answer them |
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