Skip to content

fix(platform-wallet): make asset-lock spends visible to every balance reader - #4336

Open
HashEngineering wants to merge 2 commits into
dashpay:v4.2-devfrom
HashEngineering:fix/asset-lock-spend-visibility
Open

fix(platform-wallet): make asset-lock spends visible to every balance reader#4336
HashEngineering wants to merge 2 commits into
dashpay:v4.2-devfrom
HashEngineering:fix/asset-lock-spend-visibility

Conversation

@HashEngineering

@HashEngineering HashEngineering commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Issue being fixed or feature implemented

An SDK-built asset lock leaves every balance reader believing its funding TXOs are still spendable, long after the lock chain-locked and its top-up was credited. Observed live on an Android testnet wallet: the home header and MAX displayed 0.21 DASH on a 0.1 wallet, the phantom figure was persisted as the next launch's holding balance, and each relaunch re-inflated the engine's lock-free balance back to the phantom (0.22 after a later deposit).

Root cause, one sentence: an asset-lock tx burns its value into the special-tx payload and often has no wallet-owned standard output, so SPV block matching can miss it — the spender's transaction record never leaves mempool context, and every downstream "spent" signal keyed on in-block context never fires.

Three readers were affected:

  1. Persistence mirrors (Kotlin Room / Swift SwiftData): the TXO's isSpent flips only when the spending tx reaches in-block context — which never arrives — so rows sit at isSpent = false with spendingTxid set, forever.
  2. Per-account balance aggregates (account_balances_blocking): served the cached per-account WalletCoreBalance, which refreshes only when transaction processing runs update_balance() — stale indefinitely, while coin selection (reading the live UTXO set) was always right.
  3. UTXO restore on relaunch: the restore guard skips a spent-linked TXO only when its spending tx is in-block — same missing signal — so every relaunch handed the consumed outputs back to Rust as spendable and the lock-free wallet balance re-inflated.

What was done?

The tracked lock's own status is a signal that provably arrives (the proof wait drives Built → Broadcast → InstantSendLocked/ChainLocked → Consumed upserts), so the fixes key on it:

  • Kotlin + Swift persistence: the asset-lock upsert flips isSpent on the TXOs already linked to the lock's funding txid once status reaches InstantSendLocked (the network has locked the inputs). A terminal Consumed upsert heals rows an earlier missed flip left stale.
  • account_balances_blocking: computes each account's balance read-only from account.utxos with the exact bucket rules of ManagedCoreFundsAccount::update_balance, instead of serving the cache — the snapshot now derives from the same source selection uses and cannot disagree with it.
  • Kotlin UTXO restore: the stale-flag guard also consults the tracked lock's status via the spending txid's outpoint key (credit outpoints are always vout 0); from InstantSendLocked on, the output is skipped and its isSpent healed in place, so already-poisoned wallets converge on their next launch — terminal Consumed rows never re-upsert, so waiting for a status write would never heal them.

Deeper engine follow-up (deliberately out of scope): promote a self-authored transaction's record context by txid when its block is processed, so records don't depend on script matching — that would make these reconciles redundant rather than load-bearing.

How Has This Been Tested?

  • Rust: a regression test that dirties the UTXO set while deliberately leaving the cache stale — the accessor must report the live truth (cargo test -p platform-wallet --lib: 572 passed).
  • Kotlin/Robolectric: Broadcast must not flip (a pre-broadcast abort can still release the inputs), InstantSendLocked flips, and a terminal Consumed heals a stale row (:sdk:testReleaseUnitTest green).
  • The failure itself was reproduced and diagnosed live on an Android testnet wallet (Room mirror inspected directly; the persisted holding figure decoded at exactly the phantom sum).
  • ⚠️ The Swift mirror change is compile-unverified here (no iOS toolchain run) — needs CI / an iOS build.

Breaking Changes

