-
Notifications
You must be signed in to change notification settings - Fork 56
fix(platform-wallet): make asset-lock spends visible to every balance reader #4336
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: v4.2-dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+2298
to
+2302
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Suggested change
source: ['coderabbit'] |
||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+2298
to
+2303
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
🛡️ 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
| } | ||
|
Comment on lines
+2768
to
+2841
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 source: ['codex'] |
||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -385,6 +385,7 @@ impl<P: PlatformWalletPersistence + 'static> PlatformWalletManager<P> { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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<P: PlatformWalletPersistence + 'static> PlatformWalletManager<P> { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 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) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+1258
to
+1279
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: Balance classification now has two independent implementations
source: ['codex'] |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| #[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" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+1309
to
+1323
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Suggested change
source: ['codex'] |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<PersistentTxo>( | ||
| predicate: #Predicate { | ||
| $0.spendingTransaction?.txid == wireTxid && $0.isSpent == false | ||
| } | ||
| ) | ||
| if let stale = try? backgroundContext.fetch(staleDescriptor) { | ||
|
Comment on lines
+294
to
+303
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 source: ['codex'] |
||
| for txo in stale { | ||
| txo.isSpent = true | ||
| txo.lastUpdated = Date() | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| for outPointHex in removed { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.