diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index abc115a0726..fe25142c4bb 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -1523,6 +1523,23 @@ class PlatformWalletPersistenceHandler( updatedAt = now(), ), ) + // Spend-visibility reconcile: 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 entirely — + // the spender's transaction row then never leaves mempool + // context and onWalletChangesetTransaction's in-block flip never + // runs, leaving the funding TXOs isSpent=0 (spendingTxid set) + // FOREVER. The lock's own STATUS is a signal that provably + // does arrive (the proof wait drives it): once it reaches + // InstantSendLocked (2) the network has locked the inputs, so + // flip the linked TXOs here. Monotonic, and keyed strictly to + // TXOs already linked to THIS lock's funding txid. + if ((status.toInt() and 0xFF) >= ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED) { + val fundingTxid = outPoint.copyOfRange(0, 32) + for (txo in db.txoDao().getUnspentBySpendingTxid(fundingTxid)) { + db.txoDao().upsert(txo.copy(isSpent = true, lastUpdated = now())) + } + } } 0 } @@ -2267,6 +2284,23 @@ class PlatformWalletPersistenceHandler( if (spendingTxid != null) { val spending = database.transactionDao().getByTxid(spendingTxid) if (spending != null && spending.context >= CONTEXT_IN_BLOCK) continue + // Asset-lock spender: the 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 its row sits + // at mempool context FOREVER — the guard above never fires, + // and every relaunch resurrects the consumed output into the + // engine's balance. The tracked lock's own status is the + // finality signal that provably arrives; from + // InstantSendLocked on this output is gone. Skip it, and + // heal the flag so isSpent-based readers stop counting it. + val lockKey = encodeOutPointHex(spendingTxid + ByteArray(4)) + val lock = database.assetLockDao().getByOutPointHex(lockKey) + if (lock != null && lock.statusRaw >= ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED) { + if (!txo.isSpent) { + database.txoDao().upsert(txo.copy(isSpent = true, lastUpdated = now())) + } + continue + } } val account = txo.accountId?.let { database.accountDao().getById(it) } ?: accountByAddress.getOrPut(txo.address) { @@ -3090,6 +3124,15 @@ class PlatformWalletPersistenceHandler( /** `TransactionContext::InBlock` — spends only count once in-block. */ private const val CONTEXT_IN_BLOCK = 2 + /** + * Rust `AssetLockStatus` wire bytes (asset_lock_persistence.rs): + * Built 0, Broadcast 1, InstantSendLocked 2, ChainLocked 3, + * Consumed 4. At InstantSendLocked the network has locked the + * funding inputs — the spend-visibility reconcile treats the + * linked TXOs as spent from there. + */ + private const val ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED = 2 + /** `Network.testnet` rawValue — the Swift fallback network. */ private const val NETWORK_TESTNET = 1 diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index c3d16f7c511..fa36f5dc23d 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt @@ -9,6 +9,8 @@ import org.dashfoundation.dashsdk.errors.DashSdkError import org.dashfoundation.dashsdk.ffi.NativePersistenceBridge import org.dashfoundation.dashsdk.wallet.PlatformWalletPersistenceCapabilities import org.dashfoundation.dashsdk.persistence.entities.CoreAddressEntity +import org.dashfoundation.dashsdk.persistence.entities.TransactionEntity +import org.dashfoundation.dashsdk.persistence.entities.TxoEntity import org.dashfoundation.dashsdk.persistence.entities.IdentityEntity import org.dashfoundation.dashsdk.persistence.entities.PlatformAddressEntity import org.dashfoundation.dashsdk.persistence.entities.WalletEntity @@ -2762,4 +2764,79 @@ class PlatformWalletPersistenceHandlerTest { // Unchanged pre-invitation behavior: no account row conjured. assertTrue(db.accountDao().observeByWallet(walletId).first().isEmpty()) } + + // ── 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) + } } diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index ef0c780e3eb..30c962c22fd 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -385,6 +385,7 @@ impl PlatformWalletManager

{ let Some(info) = wm.get_wallet_info(wallet_id) else { return Vec::new(); }; + let last_processed_height = info.core_wallet.metadata.last_processed_height; info.core_wallet .accounts .all_accounts() @@ -393,7 +394,18 @@ impl PlatformWalletManager

{ // Balance lives on the funds-bearing variant only; // keys-only accounts (identity, asset-lock, provider) // never carry UTXOs. - let balance = account.as_funds().map(|a| a.balance).unwrap_or_default(); + // + // Computed FRESH from the account's UTXO set — NOT the cached + // `a.balance` field. The cache refreshes only when transaction + // processing runs `update_balance()`, and a self-authored + // asset-lock spend can leave it stale long after the UTXO set + // (which coin selection reads) has moved on. Deriving from the + // same source selection uses makes disagreement impossible; + // the fold is bounded by the account's UTXO count. + let balance = account + .as_funds() + .map(|a| computed_core_balance(a, last_processed_height)) + .unwrap_or_default(); // Walk every pool on the account, sum // `used` + total entries. Cheap — pools are bounded by // the gap limit. @@ -1237,3 +1249,77 @@ mod spv_rescan_tests { .expect("blocking accessor task"); } } + +/// Read-only [`WalletCoreBalance`] over an account's live UTXO set, with the +/// exact bucket rules of `ManagedCoreFundsAccount::update_balance` (which +/// requires `&mut self` and mutates the cache, so it cannot serve a +/// read-path): locked, else immature, else confirmed when in a block / +/// InstantSend-locked / trusted change, else unconfirmed. +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) +} + +#[cfg(test)] +mod computed_balance_tests { + use super::*; + use key_wallet::account::StandardAccountType; + + /// The accessor's per-account figure must come from the LIVE UTXO set, + /// not the cached balance field: a self-authored asset-lock spend can + /// leave the cache stale long after selection has moved on. Dirty the + /// UTXO set + /// without running update_balance and assert the fold reports the fresh + /// truth while the cache still holds the stale figure. + #[tokio::test] + async fn computed_balance_ignores_the_stale_cache() { + let (wallet_manager, wallet_id, _balance, _signer) = + crate::test_support::funded_wallet_manager(StandardAccountType::BIP44Account).await; + + let mut wm = wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("wallet"); + let height = info.core_wallet.metadata.last_processed_height; + let account = info + .core_wallet + .accounts + .standard_bip44_accounts + .get_mut(&0) + .expect("bip44 account 0"); + + // Freshen the cache, then remove every UTXO WITHOUT refreshing it — + // the shape an unprocessed self-spend leaves behind. + 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" + ); + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index a665f6d1e9f..e288496eb81 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -280,6 +280,33 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) backgroundContext.insert(record) } + + // Spend-visibility reconcile (mirror of the Kotlin handler's + // onPersistAssetLockUpsert): 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 then never leaves mempool context + // and resolveInputOutpoint's in-block flip never runs, leaving + // the funding TXOs isSpent=false forever. The lock's + // own STATUS keeps arriving via this callback; from + // InstantSendLocked (2) the network has locked the inputs, so + // flip the TXOs already linked to this lock's funding tx. + 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( + predicate: #Predicate { + $0.spendingTransaction?.txid == wireTxid && $0.isSpent == false + } + ) + if let stale = try? backgroundContext.fetch(staleDescriptor) { + for txo in stale { + txo.isSpent = true + txo.lastUpdated = Date() + } + } + } } for outPointHex in removed {