None. All three changes tighten existing readers; no API or schema changes.

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 made corresponding changes to the documentation

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Wallet balances now reflect the current UTXO state instead of potentially stale cached values.
    • Asset-lock funding outputs are correctly marked as spent once transactions reach InstantSend finality.
    • Wallet restoration no longer reintroduces outputs that were already consumed by finalized asset-lock transactions.
    • Improved handling keeps locked, immature, confirmed, and unconfirmed funds accurately classified.

HashEngineering and others added 2 commits August 7, 2026 19:12
… reader

An SDK-built asset lock left both persistence mirrors and the per-account
balance aggregates believing its funding TXOs were still spendable,
long after the lock chain-locked and its top-up was credited — observed
live as an Android wallet displaying 0.21 DASH with 0.1 spendable, and a
MAX button offering the phantom total. Two independent gaps, one cause:
an asset-lock tx burns its value into the special-tx PAYLOAD and often
has no wallet-owned standard output, so SPV block matching can miss it
and the spender's transaction record never leaves mempool context.

Gap 1 — the persistence mirrors never flipped isSpent. Both handlers
flip a TXO's spent flag only when the SPENDING tx reaches in-block
context; for a block-matching-missed asset lock that context advance
never arrives, so the TXO kept isSpent=false (spendingTxid set) forever.
The lock's own STATUS is a signal that provably does arrive — the proof
wait drives Built → Broadcast → InstantSendLocked/ChainLocked →
Consumed upserts. From InstantSendLocked on, the network has locked the
inputs, so the asset-lock upsert now flips the TXOs already linked to
the lock's funding txid. Kotlin (onPersistAssetLockUpsert) and Swift
(persistAssetLocks) get the same reconcile; a terminal Consumed upsert
heals rows an earlier missed IS/CL left stale.

Gap 2 — account_balances_blocking served the cached per-account balance
field, which refreshes only when transaction processing runs
update_balance(); the miss above leaves it stale indefinitely, while the
coin selection reads the live UTXO set (and was always right). The
accessor now computes the balance read-only from account.utxos with the
exact bucket rules of ManagedCoreFundsAccount::update_balance, so the
per-account snapshot derives from the same source selection uses and
cannot disagree with it.

Deeper engine follow-up (out of scope here): promote a self-authored
transaction's record context by txid when its block is processed, so
records do not depend on script matching — that would make the
reconciles above redundant rather than load-bearing.

Tests: Rust — a dirtied UTXO set with a deliberately stale cache, the
accessor must report the live truth (571+1 pass). Kotlin/Robolectric —
Broadcast must NOT flip, InstantSendLocked flips, terminal Consumed
heals a stale row. clippy clean; kotlin-sdk unit suite green. Swift
mirror is compile-unverified here (no iOS toolchain run) — flagged for
CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed outputs

Completes the spend-visibility fix for the RESTART path. The restore
guard skipped a spent-linked TXO only when its spending tx had reached
in-block context — the very context a block-matching-missed asset lock
never reaches — so every relaunch handed the consumed outputs back to
Rust as spendable and the engine's lock-free balance re-inflated
(observed live: a 0.1 wallet re-showing 0.22 after each restart, and
persisting the phantom as the next launch's holding figure).

The guard now also consults the tracked lock's own status — the
finality signal that provably arrives — via the spending txid's
outpoint key (credit outpoints are always vout 0): from
InstantSendLocked on, the output is skipped AND its isSpent flag is
healed in place, so already-poisoned wallets converge on their next
launch without waiting for a status re-upsert that terminal Consumed
rows will never send.

kotlin-sdk unit suite green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The Kotlin and Swift persistence handlers reconcile asset-lock funding TXOs after finalized statuses. The Rust wallet computes funds-account balances from live UTXOs and processed height instead of cached values. Regression tests cover both behaviors.

Changes

Asset-lock reconciliation

Layer / File(s) Summary
Track finalized asset-lock spends
packages/kotlin-sdk/sdk/src/main/kotlin/.../PlatformWalletPersistenceHandler.kt, packages/swift-sdk/Sources/.../PlatformWalletPersistenceHandler.swift
The persistence handlers mark linked funding TXOs as spent when the asset lock reaches InstantSendLocked or later. Kotlin adds the documented status threshold constant.
Prevent restoration of finalized spends
packages/kotlin-sdk/sdk/src/main/kotlin/.../PlatformWalletPersistenceHandler.kt, packages/kotlin-sdk/sdk/src/test/kotlin/.../PlatformWalletPersistenceHandlerTest.kt
UTXO restoration repairs stale spend flags and skips finalized asset-lock spends. Tests cover Broadcast, InstantSendLocked, and Consumed statuses.

Live wallet balance calculation

Layer / File(s) Summary
Compute balances from live UTXOs
packages/rs-platform-wallet/src/manager/accessors.rs
account_balances_blocking classifies current UTXOs using the processed height and ignores stale cached balances. A regression test covers an empty live UTXO set.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant WalletPersistence
  participant AssetLock
  participant FundingTXOs
  WalletPersistence->>AssetLock: read asset-lock status and funding outpoint
  AssetLock-->>WalletPersistence: return finalized status
  WalletPersistence->>FundingTXOs: mark linked TXOs spent
Loading

Possibly related PRs

Suggested reviewers: quantumexplorer, shumkov

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: exposing asset-lock spends across wallet balance readers.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

@thepastaclaw

thepastaclaw commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

⛔ Blockers found — Opus deferred (commit 4deaf3b)
Canonical validated blockers: 1

@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: 2

🧹 Nitpick comments (2)
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift (1)

294-294: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the InstantSendLocked threshold into a named constant.

entry.statusRaw >= 2 uses a raw literal for the InstantSendLocked wire value. The Kotlin mirror of this same reconcile names it ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED with a comment citing the Rust source. Mirror that here so both SDKs document the same protocol value in one place and a future Rust enum change is easier to grep for.

♻️ Proposed named constant
+    /// `AssetLockStatus` wire value for `InstantSendLocked` (mirrors the
+    /// Kotlin handler's `ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED`).
+    private static let assetLockStatusInstantSendLocked = 2
+
     func persistAssetLocks(
-                if entry.statusRaw >= 2,
+                if entry.statusRaw >= Self.assetLockStatusInstantSendLocked,
🤖 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`
at line 294, Replace the raw threshold in the reconciliation condition around
entry.statusRaw with a named constant representing InstantSendLocked, such as
ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED. Define the constant in the appropriate
shared scope, document that its value mirrors the Rust wire enum, and update the
comparison to use it.
packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt (1)

2767-2841: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add restore-time coverage for the asset-lock heal branch.

Seed a stale TxoEntity and an AssetLockEntity with statusRaw >= ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED, then call onLoadWalletList(). Assert that the TXO is excluded from utxos and persisted with isSpent = true.

🤖 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/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt`
around lines 2767 - 2841, Add a restore-time test alongside asset-lock
persistence coverage that seeds a stale unspent TxoEntity linked by spendingTxid
to an AssetLockEntity whose statusRaw is at least
ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED, then invokes onLoadWalletList(). Assert
the loaded wallet excludes that TXO from utxos and the database row is persisted
with isSpent = true.
🤖 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/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt`:
- Around line 2298-2303: Guard the opportunistic txoDao().upsert healing write
in buildUtxoRestoreData with the same Throwable-catching, logging, and
continuation pattern used by scrubAliases. Keep marking the txo as spent when
the write succeeds, but ensure a failed single-row heal does not propagate
through onLoadWalletList or cause guardedLoad to discard the entire wallet list.
- Around line 1526-1542: The asset-lock spend reconciliation must be scoped to
the current wallet. In
packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt:1526-1542,
update the TXO query in stage(walletId) to filter by walletId, spendingTxid, and
isSpent = 0. In
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:283-309,
update staleDescriptor to include the walletId predicate alongside the existing
transaction filter.

---

Nitpick comments:
In
`@packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt`:
- Around line 2767-2841: Add a restore-time test alongside asset-lock
persistence coverage that seeds a stale unspent TxoEntity linked by spendingTxid
to an AssetLockEntity whose statusRaw is at least
ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED, then invokes onLoadWalletList(). Assert
the loaded wallet excludes that TXO from utxos and the database row is persisted
with isSpent = true.

In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- Line 294: Replace the raw threshold in the reconciliation condition around
entry.statusRaw with a named constant representing InstantSendLocked, such as
ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED. Define the constant in the appropriate
shared scope, document that its value mirrors the Rust wire enum, and update the
comparison to use it.
🪄 Autofix

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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 79d79ff2-a571-4ace-9bd5-b602bf5e7216

📥 Commits

Reviewing files that changed from the base of the PR and between 8f98180 and 4deaf3b.

📒 Files selected for processing (4)
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt
  • packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt
  • packages/rs-platform-wallet/src/manager/accessors.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift

Comment on lines +2298 to +2303
if (lock != null && lock.statusRaw >= ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED) {
if (!txo.isSpent) {
database.txoDao().upsert(txo.copy(isSpent = true, lastUpdated = now()))
}
continue
}

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard the healing write against exceptions inside the load path.

database.txoDao().upsert(...) here runs unguarded inside buildUtxoRestoreData, which is called from onLoadWalletList. That whole call is wrapped in guardedLoad(emptyArray()) { ... }: if this single-row healing write throws, the exception propagates out of the loop over wallets, and the catch in guardedLoad returns an empty array for the ENTIRE wallet list — not just the one stale row. A single failed heal would make every wallet look non-restorable.

scrubAliases in this same file follows the safer pattern for opportunistic cleanup: it catches Throwable, logs, and lets the round continue rather than propagating. Apply the same pattern here.

🛡️ Proposed fix to prevent a healing failure from emptying the whole restore
                 if (lock != null && lock.statusRaw >= ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED) {
                     if (!txo.isSpent) {
-                        database.txoDao().upsert(txo.copy(isSpent = true, lastUpdated = now()))
+                        try {
+                            database.txoDao().upsert(txo.copy(isSpent = true, lastUpdated = now()))
+                        } catch (t: Throwable) {
+                            Log.w(TAG, "load: failed to heal stale asset-lock-consumed TXO", t)
+                        }
                     }
                     continue
                 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (lock != null && lock.statusRaw >= ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED) {
if (!txo.isSpent) {
database.txoDao().upsert(txo.copy(isSpent = true, lastUpdated = now()))
}
continue
}
if (lock != null && lock.statusRaw >= ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED) {
if (!txo.isSpent) {
try {
database.txoDao().upsert(txo.copy(isSpent = true, lastUpdated = now()))
} catch (t: Throwable) {
Log.w(TAG, "load: failed to heal stale asset-lock-consumed TXO", t)
}
}
continue
}
🤖 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/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt`
around lines 2298 - 2303, Guard the opportunistic txoDao().upsert healing write
in buildUtxoRestoreData with the same Throwable-catching, logging, and
continuation pattern used by scrubAliases. Keep marking the txo as spent when
the write succeeds, but ensure a failed single-row heal does not propagate
through onLoadWalletList or cause guardedLoad to discard the entire wallet list.

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

Preliminary review — Codex only

The Rust live-balance calculation and callback-time asset-lock reconciliation are sound, but the Swift restore path still rehydrates stale funding TXOs for locks finalized before this fix, leaving upgraded iOS wallets with persistent phantom balances. The remaining suggestions cover failure isolation and regression coverage for the Kotlin restore repair, shared ownership of the Rust balance rules, and a stronger non-empty-balance assertion.
Source: reviewers gpt-5.6-sol (general) and gpt-5.6-sol (rust-quality); final verifier gpt-5.6-sol. Orchestration-only: openclaw-agent/cliproxy/gpt-5.6-sol (not reviewer evidence).

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — rust-quality (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 1 blocking | 🟡 4 suggestion(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:294-303: Previously consumed Swift asset locks never trigger this reconciliation
  The new reconciliation only runs when `persistAssetLocks` receives another upsert. A wallet upgraded with an existing lock at `InstantSendLocked`, `ChainLocked`, or especially terminal `Consumed` status can already have linked TXOs persisted with `isSpent == false`. The Swift load path at lines 4482–4484 still fetches every such row solely by `isSpent == false` and marshals it back to Rust without consulting the persisted asset-lock status. Consumed rows are intentionally retained for history and never advance again, so no future callback is guaranteed to repair them; the phantom UTXO can therefore return on every launch. Add a load-time finalized-lock exclusion and healing step equivalent to Kotlin's `buildUtxoRestoreData` guard.

In `packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt`:
- [SUGGESTION] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt:2298-2302: Guard the healing write against exceptions inside the load path
  This healing upsert runs inside `onLoadWalletList`, whose entire body is wrapped by `guardedLoad(emptyArray())`. If the single-row write throws, the exception escapes the wallet loop and `guardedLoad` returns an empty array for every wallet, even though excluding this finalized TXO from the current restore does not depend on the repair being durable. Treat the write as opportunistic: log its failure and continue skipping the consumed row so one failed repair cannot discard the complete restore result.

In `packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt`:
- [SUGGESTION] packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt:2768-2841: Add coverage for the finalized asset-lock restore guard
  The added tests exercise only callback-time flips for statuses 2 and 4. They never invoke `onLoadWalletList()` with the legacy state this new restore branch is meant to repair: an unspent TXO linked to a mempool-context spending transaction and an already-finalized asset-lock row. Add a restore test that seeds this state, verifies the TXO is absent from the returned `utxos`, and verifies its Room row is healed to `isSpent = true`; this also pins the synthetic vout-0 key and txid byte orientation.

In `packages/rs-platform-wallet/src/manager/accessors.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/manager/accessors.rs:1258-1279: Balance classification now has two independent implementations
  `computed_core_balance` duplicates the pinned key-wallet `ManagedCoreFundsAccount::update_balance` bucket rules line for line. A future key-wallet change to maturity, locking, trust, or confirmed/unconfirmed classification can compile cleanly while this accessor retains the old behavior, recreating disagreement between balance readers. Add an immutable balance-calculation method in key-wallet and have both its mutating cache update and this accessor call that shared implementation.
- [SUGGESTION] packages/rs-platform-wallet/src/manager/accessors.rs:1309-1323: Regression test passes if the calculator always returns zero
  The test checks `computed_core_balance` only after clearing every UTXO and expecting zero. An implementation that always returned `WalletCoreBalance::default()` would therefore pass. Before clearing the account, compare the computed balance with the freshly updated, non-empty cache so the test establishes that the live fold classifies funded UTXOs as well as observing their removal.

Comment on lines +294 to +303
if entry.statusRaw >= 2,
let displayTxidHex = entry.outPointHex.split(separator: ":").first,
let displayTxid = Data(hexString: String(displayTxidHex)) {
let wireTxid = Data(displayTxid.reversed())
let staleDescriptor = FetchDescriptor<PersistentTxo>(
predicate: #Predicate {
$0.spendingTransaction?.txid == wireTxid && $0.isSpent == false
}
)
if let stale = try? backgroundContext.fetch(staleDescriptor) {

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: Previously consumed Swift asset locks never trigger this reconciliation

The new reconciliation only runs when persistAssetLocks receives another upsert. A wallet upgraded with an existing lock at InstantSendLocked, ChainLocked, or especially terminal Consumed status can already have linked TXOs persisted with isSpent == false. The Swift load path at lines 4482–4484 still fetches every such row solely by isSpent == false and marshals it back to Rust without consulting the persisted asset-lock status. Consumed rows are intentionally retained for history and never advance again, so no future callback is guaranteed to repair them; the phantom UTXO can therefore return on every launch. Add a load-time finalized-lock exclusion and healing step equivalent to Kotlin's buildUtxoRestoreData guard.

source: ['codex']

Comment on lines +2298 to +2302
if (lock != null && lock.statusRaw >= ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED) {
if (!txo.isSpent) {
database.txoDao().upsert(txo.copy(isSpent = true, lastUpdated = now()))
}
continue

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: Guard the healing write against exceptions inside the load path

This healing upsert runs inside onLoadWalletList, whose entire body is wrapped by guardedLoad(emptyArray()). If the single-row write throws, the exception escapes the wallet loop and guardedLoad returns an empty array for every wallet, even though excluding this finalized TXO from the current restore does not depend on the repair being durable. Treat the write as opportunistic: log its failure and continue skipping the consumed row so one failed repair cannot discard the complete restore result.

Suggested change
if (lock != null && lock.statusRaw >= ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED) {
if (!txo.isSpent) {
database.txoDao().upsert(txo.copy(isSpent = true, lastUpdated = now()))
}
continue
if (lock != null && lock.statusRaw >= ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED) {
if (!txo.isSpent) {
try {
database.txoDao().upsert(txo.copy(isSpent = true, lastUpdated = now()))
} catch (t: Throwable) {
Log.w(TAG, "load: failed to heal stale asset-lock-consumed TXO", t)
}
}
continue
}

source: ['coderabbit']

Comment on lines +2768 to +2841
// ── Asset-lock spend visibility ────────────────────────────────────

/**
* An asset-lock tx burns its value into the special-tx payload and often
* has no wallet-owned standard output, so SPV block matching can miss it:
* the spender's transaction row never advances past mempool context and
* the in-block flip in onWalletChangesetTransaction never runs — the
* funding TXO sits at isSpent=false (spendingTxid set) FOREVER, and every
* isSpent-based balance read overstates the wallet. The lock's own status
* DOES keep arriving; from InstantSendLocked on, the upsert must flip
* linked TXOs.
*/
@Test
fun assetLockStatusAdvanceFlipsItsFundingTxos() = runTest {
handler.onPersistWalletMetadata(walletId, testnet, groupId, 0)
val lockTxid = ByteArray(32) { 7 }
db.transactionDao().upsert(
TransactionEntity(txid = lockTxid, transactionData = ByteArray(4), context = 0),
)
val outpoint = ByteArray(36) { 9 }
db.txoDao().upsert(
TxoEntity(
outpoint = outpoint,
vout = 0,
amount = 1_000_000,
address = "yTest",
walletId = walletId,
spendingTxid = lockTxid,
spendingInputIndex = 0,
isSpent = false,
),
)

// Broadcast (1) must NOT flip — the network holds no lock yet and a
// pre-broadcast abort could still release the inputs.
handler.onPersistAssetLockUpsert(
walletId, lockTxid + ByteArray(4), ByteArray(4), 0, 1, 0, 999_545, 1, null,
)
assertFalse(db.txoDao().getByOutpoint(outpoint)!!.isSpent)

// InstantSendLocked (2): the network has locked the inputs — flip.
handler.onPersistAssetLockUpsert(
walletId, lockTxid + ByteArray(4), ByteArray(4), 0, 1, 0, 999_545, 2, null,
)
assertTrue(db.txoDao().getByOutpoint(outpoint)!!.isSpent)
}

/** The Consumed (4) terminal upsert heals rows a missed IS/CL never flipped. */
@Test
fun assetLockConsumedHealsAStaleUnspentRow() = runTest {
handler.onPersistWalletMetadata(walletId, testnet, groupId, 0)
val lockTxid = ByteArray(32) { 8 }
db.transactionDao().upsert(
TransactionEntity(txid = lockTxid, transactionData = ByteArray(4), context = 0),
)
val outpoint = ByteArray(36) { 10 }
db.txoDao().upsert(
TxoEntity(
outpoint = outpoint,
vout = 0,
amount = 9_999_545,
address = "yTest2",
walletId = walletId,
spendingTxid = lockTxid,
spendingInputIndex = 0,
isSpent = false,
),
)

handler.onPersistAssetLockUpsert(
walletId, lockTxid + ByteArray(4), ByteArray(4), 0, 1, 0, 9_999_545, 4, null,
)
assertTrue(db.txoDao().getByOutpoint(outpoint)!!.isSpent)
}

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: Add coverage for the finalized asset-lock restore guard

The added tests exercise only callback-time flips for statuses 2 and 4. They never invoke onLoadWalletList() with the legacy state this new restore branch is meant to repair: an unspent TXO linked to a mempool-context spending transaction and an already-finalized asset-lock row. Add a restore test that seeds this state, verifies the TXO is absent from the returned utxos, and verifies its Room row is healed to isSpent = true; this also pins the synthetic vout-0 key and txid byte orientation.

source: ['codex']

Comment on lines +1258 to +1279
fn computed_core_balance(
account: &key_wallet::managed_account::ManagedCoreFundsAccount,
last_processed_height: u32,
) -> key_wallet::wallet::balance::WalletCoreBalance {
let mut confirmed = 0u64;
let mut unconfirmed = 0u64;
let mut immature = 0u64;
let mut locked = 0u64;
for utxo in account.utxos.values() {
let value = utxo.txout.value;
if utxo.is_locked {
locked += value;
} else if !utxo.is_mature(last_processed_height) {
immature += value;
} else if utxo.is_confirmed || utxo.is_instantlocked || utxo.is_trusted {
confirmed += value;
} else {
unconfirmed += value;
}
}
key_wallet::wallet::balance::WalletCoreBalance::new(confirmed, unconfirmed, immature, locked)
}

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: Balance classification now has two independent implementations

computed_core_balance duplicates the pinned key-wallet ManagedCoreFundsAccount::update_balance bucket rules line for line. A future key-wallet change to maturity, locking, trust, or confirmed/unconfirmed classification can compile cleanly while this accessor retains the old behavior, recreating disagreement between balance readers. Add an immutable balance-calculation method in key-wallet and have both its mutating cache update and this accessor call that shared implementation.

source: ['codex']

Comment on lines +1309 to +1323
account.update_balance(height);
let cached_before = account.balance;
assert!(cached_before.total() > 0, "fixture must be funded");
account.utxos.clear();

assert_eq!(
account.balance.total(),
cached_before.total(),
"precondition: the cache must still hold the stale figure"
);
assert_eq!(
computed_core_balance(account, height).total(),
0,
"the accessor's fold must see the live (empty) UTXO set"
);

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: Regression test passes if the calculator always returns zero

The test checks computed_core_balance only after clearing every UTXO and expecting zero. An implementation that always returned WalletCoreBalance::default() would therefore pass. Before clearing the account, compare the computed balance with the freshly updated, non-empty cache so the test establishes that the live fold classifies funded UTXOs as well as observing their removal.

Suggested change
account.update_balance(height);
let cached_before = account.balance;
assert!(cached_before.total() > 0, "fixture must be funded");
account.utxos.clear();
assert_eq!(
account.balance.total(),
cached_before.total(),
"precondition: the cache must still hold the stale figure"
);
assert_eq!(
computed_core_balance(account, height).total(),
0,
"the accessor's fold must see the live (empty) UTXO set"
);
account.update_balance(height);
let cached_before = account.balance;
assert!(cached_before.total() > 0, "fixture must be funded");
assert_eq!(
computed_core_balance(account, height),
cached_before,
"the live fold must reproduce a freshly updated non-empty balance"
);
account.utxos.clear();
assert_eq!(
account.balance.total(),
cached_before.total(),
"precondition: the cache must still hold the stale figure"
);
assert_eq!(
computed_core_balance(account, height).total(),
0,
"the accessor's fold must see the live (empty) UTXO set"
);

source: ['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