From 3dc04f14caf746a014c18591f6e44126c3f3f57b Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 1 Nov 2026 13:25:55 -0600 Subject: [PATCH 01/21] feat(platform-wallet): own the shielded-tip payment decisions in the wallet The example apps decided when a tip was still in flight, whether a failed tip could be sent again, whether a username's recipient had changed, and whether an identity's tip balance could be shown. Each kept that state in memory or in host preferences, so a relaunch forgot an unsettled tip and the user could pay twice. The wallet now makes each of these decisions. - A tip is recorded as unresolved before broadcast: the activity row is flushed and a parked recovery record is armed in the shielded store, so it survives a relaunch. A tip to an address with an unsettled payment (or to the address last confirmed for the name) is refused, as is a second tip while one is being sent. The send's own outcome, a scan, or the anchor prune settles the record. - ShieldedTipDisposition (NotSent / Unresolved) is the retry decision, passed through the C send as out_disposition and thrown as ShieldedTipFailure in Swift and Kotlin. - review_shielded_tip returns the recipient, how it compares with the one last confirmed for the name (kept per wallet in the shielded store), and whether a payment to it is unresolved. - identity_tip_account reports whether this session proved the identity's slot (discovery, load, or a seed check); a restored index is unverified. Closes #5674. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/ffi/FundingNative.kt | 11 +- .../dashfoundation/dashsdk/tokens/Dashpay.kt | 9 + .../dashsdk/tokens/ShieldedTipRecipient.kt | 59 +++ .../dashsdk/wallet/PlatformWalletManager.kt | 38 +- .../dashsdk/tokens/ShieldedTipReviewTest.kt | 42 ++ .../src/shielded_send.rs | 233 ++++++++++- .../rs-platform-wallet/docs/SHIELDED_TIPS.md | 27 +- packages/rs-platform-wallet/src/error.rs | 6 + packages/rs-platform-wallet/src/lib.rs | 3 +- .../state/managed_identity/identity_ops.rs | 2 + .../identity/state/managed_identity/mod.rs | 7 + .../identity/state/manager/lifecycle.rs | 15 +- .../src/wallet/platform_wallet.rs | 42 +- .../src/wallet/shielded/file_store.rs | 112 +++++- .../src/wallet/shielded/mod.rs | 3 +- .../src/wallet/shielded/operations.rs | 254 +++++++++++- .../src/wallet/shielded/store.rs | 51 +++ .../src/wallet/shielded/tips.rs | 363 +++++++++++++++++- .../wallet/shielded/viewing_key_bind_tests.rs | 64 +++ packages/rs-unified-sdk-jni/src/funding.rs | 109 ++++++ .../PlatformWalletManagerShieldedSync.swift | 135 ++++++- .../SwiftDashSDK/PlatformWallet/README.md | 19 +- 22 files changed, 1548 insertions(+), 56 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipReviewTest.kt diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt index 5b3ae7ad0aa..acb9d443b54 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt @@ -21,12 +21,19 @@ internal object FundingNative { manager: Long, walletId: ByteArray, resolver: Long, identityId: ByteArray, ): ByteArray external fun resolveShieldedTip(wallet: Long, username: String): ByteArray - /** A non-null [sourceIdentityId] spends that identity's tip account; otherwise [sourceAccount]. */ + /** + * A non-null [sourceIdentityId] spends that identity's tip account; otherwise [sourceAccount]. + * [outDisposition] (one element) receives Rust's retry decision, also when this throws. + */ external fun sendShieldedTip( manager: Long, walletId: ByteArray, resolver: Long, sourceIdentityId: ByteArray?, sourceAccount: Int, username: String, expectedId: ByteArray, expectedAddress: ByteArray, - amount: Long, memo: String?, + amount: Long, memo: String?, outDisposition: IntArray, ) + /** 152 bytes: identity, address, change kind, previous identity, previous address, unresolved. */ + external fun reviewShieldedTip(manager: Long, walletId: ByteArray, username: String): ByteArray + /** `[account, verified]` for a wallet identity's dedicated tip account. */ + external fun identityTipAccount(wallet: Long, identityId: ByteArray): IntArray external fun shieldedIdentityDebitRecoveryRecords( diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt index 504481bd847..83bd03ac53c 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt @@ -44,6 +44,15 @@ class Dashpay internal constructor(private val walletHandle: Long, ShieldedTipRecipient(bytes.copyOfRange(0, 32), bytes.copyOfRange(32, 75)) } + /** The identity's dedicated tip account, and whether Rust has verified its slot this session. */ + suspend fun identityTipAccount(identityId: ByteArray): IdentityTipAccount = gate.op { + val values = mapNativeErrors { + org.dashfoundation.dashsdk.ffi.FundingNative.identityTipAccount(walletHandle, identityId) + } + check(values.size == 2) { "Invalid tip account response" } + IdentityTipAccount(values[0], values[1] != 0) + } + /** * Send a contact request to [recipientIdentityId], signing the document * state-transition with [signerHandle] and keying the contact crypto diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipient.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipient.kt index 353573c87a5..ada10346611 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipient.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipient.kt @@ -39,3 +39,62 @@ sealed class ShieldedTipSource { } } } + +/** Rust's decision whether a failed tip may be sent again. */ +enum class ShieldedTipDisposition { + /** Nothing was broadcast, or it was definitively rejected: review and send again. */ + NOT_SENT, + + /** The payment may have executed or may still execute: do not send it again. */ + UNRESOLVED; + + internal companion object { + /** The native `SHIELDED_TIP_DISPOSITION_NOT_SENT`; every other value is unresolved. */ + const val NATIVE_NOT_SENT = 1 + + fun fromNative(value: Int): ShieldedTipDisposition = + if (value == NATIVE_NOT_SENT) NOT_SENT else UNRESOLVED + } +} + +/** A tip send that failed; [disposition] says whether it may be sent again. */ +class ShieldedTipFailure(val disposition: ShieldedTipDisposition, cause: Throwable) : + Exception(cause.message, cause) + +/** How a tip recipient compares with the one last confirmed for the same name from this wallet. */ +sealed class ShieldedTipRecipientChange { + object New : ShieldedTipRecipientChange() + object Unchanged : ShieldedTipRecipientChange() + class Changed(val previous: ShieldedTipRecipient) : ShieldedTipRecipientChange() +} + +/** + * What a user confirms before tipping a name. [unresolved] means an earlier payment to + * this address is unsettled, and a send is refused until shielded sync settles it. + */ +class ShieldedTipReview( + val recipient: ShieldedTipRecipient, + val change: ShieldedTipRecipientChange, + val unresolved: Boolean, +) { + internal companion object { + fun fromNative(bytes: ByteArray): ShieldedTipReview { + check(bytes.size == 152) { "Invalid tip review response" } + val recipient = ShieldedTipRecipient(bytes.copyOfRange(0, 32), bytes.copyOfRange(32, 75)) + val change = when (bytes[75].toInt()) { + 0 -> ShieldedTipRecipientChange.New + 1 -> ShieldedTipRecipientChange.Unchanged + else -> ShieldedTipRecipientChange.Changed( + ShieldedTipRecipient(bytes.copyOfRange(76, 108), bytes.copyOfRange(108, 151)), + ) + } + return ShieldedTipReview(recipient, change, bytes[151].toInt() != 0) + } + } +} + +/** + * A wallet identity's dedicated tip account. Show its balance as the identity's only + * when [verified]: unverified, the account may belong to another identity. + */ +data class IdentityTipAccount(val account: Int, val verified: Boolean) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index ab3ed08fe2c..41c153827c9 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -2022,7 +2022,23 @@ class PlatformWalletManager( mapNativeErrors { FundingNative.prepareShieldedTipAddress(managerHandle, walletId, mnemonicResolver.nativeHandle, identityId) } } - /** Send only if fresh resolution still matches the recipient shown in confirmation. */ + /** + * Resolve [username] for the user to confirm: its recipient, whether it changed since this + * wallet last confirmed the name, and whether an earlier payment to it is unresolved. + */ + suspend fun reviewShieldedTip( + walletId: ByteArray, username: String, + ): org.dashfoundation.dashsdk.tokens.ShieldedTipReview = teardownGate.op { + org.dashfoundation.dashsdk.tokens.ShieldedTipReview.fromNative( + mapNativeErrors { FundingNative.reviewShieldedTip(managerHandle, walletId, username) }, + ) + } + + /** + * Send only if fresh resolution still matches the recipient shown in confirmation. A native + * failure is thrown as [org.dashfoundation.dashsdk.tokens.ShieldedTipFailure] carrying Rust's + * decision whether the tip may be sent again; argument errors are thrown before anything starts. + */ suspend fun sendShieldedTip( walletId: ByteArray, username: String, recipient: org.dashfoundation.dashsdk.tokens.ShieldedTipRecipient, @@ -2034,10 +2050,22 @@ class PlatformWalletManager( is org.dashfoundation.dashsdk.tokens.ShieldedTipSource.Identity -> source.identityId to 0 is org.dashfoundation.dashsdk.tokens.ShieldedTipSource.Account -> null to source.index } - mapNativeErrors { - FundingNative.sendShieldedTip(managerHandle, walletId, mnemonicResolver.nativeHandle, - sourceIdentityId, sourceAccount, username, recipient.identityId, recipient.address, - amount, memo) + // A failure before JNI is entered sent nothing; JNI overwrites this with Rust's decision. + val disposition = intArrayOf( + org.dashfoundation.dashsdk.tokens.ShieldedTipDisposition.NATIVE_NOT_SENT, + ) + try { + mapNativeErrors { + FundingNative.sendShieldedTip(managerHandle, walletId, mnemonicResolver.nativeHandle, + sourceIdentityId, sourceAccount, username, recipient.identityId, recipient.address, + amount, memo, disposition) + } + } catch (error: kotlinx.coroutines.CancellationException) { + throw error + } catch (error: Exception) { + throw org.dashfoundation.dashsdk.tokens.ShieldedTipFailure( + org.dashfoundation.dashsdk.tokens.ShieldedTipDisposition.fromNative(disposition[0]), error, + ) } } diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipReviewTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipReviewTest.kt new file mode 100644 index 00000000000..235b177f487 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipReviewTest.kt @@ -0,0 +1,42 @@ +package org.dashfoundation.dashsdk.tokens + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Marshalling of the native tip review and retry decision; the decisions themselves are Rust's. */ +class ShieldedTipReviewTest { + private fun review(change: Int, unresolved: Boolean): ByteArray = + ByteArray(32) { 1 } + ByteArray(43) { 2 } + byteArrayOf(change.toByte()) + + ByteArray(32) { 3 } + ByteArray(43) { 4 } + byteArrayOf(if (unresolved) 1 else 0) + + @Test + fun shouldDecodeEachRecipientChange() { + val new = ShieldedTipReview.fromNative(review(0, unresolved = false)) + assertEquals(ShieldedTipRecipientChange.New, new.change) + assertArrayEquals(ByteArray(32) { 1 }, new.recipient.identityId) + assertArrayEquals(ByteArray(43) { 2 }, new.recipient.address) + assertFalse(new.unresolved) + + assertEquals( + ShieldedTipRecipientChange.Unchanged, + ShieldedTipReview.fromNative(review(1, unresolved = true)).change, + ) + + val changed = ShieldedTipReview.fromNative(review(2, unresolved = true)) + val previous = (changed.change as ShieldedTipRecipientChange.Changed).previous + assertArrayEquals(ByteArray(32) { 3 }, previous.identityId) + assertArrayEquals(ByteArray(43) { 4 }, previous.address) + assertTrue(changed.unresolved) + } + + @Test + fun shouldTreatAnyDispositionButNotSentAsUnresolved() { + assertEquals(ShieldedTipDisposition.NOT_SENT, ShieldedTipDisposition.fromNative(1)) + for (value in listOf(0, 2, -1)) { + assertEquals(ShieldedTipDisposition.UNRESOLVED, ShieldedTipDisposition.fromNative(value)) + } + } +} diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 7d090337d94..ed2841449c9 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -53,7 +53,10 @@ use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; use dpp::ProtocolError; use platform_wallet::wallet::asset_lock::AssetLockFunding; use platform_wallet::wallet::shielded::CachedOrchardProver; -use platform_wallet::{PlatformWalletError, ShieldedTipSource}; +use platform_wallet::{ + PlatformWalletError, ShieldedTipDisposition, ShieldedTipRecipientChange, ShieldedTipReview, + ShieldedTipSource, +}; use rs_sdk_ffi::{ MnemonicResolverCoreSigner, MnemonicResolverHandle, SignerHandle, VTableSigner, DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX, @@ -2395,9 +2398,20 @@ fn decode_tip_source( } } +/// `out_disposition` of a tip send: the tip was sent. +pub const SHIELDED_TIP_DISPOSITION_SENT: u32 = 0; +/// `out_disposition` of a tip send: nothing was broadcast, or it was +/// definitively rejected; the user may review and send again. +pub const SHIELDED_TIP_DISPOSITION_NOT_SENT: u32 = 1; +/// `out_disposition` of a tip send: the payment may have executed or may still +/// execute; do not send it again, shielded sync settles it. +pub const SHIELDED_TIP_DISPOSITION_UNRESOLVED: u32 = 2; + /// Send a tip only if fresh resolution matches the recipient the user confirmed. /// `source` must point to a readable [`ShieldedTipSourceFFI`]; the ID/address -/// pointers must reference 32/43 readable bytes respectively. +/// pointers must reference 32/43 readable bytes respectively. Whatever the +/// result, `out_disposition` receives one of the `SHIELDED_TIP_DISPOSITION_*` +/// values: the wallet's decision whether the tip may be sent again. #[no_mangle] pub unsafe extern "C" fn platform_wallet_manager_send_shielded_tip( handle: Handle, @@ -2409,7 +2423,10 @@ pub unsafe extern "C" fn platform_wallet_manager_send_shielded_tip( expected_address: *const u8, amount: u64, memo_text: *const c_char, + out_disposition: *mut u32, ) -> PlatformWalletFFIResult { + check_ptr!(out_disposition); + *out_disposition = SHIELDED_TIP_DISPOSITION_NOT_SENT; check_ptr!(wallet_id_bytes); check_ptr!(mnemonic_resolver_handle); check_ptr!(source); @@ -2464,7 +2481,9 @@ pub unsafe extern "C" fn platform_wallet_manager_send_shielded_tip( let Some((wallet, coordinator, seed)) = resolved else { return preflight; }; - catch_spend_panic("shielded tip", || { + // A panic past this point may follow a broadcast, so it stays unresolved. + let mut disposition = SHIELDED_TIP_DISPOSITION_UNRESOLVED; + let result = catch_spend_panic("shielded tip", || { let result = block_on_worker(async move { maybe_inject_tip_worker_panic(); let recipient = platform_wallet::ShieldedTipRecipient { @@ -2487,8 +2506,164 @@ pub unsafe extern "C" fn platform_wallet_manager_send_shielded_tip( poke_sync_on_unconfirmed(&result, handle); result }); + disposition = match &result { + Ok(()) => SHIELDED_TIP_DISPOSITION_SENT, + Err(e) => match ShieldedTipDisposition::of(e) { + ShieldedTipDisposition::NotSent => SHIELDED_TIP_DISPOSITION_NOT_SENT, + ShieldedTipDisposition::Unresolved => SHIELDED_TIP_DISPOSITION_UNRESOLVED, + }, + }; map_spend_result(result, "shielded tip") - }) + }); + *out_disposition = disposition; + result +} + +/// `ShieldedTipReviewFFI::change`: no payment to this name was confirmed from +/// this wallet before. +pub const SHIELDED_TIP_RECIPIENT_NEW: u32 = 0; +/// `ShieldedTipReviewFFI::change`: the same identity and address as last +/// confirmed. +pub const SHIELDED_TIP_RECIPIENT_UNCHANGED: u32 = 1; +/// `ShieldedTipReviewFFI::change`: the name now resolves to a different +/// identity or address; `previous_*` hold the one last confirmed. +pub const SHIELDED_TIP_RECIPIENT_CHANGED: u32 = 2; + +/// What a user confirms before tipping a name; see +/// `PlatformWallet::review_shielded_tip`. `previous_*` are zero unless +/// `change` is [`SHIELDED_TIP_RECIPIENT_CHANGED`]. `unresolved` means an +/// earlier payment to this address is unsettled and a send is refused. +#[repr(C)] +pub struct ShieldedTipReviewFFI { + pub identity_id: [u8; 32], + pub address: [u8; 43], + pub change: u32, + pub previous_identity_id: [u8; 32], + pub previous_address: [u8; 43], + pub unresolved: bool, +} + +impl ShieldedTipReviewFFI { + fn empty() -> Self { + Self { + identity_id: [0; 32], + address: [0; 43], + change: SHIELDED_TIP_RECIPIENT_NEW, + previous_identity_id: [0; 32], + previous_address: [0; 43], + unresolved: false, + } + } + + fn from_review(review: &ShieldedTipReview) -> Self { + let (change, previous_identity_id, previous_address) = match &review.change { + ShieldedTipRecipientChange::New => (SHIELDED_TIP_RECIPIENT_NEW, [0; 32], [0; 43]), + ShieldedTipRecipientChange::Unchanged => { + (SHIELDED_TIP_RECIPIENT_UNCHANGED, [0; 32], [0; 43]) + } + ShieldedTipRecipientChange::Changed { previous } => ( + SHIELDED_TIP_RECIPIENT_CHANGED, + previous.identity_id.to_buffer(), + previous.address, + ), + }; + Self { + identity_id: review.recipient.identity_id.to_buffer(), + address: review.recipient.address, + change, + previous_identity_id, + previous_address, + unresolved: review.unresolved, + } + } +} + +/// Resolve `username` for the user to confirm a tip from wallet +/// `wallet_id_bytes` (32 readable bytes): its current recipient, whether that +/// changed since the user last confirmed the name, and whether an earlier +/// payment to it is unresolved. `out_review` is zeroed on error. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_review_shielded_tip( + handle: Handle, + wallet_id_bytes: *const u8, + username: *const c_char, + out_review: *mut ShieldedTipReviewFFI, +) -> PlatformWalletFFIResult { + check_ptr!(out_review); + std::ptr::write(out_review, ShieldedTipReviewFFI::empty()); + check_ptr!(wallet_id_bytes); + check_ptr!(username); + let username = match CStr::from_ptr(username).to_str() { + Ok(s) => s.to_owned(), + Err(e) => return e.into(), + }; + let wallet_id: [u8; 32] = std::slice::from_raw_parts(wallet_id_bytes, 32) + .try_into() + .unwrap(); + let mut review = None; + let result = catch_query_panic("shielded tip review", || { + let (wallet, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) { + Ok(value) => value, + Err(e) => return e, + }; + match block_on_worker(async move { + maybe_inject_tip_worker_panic(); + wallet.review_shielded_tip(&coordinator, &username).await + }) { + Ok(value) => { + review = Some(ShieldedTipReviewFFI::from_review(&value)); + PlatformWalletFFIResult::ok() + } + Err(e) => e.into(), + } + }); + if let Some(review) = review { + std::ptr::write(out_review, review); + } + result +} + +/// The dedicated tip account of a wallet identity's slot (`identity_id`: 32 +/// readable bytes), and whether this session proved the identity holds that +/// slot. Show the account's balance as the identity's only when verified. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_identity_tip_account( + wallet_handle: Handle, + identity_id: *const u8, + out_account: *mut u32, + out_verified: *mut bool, +) -> PlatformWalletFFIResult { + check_ptr!(out_account); + check_ptr!(out_verified); + *out_account = 0; + *out_verified = false; + let identity_id = match crate::types::read_identifier(identity_id) { + Ok(id) => id, + Err(e) => return e.into(), + }; + let mut tip_account = None; + let result = catch_query_panic("identity tip account", || { + let Some(wallet) = + PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| wallet.clone()) + else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::NotFound, + "Wallet not found", + ); + }; + match block_on_worker(async move { wallet.identity_tip_account(&identity_id).await }) { + Ok(value) => { + tip_account = Some(value); + PlatformWalletFFIResult::ok() + } + Err(e) => e.into(), + } + }); + if let Some(tip_account) = tip_account { + *out_account = tip_account.account; + *out_verified = tip_account.verified; + } + result } /// Return the dedicated ZIP-32 tip account for a wallet identity derivation index. @@ -2548,6 +2723,23 @@ mod tests { ); } + #[test] + fn identity_tip_account_reports_a_missing_wallet_with_zeroed_outputs() { + let mut account = 7u32; + let mut verified = true; + let mut result = unsafe { + platform_wallet_identity_tip_account( + NULL_HANDLE, + [1u8; 32].as_ptr(), + &mut account, + &mut verified, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::NotFound); + assert_eq!((account, verified), (0, false)); + unsafe { crate::platform_wallet_ffi_result_free(&mut result) }; + } + #[test] fn encode_memo_text_none_is_empty() { let bytes = encode_memo_text(None).expect("None must encode"); @@ -3157,6 +3349,7 @@ mod tests { let identity_id = [0x21u8; 32]; let address = [0x43u8; 43]; let mut out_address = [0xAAu8; 43]; + let mut disposition = u32::MAX; let (mut send, mut prepare) = runtime().block_on(async { let send = unsafe { @@ -3174,6 +3367,7 @@ mod tests { address.as_ptr(), 1_000, std::ptr::null(), + &mut disposition, ) }; let prepare = unsafe { @@ -3189,6 +3383,7 @@ mod tests { }); assert_eq!(send.code, PlatformWalletFFIResultCode::ErrorWalletOperation); + assert_eq!(disposition, SHIELDED_TIP_DISPOSITION_NOT_SENT); let message = message_of(&send); assert!( message.contains("shielded tip preflight panicked") @@ -3240,6 +3435,7 @@ mod tests { INJECT_TIP_WORKER_PANIC.store(true, SeqCst); // Spending export: the ambiguous, do-not-retry contract. + let mut disposition = u32::MAX; let mut send = unsafe { platform_wallet_manager_send_shielded_tip( handle, @@ -3255,12 +3451,14 @@ mod tests { address.as_ptr(), 1_000, std::ptr::null(), + &mut disposition, ) }; assert_eq!( send.code, PlatformWalletFFIResultCode::ErrorShieldedSpendUnconfirmed ); + assert_eq!(disposition, SHIELDED_TIP_DISPOSITION_UNRESOLVED); let message = message_of(&send); assert!( message.contains("injected tip worker panic") && message.contains("do NOT retry"), @@ -3319,11 +3517,38 @@ mod tests { assert_eq!(out_identity, [0u8; 32]); assert_eq!(out_recipient, [0u8; 43]); + let mut review_out = ShieldedTipReviewFFI { + identity_id: [0xAA; 32], + address: [0xAA; 43], + change: SHIELDED_TIP_RECIPIENT_CHANGED, + previous_identity_id: [0xAA; 32], + previous_address: [0xAA; 43], + unresolved: true, + }; + let mut review = unsafe { + platform_wallet_manager_review_shielded_tip( + handle, + wallet_id.as_ptr(), + username.as_ptr(), + &mut review_out, + ) + }; + assert_eq!( + review.code, + PlatformWalletFFIResultCode::ErrorWalletOperation + ); + assert!(message_of(&review).contains("injected tip worker panic")); + assert_eq!(review_out.identity_id, [0u8; 32]); + assert_eq!(review_out.address, [0u8; 43]); + assert_eq!(review_out.change, SHIELDED_TIP_RECIPIENT_NEW); + assert!(!review_out.unresolved); + INJECT_TIP_WORKER_PANIC.store(false, SeqCst); unsafe { crate::platform_wallet_ffi_result_free(&mut send); crate::platform_wallet_ffi_result_free(&mut prepare); crate::platform_wallet_ffi_result_free(&mut resolve); + crate::platform_wallet_ffi_result_free(&mut review); crate::platform_wallet_ffi_result_free(&mut got); let mut destroyed = crate::wallet::platform_wallet_destroy(wallet_handle); crate::platform_wallet_ffi_result_free(&mut destroyed); diff --git a/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md b/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md index 4ffa17dbd95..d0f8578bbe7 100644 --- a/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md +++ b/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md @@ -50,6 +50,11 @@ before a move keeps receiving into its old account until the profile is updated. Tip accounts have distinct viewing keys. Hosts must exclude them from ordinary receive, balance, and automatic spending choices, as the example apps do. +`identity_tip_account(identity_id)` returns an identity's tip account and whether this +session proved the identity holds its slot (discovery, loading, or a seed check in +preparation or a tip send). A restored index is unverified until then, because +it may be a host placeholder whose account belongs to another identity; show the tip +balance only when verified. `shielded_balances()` returns all bound accounts separately, including tip accounts; it does not apply this policy. Spending a tip balance is an explicit host action. @@ -85,8 +90,14 @@ funds, viewing keys, and recovery belong to the external wallet. ## Resolving and paying -Use `wallet.identity().dashpay().resolve_shielded_tip(username)` to obtain a fresh -`ShieldedTipRecipient`. Display the resolved identity and address for confirmation. +Use `wallet.review_shielded_tip(coordinator, username)` to obtain what the user +confirms: the fresh `ShieldedTipRecipient`, how it compares with the recipient last +confirmed for the name from this wallet (`New`, `Unchanged`, or `Changed` with the +previous one, to warn about), and whether an earlier payment to its address is +unresolved, counting the address last confirmed for the name in case the recipient +rotated it. Confirmed recipients are kept per wallet in the shielded store, keyed by +the normalized DPNS label, and survive a shielded Clear; a send records the recipient +it pays. `wallet.send_shielded_tip(...)` takes that confirmed recipient, re-resolves the name and profile, and refuses payment if either changed. Its `ShieldedTipSource` names what pays: `Identity(id)` spends that identity's tip account, after the seed @@ -95,6 +106,18 @@ account of a slot that holds no identity (a retired identity's funds). It never transparent destination. This is a snapshot check: the submitted payment always uses the address that was confirmed, even if the profile changes afterward. +A tip is recorded as unresolved before it is broadcast: its activity row is flushed to +the host and a parked recovery record is kept in the shielded store, so a relaunch +still knows its outcome is open. While a payment to an address is unresolved, a tip to +that address is refused (`ShieldedTipPending`), as is a second tip while one is being +sent. The record is settled by the send's own outcome, or, after an interrupted send, +when a scan sees its notes spent or the release pass finds its anchor pruned. After a +failure, `ShieldedTipDisposition::of(error)` decides whether the tip may be sent again: +`NotSent` (nothing broadcast, or definitively rejected) or `Unresolved` (do not send +again; shielded sync settles it). The C, Swift and Kotlin sends report the same decision. +Keep the paying account bound until the tip settles: a bind that drops an account +purges its recovery records. + Tips to a public profile address are anonymous from the receiver's perspective unless the payer provides additional context. This feature does not implement per-contact addresses or authenticated sender attribution. diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 5974133f9dd..7867b9b566a 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -975,6 +975,12 @@ pub enum PlatformWalletError { #[error("Identity {} has an unresolved shielded debit; this request was not started. Wait for shielded sync", hex::encode(identity_id))] ShieldedIdentityDebitPending { identity_id: [u8; 32] }, + /// Another tip from this wallet is being sent, or an earlier shielded + /// transfer to this tip recipient is unresolved (it may have executed or + /// may still execute). This request was not started. + #[error("An earlier tip from this wallet is still being sent or has not been confirmed yet; this tip was not sent. Wait for it to finish or for shielded sync")] + ShieldedTipPending, + /// Durable recovery data cannot safely identify or reconstruct a payment. #[error("shielded recovery record is damaged (account {account_index:?}): {reason}; restore a known-good backup or inspect recovery records before explicitly accepting an unknown payment outcome")] ShieldedRecoveryCorrupted { diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index 0989c9b71b9..aa940578f72 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -122,6 +122,7 @@ pub use wallet::identity::types::dashpay::profile::{ #[cfg(feature = "shielded")] pub use wallet::shielded::tips::{ - is_shielded_tip_account, shielded_tip_account_index, ShieldedTipSource, + is_shielded_tip_account, shielded_tip_account_index, IdentityTipAccount, + ShieldedTipDisposition, ShieldedTipRecipientChange, ShieldedTipReview, ShieldedTipSource, SHIELDED_TIP_ACCOUNT_BASE, }; diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index bc960262ea9..2949cc4a413 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -86,6 +86,7 @@ impl ManagedIdentity { Self { identity, identity_index: Some(identity_index), + slot_verified: false, last_updated_balance_block_time: None, pending_balance_snapshot: None, last_synced_keys_block_time: None, @@ -107,6 +108,7 @@ impl ManagedIdentity { Self { identity, identity_index: None, + slot_verified: false, last_updated_balance_block_time: None, pending_balance_snapshot: None, last_synced_keys_block_time: None, diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs index 436dbaaabf6..f10cf883b88 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs @@ -50,6 +50,13 @@ pub struct ManagedIdentity { /// have no HD-derivation context. pub identity_index: Option, + /// Whether this session proved, by key derivation, that the identity + /// holds its slot (`identity_index`): discovery and load derive its keys + /// there, and a seed check during tip preparation or a tip send proves it + /// too. Not persisted: a restored index may be a host placeholder, so it + /// stays unverified until one of those proves it. + pub(crate) slot_verified: bool, + /// Last block time when balance was updated for this identity pub last_updated_balance_block_time: Option, diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs b/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs index 620388072e3..92a5cf6f433 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs @@ -153,13 +153,17 @@ impl IdentityManager { wallet_id: WalletId, ) -> Result { let identity_id = identity.id(); - if self.identity(&identity_id).is_none() { + let placement = if self.identity(&identity_id).is_none() { self.add_identity_unstored(identity, identity_index, wallet_id)?; - Ok(VerifiedPlacement::Added) + VerifiedPlacement::Added } else { self.adopt_into_wallet(&identity_id, wallet_id, identity_index)?; - Ok(VerifiedPlacement::Adopted) + VerifiedPlacement::Adopted + }; + if let Some(managed) = self.managed_identity_mut(&identity_id) { + managed.slot_verified = true; } + Ok(placement) } /// Move an already-known identity into `wallet_id`'s bucket at the @@ -228,6 +232,11 @@ impl IdentityManager { .get_mut(&wallet_id) .and_then(|bucket| bucket.get_mut(&identity_index)) { + if managed.identity_index != Some(identity_index) + || managed.wallet_id != Some(wallet_id) + { + managed.slot_verified = false; + } managed.wallet_id = Some(wallet_id); managed.identity_index = Some(identity_index); } diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 01f8d0025c1..88d9de50ea4 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -399,6 +399,12 @@ pub struct PlatformWallet { #[cfg(feature = "shielded")] pub(crate) prepared_tip_accounts: Arc>>, + /// Held for the whole of a tip send. A second tip while one is being + /// sent is refused rather than queued: queued, an accidental repeat + /// would run after the first succeeded and pay again. `Arc` so cloned + /// wallet handles share it. + #[cfg(feature = "shielded")] + pub(crate) tip_send_in_progress: Arc>, /// Set once this wallet has been removed from the manager, to stop /// a handle that outlives the removal from binding shielded state /// back onto the coordinator. Callers resolve an @@ -795,6 +801,8 @@ impl PlatformWallet { #[cfg(feature = "shielded")] prepared_tip_accounts: Arc::new(std::sync::Mutex::new(BTreeMap::new())), #[cfg(feature = "shielded")] + tip_send_in_progress: Arc::new(tokio::sync::Mutex::new(())), + #[cfg(feature = "shielded")] shielded_detached: Arc::new(std::sync::atomic::AtomicBool::new(false)), } } @@ -1472,6 +1480,35 @@ impl PlatformWallet { amount: u64, memo: [u8; 36], prover: P, + ) -> Result<(), PlatformWalletError> { + self.shielded_transfer_with( + coordinator, + seed, + account, + recipient_raw_43, + amount, + memo, + prover, + false, + ) + .await + } + + /// [`Self::shielded_transfer_to`], optionally recorded as an unresolved + /// payment before broadcast so its outcome survives a relaunch (see + /// `operations::transfer_with`). + #[cfg(feature = "shielded")] + #[allow(clippy::too_many_arguments)] + pub(crate) async fn shielded_transfer_with( + &self, + coordinator: &Arc, + seed: &[u8], + account: u32, + recipient_raw_43: &[u8; 43], + amount: u64, + memo: [u8; 36], + prover: P, + guard_before_broadcast: bool, ) -> Result<(), PlatformWalletError> { let keyset = self.derive_spend_keyset(seed, account).await?; let recipient = Option::::from( @@ -1482,7 +1519,7 @@ impl PlatformWallet { "invalid Orchard payment address bytes".to_string(), ) })?; - super::shielded::operations::transfer( + super::shielded::operations::transfer_with( &self.sdk, coordinator.store(), Some(&self.persister), @@ -1493,6 +1530,7 @@ impl PlatformWallet { amount, memo, &prover, + guard_before_broadcast, ) .await } @@ -2356,6 +2394,8 @@ impl Clone for PlatformWallet { #[cfg(feature = "shielded")] prepared_tip_accounts: self.prepared_tip_accounts.clone(), #[cfg(feature = "shielded")] + tip_send_in_progress: self.tip_send_in_progress.clone(), + #[cfg(feature = "shielded")] shielded_detached: self.shielded_detached.clone(), } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index 0970e75d48a..7a2dac1c3ed 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -20,11 +20,12 @@ use std::sync::Mutex; use dpp::serialization::{PlatformDeserializableTrusted, PlatformSerializable}; use dpp::state_transition::StateTransition; use grovedb_commitment_tree::{ClientPersistentCommitmentTree, Position, Retention}; +use rusqlite::OptionalExtension; use super::balance::{ShieldedBalanceSource, ShieldedLocalAccountBalance}; use super::store::{ - PendingRedrive, ShieldedNote, ShieldedOutgoingNote, ShieldedStore, StalePendingSpend, - SubwalletId, SubwalletState, + ConfirmedTipRecipient, PendingRedrive, ShieldedNote, ShieldedOutgoingNote, ShieldedStore, + StalePendingSpend, SubwalletId, SubwalletState, }; use crate::error::PlatformWalletError; use crate::wallet::platform_wallet::WalletId; @@ -194,6 +195,20 @@ impl FileBackedShieldedStore { .map_err(|e| { FileShieldedStoreError::Storage(format!("create pending_spends table: {e}")) })?; + pending_conn + .execute( + "CREATE TABLE IF NOT EXISTS shielded_tip_recipients ( + wallet_id BLOB NOT NULL, + label TEXT NOT NULL, + identity_id BLOB NOT NULL, + address BLOB NOT NULL, + PRIMARY KEY (wallet_id, label) + )", + [], + ) + .map_err(|e| { + FileShieldedStoreError::Storage(format!("create tip_recipients table: {e}")) + })?; // Additive, idempotent migration: old guards remain active. let columns = { let mut statement = pending_conn @@ -894,6 +909,13 @@ impl ShieldedStore for FileBackedShieldedStore { .map_err(|e| { FileShieldedStoreError::Storage(format!("purge pending spends for wallet: {e}")) })?; + conn.execute( + "DELETE FROM shielded_tip_recipients WHERE wallet_id = ?1", + rusqlite::params![wallet_id.as_slice()], + ) + .map_err(|e| { + FileShieldedStoreError::Storage(format!("purge tip recipients for wallet: {e}")) + })?; } // Per-subwallet note / watermark / checkpoint state is // in-memory only (`subwallets`); the commitment tree in @@ -902,6 +924,53 @@ impl ShieldedStore for FileBackedShieldedStore { Ok(()) } + fn confirmed_tip_recipient( + &self, + wallet_id: WalletId, + label: &str, + ) -> Result, Self::Error> { + let conn = self.pending_conn.lock().expect("pending_conn mutex"); + let row = conn + .query_row( + "SELECT identity_id, address FROM shielded_tip_recipients \ + WHERE wallet_id = ?1 AND label = ?2", + rusqlite::params![wallet_id.as_slice(), label], + |row| Ok((row.get::<_, Vec>(0)?, row.get::<_, Vec>(1)?)), + ) + .optional() + .map_err(|e| FileShieldedStoreError::Storage(format!("read tip recipient: {e}")))?; + row.map( + |(identity_id, address)| match (identity_id.try_into(), address.try_into()) { + (Ok(identity_id), Ok(address)) => Ok((identity_id, address)), + _ => Err(FileShieldedStoreError::Storage( + "malformed tip recipient row".to_string(), + )), + }, + ) + .transpose() + } + + fn confirm_tip_recipient( + &mut self, + wallet_id: WalletId, + label: &str, + recipient: ConfirmedTipRecipient, + ) -> Result<(), Self::Error> { + let conn = self.pending_conn.lock().expect("pending_conn mutex"); + conn.execute( + "INSERT OR REPLACE INTO shielded_tip_recipients \ + (wallet_id, label, identity_id, address) VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![ + wallet_id.as_slice(), + label, + recipient.0.as_slice(), + recipient.1.as_slice() + ], + ) + .map_err(|e| FileShieldedStoreError::Storage(format!("write tip recipient: {e}")))?; + Ok(()) + } + fn purge_subwallet(&mut self, id: SubwalletId) -> Result<(), Self::Error> { // Note-spend redrives are account-scoped and go with the account. // Empty-nullifier ShieldFromIdentity guards are wallet-wide: the signed @@ -1483,6 +1552,45 @@ mod tests { let _ = std::fs::remove_file(&path); } + /// Confirmed tip recipients survive a reopen and a shielded Clear, are + /// kept per wallet, and go with their wallet's purge. + #[test] + fn tip_recipient_confirmations_survive_reopen_and_purge_with_their_wallet() { + let path = temp_tree_path("tip_recipients"); + let (wallet, other) = ([0xB1; 32], [0xB2; 32]); + let first = ([1; 32], [2; 43]); + let rotated = ([1; 32], [3; 43]); + { + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); + assert_eq!( + store.confirmed_tip_recipient(wallet, "a11ce").unwrap(), + None + ); + store.confirm_tip_recipient(wallet, "a11ce", first).unwrap(); + store + .confirm_tip_recipient(wallet, "a11ce", rotated) + .unwrap(); + store.confirm_tip_recipient(other, "a11ce", first).unwrap(); + } + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("reopen"); + store.purge_all_subwallets().unwrap(); + assert_eq!( + store.confirmed_tip_recipient(wallet, "a11ce").unwrap(), + Some(rotated) + ); + store.purge_wallet(wallet).unwrap(); + assert_eq!( + store.confirmed_tip_recipient(wallet, "a11ce").unwrap(), + None + ); + assert_eq!( + store.confirmed_tip_recipient(other, "a11ce").unwrap(), + Some(first) + ); + drop(store); + let _ = std::fs::remove_file(&path); + } + #[test] fn account_purge_durably_retains_identity_guard_only() { let path = temp_tree_path("purge_subwallet_identity_guard"); diff --git a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs index cbb0cfd1637..7322b11e234 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs @@ -50,7 +50,8 @@ pub mod store; pub mod sync; pub mod tips; pub use tips::{ - is_shielded_tip_account, shielded_tip_account_index, ShieldedTipSource, + is_shielded_tip_account, shielded_tip_account_index, IdentityTipAccount, + ShieldedTipDisposition, ShieldedTipRecipientChange, ShieldedTipReview, ShieldedTipSource, SHIELDED_TIP_ACCOUNT_BASE, }; #[cfg(test)] diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index ea864889a3f..a021a2b237a 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -1767,6 +1767,35 @@ pub async fn transfer( amount: u64, memo: [u8; 36], prover: &P, +) -> Result<(), PlatformWalletError> { + transfer_with( + sdk, store, persister, wallet_id, keys, account, to_address, amount, memo, prover, false, + ) + .await +} + +/// [`transfer`], optionally recording the signed transfer as an unresolved +/// payment before it is broadcast (`guard_before_broadcast`): the activity +/// row is flushed to the host and a parked recovery record is armed, so a +/// relaunch after an interrupted broadcast still knows the payment's +/// outcome is open. The record is parked (its re-drive budget is spent), so +/// the sync re-drive never re-broadcasts it alongside this call; this call's +/// own outcome drops it (sent or definitively failed) or re-arms it for +/// re-drive (unconfirmed). Left behind by a crash, it is settled when a scan +/// sees its notes spent or the release pass finds its anchor pruned. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn transfer_with( + sdk: &Arc, + store: &Arc>, + persister: Option<&WalletPersister>, + wallet_id: WalletId, + keys: &OrchardKeySet, + account: u32, + to_address: &PaymentAddress, + amount: u64, + memo: [u8; 36], + prover: &P, + guard_before_broadcast: bool, ) -> Result<(), PlatformWalletError> { let recipient_addr = payment_address_to_orchard(to_address)?; let views = keys.viewing_keys(); @@ -1841,18 +1870,32 @@ pub async fn transfer( arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await; trace!("Shielded transfer: state transition built, broadcasting..."); - broadcast_shielded_spend_with_redrive( - sdk, - store, - id, - &pending_entry, - anchor_bytes, - &selected_notes, - &state_transition, - "transfer", - SpendResultWait::ExecutionProved, - ) - .await + if guard_before_broadcast { + arm_parked_spend_guard( + store, + persister, + id, + &pending_entry, + anchor_bytes, + &selected_notes, + &state_transition, + ) + .await?; + broadcast_guarded_transfer(sdk, store, id, &pending_entry, &state_transition).await + } else { + broadcast_shielded_spend_with_redrive( + sdk, + store, + id, + &pending_entry, + anchor_bytes, + &selected_notes, + &state_transition, + "transfer", + SpendResultWait::ExecutionProved, + ) + .await + } } .await; @@ -3036,6 +3079,110 @@ async fn arm_redrive_record( } } +/// Record a note spend as an unresolved payment before its broadcast: flush +/// the pending activity row to the host, then persist a parked recovery +/// record (re-drive budget spent, so only this spend's own outcome or the +/// scan and prune passes settle it). Fails closed: a spend that cannot be +/// recorded is not broadcast. +async fn arm_parked_spend_guard( + store: &Arc>, + persister: Option<&WalletPersister>, + id: SubwalletId, + pending_entry: &Option, + anchor: [u8; 32], + notes: &[ShieldedNote], + state_transition: &StateTransition, +) -> Result<(), PlatformWalletError> { + use dpp::serialization::PlatformSerializable; + + let entry = pending_entry.as_ref().ok_or_else(|| { + PlatformWalletError::ShieldedBuildError( + "the transfer produced no wallet-visible activity entry".to_string(), + ) + })?; + if let Some(persister) = persister { + persister + .flush() + .map_err(|e| PlatformWalletError::Persistence(e.to_string()))?; + } + let st_bytes = state_transition.serialize_to_bytes().map_err(|e| { + PlatformWalletError::ShieldedBuildError(format!("serialize transfer guard: {e}")) + })?; + store + .write() + .await + .arm_redrive( + id, + PendingRedrive { + activity_id: entry.id, + anchor, + nullifiers: notes.iter().map(|n| n.nullifier).collect(), + st_bytes, + attempts: MAX_REDRIVE_ATTEMPTS, + identity_nonce_finalized: false, + identity_user_abandoned: false, + }, + ) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string())) +} + +/// Broadcast a transfer whose parked guard is armed. An ambiguous result +/// re-arms the guard for re-drive (see [`activate_parked_guard`]). +async fn broadcast_guarded_transfer( + sdk: &Arc, + store: &Arc>, + id: SubwalletId, + pending_entry: &Option, + state_transition: &StateTransition, +) -> Result { + let result = broadcast_shielded_spend( + sdk, + state_transition, + "transfer", + SpendResultWait::ExecutionProved, + ) + .await + .map(|(result, _)| result); + if matches!( + &result, + Err(PlatformWalletError::ShieldedSpendUnconfirmed { .. }) + ) { + activate_parked_guard(store, id, pending_entry).await; + } + result +} + +/// Open a guarded spend's parked record to the sync re-drive after an +/// ambiguous result, if the record is still armed: a scan that saw the spend +/// land meanwhile has dropped it, and re-inserting would leave a record +/// nothing settles. Best-effort, like [`arm_redrive_record`]: without the +/// re-drive the record still settles through the scan or the prune backstop. +async fn activate_parked_guard( + store: &Arc>, + id: SubwalletId, + pending_entry: &Option, +) { + let Some(entry) = pending_entry else { + return; + }; + let mut store = store.write().await; + let parked = match store.pending_redrives(id) { + Ok(records) => records + .into_iter() + .find(|record| record.activity_id == entry.id), + Err(e) => { + warn!(error = %e, "failed to read the parked transfer guard; re-drive disabled"); + return; + } + }; + if let Some(mut record) = parked { + record.attempts = 0; + if let Err(e) = store.arm_redrive(id, record) { + warn!(error = %e, "failed to re-arm the transfer guard; re-drive disabled"); + } + } +} + /// Whether an SDK error is Platform's `NullifierAlreadySpentError` — /// on a RE-broadcast of our own byte-identical transition this means /// the ORIGINAL broadcast executed: the nullifiers are consumed by the @@ -4769,3 +4916,86 @@ mod select_recorded_spends_tests { } } } + +#[cfg(test)] +mod parked_spend_guard_tests { + use super::super::activity::{ShieldedActivityEntry, ShieldedDirection}; + use super::super::store::InMemoryShieldedStore; + use super::*; + use dpp::state_transition::shielded_transfer_transition::v0::ShieldedTransferTransitionV0; + use dpp::state_transition::shielded_transfer_transition::ShieldedTransferTransition; + + fn transfer() -> StateTransition { + StateTransition::ShieldedTransfer(ShieldedTransferTransition::V0( + ShieldedTransferTransitionV0 { + actions: vec![], + value_balance: 0, + anchor: [0; 32], + proof: vec![], + binding_signature: [0; 64], + }, + )) + } + + fn sent_entry() -> ShieldedActivityEntry { + ShieldedActivityEntry { + id: [2; 32], + kind: ShieldedActivityKind::Sent, + direction: ShieldedDirection::Out, + amount: 1_000, + fee: None, + counterparty: Some(vec![4; 43]), + memo: None, + block_height: None, + status: ShieldedActivityStatus::Pending, + created_at_ms: 0, + min_note_position: None, + note_cmxs: vec![], + spent_nullifiers: vec![], + } + } + + /// The guard a tip arms before broadcast is parked: its re-drive budget + /// is spent, so the sync re-drive never re-broadcasts it alongside the + /// tip's own broadcast. A spend with no activity row is not broadcast. + #[tokio::test] + async fn should_arm_a_parked_guard_and_fail_closed_without_an_activity_row() { + let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); + let id = SubwalletId::new([1; 32], 0); + let entry = sent_entry(); + + let refused = + arm_parked_spend_guard(&store, None, id, &None, [3; 32], &[], &transfer()).await; + assert!(refused.is_err()); + assert!(store.read().await.pending_redrives(id).unwrap().is_empty()); + + arm_parked_spend_guard(&store, None, id, &Some(entry), [3; 32], &[], &transfer()) + .await + .unwrap(); + let armed = store.read().await.pending_redrives(id).unwrap(); + assert_eq!(armed.len(), 1); + assert_eq!(armed[0].activity_id, [2; 32]); + assert_eq!(armed[0].anchor, [3; 32]); + assert_eq!(armed[0].attempts, MAX_REDRIVE_ATTEMPTS); + } + + /// An ambiguous result opens a still-armed guard to the re-drive, and + /// re-creates nothing once a scan has dropped it. + #[tokio::test] + async fn should_reactivate_only_a_guard_that_is_still_armed() { + let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); + let id = SubwalletId::new([1; 32], 0); + let entry = Some(sent_entry()); + + activate_parked_guard(&store, id, &entry).await; + assert!(store.read().await.pending_redrives(id).unwrap().is_empty()); + + arm_parked_spend_guard(&store, None, id, &entry, [3; 32], &[], &transfer()) + .await + .unwrap(); + activate_parked_guard(&store, id, &entry).await; + let armed = store.read().await.pending_redrives(id).unwrap(); + assert_eq!(armed.len(), 1); + assert_eq!(armed[0].attempts, 0); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index 6e20a0ae1fc..59635ff0512 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -133,6 +133,9 @@ pub struct ShieldedOutgoingNote { /// Platform's recorded set (the spend can then never execute). pub type StalePendingSpend = ([u8; 32], [u8; 32], Option<[u8; 32]>); +/// A confirmed tip recipient: `(identity id, raw Orchard address)`. +pub type ConfirmedTipRecipient = ([u8; 32], [u8; 43]); + /// An unresolved signed transition and its durable retry guard: the signed /// transition bytes plus everything the sync-time re-drive needs to /// resolve the ambiguity actively — re-broadcast the transition @@ -488,6 +491,28 @@ pub trait ShieldedStore: Send + Sync { /// rather than resuming behind the stale watermark. fn purge_wallet(&mut self, wallet_id: WalletId) -> Result<(), Self::Error>; + // ── Confirmed tip recipients (per wallet) ────────────────────────── + + /// The recipient `(identity id, raw Orchard address)` the user last + /// confirmed paying under the normalized DPNS label `label`, if any. + /// Durable where the backend is (the file store is), and dropped with + /// the wallet by [`Self::purge_wallet`]. A shielded Clear + /// ([`Self::purge_all_subwallets`]) keeps it: the chain cannot rebuild + /// it, and without it a name taken over later would review as new. + fn confirmed_tip_recipient( + &self, + wallet_id: WalletId, + label: &str, + ) -> Result, Self::Error>; + + /// Record `recipient` as the one the user confirmed for `label`. + fn confirm_tip_recipient( + &mut self, + wallet_id: WalletId, + label: &str, + recipient: ConfirmedTipRecipient, + ) -> Result<(), Self::Error>; + /// Drop the account-scoped state and nullifier-backed redrives for exactly /// one subwallet, leaving every other subwallet and the shared commitment /// tree intact. Empty-nullifier identity-debit guards are wallet-wide and @@ -853,6 +878,8 @@ pub struct InMemoryShieldedStore { checkpoints: Vec, /// Placeholder anchor; production stores compute the real Sinsemilla root. anchor: [u8; 32], + /// Confirmed tip recipients by `(wallet, normalized label)`. + tip_recipients: BTreeMap<(WalletId, String), ConfirmedTipRecipient>, } impl InMemoryShieldedStore { @@ -1113,6 +1140,30 @@ impl ShieldedStore for InMemoryShieldedStore { fn purge_wallet(&mut self, wallet_id: WalletId) -> Result<(), Self::Error> { self.subwallets.retain(|id, _| id.wallet_id != wallet_id); + self.tip_recipients + .retain(|(wallet, _), _| *wallet != wallet_id); + Ok(()) + } + + fn confirmed_tip_recipient( + &self, + wallet_id: WalletId, + label: &str, + ) -> Result, Self::Error> { + Ok(self + .tip_recipients + .get(&(wallet_id, label.to_string())) + .copied()) + } + + fn confirm_tip_recipient( + &mut self, + wallet_id: WalletId, + label: &str, + recipient: ConfirmedTipRecipient, + ) -> Result<(), Self::Error> { + self.tip_recipients + .insert((wallet_id, label.to_string()), recipient); Ok(()) } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/tips.rs b/packages/rs-platform-wallet/src/wallet/shielded/tips.rs index 7e7581a37ff..3129f45ce38 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/tips.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/tips.rs @@ -8,11 +8,14 @@ //! reconstructs it even after publication was removed. Address rotation uses //! another diversifier in the same account, so all old addresses remain covered. +use super::store::ShieldedStore; use super::NetworkShieldedCoordinator; -use crate::wallet::platform_wallet::PlatformWallet; +use crate::wallet::platform_wallet::{PlatformWallet, WalletId}; use crate::{PlatformWalletError, ShieldedTipRecipient}; use dpp::identity::accessors::IdentityGettersV0; use dpp::prelude::Identifier; +use dpp::serialization::PlatformDeserializableTrusted; +use dpp::state_transition::StateTransition; use grovedb_commitment_tree::PaymentAddress; use std::sync::{Arc, PoisonError}; @@ -43,6 +46,142 @@ pub enum ShieldedTipSource { Account(u32), } +/// A wallet identity's dedicated tip account; see +/// [`PlatformWallet::identity_tip_account`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct IdentityTipAccount { + pub account: u32, + /// Whether this session proved the identity holds the slot selecting + /// `account`. Unverified, the account may belong to another identity. + pub verified: bool, +} + +/// How a tip recipient compares with the one last confirmed for the same +/// name from this wallet. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ShieldedTipRecipientChange { + /// No payment to this name was confirmed from this wallet before. + New, + /// The same identity and address as last confirmed. + Unchanged, + /// The name now resolves to a different identity or address. + Changed { previous: ShieldedTipRecipient }, +} + +/// What a user confirms before tipping a name; see +/// [`PlatformWallet::review_shielded_tip`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ShieldedTipReview { + pub recipient: ShieldedTipRecipient, + pub change: ShieldedTipRecipientChange, + /// An earlier payment to this address is unresolved, and a send is + /// refused until shielded sync settles it. + pub unresolved: bool, +} + +/// Whether a failed tip may be submitted again. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShieldedTipDisposition { + /// Nothing was broadcast, or it was definitively rejected: the user may + /// review and send again. + NotSent, + /// The payment may have executed or may still execute: do not send it + /// again; shielded sync settles it. + Unresolved, +} + +impl ShieldedTipDisposition { + /// The disposition of a failed [`PlatformWallet::send_shielded_tip`]. + /// Only [`PlatformWalletError::ShieldedSpendUnconfirmed`] can follow a + /// broadcast whose outcome is open: every other error of the guarded + /// transfer is raised before its broadcast or after a definitive + /// rejection that released its notes. + pub fn of(error: &PlatformWalletError) -> Self { + match error { + PlatformWalletError::ShieldedSpendUnconfirmed { .. } => Self::Unresolved, + _ => Self::NotSent, + } + } +} + +/// Whether `wallet_id` holds an unresolved shielded transfer to `address`: +/// one recorded before broadcast whose outcome is not yet known. A transfer +/// whose activity row is missing names no recipient and counts as unresolved +/// for every address. +fn has_unresolved_transfer_to( + store: &S, + wallet_id: WalletId, + address: &[u8; 43], +) -> Result { + let redrives = store + .pending_redrives_for_wallet(wallet_id) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + for (id, redrive) in redrives { + if !matches!( + StateTransition::deserialize_from_bytes_trusted(&redrive.st_bytes), + Ok(StateTransition::ShieldedTransfer(_)) + ) { + continue; + } + let entry = store + .get_activity_by_entry_id(id, &redrive.activity_id) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + match entry.and_then(|entry| entry.counterparty) { + Some(counterparty) if counterparty.as_slice() != address.as_slice() => {} + _ => return Ok(true), + } + } + Ok(false) +} + +/// The recipient last confirmed for `label` from `wallet_id`, if any. +fn confirmed_tip_recipient( + store: &S, + wallet_id: WalletId, + label: &str, +) -> Result, PlatformWalletError> { + Ok(store + .confirmed_tip_recipient(wallet_id, label) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))? + .map(|(identity_id, address)| ShieldedTipRecipient { + identity_id: identity_id.into(), + address, + })) +} + +/// Whether a tip to a name may still be paid by an unsettled earlier one: +/// an unresolved transfer to its current address, or to the address last +/// confirmed for the name (the recipient may have rotated its address since +/// the earlier tip). +fn has_unresolved_tip_to( + store: &S, + wallet_id: WalletId, + address: &[u8; 43], + previous: Option<&ShieldedTipRecipient>, +) -> Result { + if has_unresolved_transfer_to(store, wallet_id, address)? { + return Ok(true); + } + match previous { + Some(previous) if previous.address != *address => { + has_unresolved_transfer_to(store, wallet_id, &previous.address) + } + _ => Ok(false), + } +} + +/// The key a confirmed recipient is stored under: the DPNS label with an +/// optional `.dash` suffix removed, in homograph-safe form, as name +/// resolution matches it. +fn tip_recipient_label(username: &str) -> String { + let name = username.trim(); + let label = match name.rfind('.') { + Some(dot) if name[dot..].eq_ignore_ascii_case(".dash") => &name[..dot], + _ => name, + }; + dash_sdk::platform::dpns_usernames::convert_to_homograph_safe_chars(label) +} + /// Prove `identity_index` with the seed: the identity must carry the MASTER /// authentication key this wallet derives at that index (the key discovery and /// registration both use). Fails closed when no key matches, including when @@ -169,13 +308,13 @@ impl PlatformWallet { seed: &[u8], identity_id: &Identifier, ) -> Result { - let wm = self.wallet_manager.read().await; + let mut wm = self.wallet_manager.write().await; let info = wm - .get_wallet_info(&self.wallet_id()) + .get_wallet_info_mut(&self.wallet_id()) .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id())))?; let identity = info .identity_manager - .managed_identity(identity_id) + .managed_identity_mut(identity_id) .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; if identity.wallet_id != Some(self.wallet_id()) { return Err(PlatformWalletError::InvalidIdentityData( @@ -188,9 +327,41 @@ impl PlatformWallet { ) })?; verify_identity_index(seed, self.sdk.network, index, &identity.identity)?; + identity.slot_verified = true; shielded_tip_account_index(index) } + /// The dedicated tip account of a wallet identity's slot, and whether + /// this session has proved the identity holds that slot. Hosts show an + /// identity's tip balance only when it is verified: an unverified index + /// may be a restored placeholder whose account belongs to another + /// identity. Discovery, loading, or preparing the identity's tip address + /// verifies it. + pub async fn identity_tip_account( + &self, + identity_id: &Identifier, + ) -> Result { + let wm = self.wallet_manager.read().await; + let identity = wm + .get_wallet_info(&self.wallet_id()) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id())))? + .identity_manager + .managed_identity(identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + let index = identity + .identity_index + .filter(|_| identity.wallet_id == Some(self.wallet_id())) + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData( + "Tip account requires a wallet-owned identity".to_string(), + ) + })?; + Ok(IdentityTipAccount { + account: shielded_tip_account_index(index)?, + verified: identity.slot_verified, + }) + } + /// The account `source` spends. An identity spends the tip account of /// the slot the seed proves it holds. An explicit tip account is refused /// while its slot holds an identity: the user chose that identity's @@ -283,6 +454,41 @@ impl PlatformWallet { } } + /// Resolve `username` for the user to confirm a tip: its current + /// recipient, how that compares with the recipient last confirmed for the + /// name from this wallet, and whether an earlier payment to that address + /// is still unresolved (a send to it would be refused). + pub async fn review_shielded_tip( + &self, + coordinator: &Arc, + username: &str, + ) -> Result { + let recipient = self + .identity() + .dashpay() + .resolve_shielded_tip(username) + .await?; + let store = coordinator.store().read().await; + let previous = + confirmed_tip_recipient(&*store, self.wallet_id(), &tip_recipient_label(username))?; + let unresolved = has_unresolved_tip_to( + &*store, + self.wallet_id(), + &recipient.address, + previous.as_ref(), + )?; + let change = match previous { + None => ShieldedTipRecipientChange::New, + Some(previous) if previous == recipient => ShieldedTipRecipientChange::Unchanged, + Some(previous) => ShieldedTipRecipientChange::Changed { previous }, + }; + Ok(ShieldedTipReview { + recipient, + change, + unresolved, + }) + } + /// Send only to the identity/address pair that the user confirmed. A changed /// name owner or profile requires a new confirmation; there is no fallback to /// a transparent rail. Network changes after this check cannot change the @@ -303,6 +509,10 @@ impl PlatformWallet { memo: [u8; 36], prover: P, ) -> Result<(), PlatformWalletError> { + let _sending = self + .tip_send_in_progress + .try_lock() + .map_err(|_| PlatformWalletError::ShieldedTipPending)?; let _placement = self.identity.identity_placement.read().await; let account = self.tip_source_account(seed, source).await?; let current = self @@ -315,7 +525,32 @@ impl PlatformWallet { "The tip recipient changed; review and confirm the payment again".to_string(), )); } - self.shielded_transfer_to( + // An earlier payment to this address may still execute, so a second + // one is refused until sync settles it; then the user's confirmation + // becomes the recipient on file for the name. + { + let label = tip_recipient_label(username); + let mut store = coordinator.store().write().await; + let previous = confirmed_tip_recipient(&*store, self.wallet_id(), &label)?; + if has_unresolved_tip_to( + &*store, + self.wallet_id(), + ¤t.address, + previous.as_ref(), + )? { + return Err(PlatformWalletError::ShieldedTipPending); + } + store + .confirm_tip_recipient( + self.wallet_id(), + &label, + (current.identity_id.to_buffer(), current.address), + ) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + } + // Recorded as unresolved before broadcast, so a relaunch still + // refuses a second payment until its outcome is known. + self.shielded_transfer_with( coordinator, seed, account, @@ -323,6 +558,7 @@ impl PlatformWallet { amount, memo, prover, + true, ) .await } @@ -375,10 +611,127 @@ pub(crate) mod test_hooks { #[cfg(test)] mod tests { + use super::super::activity::{ + ShieldedActivityEntry, ShieldedActivityKind, ShieldedActivityStatus, ShieldedDirection, + }; + use super::super::store::{InMemoryShieldedStore, PendingRedrive, SubwalletId}; use super::super::OrchardKeySet; use super::*; + use dpp::serialization::PlatformSerializable; + use dpp::state_transition::shielded_transfer_transition::v0::ShieldedTransferTransitionV0; + use dpp::state_transition::shielded_transfer_transition::ShieldedTransferTransition; use key_wallet::Network; + #[test] + fn should_key_confirmed_recipients_by_the_resolved_dpns_label() { + assert_eq!( + tip_recipient_label("Alice"), + tip_recipient_label("a11ce.DASH") + ); + assert_eq!( + tip_recipient_label(" alice.dash "), + tip_recipient_label("alice") + ); + assert_ne!( + tip_recipient_label("alice"), + tip_recipient_label("alice.dash.dash") + ); + } + + #[test] + fn should_classify_only_an_unconfirmed_spend_as_unresolved() { + let unconfirmed = PlatformWalletError::ShieldedSpendUnconfirmed { + operation: "transfer", + reason: "result wait timed out".to_string(), + }; + assert_eq!( + ShieldedTipDisposition::of(&unconfirmed), + ShieldedTipDisposition::Unresolved + ); + for not_sent in [ + PlatformWalletError::ShieldedTipPending, + PlatformWalletError::ShieldedBroadcastFailed("rejected".to_string()), + PlatformWalletError::InvalidIdentityData("recipient changed".to_string()), + ] { + assert_eq!( + ShieldedTipDisposition::of(¬_sent), + ShieldedTipDisposition::NotSent, + "{not_sent}" + ); + } + } + + /// A transfer recorded before broadcast blocks another payment to its + /// recipient until it settles. Without its activity row the recipient is + /// unknown, so it blocks every recipient; records of other spend kinds + /// and other wallets do not count. + #[test] + fn should_treat_a_recorded_transfer_as_unresolved_for_its_recipient() { + let wallet_id = [1; 32]; + let id = SubwalletId::new(wallet_id, 0); + let transfer = StateTransition::ShieldedTransfer(ShieldedTransferTransition::V0( + ShieldedTransferTransitionV0 { + actions: vec![], + value_balance: 0, + anchor: [0; 32], + proof: vec![], + binding_signature: [0; 64], + }, + )) + .serialize_to_bytes() + .unwrap(); + let record = |st_bytes: Vec| PendingRedrive { + activity_id: [2; 32], + anchor: [0; 32], + nullifiers: vec![[3; 32]], + st_bytes, + attempts: 0, + identity_nonce_finalized: false, + identity_user_abandoned: false, + }; + let (recipient, other) = ([4; 43], [5; 43]); + + let mut store = InMemoryShieldedStore::new(); + store.arm_redrive(id, record(transfer)).unwrap(); + assert!(has_unresolved_transfer_to(&store, wallet_id, &other).unwrap()); + store + .save_activity( + id, + &ShieldedActivityEntry { + id: [2; 32], + kind: ShieldedActivityKind::Sent, + direction: ShieldedDirection::Out, + amount: 1_000, + fee: None, + counterparty: Some(recipient.to_vec()), + memo: None, + block_height: None, + status: ShieldedActivityStatus::Pending, + created_at_ms: 0, + min_note_position: None, + note_cmxs: vec![], + spent_nullifiers: vec![], + }, + ) + .unwrap(); + assert!(has_unresolved_transfer_to(&store, wallet_id, &recipient).unwrap()); + assert!(!has_unresolved_transfer_to(&store, wallet_id, &other).unwrap()); + assert!(!has_unresolved_transfer_to(&store, [9; 32], &recipient).unwrap()); + + let mut unshield_like = InMemoryShieldedStore::new(); + unshield_like.arm_redrive(id, record(vec![7; 16])).unwrap(); + assert!(!has_unresolved_transfer_to(&unshield_like, wallet_id, &recipient).unwrap()); + + // The name's recipient rotated its address after the earlier tip: the + // tip to the new address is still blocked through the confirmed one. + let confirmed = ShieldedTipRecipient { + identity_id: [6; 32].into(), + address: recipient, + }; + assert!(has_unresolved_tip_to(&store, wallet_id, &other, Some(&confirmed)).unwrap()); + assert!(!has_unresolved_tip_to(&store, wallet_id, &other, None).unwrap()); + } + #[test] fn should_derive_distinct_recoverable_tip_accounts() { let seed = [42u8; 64]; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs index 6a465fd2e15..43235c8a11f 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs @@ -1887,3 +1887,67 @@ async fn should_order_an_identity_move_after_tip_preparation_and_refuse_its_stal .expect("publishable"); } } + +/// A tip account is reported verified only while this session has proved the +/// identity's slot. Adding an identity at a caller-supplied index does not +/// prove it; a seed check does; a persisted entry applied over it (a host +/// restore, here to placeholder slot 0) clears it, even when a later entry +/// moves it back, until a seed check proves it again. +#[tokio::test] +async fn should_report_a_restored_tip_account_unverified_until_the_seed_proves_it() { + use crate::changeset::{IdentityChangeSet, IdentityEntry, PlatformWalletChangeSet}; + use crate::shielded_tip_account_index; + let (wallet, seed, id, coordinator) = tip_race_fixture("tips_verified_slot").await; + let tip = |index| shielded_tip_account_index(index).unwrap(); + let reported = + |wallet: PlatformWallet| async move { wallet.identity_tip_account(&id).await.unwrap() }; + let restore_at = |wallet: PlatformWallet, index: u32| async move { + let entry = { + let wm = wallet.wallet_manager().read().await; + let mut managed = wm + .get_wallet_info(&wallet.wallet_id()) + .unwrap() + .identity_manager + .managed_identity(&id) + .unwrap() + .clone(); + managed.identity_index = Some(index); + IdentityEntry::from_managed(&managed) + }; + let mut identities = IdentityChangeSet::default(); + identities.identities.insert(id, entry); + wallet + .apply(PlatformWalletChangeSet { + identities: Some(identities), + ..Default::default() + }) + .await + .unwrap(); + }; + + let added = reported(wallet.clone()).await; + assert_eq!((added.account, added.verified), (tip(2), false)); + wallet + .prepare_shielded_tip_address(&seed, &id, &coordinator) + .await + .unwrap(); + assert!(reported(wallet.clone()).await.verified); + + restore_at(wallet.clone(), 0).await; + let placeholder = reported(wallet.clone()).await; + assert_eq!((placeholder.account, placeholder.verified), (tip(0), false)); + assert!(wallet + .prepare_shielded_tip_address(&seed, &id, &coordinator) + .await + .is_err()); + assert!(!reported(wallet.clone()).await.verified); + + restore_at(wallet.clone(), 2).await; + let moved_back = reported(wallet.clone()).await; + assert_eq!((moved_back.account, moved_back.verified), (tip(2), false)); + wallet + .prepare_shielded_tip_address(&seed, &id, &coordinator) + .await + .unwrap(); + assert!(reported(wallet.clone()).await.verified); +} diff --git a/packages/rs-unified-sdk-jni/src/funding.rs b/packages/rs-unified-sdk-jni/src/funding.rs index e1ddef36184..c55750f1467 100644 --- a/packages/rs-unified-sdk-jni/src/funding.rs +++ b/packages/rs-unified-sdk-jni/src/funding.rs @@ -1327,6 +1327,10 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_resolve /// Revalidates the confirmed recipient before spending. A non-null /// `source_identity_id` spends that identity's dedicated tip account and /// `source_account` is ignored; otherwise `source_account` is spent. +/// `out_disposition[0]` is set to `SHIELDED_TIP_DISPOSITION_NOT_SENT` on +/// entry, so a failure before the native send reads as not started, and then +/// receives the wallet's `SHIELDED_TIP_DISPOSITION_*` decision once the send +/// has run, including when it throws. #[no_mangle] pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_sendShieldedTip( mut env: JNIEnv, @@ -1341,8 +1345,18 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_sendShi expected_address: JByteArray, amount: jlong, memo: JString, + out_disposition: jni::objects::JIntArray, ) { guard(&mut env, (), |env| { + let not_sent = platform_wallet_ffi::SHIELDED_TIP_DISPOSITION_NOT_SENT as i32; + if env + .set_int_array_region(&out_disposition, 0, &[not_sent]) + .is_err() + { + let _ = env.exception_clear(); + throw_sdk_exception(env, 1, "outDisposition must hold one element"); + return; + } if source_account < 0 || amount <= 0 { throw_sdk_exception(env, 1, "Invalid account or amount"); return; @@ -1383,6 +1397,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_sendShi Ok(value) => value, Err(()) => return, }; + let mut disposition = platform_wallet_ffi::SHIELDED_TIP_DISPOSITION_NOT_SENT; let result = unsafe { platform_wallet_ffi::platform_wallet_manager_send_shielded_tip( manager as Handle, @@ -1394,12 +1409,106 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_sendShi address.as_ptr(), amount as u64, memo.as_ref().map_or(ptr::null(), |m| m.as_ptr()), + &mut disposition, ) }; + // Written before the error is raised, so the caller can read it from + // the catch block. The array held one element on entry. + let _ = env.set_int_array_region(&out_disposition, 0, &[disposition as i32]); let _ = take_pwffi_error(env, result); }) } +/// The review a user confirms before tipping a name: 32-byte identity ID, +/// 43-byte address, change kind (`SHIELDED_TIP_RECIPIENT_*`), the previous +/// 32-byte identity ID and 43-byte address (zero unless changed), and an +/// unresolved flag; 152 bytes. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_reviewShieldedTip( + mut env: JNIEnv, + _class: JClass, + manager: jlong, + wallet_id: JByteArray, + username: JString, +) -> jni::sys::jbyteArray { + guard(&mut env, ptr::null_mut(), |env| { + let Some(wid) = read_id32(env, &wallet_id, "walletId") else { + return ptr::null_mut(); + }; + let username = match read_cstring_opt(env, &username, "username") { + Ok(Some(name)) => name, + _ => { + throw_sdk_exception(env, 1, "username is required"); + return ptr::null_mut(); + } + }; + let mut review = + std::mem::MaybeUninit::::uninit(); + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_review_shielded_tip( + manager as Handle, + wid.as_ptr(), + username.as_ptr(), + review.as_mut_ptr(), + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + // The export initializes `out_review` before any fallible step. + let review = unsafe { review.assume_init() }; + let mut bytes = Vec::with_capacity(152); + bytes.extend_from_slice(&review.identity_id); + bytes.extend_from_slice(&review.address); + bytes.push(review.change as u8); + bytes.extend_from_slice(&review.previous_identity_id); + bytes.extend_from_slice(&review.previous_address); + bytes.push(review.unresolved as u8); + env.byte_array_from_slice(&bytes) + .map(|a| a.into_raw()) + .unwrap_or(ptr::null_mut()) + }) +} + +/// `[account, verified]` for a wallet identity's dedicated tip account; +/// `verified` is 1 when this session proved the identity holds its slot. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_identityTipAccount( + mut env: JNIEnv, + _class: JClass, + wallet: jlong, + identity_id: JByteArray, +) -> jni::sys::jintArray { + guard(&mut env, ptr::null_mut(), |env| { + let Some(id) = read_id32(env, &identity_id, "identityId") else { + return ptr::null_mut(); + }; + let mut account = 0u32; + let mut verified = false; + let result = unsafe { + platform_wallet_ffi::platform_wallet_identity_tip_account( + wallet as Handle, + id.as_ptr(), + &mut account, + &mut verified, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + let Ok(array) = env.new_int_array(2) else { + return ptr::null_mut(); + }; + if env + .set_int_array_region(&array, 0, &[account as i32, verified as i32]) + .is_err() + { + return ptr::null_mut(); + } + array.into_raw() + }) +} + #[no_mangle] pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_tipAccountIndex( mut env: JNIEnv, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index 951651390a0..7e92686d9d0 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -1595,7 +1595,88 @@ public enum ShieldedTipSource: Sendable, Equatable { } } +/// Rust's decision whether a failed tip may be sent again. +public enum ShieldedTipDisposition: Sendable, Equatable { + /// Nothing was broadcast, or it was definitively rejected: review and send again. + case notSent + /// The payment may have executed or may still execute: do not send it again. + case unresolved + + init(native: UInt32) { + self = native == SHIELDED_TIP_DISPOSITION_NOT_SENT ? .notSent : .unresolved + } +} + +/// A tip send that failed; `disposition` says whether it may be sent again. +public struct ShieldedTipFailure: Error, LocalizedError { + public let disposition: ShieldedTipDisposition + public let underlying: Error + + public var errorDescription: String? { underlying.localizedDescription } +} + +/// How a tip recipient compares with the one last confirmed for the same name +/// from this wallet. +public enum ShieldedTipRecipientChange: Sendable, Equatable { + case new + case unchanged + case changed(previous: ShieldedTipRecipient) +} + +/// What a user confirms before tipping a name. `unresolved` means an earlier +/// payment to this address is unsettled, and a send is refused until shielded +/// sync settles it. +public struct ShieldedTipReview: Sendable, Equatable { + public let recipient: ShieldedTipRecipient + public let change: ShieldedTipRecipientChange + public let unresolved: Bool + + init(_ ffi: ShieldedTipReviewFFI) { + func bytes(_ value: T) -> Data { withUnsafeBytes(of: value) { Data($0) } } + recipient = ShieldedTipRecipient(identityId: bytes(ffi.identity_id), address: bytes(ffi.address)) + switch ffi.change { + case SHIELDED_TIP_RECIPIENT_NEW: + change = .new + case SHIELDED_TIP_RECIPIENT_UNCHANGED: + change = .unchanged + default: + change = .changed(previous: ShieldedTipRecipient( + identityId: bytes(ffi.previous_identity_id), address: bytes(ffi.previous_address))) + } + unresolved = ffi.unresolved + } +} + +/// A wallet identity's dedicated tip account. Show its balance as the +/// identity's only when `verified`: unverified, the account may belong to +/// another identity. +public struct IdentityTipAccount: Sendable, Equatable { + public let account: UInt32 + public let verified: Bool +} + extension ManagedPlatformWallet { + /// The identity's dedicated tip account, and whether Rust has verified its + /// slot this session. + public func identityTipAccount(identityId: Data) async throws -> IdentityTipAccount { + guard identityId.count == 32 else { + throw PlatformWalletError.invalidParameter("Expected a 32-byte identity ID") + } + // `deinit` destroys `handle`, so the wallet stays alive for the native call. + return try await Task.detached(priority: .userInitiated) { [self] in + try withExtendedLifetime(self) { + var account: UInt32 = 0 + var verified = false + try identityId.withUnsafeBytes { id in + try platform_wallet_identity_tip_account( + handle, id.baseAddress!.assumingMemoryBound(to: UInt8.self), &account, &verified + ).check() + } + return IdentityTipAccount(account: account, verified: verified) + } + }.value + } + /// Fetch and verify the current DPNS identity and shielded tip address. public func resolveShieldedTip(username: String) async throws -> ShieldedTipRecipient { // `deinit` destroys `handle`, so the wallet stays alive for the native call. @@ -1638,7 +1719,33 @@ extension PlatformWalletManager { }.value } - /// Recheck the confirmed recipient before building and broadcasting the payment. + /// Resolve `username` for the user to confirm: its recipient, whether it + /// changed since this wallet last confirmed the name, and whether an + /// earlier payment to it is unresolved. + public func reviewShieldedTip(walletId: Data, username: String) async throws -> ShieldedTipReview { + guard walletId.count == 32 else { + throw PlatformWalletError.invalidParameter("Expected a 32-byte wallet ID") + } + try admitNativeOp("reviewShieldedTip") + defer { finishNativeOp() } + let handle = self.handle + return try await Task.detached(priority: .userInitiated) { + var review = ShieldedTipReviewFFI() + try walletId.withUnsafeBytes { wallet in + try username.withCString { name in + try platform_wallet_manager_review_shielded_tip( + handle, wallet.baseAddress!.assumingMemoryBound(to: UInt8.self), name, &review + ).check() + } + } + return ShieldedTipReview(review) + }.value + } + + /// Recheck the confirmed recipient before building and broadcasting the + /// payment. A native failure is thrown as `ShieldedTipFailure` carrying + /// Rust's decision whether the tip may be sent again; argument errors are + /// thrown as `PlatformWalletError` before anything starts. public func sendShieldedTip(walletId: Data, resolver: MnemonicResolver, source: ShieldedTipSource, username: String, recipient: ShieldedTipRecipient, amount: UInt64) async throws { guard walletId.count == 32, recipient.identityId.count == 32, recipient.address.count == 43, @@ -1650,20 +1757,26 @@ extension PlatformWalletManager { let handle = self.handle try await Task.detached(priority: .userInitiated) { var source = sourceFFI - try withExtendedLifetime(resolver) { - try walletId.withUnsafeBytes { wallet in - try recipient.identityId.withUnsafeBytes { identity in - try recipient.address.withUnsafeBytes { address in - try username.withCString { name in - try platform_wallet_manager_send_shielded_tip( - handle, wallet.baseAddress!.assumingMemoryBound(to: UInt8.self), resolverHandle, - &source, name, identity.baseAddress!.assumingMemoryBound(to: UInt8.self), - address.baseAddress!.assumingMemoryBound(to: UInt8.self), amount, nil - ).check() + var disposition = SHIELDED_TIP_DISPOSITION_UNRESOLVED + do { + try withExtendedLifetime(resolver) { + try walletId.withUnsafeBytes { wallet in + try recipient.identityId.withUnsafeBytes { identity in + try recipient.address.withUnsafeBytes { address in + try username.withCString { name in + try platform_wallet_manager_send_shielded_tip( + handle, wallet.baseAddress!.assumingMemoryBound(to: UInt8.self), resolverHandle, + &source, name, identity.baseAddress!.assumingMemoryBound(to: UInt8.self), + address.baseAddress!.assumingMemoryBound(to: UInt8.self), amount, nil, + &disposition + ).check() + } } } } } + } catch { + throw ShieldedTipFailure(disposition: ShieldedTipDisposition(native: disposition), underlying: error) } }.value } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md index 2186860d536..b0f3c421225 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md @@ -715,13 +715,18 @@ from the recovered identity indices, including accounts whose profile addresses were subsequently removed. Removing a published address does not revoke copies already shared or stop monitoring previously received tips. -To pay a username, call `resolveShieldedTip`, show the returned identity and -address for confirmation, then call `sendShieldedTip` with that recipient and a -`ShieldedTipSource`: `.identity(id)` pays from that identity's tip account, and -`.account(n)` from an ordinary account or a retired identity's tip account. The -send operation verifies fresh resolution still matches the confirmed recipient. -A `shieldedSpendUnconfirmed` error must not be retried automatically: the payment -may already have been accepted. +To pay a username, call `reviewShieldedTip` and show the returned recipient for +confirmation, with a warning when `change` is `.changed`. Disable sending while +`unresolved` is true: an earlier payment to that address is still settling. Then +call `sendShieldedTip` with that recipient and a `ShieldedTipSource`: +`.identity(id)` pays from that identity's tip account, and `.account(n)` from an +ordinary account or a retired identity's tip account. The send verifies that fresh +resolution still matches the confirmed recipient. A native failure is thrown as +`ShieldedTipFailure`; send again only when its `disposition` is `.notSent`, and review +again first, because a refusal for an unresolved earlier tip is also `.notSent`. The +wallet records each tip as unresolved before broadcasting it, so this holds across +relaunches. Show an identity's tip balance only when `identityTipAccount` reports +it `verified`. The published address is publicly associated with the username. Dedicated accounts isolate viewing keys and ordinary receiving activity; transfers between From 3a21dd5ab182fa9d2208b3b957b3d8f756d0ad9d Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 1 Nov 2026 13:30:48 -0600 Subject: [PATCH 02/21] feat(platform-wallet): parse shielded-tip amounts in the wallet The Swift and Kotlin tip screens each parsed the typed DASH amount, with different rules: Kotlin's BigDecimal accepted exponents and signs that Swift refused. parse_shielded_tip_amount turns ASCII digits with an optional decimal point into exact credits and refuses signs, grouping, exponents, zero, and fractions finer than one credit; C, JNI, Swift and Kotlin expose it. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/ffi/FundingNative.kt | 1 + .../dashsdk/wallet/PlatformWalletManager.kt | 4 ++ .../src/shielded_send.rs | 44 +++++++++++++ .../rs-platform-wallet/docs/SHIELDED_TIPS.md | 4 ++ packages/rs-platform-wallet/src/lib.rs | 6 +- .../src/wallet/shielded/mod.rs | 6 +- .../src/wallet/shielded/tips.rs | 66 +++++++++++++++++++ packages/rs-unified-sdk-jni/src/funding.rs | 30 +++++++++ .../PlatformWalletManagerShieldedSync.swift | 8 +++ .../SwiftDashSDK/PlatformWallet/README.md | 3 +- 10 files changed, 165 insertions(+), 7 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt index acb9d443b54..98b574f642d 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt @@ -17,6 +17,7 @@ package org.dashfoundation.dashsdk.ffi */ internal object FundingNative { external fun tipAccountIndex(identityIndex: Int): Int + external fun parseShieldedTipAmount(text: String): Long external fun prepareShieldedTipAddress( manager: Long, walletId: ByteArray, resolver: Long, identityId: ByteArray, ): ByteArray diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 41c153827c9..534384bc98f 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -2015,6 +2015,10 @@ class PlatformWalletManager( // — the caller must NOT retry (the spent notes stay reserved Rust-side; // the next shielded sync reconciles the outcome). + /** Exact credits for a tip amount typed in DASH, parsed by Rust; throws on ambiguous input. */ + fun parseShieldedTipAmount(text: String): Long = + mapNativeErrors { FundingNative.parseShieldedTipAmount(text) } + /** Dedicated account index derived by Rust's wallet convention. */ fun shieldedTipAccountIndex(identityIndex: Int): Int = mapNativeErrors { FundingNative.tipAccountIndex(identityIndex) } diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index ed2841449c9..8bbb02fe2d2 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -2666,6 +2666,29 @@ pub unsafe extern "C" fn platform_wallet_identity_tip_account( result } +/// Parse a tip amount typed in DASH into exact credits; see +/// `platform_wallet::parse_shielded_tip_amount`. `out_credits` is zero on error. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_parse_shielded_tip_amount( + text: *const c_char, + out_credits: *mut u64, +) -> PlatformWalletFFIResult { + check_ptr!(out_credits); + *out_credits = 0; + check_ptr!(text); + let text = match CStr::from_ptr(text).to_str() { + Ok(s) => s, + Err(e) => return e.into(), + }; + match platform_wallet::parse_shielded_tip_amount(text) { + Ok(credits) => { + *out_credits = credits; + PlatformWalletFFIResult::ok() + } + Err(e) => e.into(), + } +} + /// Return the dedicated ZIP-32 tip account for a wallet identity derivation index. #[no_mangle] pub unsafe extern "C" fn platform_wallet_shielded_tip_account_index( @@ -2723,6 +2746,27 @@ mod tests { ); } + #[test] + fn should_parse_a_tip_amount_and_zero_the_output_on_invalid_input() { + let mut credits = 7u64; + let valid = std::ffi::CString::new("0.5").unwrap(); + let mut result = + unsafe { platform_wallet_parse_shielded_tip_amount(valid.as_ptr(), &mut credits) }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(credits, 50_000_000_000); + unsafe { crate::platform_wallet_ffi_result_free(&mut result) }; + + let invalid = std::ffi::CString::new("1e3").unwrap(); + let mut result = + unsafe { platform_wallet_parse_shielded_tip_amount(invalid.as_ptr(), &mut credits) }; + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorInvalidParameter + ); + assert_eq!(credits, 0); + unsafe { crate::platform_wallet_ffi_result_free(&mut result) }; + } + #[test] fn identity_tip_account_reports_a_missing_wallet_with_zeroed_outputs() { let mut account = 7u32; diff --git a/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md b/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md index d0f8578bbe7..fcd325b45c9 100644 --- a/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md +++ b/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md @@ -90,6 +90,10 @@ funds, viewing keys, and recovery belong to the external wallet. ## Resolving and paying +Parse the amount the user typed with `parse_shielded_tip_amount`: exact credits from +ASCII digits and an optional `.`, refusing signs, grouping, exponents, zero, and +fractions finer than one credit. + Use `wallet.review_shielded_tip(coordinator, username)` to obtain what the user confirms: the fresh `ShieldedTipRecipient`, how it compares with the recipient last confirmed for the name from this wallet (`New`, `Unchanged`, or `Changed` with the diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index aa940578f72..c17d0419092 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -122,7 +122,7 @@ pub use wallet::identity::types::dashpay::profile::{ #[cfg(feature = "shielded")] pub use wallet::shielded::tips::{ - is_shielded_tip_account, shielded_tip_account_index, IdentityTipAccount, - ShieldedTipDisposition, ShieldedTipRecipientChange, ShieldedTipReview, ShieldedTipSource, - SHIELDED_TIP_ACCOUNT_BASE, + is_shielded_tip_account, parse_shielded_tip_amount, shielded_tip_account_index, + IdentityTipAccount, ShieldedTipDisposition, ShieldedTipRecipientChange, ShieldedTipReview, + ShieldedTipSource, SHIELDED_TIP_ACCOUNT_BASE, }; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs index 7322b11e234..964f0d4db77 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs @@ -50,9 +50,9 @@ pub mod store; pub mod sync; pub mod tips; pub use tips::{ - is_shielded_tip_account, shielded_tip_account_index, IdentityTipAccount, - ShieldedTipDisposition, ShieldedTipRecipientChange, ShieldedTipReview, ShieldedTipSource, - SHIELDED_TIP_ACCOUNT_BASE, + is_shielded_tip_account, parse_shielded_tip_amount, shielded_tip_account_index, + IdentityTipAccount, ShieldedTipDisposition, ShieldedTipRecipientChange, ShieldedTipReview, + ShieldedTipSource, SHIELDED_TIP_ACCOUNT_BASE, }; #[cfg(test)] mod viewing_key_bind_tests; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/tips.rs b/packages/rs-platform-wallet/src/wallet/shielded/tips.rs index 3129f45ce38..3db4650ee88 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/tips.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/tips.rs @@ -12,6 +12,7 @@ use super::store::ShieldedStore; use super::NetworkShieldedCoordinator; use crate::wallet::platform_wallet::{PlatformWallet, WalletId}; use crate::{PlatformWalletError, ShieldedTipRecipient}; +use dpp::balances::credits::CREDITS_PER_DUFF; use dpp::identity::accessors::IdentityGettersV0; use dpp::prelude::Identifier; use dpp::serialization::PlatformDeserializableTrusted; @@ -134,6 +135,42 @@ fn has_unresolved_transfer_to( Ok(false) } +/// Credits in one DASH: 10^8 duffs of [`CREDITS_PER_DUFF`] credits each. +const CREDITS_PER_DASH: u64 = 100_000_000 * CREDITS_PER_DUFF; +/// Fractional DASH digits a credit amount can carry. +const DASH_DECIMALS: usize = 11; + +/// Parse a tip amount the user typed in DASH into exact credits. Accepts +/// ASCII digits with an optional `.` decimal separator; refuses signs, +/// grouping, exponents, zero, and nonzero digits finer than one credit +/// (extra trailing zeros are exact and allowed). +pub fn parse_shielded_tip_amount(text: &str) -> Result { + let invalid = + || PlatformWalletError::InvalidParameter(format!("Invalid DASH amount: {text:?}")); + let (whole, fraction) = text.split_once('.').unwrap_or((text, "")); + let digits = |part: &str| part.bytes().all(|b| b.is_ascii_digit()); + if whole.is_empty() + || !digits(whole) + || (text.contains('.') && fraction.is_empty()) + || !digits(fraction) + || fraction.bytes().skip(DASH_DECIMALS).any(|b| b != b'0') + { + return Err(invalid()); + } + let fraction = &fraction[..fraction.len().min(DASH_DECIMALS)]; + let fractional_credits = format!("{fraction:0() + .map_err(|_| invalid())?; + let credits = whole + .parse::() + .ok() + .and_then(|whole| whole.checked_mul(CREDITS_PER_DASH)) + .and_then(|credits| credits.checked_add(fractional_credits)) + .filter(|credits| *credits > 0) + .ok_or_else(invalid)?; + Ok(credits) +} + /// The recipient last confirmed for `label` from `wallet_id`, if any. fn confirmed_tip_recipient( store: &S, @@ -622,6 +659,35 @@ mod tests { use dpp::state_transition::shielded_transfer_transition::ShieldedTransferTransition; use key_wallet::Network; + #[test] + fn should_parse_exact_tip_amounts_and_refuse_ambiguous_input() { + assert_eq!(parse_shielded_tip_amount("1").unwrap(), 100_000_000_000); + assert_eq!(parse_shielded_tip_amount("0.5").unwrap(), 50_000_000_000); + assert_eq!(parse_shielded_tip_amount("0.00000000001").unwrap(), 1); + assert_eq!( + parse_shielded_tip_amount("2.10000000000000").unwrap(), + 210_000_000_000 + ); + for refused in [ + "", + "0", + "0.0", + ".5", + "1.", + "-1", + "+1", + "1e3", + "1,5", + "1 000", + " 1", + "0.000000000001", + "184467441", + "1.2.3", + ] { + assert!(parse_shielded_tip_amount(refused).is_err(), "{refused:?}"); + } + } + #[test] fn should_key_confirmed_recipients_by_the_resolved_dpns_label() { assert_eq!( diff --git a/packages/rs-unified-sdk-jni/src/funding.rs b/packages/rs-unified-sdk-jni/src/funding.rs index c55750f1467..8a61f239ec0 100644 --- a/packages/rs-unified-sdk-jni/src/funding.rs +++ b/packages/rs-unified-sdk-jni/src/funding.rs @@ -1509,6 +1509,36 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_identit }) } +/// Exact credits for a tip amount typed in DASH. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_parseShieldedTipAmount( + mut env: JNIEnv, + _class: JClass, + text: JString, +) -> jlong { + guard(&mut env, 0, |env| { + let text = match read_cstring_opt(env, &text, "text") { + Ok(Some(text)) => text, + _ => { + throw_sdk_exception(env, 1, "text is required"); + return 0; + } + }; + let mut credits = 0u64; + let result = unsafe { + platform_wallet_ffi::platform_wallet_parse_shielded_tip_amount( + text.as_ptr(), + &mut credits, + ) + }; + if take_pwffi_error(env, result) { + return 0; + } + // Credits fit in a signed 64-bit value for any DASH supply. + credits as jlong + }) +} + #[no_mangle] pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_tipAccountIndex( mut env: JNIEnv, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index 7e92686d9d0..cb5e9fae822 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -1783,6 +1783,14 @@ extension PlatformWalletManager { } extension PlatformWalletManager { + /// Exact credits for a tip amount typed in DASH, parsed by Rust; throws on + /// ambiguous input (signs, grouping, exponents, sub-credit fractions). + public static func parseShieldedTipAmount(_ text: String) throws -> UInt64 { + var credits: UInt64 = 0 + try text.withCString { try platform_wallet_parse_shielded_tip_amount($0, &credits).check() } + return credits + } + public static func shieldedTipAccountIndex(identityIndex: UInt32) throws -> UInt32 { var account: UInt32 = 0 try platform_wallet_shielded_tip_account_index(identityIndex, &account).check() diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md index b0f3c421225..5cfd3112b81 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md @@ -715,7 +715,8 @@ from the recovered identity indices, including accounts whose profile addresses were subsequently removed. Removing a published address does not revoke copies already shared or stop monitoring previously received tips. -To pay a username, call `reviewShieldedTip` and show the returned recipient for +To pay a username, parse the typed amount with `parseShieldedTipAmount`, call +`reviewShieldedTip` and show the returned recipient for confirmation, with a warning when `change` is `.changed`. Disable sending while `unresolved` is true: an earlier payment to that address is still settling. Then call `sendShieldedTip` with that recipient and a `ShieldedTipSource`: From a73ce5bff19fcd7b7513b8b648fa054d08e0cf00 Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 1 Nov 2026 13:49:38 -0600 Subject: [PATCH 03/21] fix(platform-wallet): accept keypad decimals and report pre-native tip failures as not sent - parse_shielded_tip_amount trims whitespace and takes one `.` or `,` separator, since iOS decimal keypads type the locale's; a missing whole part (".5") is zero. JNI refuses a parsed amount that does not fit a signed 64-bit value instead of wrapping it negative. - The Swift and Kotlin sends throw ShieldedTipFailure(notSent) for a failure raised before the native call (invalid arguments, manager shutdown), so hosts read only the disposition instead of knowing where the SDK throws. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/wallet/PlatformWalletManager.kt | 26 +++++----- .../rs-platform-wallet/docs/SHIELDED_TIPS.md | 5 +- .../src/wallet/shielded/tips.rs | 49 +++++++++++++------ packages/rs-unified-sdk-jni/src/funding.rs | 9 +++- .../PlatformWalletManagerShieldedSync.swift | 15 ++++-- .../SwiftDashSDK/PlatformWallet/README.md | 3 +- 6 files changed, 69 insertions(+), 38 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 534384bc98f..9cff241b608 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -2039,30 +2039,32 @@ class PlatformWalletManager( } /** - * Send only if fresh resolution still matches the recipient shown in confirmation. A native + * Send only if fresh resolution still matches the recipient shown in confirmation. Every * failure is thrown as [org.dashfoundation.dashsdk.tokens.ShieldedTipFailure] carrying Rust's - * decision whether the tip may be sent again; argument errors are thrown before anything starts. + * decision whether the tip may be sent again; one raised before the native send is not sent. */ suspend fun sendShieldedTip( walletId: ByteArray, username: String, recipient: org.dashfoundation.dashsdk.tokens.ShieldedTipRecipient, amount: Long, source: org.dashfoundation.dashsdk.tokens.ShieldedTipSource, memo: String? = null, - ): Unit = teardownGate.op { - require(amount > 0) { "amount must be positive, got $amount" } - val (sourceIdentityId, sourceAccount) = when (source) { - is org.dashfoundation.dashsdk.tokens.ShieldedTipSource.Identity -> source.identityId to 0 - is org.dashfoundation.dashsdk.tokens.ShieldedTipSource.Account -> null to source.index - } + ) { // A failure before JNI is entered sent nothing; JNI overwrites this with Rust's decision. val disposition = intArrayOf( org.dashfoundation.dashsdk.tokens.ShieldedTipDisposition.NATIVE_NOT_SENT, ) try { - mapNativeErrors { - FundingNative.sendShieldedTip(managerHandle, walletId, mnemonicResolver.nativeHandle, - sourceIdentityId, sourceAccount, username, recipient.identityId, recipient.address, - amount, memo, disposition) + teardownGate.op { + require(amount > 0) { "amount must be positive, got $amount" } + val (sourceIdentityId, sourceAccount) = when (source) { + is org.dashfoundation.dashsdk.tokens.ShieldedTipSource.Identity -> source.identityId to 0 + is org.dashfoundation.dashsdk.tokens.ShieldedTipSource.Account -> null to source.index + } + mapNativeErrors { + FundingNative.sendShieldedTip(managerHandle, walletId, mnemonicResolver.nativeHandle, + sourceIdentityId, sourceAccount, username, recipient.identityId, recipient.address, + amount, memo, disposition) + } } } catch (error: kotlinx.coroutines.CancellationException) { throw error diff --git a/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md b/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md index fcd325b45c9..76b97835433 100644 --- a/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md +++ b/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md @@ -91,8 +91,9 @@ funds, viewing keys, and recovery belong to the external wallet. ## Resolving and paying Parse the amount the user typed with `parse_shielded_tip_amount`: exact credits from -ASCII digits and an optional `.`, refusing signs, grouping, exponents, zero, and -fractions finer than one credit. +ASCII digits and one optional `.` or `,` separator (decimal keypads type the locale's), +refusing signs, grouping, exponents, zero, and fractions finer than one credit. Show the +parsed amount in the confirmation, not the typed text. Use `wallet.review_shielded_tip(coordinator, username)` to obtain what the user confirms: the fresh `ShieldedTipRecipient`, how it compares with the recipient last diff --git a/packages/rs-platform-wallet/src/wallet/shielded/tips.rs b/packages/rs-platform-wallet/src/wallet/shielded/tips.rs index 3db4650ee88..c27a6b49555 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/tips.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/tips.rs @@ -141,17 +141,23 @@ const CREDITS_PER_DASH: u64 = 100_000_000 * CREDITS_PER_DUFF; const DASH_DECIMALS: usize = 11; /// Parse a tip amount the user typed in DASH into exact credits. Accepts -/// ASCII digits with an optional `.` decimal separator; refuses signs, -/// grouping, exponents, zero, and nonzero digits finer than one credit -/// (extra trailing zeros are exact and allowed). +/// ASCII digits with one optional decimal separator, `.` or `,` (decimal +/// keypads type the locale's), and ignores surrounding whitespace; refuses +/// signs, grouping, exponents, zero, and nonzero digits finer than one credit +/// (extra trailing zeros are exact and allowed). Show the user the parsed +/// amount, not the typed text: `1,000` is one DASH. pub fn parse_shielded_tip_amount(text: &str) -> Result { let invalid = || PlatformWalletError::InvalidParameter(format!("Invalid DASH amount: {text:?}")); - let (whole, fraction) = text.split_once('.').unwrap_or((text, "")); + let trimmed = text.trim(); + let (whole, fraction, separated) = match trimmed.split_once(['.', ',']) { + Some((whole, fraction)) => (whole, fraction, true), + None => (trimmed, "", false), + }; let digits = |part: &str| part.bytes().all(|b| b.is_ascii_digit()); - if whole.is_empty() + if (whole.is_empty() && fraction.is_empty()) + || (separated && fraction.is_empty()) || !digits(whole) - || (text.contains('.') && fraction.is_empty()) || !digits(fraction) || fraction.bytes().skip(DASH_DECIMALS).any(|b| b != b'0') { @@ -161,6 +167,7 @@ pub fn parse_shielded_tip_amount(text: &str) -> Result let fractional_credits = format!("{fraction:0() .map_err(|_| invalid())?; + let whole = if whole.is_empty() { "0" } else { whole }; let credits = whole .parse::() .ok() @@ -661,28 +668,38 @@ mod tests { #[test] fn should_parse_exact_tip_amounts_and_refuse_ambiguous_input() { - assert_eq!(parse_shielded_tip_amount("1").unwrap(), 100_000_000_000); - assert_eq!(parse_shielded_tip_amount("0.5").unwrap(), 50_000_000_000); - assert_eq!(parse_shielded_tip_amount("0.00000000001").unwrap(), 1); - assert_eq!( - parse_shielded_tip_amount("2.10000000000000").unwrap(), - 210_000_000_000 - ); + for (text, credits) in [ + ("1", 100_000_000_000), + ("0.5", 50_000_000_000), + ("0.00000000001", 1), + ("2.10000000000000", 210_000_000_000), + ("1,5", 150_000_000_000), + (".5", 50_000_000_000), + (" 1 ", 100_000_000_000), + ] { + assert_eq!( + parse_shielded_tip_amount(text).unwrap(), + credits, + "{text:?}" + ); + } for refused in [ "", + " ", "0", "0.0", - ".5", + ".", "1.", "-1", "+1", "1e3", - "1,5", "1 000", - " 1", + "1,000.5", + "1.000,5", "0.000000000001", "184467441", "1.2.3", + "\u{663}", ] { assert!(parse_shielded_tip_amount(refused).is_err(), "{refused:?}"); } diff --git a/packages/rs-unified-sdk-jni/src/funding.rs b/packages/rs-unified-sdk-jni/src/funding.rs index 8a61f239ec0..407df97f287 100644 --- a/packages/rs-unified-sdk-jni/src/funding.rs +++ b/packages/rs-unified-sdk-jni/src/funding.rs @@ -1534,8 +1534,13 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_parseSh if take_pwffi_error(env, result) { return 0; } - // Credits fit in a signed 64-bit value for any DASH supply. - credits as jlong + match jlong::try_from(credits) { + Ok(credits) => credits, + Err(_) => { + throw_sdk_exception(env, 2, "Amount exceeds the largest supported value"); + 0 + } + } }) } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index cb5e9fae822..42830c4a502 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -1743,16 +1743,21 @@ extension PlatformWalletManager { } /// Recheck the confirmed recipient before building and broadcasting the - /// payment. A native failure is thrown as `ShieldedTipFailure` carrying - /// Rust's decision whether the tip may be sent again; argument errors are - /// thrown as `PlatformWalletError` before anything starts. + /// payment. Every failure is thrown as `ShieldedTipFailure` carrying Rust's + /// decision whether the tip may be sent again; one raised before the native + /// send is `.notSent`. public func sendShieldedTip(walletId: Data, resolver: MnemonicResolver, source: ShieldedTipSource, username: String, recipient: ShieldedTipRecipient, amount: UInt64) async throws { guard walletId.count == 32, recipient.identityId.count == 32, recipient.address.count == 43, let sourceFFI = source.ffi, let resolverHandle = resolver.handle else { - throw PlatformWalletError.invalidParameter("Invalid shielded tip source, recipient or wallet") + throw ShieldedTipFailure(disposition: .notSent, underlying: + PlatformWalletError.invalidParameter("Invalid shielded tip source, recipient or wallet")) + } + do { + try admitNativeOp("sendShieldedTip") + } catch { + throw ShieldedTipFailure(disposition: .notSent, underlying: error) } - try admitNativeOp("sendShieldedTip") defer { finishNativeOp() } let handle = self.handle try await Task.detached(priority: .userInitiated) { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md index 5cfd3112b81..0abbd520f75 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md @@ -723,7 +723,8 @@ call `sendShieldedTip` with that recipient and a `ShieldedTipSource`: `.identity(id)` pays from that identity's tip account, and `.account(n)` from an ordinary account or a retired identity's tip account. The send verifies that fresh resolution still matches the confirmed recipient. A native failure is thrown as -`ShieldedTipFailure`; send again only when its `disposition` is `.notSent`, and review +`ShieldedTipFailure` (also when it fails before the native call, as `.notSent`); send +again only when its `disposition` is `.notSent`, and review again first, because a refusal for an unresolved earlier tip is also `.notSent`. The wallet records each tip as unresolved before broadcasting it, so this holds across relaunches. Show an identity's tip balance only when `identityTipAccount` reports From c3ec75929311702aa3b3e61f82db3fb6807ebd5f Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 1 Nov 2026 13:53:18 -0600 Subject: [PATCH 04/21] fix(swift-sdk): convert the tip disposition and review constants to UInt32 cbindgen emits them as macros, which Swift imports as Int32. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWalletManagerShieldedSync.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index 42830c4a502..53b429c2dae 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -1603,7 +1603,7 @@ public enum ShieldedTipDisposition: Sendable, Equatable { case unresolved init(native: UInt32) { - self = native == SHIELDED_TIP_DISPOSITION_NOT_SENT ? .notSent : .unresolved + self = native == UInt32(SHIELDED_TIP_DISPOSITION_NOT_SENT) ? .notSent : .unresolved } } @@ -1635,9 +1635,9 @@ public struct ShieldedTipReview: Sendable, Equatable { func bytes(_ value: T) -> Data { withUnsafeBytes(of: value) { Data($0) } } recipient = ShieldedTipRecipient(identityId: bytes(ffi.identity_id), address: bytes(ffi.address)) switch ffi.change { - case SHIELDED_TIP_RECIPIENT_NEW: + case UInt32(SHIELDED_TIP_RECIPIENT_NEW): change = .new - case SHIELDED_TIP_RECIPIENT_UNCHANGED: + case UInt32(SHIELDED_TIP_RECIPIENT_UNCHANGED): change = .unchanged default: change = .changed(previous: ShieldedTipRecipient( @@ -1762,7 +1762,7 @@ extension PlatformWalletManager { let handle = self.handle try await Task.detached(priority: .userInitiated) { var source = sourceFFI - var disposition = SHIELDED_TIP_DISPOSITION_UNRESOLVED + var disposition = UInt32(SHIELDED_TIP_DISPOSITION_UNRESOLVED) do { try withExtendedLifetime(resolver) { try walletId.withUnsafeBytes { wallet in From 688c75e118faf6a498157acafad6c8a04ee5a4a4 Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 1 Nov 2026 15:00:22 -0600 Subject: [PATCH 05/21] fix(platform-wallet): keep a durable barrier per shielded tip until its outcome is proven The tip's retry guard was the generic recovery record, whose lifetime follows note reservations and account state. Review found four ways it could vanish or be ignored before the original payment's outcome was known, each permitting a second payment. - A tip records a TipBarrier (recipient, notes, anchor) in a new shielded_tip_barriers table, apart from note and recovery state, so account purges, Clear and rescans do not remove it. - The store settles it as executed in mark_spent, before the recovery row is deleted. It settles as failed only on the tip's own definitive rejection: the tip is broadcast once with no retries, so a consensus rejection speaks for the only node that received it; transport errors such as Unavailable, and NullifierAlreadySpent, stay unresolved. The release pass and re-drive rejections no longer settle tips. - An unresolved tip whose outcome stays unknown is released only by abandon_unresolved_shielded_tips with the user's acknowledgement that it may still execute (C, JNI, Swift, Kotlin). - While a tip is pending, a bind that drops or re-keys its paying account and a shielded Clear are refused; the send holds shielded_config_lock. - An unreadable barrier blocks its wallet's tips instead of being skipped. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/ffi/FundingNative.kt | 1 + .../dashsdk/wallet/PlatformWalletManager.kt | 12 + .../src/shielded_send.rs | 42 +- .../rs-platform-wallet/docs/SHIELDED_TIPS.md | 32 +- .../src/wallet/shielded/coordinator.rs | 35 ++ .../src/wallet/shielded/file_store.rs | 397 +++++++++++++++++- .../src/wallet/shielded/operations.rs | 210 ++++++++- .../src/wallet/shielded/store.rs | 153 +++++++ .../src/wallet/shielded/tips.rs | 142 +++---- .../wallet/shielded/viewing_key_bind_tests.rs | 103 +++++ packages/rs-unified-sdk-jni/src/funding.rs | 31 ++ .../PlatformWalletManagerShieldedSync.swift | 26 ++ .../SwiftDashSDK/PlatformWallet/README.md | 5 +- 13 files changed, 1073 insertions(+), 116 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt index 98b574f642d..9fec36be3d5 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt @@ -33,6 +33,7 @@ internal object FundingNative { ) /** 152 bytes: identity, address, change kind, previous identity, previous address, unresolved. */ external fun reviewShieldedTip(manager: Long, walletId: ByteArray, username: String): ByteArray + external fun abandonUnresolvedShieldedTips(manager: Long, walletId: ByteArray, acknowledge: Boolean): Int /** `[account, verified]` for a wallet identity's dedicated tip account. */ external fun identityTipAccount(wallet: Long, identityId: ByteArray): IntArray diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 9cff241b608..5d32f168735 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -2026,6 +2026,18 @@ class PlatformWalletManager( mapNativeErrors { FundingNative.prepareShieldedTipAddress(managerHandle, walletId, mnemonicResolver.nativeHandle, identityId) } } + /** + * Allow new tips although earlier ones from this wallet are unresolved. Pass the user's + * acknowledgement that those tips may still execute; returns how many were released. + */ + suspend fun abandonUnresolvedShieldedTips( + walletId: ByteArray, acknowledgePossibleExecution: Boolean, + ): Int = teardownGate.op { + mapNativeErrors { + FundingNative.abandonUnresolvedShieldedTips(managerHandle, walletId, acknowledgePossibleExecution) + } + } + /** * Resolve [username] for the user to confirm: its recipient, whether it changed since this * wallet last confirmed the name, and whether an earlier payment to it is unresolved. diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 8bbb02fe2d2..fc42850a127 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -2599,7 +2599,7 @@ pub unsafe extern "C" fn platform_wallet_manager_review_shielded_tip( }; let wallet_id: [u8; 32] = std::slice::from_raw_parts(wallet_id_bytes, 32) .try_into() - .unwrap(); + .expect("wallet_id slice is exactly 32 bytes by construction"); let mut review = None; let result = catch_query_panic("shielded tip review", || { let (wallet, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) { @@ -2623,6 +2623,46 @@ pub unsafe extern "C" fn platform_wallet_manager_review_shielded_tip( result } +/// Allow new tips from wallet `wallet_id_bytes` (32 readable bytes) although +/// earlier ones are unresolved; see +/// `PlatformWallet::abandon_unresolved_shielded_tips`. The host passes the +/// user's acknowledgement that those tips may still execute. +/// `out_released` receives how many unresolved tips were released. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_abandon_unresolved_shielded_tips( + handle: Handle, + wallet_id_bytes: *const u8, + acknowledge_possible_execution: bool, + out_released: *mut u32, +) -> PlatformWalletFFIResult { + check_ptr!(out_released); + *out_released = 0; + check_ptr!(wallet_id_bytes); + let wallet_id: [u8; 32] = std::slice::from_raw_parts(wallet_id_bytes, 32) + .try_into() + .expect("wallet_id slice is exactly 32 bytes by construction"); + let mut released = 0usize; + let result = catch_query_panic("unresolved tip release", || { + let (wallet, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) { + Ok(value) => value, + Err(e) => return e, + }; + match block_on_worker(async move { + wallet + .abandon_unresolved_shielded_tips(&coordinator, acknowledge_possible_execution) + .await + }) { + Ok(count) => { + released = count; + PlatformWalletFFIResult::ok() + } + Err(e) => e.into(), + } + }); + *out_released = u32::try_from(released).unwrap_or(u32::MAX); + result +} + /// The dedicated tip account of a wallet identity's slot (`identity_id`: 32 /// readable bytes), and whether this session proved the identity holds that /// slot. Show the account's balance as the identity's only when verified. diff --git a/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md b/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md index 76b97835433..f51d4d08e62 100644 --- a/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md +++ b/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md @@ -112,16 +112,28 @@ transparent destination. This is a snapshot check: the submitted payment always uses the address that was confirmed, even if the profile changes afterward. A tip is recorded as unresolved before it is broadcast: its activity row is flushed to -the host and a parked recovery record is kept in the shielded store, so a relaunch -still knows its outcome is open. While a payment to an address is unresolved, a tip to -that address is refused (`ShieldedTipPending`), as is a second tip while one is being -sent. The record is settled by the send's own outcome, or, after an interrupted send, -when a scan sees its notes spent or the release pass finds its anchor pruned. After a -failure, `ShieldedTipDisposition::of(error)` decides whether the tip may be sent again: -`NotSent` (nothing broadcast, or definitively rejected) or `Unresolved` (do not send -again; shielded sync settles it). The C, Swift and Kotlin sends report the same decision. -Keep the paying account bound until the tip settles: a bind that drops an account -purges its recovery records. +the host, and the shielded store durably records a tip barrier (recipient, notes, +anchor) together with a parked recovery record that keeps its notes reserved across a +relaunch. The barrier is kept apart from note and recovery state, so account purges and +scans do not remove it. While a barrier to an address is pending, a tip to that address +is refused (`ShieldedTipPending`), as is a second tip while one is being sent; an +unreadable barrier blocks its wallet's tips instead of vanishing. + +A barrier settles as executed when the store sees one of its notes spent, in the same +step that resolves the note and before its recovery record goes. It settles as failed +only on its own send's definitive rejection: the tip is broadcast to a single node with +no retries, and only that node's consensus rejection, or the committed result of the +wait, counts. Nothing reported later (a re-drive rejection, an anchor missing from one +node's set) settles it as failed, because another node may still hold the tip. A tip +whose outcome stays unknown therefore stays pending until its notes are seen spent, or +until the user, acknowledging that it may still execute, calls +`abandon_unresolved_shielded_tips`. While a tip is pending, a bind that would drop or +re-key its paying account and a shielded Clear are refused. + +After a failure, `ShieldedTipDisposition::of(error)` decides whether the tip may be sent +again: `NotSent` (nothing broadcast, or definitively rejected) or `Unresolved` (do not +send again; shielded sync settles it). The C, Swift and Kotlin sends report the same +decision. Tips to a public profile address are anonymous from the receiver's perspective unless the payer provides additional context. This feature does not implement diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index 731b35e51ad..d6297203b9a 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -695,6 +695,30 @@ impl NetworkShieldedCoordinator { // displace an owner, so they keep their store-lock-free fast path. if !stale.is_empty() || registered_count == 0 { let store = self.store.read().await; + // A pending tip settles only through its paying account's scan + // and prune passes, so that account must stay bound, with its keys. + let tips = store.tip_barriers(wallet_id).map_err(|e| { + PlatformWalletError::ShieldedRecoveryCorrupted { + account_index: None, + reason: e.to_string(), + } + })?; + for tip in tips + .iter() + .filter(|tip| tip.status == super::store::TipBarrierStatus::Pending) + { + let id = SubwalletId::new(wallet_id, tip.account_index); + if !account_views.contains_key(&tip.account_index) + || stale.iter().any(|(stale_id, _)| *stale_id == id) + { + return Err(PlatformWalletError::ShieldedRecoveryKeysRequired { + account_index: tip.account_index, + reason: "the account paying an unresolved tip must stay bound with its \ + keys until shielded sync settles the tip" + .to_owned(), + }); + } + } let guards = store .pending_redrives_for_wallet(wallet_id) .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; @@ -1211,6 +1235,17 @@ impl NetworkShieldedCoordinator { let mut first_err: Option = None; { let mut store = self.store.write().await; + // A pending tip settles through the note and recovery state a + // Clear deletes; clearing first would leave it unresolvable. + match store.has_pending_tip_barriers() { + Ok(false) => {} + Ok(true) => return Err(crate::error::PlatformWalletError::ShieldedTipPending), + Err(e) => { + return Err(crate::error::PlatformWalletError::ShieldedStoreError( + e.to_string(), + )) + } + } if let Err(e) = store.reset_commitment_tree() { tracing::warn!(error = %e, "Failed to reset commitment tree on clear"); first_err.get_or_insert_with(|| { diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index 7a2dac1c3ed..80bbb147eab 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -24,8 +24,9 @@ use rusqlite::OptionalExtension; use super::balance::{ShieldedBalanceSource, ShieldedLocalAccountBalance}; use super::store::{ - ConfirmedTipRecipient, PendingRedrive, ShieldedNote, ShieldedOutgoingNote, ShieldedStore, - StalePendingSpend, SubwalletId, SubwalletState, + tip_barriers_spending, ConfirmedTipRecipient, PendingRedrive, ShieldedNote, + ShieldedOutgoingNote, ShieldedStore, StalePendingSpend, SubwalletId, SubwalletState, + TipBarrier, TipBarrierStatus, }; use crate::error::PlatformWalletError; use crate::wallet::platform_wallet::WalletId; @@ -110,6 +111,16 @@ pub struct FileBackedShieldedStore { /// two-connection setup safe. `Mutex` for the same `Sync`-shim /// reason as `tree`. pending_conn: Mutex, + /// The `shielded_tip_barriers` table, loaded at open and written + /// through: SQL first, then this map. + tip_barriers: BTreeMap<(WalletId, [u8; 32]), TipBarrier>, + /// Wallets with a stored tip barrier that could not be read. Their + /// barrier reads fail until the user abandons their unresolved tips, so a + /// damaged record blocks tips instead of vanishing. + damaged_tip_barrier_wallets: std::collections::BTreeSet, + /// A stored tip barrier whose wallet id itself is unreadable; it blocks + /// every wallet's tips the same way. + unattributed_tip_barrier_damage: bool, } impl FileBackedShieldedStore { @@ -195,6 +206,23 @@ impl FileBackedShieldedStore { .map_err(|e| { FileShieldedStoreError::Storage(format!("create pending_spends table: {e}")) })?; + pending_conn + .execute( + "CREATE TABLE IF NOT EXISTS shielded_tip_barriers ( + wallet_id BLOB NOT NULL, + activity_id BLOB NOT NULL, + account_index INTEGER NOT NULL, + recipient BLOB NOT NULL, + nullifiers BLOB NOT NULL, + anchor BLOB NOT NULL, + status INTEGER NOT NULL, + PRIMARY KEY (wallet_id, activity_id) + )", + [], + ) + .map_err(|e| { + FileShieldedStoreError::Storage(format!("create tip_barriers table: {e}")) + })?; pending_conn .execute( "CREATE TABLE IF NOT EXISTS shielded_tip_recipients ( @@ -236,8 +264,12 @@ impl FileBackedShieldedStore { max_checkpoints, subwallets: BTreeMap::new(), pending_conn: Mutex::new(pending_conn), + tip_barriers: BTreeMap::new(), + damaged_tip_barrier_wallets: std::collections::BTreeSet::new(), + unattributed_tip_barrier_damage: false, }; store.rehydrate_pending_spends()?; + store.load_tip_barriers()?; Ok(store) } @@ -396,6 +428,103 @@ impl FileBackedShieldedStore { Ok(()) } + /// Load `shielded_tip_barriers` into memory. An unreadable row is kept + /// on disk and marks its wallet (or, if its wallet id is unreadable, + /// every wallet) damaged rather than failing the open. + fn load_tip_barriers(&mut self) -> Result<(), FileShieldedStoreError> { + let conn = self.pending_conn.lock().expect("pending_conn mutex"); + let mut stmt = conn + .prepare( + "SELECT wallet_id, activity_id, account_index, recipient, nullifiers, anchor, \ + status FROM shielded_tip_barriers", + ) + .map_err(|e| FileShieldedStoreError::Storage(format!("prepare tip barriers: {e}")))?; + let rows = stmt + .query_map([], |row| { + Ok(( + row.get::<_, Vec>(0).ok(), + (|| { + Ok::<_, rusqlite::Error>(( + row.get::<_, Vec>(1)?, + row.get::<_, u32>(2)?, + row.get::<_, Vec>(3)?, + row.get::<_, Vec>(4)?, + row.get::<_, Vec>(5)?, + row.get::<_, i64>(6)?, + )) + })() + .ok(), + )) + }) + .map_err(|e| FileShieldedStoreError::Storage(format!("query tip barriers: {e}")))?; + for row in rows { + let (wallet_id, fields) = + row.map_err(|e| FileShieldedStoreError::Storage(format!("read tip barrier: {e}")))?; + let wallet_id: Option = wallet_id.and_then(|w| w.try_into().ok()); + let barrier = fields.and_then( + |(activity_id, account_index, recipient, nullifiers, anchor, status)| { + if nullifiers.is_empty() || nullifiers.len() % 32 != 0 { + return None; + } + Some(TipBarrier { + activity_id: activity_id.try_into().ok()?, + account_index, + recipient: recipient.try_into().ok()?, + nullifiers: nullifiers.as_chunks::<32>().0.to_vec(), + anchor: anchor.try_into().ok()?, + status: tip_barrier_status(status)?, + }) + }, + ); + match (wallet_id, barrier) { + (Some(wallet_id), Some(barrier)) => { + self.tip_barriers + .insert((wallet_id, barrier.activity_id), barrier); + } + (Some(wallet_id), None) => { + tracing::warn!("unreadable shielded tip barrier; blocking the wallet's tips"); + self.damaged_tip_barrier_wallets.insert(wallet_id); + } + (None, _) => { + tracing::warn!("shielded tip barrier with an unreadable wallet; blocking tips"); + self.unattributed_tip_barrier_damage = true; + } + } + } + Ok(()) + } + + /// Write `status` for `wallet_id`'s barrier `activity_id` (unless it is + /// executed already): durably first, then in memory. + fn set_tip_barrier_status( + &mut self, + wallet_id: WalletId, + activity_id: &[u8; 32], + status: TipBarrierStatus, + ) -> Result<(), FileShieldedStoreError> { + match self.tip_barriers.get(&(wallet_id, *activity_id)) { + Some(barrier) if barrier.status != TipBarrierStatus::Executed => {} + _ => return Ok(()), + } + self.pending_conn + .lock() + .expect("pending_conn mutex") + .execute( + "UPDATE shielded_tip_barriers SET status = ?3 \ + WHERE wallet_id = ?1 AND activity_id = ?2", + rusqlite::params![ + wallet_id.as_slice(), + activity_id.as_slice(), + tip_barrier_status_code(status) + ], + ) + .map_err(|e| FileShieldedStoreError::Storage(format!("settle tip barrier: {e}")))?; + if let Some(barrier) = self.tip_barriers.get_mut(&(wallet_id, *activity_id)) { + barrier.status = status; + } + Ok(()) + } + /// Identify only transition families whose funding comes from shielded /// note nullifiers. A complete outer transition decode is required so /// corrupt, unknown, or identity-funded bytes remain fail-closed. @@ -521,6 +650,27 @@ impl FileBackedShieldedStore { } } +/// The `status` column value of a [`TipBarrierStatus`]. +fn tip_barrier_status_code(status: TipBarrierStatus) -> i64 { + match status { + TipBarrierStatus::Pending => 0, + TipBarrierStatus::Executed => 1, + TipBarrierStatus::Failed => 2, + TipBarrierStatus::Abandoned => 3, + } +} + +/// The [`TipBarrierStatus`] of a `status` column value. +fn tip_barrier_status(code: i64) -> Option { + match code { + 0 => Some(TipBarrierStatus::Pending), + 1 => Some(TipBarrierStatus::Executed), + 2 => Some(TipBarrierStatus::Failed), + 3 => Some(TipBarrierStatus::Abandoned), + _ => None, + } +} + impl ShieldedStore for FileBackedShieldedStore { type Error = FileShieldedStoreError; @@ -558,6 +708,16 @@ impl ShieldedStore for FileBackedShieldedStore { } fn mark_spent(&mut self, id: SubwalletId, nullifier: &[u8; 32]) -> Result { + // Settle the tip barrier before the recovery row below goes, so a + // crash between them never loses an executed tip's record. A failed + // write leaves it pending (fail closed). + for activity_id in tip_barriers_spending(&self.tip_barriers, id.wallet_id, nullifier) { + if let Err(e) = + self.set_tip_barrier_status(id.wallet_id, &activity_id, TipBarrierStatus::Executed) + { + tracing::warn!(error = %e, "tip barrier settlement not persisted; it stays pending"); + } + } let Some(sw) = self.subwallets.get_mut(&id) else { return Ok(false); }; @@ -916,14 +1076,143 @@ impl ShieldedStore for FileBackedShieldedStore { .map_err(|e| { FileShieldedStoreError::Storage(format!("purge tip recipients for wallet: {e}")) })?; + conn.execute( + "DELETE FROM shielded_tip_barriers WHERE wallet_id = ?1", + rusqlite::params![wallet_id.as_slice()], + ) + .map_err(|e| { + FileShieldedStoreError::Storage(format!("purge tip barriers for wallet: {e}")) + })?; } // Per-subwallet note / watermark / checkpoint state is // in-memory only (`subwallets`); the commitment tree in // SQLite is chain-wide and intentionally left intact. self.subwallets.retain(|id, _| id.wallet_id != wallet_id); + self.tip_barriers + .retain(|(wallet, _), _| *wallet != wallet_id); + self.damaged_tip_barrier_wallets.remove(&wallet_id); + Ok(()) + } + + fn record_tip_barrier( + &mut self, + wallet_id: WalletId, + barrier: &TipBarrier, + ) -> Result<(), Self::Error> { + if barrier.nullifiers.is_empty() { + return Err(FileShieldedStoreError::Storage( + "a tip barrier must name the notes it spends".to_string(), + )); + } + let status = tip_barrier_status_code(barrier.status); + let conn = self.pending_conn.lock().expect("pending_conn mutex"); + conn.execute( + "DELETE FROM shielded_tip_barriers \ + WHERE wallet_id = ?1 AND recipient = ?2 AND status != 0", + rusqlite::params![wallet_id.as_slice(), barrier.recipient.as_slice()], + ) + .map_err(|e| FileShieldedStoreError::Storage(format!("prune tip barriers: {e}")))?; + conn.execute( + "INSERT OR REPLACE INTO shielded_tip_barriers \ + (wallet_id, activity_id, account_index, recipient, nullifiers, anchor, status) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![ + wallet_id.as_slice(), + barrier.activity_id.as_slice(), + barrier.account_index, + barrier.recipient.as_slice(), + barrier.nullifiers.concat(), + barrier.anchor.as_slice(), + status + ], + ) + .map_err(|e| FileShieldedStoreError::Storage(format!("write tip barrier: {e}")))?; + drop(conn); + self.tip_barriers.retain(|(wallet, _), existing| { + *wallet != wallet_id + || existing.recipient != barrier.recipient + || existing.status == TipBarrierStatus::Pending + }); + self.tip_barriers + .insert((wallet_id, barrier.activity_id), barrier.clone()); Ok(()) } + fn tip_barriers(&self, wallet_id: WalletId) -> Result, Self::Error> { + if self.unattributed_tip_barrier_damage + || self.damaged_tip_barrier_wallets.contains(&wallet_id) + { + return Err(FileShieldedStoreError::Storage( + "a stored tip barrier is unreadable".to_string(), + )); + } + Ok(self + .tip_barriers + .iter() + .filter(|((wallet, _), _)| *wallet == wallet_id) + .map(|(_, barrier)| barrier.clone()) + .collect()) + } + + fn has_pending_tip_barriers(&self) -> Result { + Ok(self.unattributed_tip_barrier_damage + || !self.damaged_tip_barrier_wallets.is_empty() + || self + .tip_barriers + .values() + .any(|barrier| barrier.status == TipBarrierStatus::Pending)) + } + + fn settle_tip_barrier( + &mut self, + wallet_id: WalletId, + activity_id: &[u8; 32], + status: TipBarrierStatus, + ) -> Result<(), Self::Error> { + self.set_tip_barrier_status(wallet_id, activity_id, status) + } + + fn abandon_tip_barriers(&mut self, wallet_id: WalletId) -> Result { + let conn = self.pending_conn.lock().expect("pending_conn mutex"); + let abandoned = conn + .execute( + "UPDATE shielded_tip_barriers SET status = 3 WHERE wallet_id = ?1 AND status = 0", + rusqlite::params![wallet_id.as_slice()], + ) + .map_err(|e| FileShieldedStoreError::Storage(format!("abandon tip barriers: {e}")))?; + // Unreadable rows cannot be settled; the acknowledgement covers them. + if self.damaged_tip_barrier_wallets.contains(&wallet_id) { + conn.execute( + "DELETE FROM shielded_tip_barriers WHERE wallet_id = ?1 AND NOT (\ + status IN (0, 1, 2, 3) AND length(activity_id) = 32 \ + AND length(recipient) = 43 AND length(anchor) = 32 \ + AND length(nullifiers) > 0 AND length(nullifiers) % 32 = 0)", + rusqlite::params![wallet_id.as_slice()], + ) + .map_err(|e| { + FileShieldedStoreError::Storage(format!("drop damaged tip barriers: {e}")) + })?; + } + if self.unattributed_tip_barrier_damage { + conn.execute( + "DELETE FROM shielded_tip_barriers WHERE length(wallet_id) != 32", + [], + ) + .map_err(|e| { + FileShieldedStoreError::Storage(format!("drop damaged tip barriers: {e}")) + })?; + } + drop(conn); + self.damaged_tip_barrier_wallets.remove(&wallet_id); + self.unattributed_tip_barrier_damage = false; + for ((wallet, _), barrier) in self.tip_barriers.iter_mut() { + if *wallet == wallet_id && barrier.status == TipBarrierStatus::Pending { + barrier.status = TipBarrierStatus::Abandoned; + } + } + Ok(abandoned) + } + fn confirmed_tip_recipient( &self, wallet_id: WalletId, @@ -1552,6 +1841,110 @@ mod tests { let _ = std::fs::remove_file(&path); } + /// A tip barrier survives a reopen and the purges that rebuild account + /// state, settles durably when its note is seen spent or its own send + /// failed definitively (a released reservation does not settle it), goes + /// with its wallet, and an unreadable row blocks only its wallet until the + /// user abandons that wallet's unresolved tips. + #[test] + fn tip_barriers_settle_durably_and_outlive_account_purges() { + let path = temp_tree_path("tip_barriers"); + let wallet = [0xC1; 32]; + let id = SubwalletId::new(wallet, 0); + let barrier = |activity: u8, nullifier: u8| TipBarrier { + activity_id: [activity; 32], + account_index: 0, + recipient: [7; 43], + nullifiers: vec![[nullifier; 32]], + anchor: [9; 32], + status: TipBarrierStatus::Pending, + }; + let statuses = |store: &FileBackedShieldedStore| { + let mut statuses: Vec<_> = store + .tip_barriers(wallet) + .unwrap() + .into_iter() + .map(|b| (b.activity_id[0], b.status)) + .collect(); + statuses.sort_by_key(|(activity, _)| *activity); + statuses + }; + { + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); + store.record_tip_barrier(wallet, &barrier(1, 0x11)).unwrap(); + store.record_tip_barrier(wallet, &barrier(2, 0x22)).unwrap(); + store.purge_subwallet(id).unwrap(); + store.purge_all_subwallets().unwrap(); + assert!(store.has_pending_tip_barriers().unwrap()); + store.mark_spent(id, &[0x11; 32]).unwrap(); + store.clear_pending(id, &[0x22; 32]).unwrap(); + assert!(store.has_pending_tip_barriers().unwrap()); + store + .settle_tip_barrier(wallet, &[2; 32], TipBarrierStatus::Failed) + .unwrap(); + // An executed tip stays executed. + store + .settle_tip_barrier(wallet, &[1; 32], TipBarrierStatus::Failed) + .unwrap(); + } + { + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("reopen"); + assert_eq!( + statuses(&store), + vec![ + (1, TipBarrierStatus::Executed), + (2, TipBarrierStatus::Failed) + ] + ); + assert!(!store.has_pending_tip_barriers().unwrap()); + // A new tip to the recipient replaces its settled rows. + store.record_tip_barrier(wallet, &barrier(3, 0x33)).unwrap(); + assert_eq!(statuses(&store), vec![(3, TipBarrierStatus::Pending)]); + assert!(store + .record_tip_barrier( + wallet, + &TipBarrier { + nullifiers: vec![], + ..barrier(4, 0x44) + } + ) + .is_err()); + store + .record_tip_barrier([0xC2; 32], &barrier(5, 0x55)) + .unwrap(); + store + .pending_conn + .lock() + .unwrap() + .execute( + "UPDATE shielded_tip_barriers SET status = 9 WHERE activity_id = ?1", + [[3u8; 32].as_slice()], + ) + .unwrap(); + } + { + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("reopen damaged"); + assert!(store.tip_barriers(wallet).is_err()); + assert_eq!(store.tip_barriers([0xC2; 32]).unwrap().len(), 1); + assert!(store.has_pending_tip_barriers().unwrap()); + store.abandon_tip_barriers([0xC2; 32]).unwrap(); + assert!(store.tip_barriers(wallet).is_err()); + assert_eq!(store.abandon_tip_barriers(wallet).unwrap(), 0); + assert!(store.tip_barriers(wallet).unwrap().is_empty()); + assert!(!store.has_pending_tip_barriers().unwrap()); + store.record_tip_barrier(wallet, &barrier(6, 0x66)).unwrap(); + store.purge_wallet(wallet).unwrap(); + } + let store = FileBackedShieldedStore::open_path(&path, 100).expect("reopen purged"); + assert!(store.tip_barriers(wallet).unwrap().is_empty()); + assert_eq!( + store.tip_barriers([0xC2; 32]).unwrap()[0].status, + TipBarrierStatus::Abandoned + ); + drop(store); + let _ = std::fs::remove_file(&path); + } + /// Confirmed tip recipients survive a reopen and a shielded Clear, are /// kept per wallet, and go with their wallet's purge. #[test] diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index a021a2b237a..77d7ed358e1 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -28,12 +28,16 @@ use super::note_selection::{ ensure_allowed_denomination, ensure_positive_amount, select_notes_for_denomination, select_notes_with_fee, ShieldedFeeKind, }; -use super::store::{PendingRedrive, ShieldedNote, ShieldedStore, SubwalletId}; +use super::store::{ + PendingRedrive, ShieldedNote, ShieldedStore, SubwalletId, TipBarrier, TipBarrierStatus, +}; use crate::broadcast_outcome::{broadcast_definitely_failed, carries_consensus_rejection}; use crate::changeset::{PlatformWalletChangeSet, ShieldedChangeSet}; use crate::error::{preserve_signer_key_unavailable_or, PlatformWalletError}; use crate::wallet::persister::WalletPersister; use crate::wallet::platform_wallet::WalletId; +use dash_sdk::platform::transition::put_settings::PutSettings; +use dash_sdk::RequestSettings; use std::collections::{BTreeMap, HashSet}; use std::sync::Arc; @@ -1876,6 +1880,7 @@ pub(crate) async fn transfer_with( persister, id, &pending_entry, + &to_address.to_raw_address_bytes(), anchor_bytes, &selected_notes, &state_transition, @@ -1943,6 +1948,19 @@ pub(crate) async fn transfer_with( None, ) .await; + // The guarded broadcast reports a failure only when it is + // definitive (or raised before the broadcast), so the tip's + // barrier, if one was recorded, settles here. A failed write + // leaves it pending (fail closed). + if let (true, Some(entry)) = (guard_before_broadcast, &pending_entry) { + if let Err(e) = store.write().await.settle_tip_barrier( + wallet_id, + &entry.id, + TipBarrierStatus::Failed, + ) { + warn!(error = %e, "tip barrier failure not persisted; it stays pending"); + } + } cancel_pending(store, id, &selected_notes).await; Err(e) } @@ -3079,16 +3097,19 @@ async fn arm_redrive_record( } } -/// Record a note spend as an unresolved payment before its broadcast: flush -/// the pending activity row to the host, then persist a parked recovery -/// record (re-drive budget spent, so only this spend's own outcome or the -/// scan and prune passes settle it). Fails closed: a spend that cannot be -/// recorded is not broadcast. +/// Record a tip as an unresolved payment before its broadcast: flush the +/// pending activity row to the host, durably record its [`TipBarrier`], and +/// arm a parked recovery record (re-drive budget spent, so only this spend's +/// own outcome or the scan and prune passes settle it) that keeps its notes +/// reserved across a restart. Fails closed: a spend that cannot be recorded +/// is not broadcast. +#[allow(clippy::too_many_arguments)] async fn arm_parked_spend_guard( store: &Arc>, persister: Option<&WalletPersister>, id: SubwalletId, pending_entry: &Option, + recipient: &[u8; 43], anchor: [u8; 32], notes: &[ShieldedNote], state_transition: &StateTransition, @@ -3108,15 +3129,28 @@ async fn arm_parked_spend_guard( let st_bytes = state_transition.serialize_to_bytes().map_err(|e| { PlatformWalletError::ShieldedBuildError(format!("serialize transfer guard: {e}")) })?; + let nullifiers: Vec<[u8; 32]> = notes.iter().map(|n| n.nullifier).collect(); + let mut store = store.write().await; + store + .record_tip_barrier( + id.wallet_id, + &TipBarrier { + activity_id: entry.id, + account_index: id.account_index, + recipient: *recipient, + nullifiers: nullifiers.clone(), + anchor, + status: TipBarrierStatus::Pending, + }, + ) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; store - .write() - .await .arm_redrive( id, PendingRedrive { activity_id: entry.id, anchor, - nullifiers: notes.iter().map(|n| n.nullifier).collect(), + nullifiers, st_bytes, attempts: MAX_REDRIVE_ATTEMPTS, identity_nonce_finalized: false, @@ -3126,8 +3160,24 @@ async fn arm_parked_spend_guard( .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string())) } -/// Broadcast a transfer whose parked guard is armed. An ambiguous result -/// re-arms the guard for re-drive (see [`activate_parked_guard`]). +/// Whether a tip's single-attempt broadcast certainly did not, and never +/// will, execute: only a consensus rejection from the one node it reached +/// proves that (nothing was sent when no address was available at all). A +/// transport error such as `Unavailable` can follow a delivered request, and +/// `NullifierAlreadySpent` means it executed, so both stay unresolved and +/// keep the tip's barrier. +fn tip_broadcast_definitely_failed(e: &dash_sdk::Error) -> bool { + use dash_sdk::dapi_client::DapiClientError; + (carries_consensus_rejection(e) && !is_nullifier_already_spent(e)) + || matches!( + e, + dash_sdk::Error::DapiClientError(DapiClientError::NoAvailableAddresses) + ) +} + +/// Broadcast a transfer whose parked guard is armed. Anything short of a +/// definitive rejection is unresolved and re-arms the guard for re-drive +/// (see [`activate_parked_guard`]). async fn broadcast_guarded_transfer( sdk: &Arc, store: &Arc>, @@ -3135,11 +3185,23 @@ async fn broadcast_guarded_transfer( pending_entry: &Option, state_transition: &StateTransition, ) -> Result { - let result = broadcast_shielded_spend( + // One node, no retries: a consensus rejection then speaks for the only + // node that received the tip, so it cannot hide a copy another node + // admitted. + let single_attempt = PutSettings { + request_settings: RequestSettings { + retries: Some(0), + ..Default::default() + }, + ..Default::default() + }; + let result = broadcast_shielded_spend_classified( sdk, state_transition, "transfer", SpendResultWait::ExecutionProved, + tip_broadcast_definitely_failed, + Some(single_attempt), ) .await .map(|(result, _)| result); @@ -3527,9 +3589,31 @@ async fn broadcast_shielded_spend( operation: &'static str, wait: SpendResultWait, ) -> Result<(StateTransitionProofResult, ResponseMetadata), PlatformWalletError> { - match state_transition.broadcast(sdk, None).await { + broadcast_shielded_spend_classified( + sdk, + state_transition, + operation, + wait, + broadcast_definitely_failed, + None, + ) + .await +} + +/// [`broadcast_shielded_spend`] with the rule for a broadcast error that +/// proves the spend never executed, and the broadcast settings, supplied by +/// the caller. +async fn broadcast_shielded_spend_classified( + sdk: &Arc, + state_transition: &StateTransition, + operation: &'static str, + wait: SpendResultWait, + definitely_failed: fn(&dash_sdk::Error) -> bool, + settings: Option, +) -> Result<(StateTransitionProofResult, ResponseMetadata), PlatformWalletError> { + match state_transition.broadcast(sdk, settings).await { Ok(()) => {} - Err(e) if broadcast_definitely_failed(&e) => { + Err(e) if definitely_failed(&e) => { return Err(crate::error::promote_address_nonce_error(&e) .unwrap_or_else(|| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))); } @@ -4257,6 +4341,44 @@ mod classify_spend_wait_failure_tests { ))) } + /// A tip's broadcast is definitive only on a consensus rejection, or + /// when no address was available at all. `Unavailable` and other + /// transport errors can follow a delivered request, and + /// `NullifierAlreadySpent` means an earlier attempt executed: both keep + /// the tip's barrier. + #[test] + fn tip_broadcast_is_definitive_only_on_a_consensus_rejection() { + use dash_sdk::dapi_client::DapiClientError; + use dash_sdk::dapi_grpc::tonic::Code; + use dpp::consensus::state::shielded::nullifier_already_spent_error::NullifierAlreadySpentError; + use dpp::consensus::state::state_error::StateError; + let rejected = broadcast_err(Some(ConsensusError::BasicError( + BasicError::ProtocolVersionParsingError(ProtocolVersionParsingError::new( + "bad version".to_string(), + )), + ))); + assert!(tip_broadcast_definitely_failed(&rejected)); + assert!(tip_broadcast_definitely_failed( + &dash_sdk::Error::DapiClientError(DapiClientError::NoAvailableAddresses) + )); + let already_spent = broadcast_err(Some(ConsensusError::StateError( + StateError::NullifierAlreadySpentError(NullifierAlreadySpentError::new([1; 32])), + ))); + for unresolved in [ + grpc_err(Code::Unavailable), + grpc_err(Code::DeadlineExceeded), + broadcast_err(None), + already_spent, + ] { + assert!( + !tip_broadcast_definitely_failed(&unresolved), + "{unresolved}" + ); + } + // The ordinary spend rule still releases notes on `Unavailable`. + assert!(broadcast_definitely_failed(&grpc_err(Code::Unavailable))); + } + /// Connection-establishment failures and non-consensus server /// rejections of `broadcast()` ARE definitive: nothing was admitted to /// a mempool. The offline case (connect refused → `Unavailable`) in @@ -4925,6 +5047,18 @@ mod parked_spend_guard_tests { use dpp::state_transition::shielded_transfer_transition::v0::ShieldedTransferTransitionV0; use dpp::state_transition::shielded_transfer_transition::ShieldedTransferTransition; + fn spent_note() -> ShieldedNote { + ShieldedNote { + position: 0, + cmx: [8; 32], + nullifier: [5; 32], + block_height: 0, + is_spent: false, + value: 1_000, + note_data: vec![], + } + } + fn transfer() -> StateTransition { StateTransition::ShieldedTransfer(ShieldedTransferTransition::V0( ShieldedTransferTransitionV0 { @@ -4964,19 +5098,42 @@ mod parked_spend_guard_tests { let id = SubwalletId::new([1; 32], 0); let entry = sent_entry(); - let refused = - arm_parked_spend_guard(&store, None, id, &None, [3; 32], &[], &transfer()).await; + let refused = arm_parked_spend_guard( + &store, + None, + id, + &None, + &[4; 43], + [3; 32], + &[spent_note()], + &transfer(), + ) + .await; assert!(refused.is_err()); assert!(store.read().await.pending_redrives(id).unwrap().is_empty()); - arm_parked_spend_guard(&store, None, id, &Some(entry), [3; 32], &[], &transfer()) - .await - .unwrap(); + arm_parked_spend_guard( + &store, + None, + id, + &Some(entry), + &[4; 43], + [3; 32], + &[spent_note()], + &transfer(), + ) + .await + .unwrap(); let armed = store.read().await.pending_redrives(id).unwrap(); assert_eq!(armed.len(), 1); assert_eq!(armed[0].activity_id, [2; 32]); assert_eq!(armed[0].anchor, [3; 32]); assert_eq!(armed[0].attempts, MAX_REDRIVE_ATTEMPTS); + let barriers = store.read().await.tip_barriers(id.wallet_id).unwrap(); + assert_eq!(barriers.len(), 1); + assert_eq!(barriers[0].status, TipBarrierStatus::Pending); + assert_eq!(barriers[0].recipient, [4; 43]); + assert_eq!(barriers[0].nullifiers, vec![[5; 32]]); } /// An ambiguous result opens a still-armed guard to the re-drive, and @@ -4990,9 +5147,18 @@ mod parked_spend_guard_tests { activate_parked_guard(&store, id, &entry).await; assert!(store.read().await.pending_redrives(id).unwrap().is_empty()); - arm_parked_spend_guard(&store, None, id, &entry, [3; 32], &[], &transfer()) - .await - .unwrap(); + arm_parked_spend_guard( + &store, + None, + id, + &entry, + &[4; 43], + [3; 32], + &[spent_note()], + &transfer(), + ) + .await + .unwrap(); activate_parked_guard(&store, id, &entry).await; let armed = store.read().await.pending_redrives(id).unwrap(); assert_eq!(armed.len(), 1); diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index 59635ff0512..6f4db525895 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -136,6 +136,57 @@ pub type StalePendingSpend = ([u8; 32], [u8; 32], Option<[u8; 32]>); /// A confirmed tip recipient: `(identity id, raw Orchard address)`. pub type ConfirmedTipRecipient = ([u8; 32], [u8; 43]); +/// What a tip's payment barrier knows about its outcome. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TipBarrierStatus { + /// Recorded before broadcast; the payment may execute. + Pending, + /// One of its notes was seen spent: the payment executed. + Executed, + /// Its own send saw a definitive rejection: a consensus verdict from + /// the single node it was sent to, or the committed result. + Failed, + /// The user accepted that it may still execute and allowed new tips. + Abandoned, +} + +/// The durable record of one shielded tip, kept apart from note and +/// recovery state so that rebuilding those (an account purge, a Clear, a +/// scan) cannot lose it. A tip to `recipient` is refused while a barrier for +/// it is [`TipBarrierStatus::Pending`]. [`ShieldedStore::mark_spent`] settles +/// it as executed in the same step that resolves its notes. Nothing a node +/// reports later (a re-drive rejection, an anchor missing from one node's +/// set) settles it as failed: only its own send's definitive rejection +/// ([`ShieldedStore::settle_tip_barrier`]) or the user's acknowledgement +/// ([`ShieldedStore::abandon_tip_barriers`]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TipBarrier { + pub activity_id: [u8; 32], + pub account_index: u32, + pub recipient: [u8; 43], + pub nullifiers: Vec<[u8; 32]>, + pub anchor: [u8; 32], + pub status: TipBarrierStatus, +} + +/// The activity ids of `wallet_id`'s barriers spending `nullifier` that a +/// spent note settles as executed: every one not executed already. +pub(super) fn tip_barriers_spending( + barriers: &BTreeMap<(WalletId, [u8; 32]), TipBarrier>, + wallet_id: WalletId, + nullifier: &[u8; 32], +) -> Vec<[u8; 32]> { + barriers + .iter() + .filter(|((wallet, _), barrier)| { + *wallet == wallet_id + && barrier.status != TipBarrierStatus::Executed + && barrier.nullifiers.contains(nullifier) + }) + .map(|((_, activity_id), _)| *activity_id) + .collect() +} + /// An unresolved signed transition and its durable retry guard: the signed /// transition bytes plus everything the sync-time re-drive needs to /// resolve the ambiguity actively — re-broadcast the transition @@ -491,6 +542,38 @@ pub trait ShieldedStore: Send + Sync { /// rather than resuming behind the stale watermark. fn purge_wallet(&mut self, wallet_id: WalletId) -> Result<(), Self::Error>; + // ── Tip payment barriers (per wallet) ────────────────────────────── + + /// Durably record `barrier` before its tip is broadcast. Replaces any + /// settled barrier of the same wallet and recipient, so the table holds + /// at most one settled row per recipient. + fn record_tip_barrier( + &mut self, + wallet_id: WalletId, + barrier: &TipBarrier, + ) -> Result<(), Self::Error>; + + /// Every tip barrier of `wallet_id`. Fails when a stored barrier is + /// unreadable, so callers fail closed. + fn tip_barriers(&self, wallet_id: WalletId) -> Result, Self::Error>; + + /// Whether any wallet holds a pending or unreadable tip barrier. + fn has_pending_tip_barriers(&self) -> Result; + + /// Set the status of `wallet_id`'s barrier `activity_id`, if present. An + /// executed barrier stays executed. + fn settle_tip_barrier( + &mut self, + wallet_id: WalletId, + activity_id: &[u8; 32], + status: TipBarrierStatus, + ) -> Result<(), Self::Error>; + + /// Mark every pending barrier of `wallet_id` abandoned, and drop its + /// unreadable ones, after the user accepted those tips may still execute. + /// Returns how many pending barriers it abandoned. + fn abandon_tip_barriers(&mut self, wallet_id: WalletId) -> Result; + // ── Confirmed tip recipients (per wallet) ────────────────────────── /// The recipient `(identity id, raw Orchard address)` the user last @@ -880,6 +963,8 @@ pub struct InMemoryShieldedStore { anchor: [u8; 32], /// Confirmed tip recipients by `(wallet, normalized label)`. tip_recipients: BTreeMap<(WalletId, String), ConfirmedTipRecipient>, + /// Tip payment barriers by `(wallet, activity id)`. + tip_barriers: BTreeMap<(WalletId, [u8; 32]), TipBarrier>, } impl InMemoryShieldedStore { @@ -921,6 +1006,11 @@ impl ShieldedStore for InMemoryShieldedStore { } fn mark_spent(&mut self, id: SubwalletId, nullifier: &[u8; 32]) -> Result { + for activity_id in tip_barriers_spending(&self.tip_barriers, id.wallet_id, nullifier) { + if let Some(barrier) = self.tip_barriers.get_mut(&(id.wallet_id, activity_id)) { + barrier.status = TipBarrierStatus::Executed; + } + } // In-memory store: the redrive map lives in the same // `SubwalletState`, so `mark_spent` already dropped any resolved // redrives — nothing durable to mirror. Surface `newly_spent`. @@ -1142,9 +1232,72 @@ impl ShieldedStore for InMemoryShieldedStore { self.subwallets.retain(|id, _| id.wallet_id != wallet_id); self.tip_recipients .retain(|(wallet, _), _| *wallet != wallet_id); + self.tip_barriers + .retain(|(wallet, _), _| *wallet != wallet_id); Ok(()) } + fn record_tip_barrier( + &mut self, + wallet_id: WalletId, + barrier: &TipBarrier, + ) -> Result<(), Self::Error> { + if barrier.nullifiers.is_empty() { + return Err(InMemoryStoreError( + "a tip barrier must name the notes it spends".to_string(), + )); + } + self.tip_barriers.retain(|(wallet, _), existing| { + *wallet != wallet_id + || existing.recipient != barrier.recipient + || existing.status == TipBarrierStatus::Pending + }); + self.tip_barriers + .insert((wallet_id, barrier.activity_id), barrier.clone()); + Ok(()) + } + + fn tip_barriers(&self, wallet_id: WalletId) -> Result, Self::Error> { + Ok(self + .tip_barriers + .iter() + .filter(|((wallet, _), _)| *wallet == wallet_id) + .map(|(_, barrier)| barrier.clone()) + .collect()) + } + + fn has_pending_tip_barriers(&self) -> Result { + Ok(self + .tip_barriers + .values() + .any(|barrier| barrier.status == TipBarrierStatus::Pending)) + } + + fn settle_tip_barrier( + &mut self, + wallet_id: WalletId, + activity_id: &[u8; 32], + status: TipBarrierStatus, + ) -> Result<(), Self::Error> { + if let Some(barrier) = self.tip_barriers.get_mut(&(wallet_id, *activity_id)) { + if barrier.status != TipBarrierStatus::Executed { + barrier.status = status; + } + } + Ok(()) + } + + fn abandon_tip_barriers(&mut self, wallet_id: WalletId) -> Result { + let mut abandoned = 0; + for ((wallet, _), barrier) in self.tip_barriers.iter_mut() { + if *wallet == wallet_id && barrier.status == TipBarrierStatus::Pending { + barrier.status = TipBarrierStatus::Abandoned; + abandoned += 1; + } + } + Ok(abandoned) + } + fn confirmed_tip_recipient( &self, wallet_id: WalletId, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/tips.rs b/packages/rs-platform-wallet/src/wallet/shielded/tips.rs index c27a6b49555..1346d3ea00a 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/tips.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/tips.rs @@ -8,15 +8,13 @@ //! reconstructs it even after publication was removed. Address rotation uses //! another diversifier in the same account, so all old addresses remain covered. -use super::store::ShieldedStore; +use super::store::{ShieldedStore, TipBarrierStatus}; use super::NetworkShieldedCoordinator; use crate::wallet::platform_wallet::{PlatformWallet, WalletId}; use crate::{PlatformWalletError, ShieldedTipRecipient}; use dpp::balances::credits::CREDITS_PER_DUFF; use dpp::identity::accessors::IdentityGettersV0; use dpp::prelude::Identifier; -use dpp::serialization::PlatformDeserializableTrusted; -use dpp::state_transition::StateTransition; use grovedb_commitment_tree::PaymentAddress; use std::sync::{Arc, PoisonError}; @@ -105,34 +103,23 @@ impl ShieldedTipDisposition { } } -/// Whether `wallet_id` holds an unresolved shielded transfer to `address`: -/// one recorded before broadcast whose outcome is not yet known. A transfer -/// whose activity row is missing names no recipient and counts as unresolved -/// for every address. +/// Whether `wallet_id` holds a pending tip to `address`: one recorded +/// before broadcast whose outcome is not yet settled. An unreadable barrier +/// fails the check rather than letting another payment through. fn has_unresolved_transfer_to( store: &S, wallet_id: WalletId, address: &[u8; 43], ) -> Result { - let redrives = store - .pending_redrives_for_wallet(wallet_id) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; - for (id, redrive) in redrives { - if !matches!( - StateTransition::deserialize_from_bytes_trusted(&redrive.st_bytes), - Ok(StateTransition::ShieldedTransfer(_)) - ) { - continue; + let barriers = store.tip_barriers(wallet_id).map_err(|e| { + PlatformWalletError::ShieldedRecoveryCorrupted { + account_index: None, + reason: e.to_string(), } - let entry = store - .get_activity_by_entry_id(id, &redrive.activity_id) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; - match entry.and_then(|entry| entry.counterparty) { - Some(counterparty) if counterparty.as_slice() != address.as_slice() => {} - _ => return Ok(true), - } - } - Ok(false) + })?; + Ok(barriers.iter().any(|barrier| { + barrier.status == TipBarrierStatus::Pending && barrier.recipient == *address + })) } /// Credits in one DASH: 10^8 duffs of [`CREDITS_PER_DUFF`] credits each. @@ -533,6 +520,36 @@ impl PlatformWallet { }) } + /// Allow new tips although earlier ones from this wallet are unresolved: + /// their outcome is unknown, and nothing short of seeing their notes spent + /// proves they will not execute. Requires the user's acknowledgement that + /// each may still execute; refused while a tip is being sent. Also clears + /// unreadable tip records of this wallet. Returns how many unresolved tips + /// it released. + pub async fn abandon_unresolved_shielded_tips( + &self, + coordinator: &Arc, + acknowledge_possible_execution: bool, + ) -> Result { + if !acknowledge_possible_execution { + return Err(PlatformWalletError::InvalidParameter( + "allowing new tips requires acknowledging that the unresolved ones may \ + still execute" + .to_string(), + )); + } + let _sending = self + .tip_send_in_progress + .try_lock() + .map_err(|_| PlatformWalletError::ShieldedTipPending)?; + coordinator + .store() + .write() + .await + .abandon_tip_barriers(self.wallet_id()) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string())) + } + /// Send only to the identity/address pair that the user confirmed. A changed /// name owner or profile requires a new confirmation; there is no fallback to /// a transparent rail. Network changes after this check cannot change the @@ -558,6 +575,9 @@ impl PlatformWallet { .try_lock() .map_err(|_| PlatformWalletError::ShieldedTipPending)?; let _placement = self.identity.identity_placement.read().await; + // A bind cannot drop or re-key the paying account while its tip is + // between note selection and recording its barrier. + let _config = self.shielded_config_lock.lock().await; let account = self.tip_source_account(seed, source).await?; let current = self .identity() @@ -655,15 +675,9 @@ pub(crate) mod test_hooks { #[cfg(test)] mod tests { - use super::super::activity::{ - ShieldedActivityEntry, ShieldedActivityKind, ShieldedActivityStatus, ShieldedDirection, - }; - use super::super::store::{InMemoryShieldedStore, PendingRedrive, SubwalletId}; + use super::super::store::{InMemoryShieldedStore, SubwalletId, TipBarrier}; use super::super::OrchardKeySet; use super::*; - use dpp::serialization::PlatformSerializable; - use dpp::state_transition::shielded_transfer_transition::v0::ShieldedTransferTransitionV0; - use dpp::state_transition::shielded_transfer_transition::ShieldedTransferTransition; use key_wallet::Network; #[test] @@ -744,56 +758,25 @@ mod tests { } } - /// A transfer recorded before broadcast blocks another payment to its - /// recipient until it settles. Without its activity row the recipient is - /// unknown, so it blocks every recipient; records of other spend kinds - /// and other wallets do not count. + /// A pending tip blocks another payment to its recipient, also through + /// the address last confirmed for the name, until the store settles it. + /// Other wallets and settled tips do not count. #[test] - fn should_treat_a_recorded_transfer_as_unresolved_for_its_recipient() { + fn should_treat_a_pending_tip_as_unresolved_for_its_recipient() { let wallet_id = [1; 32]; let id = SubwalletId::new(wallet_id, 0); - let transfer = StateTransition::ShieldedTransfer(ShieldedTransferTransition::V0( - ShieldedTransferTransitionV0 { - actions: vec![], - value_balance: 0, - anchor: [0; 32], - proof: vec![], - binding_signature: [0; 64], - }, - )) - .serialize_to_bytes() - .unwrap(); - let record = |st_bytes: Vec| PendingRedrive { - activity_id: [2; 32], - anchor: [0; 32], - nullifiers: vec![[3; 32]], - st_bytes, - attempts: 0, - identity_nonce_finalized: false, - identity_user_abandoned: false, - }; let (recipient, other) = ([4; 43], [5; 43]); - let mut store = InMemoryShieldedStore::new(); - store.arm_redrive(id, record(transfer)).unwrap(); - assert!(has_unresolved_transfer_to(&store, wallet_id, &other).unwrap()); store - .save_activity( - id, - &ShieldedActivityEntry { - id: [2; 32], - kind: ShieldedActivityKind::Sent, - direction: ShieldedDirection::Out, - amount: 1_000, - fee: None, - counterparty: Some(recipient.to_vec()), - memo: None, - block_height: None, - status: ShieldedActivityStatus::Pending, - created_at_ms: 0, - min_note_position: None, - note_cmxs: vec![], - spent_nullifiers: vec![], + .record_tip_barrier( + wallet_id, + &TipBarrier { + activity_id: [2; 32], + account_index: 0, + recipient, + nullifiers: vec![[3; 32]], + anchor: [0; 32], + status: TipBarrierStatus::Pending, }, ) .unwrap(); @@ -801,10 +784,6 @@ mod tests { assert!(!has_unresolved_transfer_to(&store, wallet_id, &other).unwrap()); assert!(!has_unresolved_transfer_to(&store, [9; 32], &recipient).unwrap()); - let mut unshield_like = InMemoryShieldedStore::new(); - unshield_like.arm_redrive(id, record(vec![7; 16])).unwrap(); - assert!(!has_unresolved_transfer_to(&unshield_like, wallet_id, &recipient).unwrap()); - // The name's recipient rotated its address after the earlier tip: the // tip to the new address is still blocked through the confirmed one. let confirmed = ShieldedTipRecipient { @@ -813,6 +792,9 @@ mod tests { }; assert!(has_unresolved_tip_to(&store, wallet_id, &other, Some(&confirmed)).unwrap()); assert!(!has_unresolved_tip_to(&store, wallet_id, &other, None).unwrap()); + + store.mark_spent(id, &[3; 32]).unwrap(); + assert!(!has_unresolved_transfer_to(&store, wallet_id, &recipient).unwrap()); } #[test] diff --git a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs index 43235c8a11f..7a851ff0210 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs @@ -1951,3 +1951,106 @@ async fn should_report_a_restored_tip_account_unverified_until_the_seed_proves_i .unwrap(); assert!(reported(wallet.clone()).await.verified); } + +/// A pending tip settles only through its paying account's state, so a +/// rebind that drops that account and a Clear are refused until it settles +/// or the user, acknowledging it may still execute, allows new tips. +#[tokio::test] +async fn should_keep_a_pending_tips_account_bound_and_refuse_clear_until_it_settles() { + use super::store::{TipBarrier, TipBarrierStatus}; + let (wallet, seed, _id, coordinator) = tip_race_fixture("tips_pending_lifecycle").await; + coordinator + .store() + .write() + .await + .record_tip_barrier( + wallet.wallet_id(), + &TipBarrier { + activity_id: [1; 32], + account_index: 0, + recipient: [4; 43], + nullifiers: vec![[5; 32]], + anchor: [6; 32], + status: TipBarrierStatus::Pending, + }, + ) + .unwrap(); + + let dropped = wallet.bind_shielded(&seed, &[1], &coordinator).await; + assert!( + matches!( + dropped, + Err(crate::PlatformWalletError::ShieldedRecoveryKeysRequired { + account_index: 0, + .. + }) + ), + "{dropped:?}" + ); + let cleared = coordinator.clear().await; + assert!( + matches!(cleared, Err(crate::PlatformWalletError::ShieldedTipPending)), + "{cleared:?}" + ); + + assert!(matches!( + wallet + .abandon_unresolved_shielded_tips(&coordinator, false) + .await, + Err(crate::PlatformWalletError::InvalidParameter(_)) + )); + { + let _sending = wallet.tip_send_in_progress.lock().await; + assert!(matches!( + wallet + .abandon_unresolved_shielded_tips(&coordinator, true) + .await, + Err(crate::PlatformWalletError::ShieldedTipPending) + )); + } + assert_eq!( + wallet + .abandon_unresolved_shielded_tips(&coordinator, true) + .await + .unwrap(), + 1 + ); + wallet + .bind_shielded(&seed, &[1], &coordinator) + .await + .expect("the settled tip no longer pins its account"); + coordinator + .clear() + .await + .expect("Clear runs once the tip settled"); +} + +/// A second tip while one is being sent is refused, not queued behind it: +/// queued, an accidental repeat would pay again after the first succeeds. +/// The guard is shared by cloned wallet handles and refuses before any +/// resolution or proving. +#[tokio::test] +async fn should_refuse_a_second_tip_while_one_is_being_sent() { + let (wallet, seed, _id, coordinator) = tip_race_fixture("tips_single_flight").await; + let _sending = wallet.tip_send_in_progress.lock().await; + let refused = wallet + .clone() + .send_shielded_tip( + &coordinator, + &seed, + crate::ShieldedTipSource::Account(0), + "recipient.dash", + &crate::ShieldedTipRecipient { + identity_id: dpp::prelude::Identifier::from([0x77; 32]), + address: [0; 43], + }, + 1_000, + [0; 36], + &super::CachedOrchardProver::new(), + ) + .await; + assert!( + matches!(refused, Err(crate::PlatformWalletError::ShieldedTipPending)), + "{refused:?}" + ); +} diff --git a/packages/rs-unified-sdk-jni/src/funding.rs b/packages/rs-unified-sdk-jni/src/funding.rs index 407df97f287..39195b90025 100644 --- a/packages/rs-unified-sdk-jni/src/funding.rs +++ b/packages/rs-unified-sdk-jni/src/funding.rs @@ -1470,6 +1470,37 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_reviewS }) } +/// Allow new tips although earlier ones are unresolved; returns how many were +/// released. `acknowledge` carries the user's acknowledgement that they may +/// still execute. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_abandonUnresolvedShieldedTips( + mut env: JNIEnv, + _class: JClass, + manager: jlong, + wallet_id: JByteArray, + acknowledge: jni::sys::jboolean, +) -> jint { + guard(&mut env, 0, |env| { + let Some(wid) = read_id32(env, &wallet_id, "walletId") else { + return 0; + }; + let mut released = 0u32; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_abandon_unresolved_shielded_tips( + manager as Handle, + wid.as_ptr(), + acknowledge != 0, + &mut released, + ) + }; + if take_pwffi_error(env, result) { + return 0; + } + jint::try_from(released).unwrap_or(jint::MAX) + }) +} + /// `[account, verified]` for a wallet identity's dedicated tip account; /// `verified` is 1 when this session proved the identity holds its slot. #[no_mangle] diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index 53b429c2dae..f45fb5774bf 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -1719,6 +1719,28 @@ extension PlatformWalletManager { }.value } + /// Allow new tips although earlier ones from this wallet are unresolved. + /// Pass the user's acknowledgement that those tips may still execute; + /// returns how many were released. + public func abandonUnresolvedShieldedTips(walletId: Data, acknowledgePossibleExecution: Bool) async throws -> Int { + guard walletId.count == 32 else { + throw PlatformWalletError.invalidParameter("Expected a 32-byte wallet ID") + } + try admitNativeOp("abandonUnresolvedShieldedTips") + defer { finishNativeOp() } + let handle = self.handle + return try await Task.detached(priority: .userInitiated) { + var released: UInt32 = 0 + try walletId.withUnsafeBytes { wallet in + try platform_wallet_manager_abandon_unresolved_shielded_tips( + handle, wallet.baseAddress!.assumingMemoryBound(to: UInt8.self), + acknowledgePossibleExecution, &released + ).check() + } + return Int(released) + }.value + } + /// Resolve `username` for the user to confirm: its recipient, whether it /// changed since this wallet last confirmed the name, and whether an /// earlier payment to it is unresolved. @@ -1791,6 +1813,10 @@ extension PlatformWalletManager { /// Exact credits for a tip amount typed in DASH, parsed by Rust; throws on /// ambiguous input (signs, grouping, exponents, sub-credit fractions). public static func parseShieldedTipAmount(_ text: String) throws -> UInt64 { + // The C export reads up to the first NUL; refuse rather than parse a prefix. + guard !text.utf8.contains(0) else { + throw PlatformWalletError.invalidParameter("DASH amount contains an embedded NUL") + } var credits: UInt64 = 0 try text.withCString { try platform_wallet_parse_shielded_tip_amount($0, &credits).check() } return credits diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md index 0abbd520f75..8ec1334f519 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md @@ -725,7 +725,10 @@ ordinary account or a retired identity's tip account. The send verifies that fre resolution still matches the confirmed recipient. A native failure is thrown as `ShieldedTipFailure` (also when it fails before the native call, as `.notSent`); send again only when its `disposition` is `.notSent`, and review -again first, because a refusal for an unresolved earlier tip is also `.notSent`. The +again first, because a refusal for an unresolved earlier tip is also `.notSent`. An +unresolved tip whose outcome stays unknown keeps blocking its recipient; after the +user acknowledges it may still be paid, `abandonUnresolvedShieldedTips` allows new +tips. The wallet records each tip as unresolved before broadcasting it, so this holds across relaunches. Show an identity's tip balance only when `identityTipAccount` reports it `verified`. From 5e9971bac993f76ab9abb6cc0fd100f6f00ddf5a Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 1 Nov 2026 15:47:52 -0600 Subject: [PATCH 06/21] fix(platform-wallet): trust only local proof that a shielded tip failed - Tip barriers and recovery rows are synced to disk before the tip is broadcast (synchronous=FULL and fullfsync on their connection), so they survive power loss, not only a crash. - A node's rejection, at broadcast or in the wait for the result, is unauthenticated, so it leaves the tip unresolved. A tip settles as failed only when it reached no node at all. - Releasing a wallet's unresolved tips deletes exactly that wallet's unreadable barrier rows; a barrier whose wallet is unknown stays and keeps blocking tips and Clear, but no longer fails shielded binds. - A Clear refused for a pending tip no longer stops background sync, which is what settles the tip. - The tip's activity row is re-stored fallibly before broadcast, and the Kotlin send is not cancellable once started, so its outcome is never lost. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/wallet/PlatformWalletManager.kt | 24 +-- .../rs-platform-wallet/docs/SHIELDED_TIPS.md | 31 ++-- .../rs-platform-wallet/src/manager/mod.rs | 73 +++++++++ .../src/wallet/shielded/coordinator.rs | 19 ++- .../src/wallet/shielded/file_store.rs | 141 +++++++++++++----- .../src/wallet/shielded/operations.rs | 131 +++++++--------- .../src/wallet/shielded/store.rs | 3 +- 7 files changed, 274 insertions(+), 148 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 5d32f168735..1685ca4b869 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -2066,16 +2066,20 @@ class PlatformWalletManager( org.dashfoundation.dashsdk.tokens.ShieldedTipDisposition.NATIVE_NOT_SENT, ) try { - teardownGate.op { - require(amount > 0) { "amount must be positive, got $amount" } - val (sourceIdentityId, sourceAccount) = when (source) { - is org.dashfoundation.dashsdk.tokens.ShieldedTipSource.Identity -> source.identityId to 0 - is org.dashfoundation.dashsdk.tokens.ShieldedTipSource.Account -> null to source.index - } - mapNativeErrors { - FundingNative.sendShieldedTip(managerHandle, walletId, mnemonicResolver.nativeHandle, - sourceIdentityId, sourceAccount, username, recipient.identityId, recipient.address, - amount, memo, disposition) + // Not cancellable once started: the native send cannot be interrupted, and a + // cancelled caller must still receive its outcome and Rust's disposition. + kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { + teardownGate.op { + require(amount > 0) { "amount must be positive, got $amount" } + val (sourceIdentityId, sourceAccount) = when (source) { + is org.dashfoundation.dashsdk.tokens.ShieldedTipSource.Identity -> source.identityId to 0 + is org.dashfoundation.dashsdk.tokens.ShieldedTipSource.Account -> null to source.index + } + mapNativeErrors { + FundingNative.sendShieldedTip(managerHandle, walletId, mnemonicResolver.nativeHandle, + sourceIdentityId, sourceAccount, username, recipient.identityId, recipient.address, + amount, memo, disposition) + } } } } catch (error: kotlinx.coroutines.CancellationException) { diff --git a/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md b/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md index f51d4d08e62..080092f85ab 100644 --- a/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md +++ b/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md @@ -111,24 +111,25 @@ account of a slot that holds no identity (a retired identity's funds). It never transparent destination. This is a snapshot check: the submitted payment always uses the address that was confirmed, even if the profile changes afterward. -A tip is recorded as unresolved before it is broadcast: its activity row is flushed to -the host, and the shielded store durably records a tip barrier (recipient, notes, -anchor) together with a parked recovery record that keeps its notes reserved across a -relaunch. The barrier is kept apart from note and recovery state, so account purges and -scans do not remove it. While a barrier to an address is pending, a tip to that address -is refused (`ShieldedTipPending`), as is a second tip while one is being sent; an -unreadable barrier blocks its wallet's tips instead of vanishing. +A tip is recorded as unresolved before it is broadcast: its activity row is stored and +flushed to the host, and the shielded store durably records a tip barrier (recipient, +notes, anchor) together with a parked recovery record that keeps its notes reserved +across a relaunch. That connection syncs every commit, so the records survive power loss, +not only an app crash. The barrier is kept apart from note and recovery state, so +account purges and scans do not remove it. While a barrier to an address is pending, a +tip to that address is refused (`ShieldedTipPending`), as is a second tip while one is +being sent. An unreadable barrier blocks its wallet's tips instead of vanishing; one +whose wallet cannot be read blocks every wallet's tips. A barrier settles as executed when the store sees one of its notes spent, in the same step that resolves the note and before its recovery record goes. It settles as failed -only on its own send's definitive rejection: the tip is broadcast to a single node with -no retries, and only that node's consensus rejection, or the committed result of the -wait, counts. Nothing reported later (a re-drive rejection, an anchor missing from one -node's set) settles it as failed, because another node may still hold the tip. A tip -whose outcome stays unknown therefore stays pending until its notes are seen spent, or -until the user, acknowledging that it may still execute, calls -`abandon_unresolved_shielded_tips`. While a tip is pending, a bind that would drop or -re-key its paying account and a shielded Clear are refused. +only when the tip never left the device (no node address was available): a rejection +a node reports, at broadcast or in the wait for the result, is unauthenticated and +leaves the tip unresolved. A tip whose outcome stays unknown therefore stays pending +until its notes are seen spent, or until the user, acknowledging that it may still +execute, calls `abandon_unresolved_shielded_tips`, which also drops that wallet's +unreadable barriers. While a tip is pending, a bind that would drop or re-key its paying +account and a shielded Clear are refused; a refused Clear leaves background sync running. After a failure, `ShieldedTipDisposition::of(error)` decides whether the tip may be sent again: `NotSent` (nothing broadcast, or definitively rejected) or `Unresolved` (do not diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 6ea8ba9b5fd..36b6f995dfb 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -987,6 +987,31 @@ impl PlatformWalletManager

{ /// fails; the host must not commit its own persistence wipe in that case. #[cfg(feature = "shielded")] pub async fn clear_shielded(&self) -> Result<(), crate::error::PlatformWalletError> { + // A pending tip settles through the background sync this Clear is + // about to stop, and the coordinator refuses to clear it. Refuse + // before quiescing so the worker keeps running; if a tip turns pending + // in between, the coordinator's refusal below restarts the worker. + if let Some(coord) = self.shielded_coordinator().await { + if coord.has_pending_tips().await? { + return Err(crate::error::PlatformWalletError::ShieldedTipPending); + } + } + let was_running = self.shielded_sync_manager.is_running(); + let result = self.quiesce_and_clear_shielded().await; + if was_running + && matches!( + result, + Err(crate::error::PlatformWalletError::ShieldedTipPending) + ) + { + Arc::clone(&self.shielded_sync_manager).start(); + } + result + } + + /// [`Self::clear_shielded`]'s quiesce and wipe, holding its latches. + #[cfg(feature = "shielded")] + async fn quiesce_and_clear_shielded(&self) -> Result<(), crate::error::PlatformWalletError> { // Hold the registry's per-key clearing latch across the WHOLE // quiesce -> wipe. While it is up, `ShieldedSyncManager::start` // (and any registry (re)start) is a no-op, so no fresh pass can @@ -1832,3 +1857,51 @@ mod tests { .expect("the adapter task was aborted on drop: it never finished"); } } + +#[cfg(all(test, feature = "shielded"))] +mod clear_shielded_tests { + use crate::wallet::shielded::store::{ShieldedStore, TipBarrier, TipBarrierStatus}; + use std::sync::Arc; + + /// A Clear refused for a pending tip leaves the background sync running: + /// that sync is what settles the tip. + #[tokio::test(flavor = "multi_thread")] + async fn a_refused_shielded_clear_keeps_background_sync_running() { + let (manager, wallet_id) = crate::test_support::test_platform_wallet_manager().await; + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!("clear_refused_{nanos}.sqlite")); + manager.configure_shielded(&path).await.unwrap(); + Arc::clone(&manager.shielded_sync_manager).start(); + assert!(manager.shielded_sync_manager.is_running()); + manager + .shielded_coordinator() + .await + .unwrap() + .store() + .write() + .await + .record_tip_barrier( + wallet_id, + &TipBarrier { + activity_id: [1; 32], + account_index: 0, + recipient: [4; 43], + nullifiers: vec![[5; 32]], + anchor: [6; 32], + status: TipBarrierStatus::Pending, + }, + ) + .unwrap(); + + let refused = manager.clear_shielded().await; + assert!( + matches!(refused, Err(crate::PlatformWalletError::ShieldedTipPending)), + "{refused:?}" + ); + assert!(manager.shielded_sync_manager.is_running()); + let _ = std::fs::remove_file(&path); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index d6297203b9a..98e3f88b3fa 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -697,12 +697,9 @@ impl NetworkShieldedCoordinator { let store = self.store.read().await; // A pending tip settles only through its paying account's scan // and prune passes, so that account must stay bound, with its keys. - let tips = store.tip_barriers(wallet_id).map_err(|e| { - PlatformWalletError::ShieldedRecoveryCorrupted { - account_index: None, - reason: e.to_string(), - } - })?; + // Unreadable barriers cannot settle that way and keep blocking + // tips and Clear on their own, so they do not block the bind. + let tips = store.tip_barriers(wallet_id).unwrap_or_default(); for tip in tips .iter() .filter(|tip| tip.status == super::store::TipBarrierStatus::Pending) @@ -1162,6 +1159,16 @@ impl NetworkShieldedCoordinator { Ok(()) } + /// Whether any wallet holds a pending (or unreadable) shielded tip; + /// [`Self::clear`] is refused while one does. + pub async fn has_pending_tips(&self) -> Result { + self.store + .read() + .await + .has_pending_tip_barriers() + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string())) + } + /// Drop every wallet registration, purge all per-subwallet /// store state (notes, spent marks, sync watermarks, /// nullifier checkpoints), empty the shared commitment tree, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index 80bbb147eab..beeb22f2a1f 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -114,12 +114,14 @@ pub struct FileBackedShieldedStore { /// The `shielded_tip_barriers` table, loaded at open and written /// through: SQL first, then this map. tip_barriers: BTreeMap<(WalletId, [u8; 32]), TipBarrier>, - /// Wallets with a stored tip barrier that could not be read. Their - /// barrier reads fail until the user abandons their unresolved tips, so a - /// damaged record blocks tips instead of vanishing. - damaged_tip_barrier_wallets: std::collections::BTreeSet, - /// A stored tip barrier whose wallet id itself is unreadable; it blocks - /// every wallet's tips the same way. + /// The rowids of stored tip barriers that could not be read, by wallet. + /// That wallet's barrier reads fail until the user abandons its + /// unresolved tips, which deletes exactly these rows, so a damaged record + /// blocks tips instead of vanishing. + damaged_tip_barrier_rows: BTreeMap>, + /// A stored tip barrier whose wallet id itself is unreadable. Its owner + /// is unknown, so it blocks every wallet's tips and no wallet's + /// acknowledgement releases it. unattributed_tip_barrier_damage: bool, } @@ -187,6 +189,13 @@ impl FileBackedShieldedStore { let tree = ClientPersistentCommitmentTree::open(conn, max_checkpoints) .map_err(|e| FileShieldedStoreError::Storage(format!("open commitment tree: {e}")))?; let pending_conn = Self::open_tuned_connection(&path)?; + // Recovery and tip-barrier rows are written before a payment is + // broadcast and must survive power loss, so this connection syncs + // every commit (the tree connection keeps NORMAL for sync speed). + // `fullfsync` makes that sync flush the drive cache on Apple platforms. + pending_conn + .execute_batch("PRAGMA synchronous=FULL; PRAGMA fullfsync=ON;") + .map_err(|e| FileShieldedStoreError::Storage(format!("sync recovery writes: {e}")))?; pending_conn .execute( "CREATE TABLE IF NOT EXISTS shielded_pending_spends ( @@ -265,7 +274,7 @@ impl FileBackedShieldedStore { subwallets: BTreeMap::new(), pending_conn: Mutex::new(pending_conn), tip_barriers: BTreeMap::new(), - damaged_tip_barrier_wallets: std::collections::BTreeSet::new(), + damaged_tip_barrier_rows: BTreeMap::new(), unattributed_tip_barrier_damage: false, }; store.rehydrate_pending_spends()?; @@ -436,12 +445,13 @@ impl FileBackedShieldedStore { let mut stmt = conn .prepare( "SELECT wallet_id, activity_id, account_index, recipient, nullifiers, anchor, \ - status FROM shielded_tip_barriers", + status, rowid FROM shielded_tip_barriers", ) .map_err(|e| FileShieldedStoreError::Storage(format!("prepare tip barriers: {e}")))?; let rows = stmt .query_map([], |row| { Ok(( + row.get::<_, i64>(7)?, row.get::<_, Vec>(0).ok(), (|| { Ok::<_, rusqlite::Error>(( @@ -458,7 +468,7 @@ impl FileBackedShieldedStore { }) .map_err(|e| FileShieldedStoreError::Storage(format!("query tip barriers: {e}")))?; for row in rows { - let (wallet_id, fields) = + let (rowid, wallet_id, fields) = row.map_err(|e| FileShieldedStoreError::Storage(format!("read tip barrier: {e}")))?; let wallet_id: Option = wallet_id.and_then(|w| w.try_into().ok()); let barrier = fields.and_then( @@ -483,7 +493,10 @@ impl FileBackedShieldedStore { } (Some(wallet_id), None) => { tracing::warn!("unreadable shielded tip barrier; blocking the wallet's tips"); - self.damaged_tip_barrier_wallets.insert(wallet_id); + self.damaged_tip_barrier_rows + .entry(wallet_id) + .or_default() + .push(rowid); } (None, _) => { tracing::warn!("shielded tip barrier with an unreadable wallet; blocking tips"); @@ -1090,7 +1103,7 @@ impl ShieldedStore for FileBackedShieldedStore { self.subwallets.retain(|id, _| id.wallet_id != wallet_id); self.tip_barriers .retain(|(wallet, _), _| *wallet != wallet_id); - self.damaged_tip_barrier_wallets.remove(&wallet_id); + self.damaged_tip_barrier_rows.remove(&wallet_id); Ok(()) } @@ -1140,7 +1153,7 @@ impl ShieldedStore for FileBackedShieldedStore { fn tip_barriers(&self, wallet_id: WalletId) -> Result, Self::Error> { if self.unattributed_tip_barrier_damage - || self.damaged_tip_barrier_wallets.contains(&wallet_id) + || self.damaged_tip_barrier_rows.contains_key(&wallet_id) { return Err(FileShieldedStoreError::Storage( "a stored tip barrier is unreadable".to_string(), @@ -1156,7 +1169,7 @@ impl ShieldedStore for FileBackedShieldedStore { fn has_pending_tip_barriers(&self) -> Result { Ok(self.unattributed_tip_barrier_damage - || !self.damaged_tip_barrier_wallets.is_empty() + || !self.damaged_tip_barrier_rows.is_empty() || self .tip_barriers .values() @@ -1174,40 +1187,34 @@ impl ShieldedStore for FileBackedShieldedStore { fn abandon_tip_barriers(&mut self, wallet_id: WalletId) -> Result { let conn = self.pending_conn.lock().expect("pending_conn mutex"); - let abandoned = conn - .execute( - "UPDATE shielded_tip_barriers SET status = 3 WHERE wallet_id = ?1 AND status = 0", - rusqlite::params![wallet_id.as_slice()], - ) - .map_err(|e| FileShieldedStoreError::Storage(format!("abandon tip barriers: {e}")))?; - // Unreadable rows cannot be settled; the acknowledgement covers them. - if self.damaged_tip_barrier_wallets.contains(&wallet_id) { - conn.execute( - "DELETE FROM shielded_tip_barriers WHERE wallet_id = ?1 AND NOT (\ - status IN (0, 1, 2, 3) AND length(activity_id) = 32 \ - AND length(recipient) = 43 AND length(anchor) = 32 \ - AND length(nullifiers) > 0 AND length(nullifiers) % 32 = 0)", - rusqlite::params![wallet_id.as_slice()], - ) - .map_err(|e| { - FileShieldedStoreError::Storage(format!("drop damaged tip barriers: {e}")) - })?; - } - if self.unattributed_tip_barrier_damage { + conn.execute( + "UPDATE shielded_tip_barriers SET status = 3 WHERE wallet_id = ?1 AND status = 0", + rusqlite::params![wallet_id.as_slice()], + ) + .map_err(|e| FileShieldedStoreError::Storage(format!("abandon tip barriers: {e}")))?; + // Unreadable rows of this wallet cannot be settled; the + // acknowledgement covers them. Rows of an unknown wallet stay. + let damaged = self + .damaged_tip_barrier_rows + .get(&wallet_id) + .cloned() + .unwrap_or_default(); + for rowid in &damaged { conn.execute( - "DELETE FROM shielded_tip_barriers WHERE length(wallet_id) != 32", - [], + "DELETE FROM shielded_tip_barriers WHERE rowid = ?1 AND wallet_id = ?2", + rusqlite::params![rowid, wallet_id.as_slice()], ) .map_err(|e| { - FileShieldedStoreError::Storage(format!("drop damaged tip barriers: {e}")) + FileShieldedStoreError::Storage(format!("drop damaged tip barrier: {e}")) })?; } drop(conn); - self.damaged_tip_barrier_wallets.remove(&wallet_id); - self.unattributed_tip_barrier_damage = false; + self.damaged_tip_barrier_rows.remove(&wallet_id); + let mut abandoned = 0; for ((wallet, _), barrier) in self.tip_barriers.iter_mut() { if *wallet == wallet_id && barrier.status == TipBarrierStatus::Pending { barrier.status = TipBarrierStatus::Abandoned; + abandoned += 1; } } Ok(abandoned) @@ -1921,6 +1928,17 @@ mod tests { [[3u8; 32].as_slice()], ) .unwrap(); + // A decoder-only violation: the account index does not fit a u32. + store.record_tip_barrier(wallet, &barrier(7, 0x77)).unwrap(); + store + .pending_conn + .lock() + .unwrap() + .execute( + "UPDATE shielded_tip_barriers SET account_index = -1 WHERE activity_id = ?1", + [[7u8; 32].as_slice()], + ) + .unwrap(); } { let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("reopen damaged"); @@ -1937,6 +1955,13 @@ mod tests { } let store = FileBackedShieldedStore::open_path(&path, 100).expect("reopen purged"); assert!(store.tip_barriers(wallet).unwrap().is_empty()); + let synchronous: i64 = store + .pending_conn + .lock() + .unwrap() + .query_row("PRAGMA synchronous", [], |row| row.get(0)) + .unwrap(); + assert_eq!(synchronous, 2, "recovery writes sync every commit (FULL)"); assert_eq!( store.tip_barriers([0xC2; 32]).unwrap()[0].status, TipBarrierStatus::Abandoned @@ -1945,6 +1970,46 @@ mod tests { let _ = std::fs::remove_file(&path); } + /// A barrier whose wallet id is unreadable has no known owner: it blocks + /// every wallet's tips, and one wallet's acknowledgement does not release it. + #[test] + fn an_unattributed_damaged_tip_barrier_is_not_released_by_another_wallet() { + let path = temp_tree_path("tip_barrier_unattributed"); + let (wallet_a, wallet_b) = ([0xD1; 32], [0xD2; 32]); + { + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); + store + .record_tip_barrier( + wallet_b, + &TipBarrier { + activity_id: [1; 32], + account_index: 0, + recipient: [7; 43], + nullifiers: vec![[2; 32]], + anchor: [3; 32], + status: TipBarrierStatus::Pending, + }, + ) + .unwrap(); + store + .pending_conn + .lock() + .unwrap() + .execute("UPDATE shielded_tip_barriers SET wallet_id = x'00'", []) + .unwrap(); + } + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("reopen"); + assert_eq!(store.abandon_tip_barriers(wallet_a).unwrap(), 0); + assert!(store.tip_barriers(wallet_a).is_err()); + assert!(store.tip_barriers(wallet_b).is_err()); + assert!(store.has_pending_tip_barriers().unwrap()); + drop(store); + let store = FileBackedShieldedStore::open_path(&path, 100).expect("reopen again"); + assert!(store.tip_barriers(wallet_b).is_err()); + drop(store); + let _ = std::fs::remove_file(&path); + } + /// Confirmed tip recipients survive a reopen and a shielded Clear, are /// kept per wallet, and go with their wallet's purge. #[test] diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 77d7ed358e1..d35df803f88 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -1948,10 +1948,10 @@ pub(crate) async fn transfer_with( None, ) .await; - // The guarded broadcast reports a failure only when it is - // definitive (or raised before the broadcast), so the tip's - // barrier, if one was recorded, settles here. A failed write - // leaves it pending (fail closed). + // The guarded broadcast fails only when the tip reached no node, + // and every earlier error is raised before it is sent, so the + // tip's barrier, if one was recorded, settles here. A failed + // write leaves it pending (fail closed). if let (true, Some(entry)) = (guard_before_broadcast, &pending_entry) { if let Err(e) = store.write().await.settle_tip_barrier( wallet_id, @@ -3121,9 +3121,15 @@ async fn arm_parked_spend_guard( "the transfer produced no wallet-visible activity entry".to_string(), ) })?; + // Re-queue the activity row fallibly: the recording above only logs a + // host write failure, and the host must know the tip before it is sent. if let Some(persister) = persister { persister - .flush() + .store(PlatformWalletChangeSet { + shielded: Some(changeset_for_entry(id, entry.clone())), + ..Default::default() + }) + .and_then(|()| persister.flush()) .map_err(|e| PlatformWalletError::Persistence(e.to_string()))?; } let st_bytes = state_transition.serialize_to_bytes().map_err(|e| { @@ -3160,24 +3166,23 @@ async fn arm_parked_spend_guard( .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string())) } -/// Whether a tip's single-attempt broadcast certainly did not, and never -/// will, execute: only a consensus rejection from the one node it reached -/// proves that (nothing was sent when no address was available at all). A -/// transport error such as `Unavailable` can follow a delivered request, and -/// `NullifierAlreadySpent` means it executed, so both stay unresolved and -/// keep the tip's barrier. -fn tip_broadcast_definitely_failed(e: &dash_sdk::Error) -> bool { +/// Whether a tip's broadcast certainly never reached a node: no address was +/// available, so nothing was sent. A rejection a node reports is not proof +/// the tip cannot execute: a dishonest node can forward the transition and +/// fabricate a consensus error, and nothing authenticates the claim. +fn tip_broadcast_never_sent(e: &dash_sdk::Error) -> bool { use dash_sdk::dapi_client::DapiClientError; - (carries_consensus_rejection(e) && !is_nullifier_already_spent(e)) - || matches!( - e, - dash_sdk::Error::DapiClientError(DapiClientError::NoAvailableAddresses) - ) + matches!( + e, + dash_sdk::Error::DapiClientError(DapiClientError::NoAvailableAddresses) + ) } -/// Broadcast a transfer whose parked guard is armed. Anything short of a -/// definitive rejection is unresolved and re-arms the guard for re-drive -/// (see [`activate_parked_guard`]). +/// Broadcast a transfer whose parked guard is armed, once and with no +/// retries. Only a broadcast that reached no node fails definitively; any +/// other broadcast or wait error is unresolved, because a node's rejection +/// is unauthenticated, and re-arms the guard for re-drive (see +/// [`activate_parked_guard`]). async fn broadcast_guarded_transfer( sdk: &Arc, store: &Arc>, @@ -3185,9 +3190,6 @@ async fn broadcast_guarded_transfer( pending_entry: &Option, state_transition: &StateTransition, ) -> Result { - // One node, no retries: a consensus rejection then speaks for the only - // node that received the tip, so it cannot hide a copy another node - // admitted. let single_attempt = PutSettings { request_settings: RequestSettings { retries: Some(0), @@ -3195,20 +3197,21 @@ async fn broadcast_guarded_transfer( }, ..Default::default() }; - let result = broadcast_shielded_spend_classified( - sdk, - state_transition, - "transfer", - SpendResultWait::ExecutionProved, - tip_broadcast_definitely_failed, - Some(single_attempt), - ) - .await - .map(|(result, _)| result); - if matches!( - &result, - Err(PlatformWalletError::ShieldedSpendUnconfirmed { .. }) - ) { + if let Err(e) = state_transition.broadcast(sdk, Some(single_attempt)).await { + if tip_broadcast_never_sent(&e) { + return Err(PlatformWalletError::ShieldedBroadcastFailed(e.to_string())); + } + warn!(error = %e, "tip broadcast returned no trusted verdict; waiting for the result"); + } + let result = state_transition + .wait_for_response_with_metadata::(sdk, None) + .await + .map(|(result, _)| result) + .map_err(|e| PlatformWalletError::ShieldedSpendUnconfirmed { + operation: "transfer", + reason: e.to_string(), + }); + if result.is_err() { activate_parked_guard(store, id, pending_entry).await; } result @@ -3589,31 +3592,9 @@ async fn broadcast_shielded_spend( operation: &'static str, wait: SpendResultWait, ) -> Result<(StateTransitionProofResult, ResponseMetadata), PlatformWalletError> { - broadcast_shielded_spend_classified( - sdk, - state_transition, - operation, - wait, - broadcast_definitely_failed, - None, - ) - .await -} - -/// [`broadcast_shielded_spend`] with the rule for a broadcast error that -/// proves the spend never executed, and the broadcast settings, supplied by -/// the caller. -async fn broadcast_shielded_spend_classified( - sdk: &Arc, - state_transition: &StateTransition, - operation: &'static str, - wait: SpendResultWait, - definitely_failed: fn(&dash_sdk::Error) -> bool, - settings: Option, -) -> Result<(StateTransitionProofResult, ResponseMetadata), PlatformWalletError> { - match state_transition.broadcast(sdk, settings).await { + match state_transition.broadcast(sdk, None).await { Ok(()) => {} - Err(e) if definitely_failed(&e) => { + Err(e) if broadcast_definitely_failed(&e) => { return Err(crate::error::promote_address_nonce_error(&e) .unwrap_or_else(|| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))); } @@ -4341,42 +4322,36 @@ mod classify_spend_wait_failure_tests { ))) } - /// A tip's broadcast is definitive only on a consensus rejection, or - /// when no address was available at all. `Unavailable` and other - /// transport errors can follow a delivered request, and - /// `NullifierAlreadySpent` means an earlier attempt executed: both keep - /// the tip's barrier. + /// A tip's broadcast fails definitively only when it reached no node. A + /// node's rejection, consensus-coded or not, is unauthenticated and keeps + /// the tip's barrier, as do transport errors that can follow delivery and + /// `NullifierAlreadySpent`. #[test] - fn tip_broadcast_is_definitive_only_on_a_consensus_rejection() { + fn tip_broadcast_is_definitive_only_when_it_reached_no_node() { use dash_sdk::dapi_client::DapiClientError; use dash_sdk::dapi_grpc::tonic::Code; use dpp::consensus::state::shielded::nullifier_already_spent_error::NullifierAlreadySpentError; use dpp::consensus::state::state_error::StateError; + assert!(tip_broadcast_never_sent(&dash_sdk::Error::DapiClientError( + DapiClientError::NoAvailableAddresses + ))); let rejected = broadcast_err(Some(ConsensusError::BasicError( BasicError::ProtocolVersionParsingError(ProtocolVersionParsingError::new( "bad version".to_string(), )), ))); - assert!(tip_broadcast_definitely_failed(&rejected)); - assert!(tip_broadcast_definitely_failed( - &dash_sdk::Error::DapiClientError(DapiClientError::NoAvailableAddresses) - )); let already_spent = broadcast_err(Some(ConsensusError::StateError( StateError::NullifierAlreadySpentError(NullifierAlreadySpentError::new([1; 32])), ))); for unresolved in [ + rejected, + already_spent, grpc_err(Code::Unavailable), grpc_err(Code::DeadlineExceeded), broadcast_err(None), - already_spent, ] { - assert!( - !tip_broadcast_definitely_failed(&unresolved), - "{unresolved}" - ); + assert!(!tip_broadcast_never_sent(&unresolved), "{unresolved}"); } - // The ordinary spend rule still releases notes on `Unavailable`. - assert!(broadcast_definitely_failed(&grpc_err(Code::Unavailable))); } /// Connection-establishment failures and non-consensus server diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index 6f4db525895..e0a9cf4bb61 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -571,7 +571,8 @@ pub trait ShieldedStore: Send + Sync { /// Mark every pending barrier of `wallet_id` abandoned, and drop its /// unreadable ones, after the user accepted those tips may still execute. - /// Returns how many pending barriers it abandoned. + /// A barrier whose wallet is unreadable is not this wallet's to release + /// and stays. Returns how many pending barriers it abandoned. fn abandon_tip_barriers(&mut self, wallet_id: WalletId) -> Result; // ── Confirmed tip recipients (per wallet) ────────────────────────── From 1309b5182f8f4b72be6f47e0c847aed2fbf9407b Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 1 Nov 2026 16:32:45 -0600 Subject: [PATCH 07/21] feat(platform-wallet): rebind discovered tip accounts and report tip balances from the ledger Review of the tip screens found two decisions still made in the apps: which shielded accounts to request when rebinding after identity discovery (Swift and Kotlin differed, and Kotlin dropped ordinary accounts other than 0), and an identity's tip balance, which the apps summed from persisted notes and so showed as zero after a shielded Clear. - refresh_shielded_accounts rebinds the ordinary accounts already bound or persisted (account 0 when none) and adds new identities' tip accounts, under the account-set lock; persisted viewing keys first, the seed only when they do not cover the request. - identity_tip_balance reports Unverified, Unavailable (no established ledger) or Available credits, from slot verification and the coordinator's ledger state. - Review and send trim the username, so both apps resolve the same names. Both are exposed through C, JNI, Swift and Kotlin. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/ffi/FundingNative.kt | 3 + .../dashsdk/tokens/ShieldedTipRecipient.kt | 20 +++++ .../dashsdk/wallet/PlatformWalletManager.kt | 19 +++++ .../src/shielded_send.rs | 74 +++++++++++++++++- .../src/shielded_sync.rs | 50 ++++++++++++ .../rs-platform-wallet/docs/SHIELDED_TIPS.md | 12 ++- packages/rs-platform-wallet/src/lib.rs | 4 +- .../src/wallet/platform_wallet.rs | 70 ++++++++++++++++- .../src/wallet/shielded/mod.rs | 4 +- .../src/wallet/shielded/tips.rs | 47 +++++++++++ .../wallet/shielded/viewing_key_bind_tests.rs | 72 +++++++++++++++++ packages/rs-unified-sdk-jni/src/funding.rs | 77 +++++++++++++++++++ .../PlatformWalletManagerShieldedSync.swift | 60 +++++++++++++++ .../SwiftDashSDK/PlatformWallet/README.md | 17 ++-- 14 files changed, 510 insertions(+), 19 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt index 9fec36be3d5..18c075825a5 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt @@ -34,6 +34,9 @@ internal object FundingNative { /** 152 bytes: identity, address, change kind, previous identity, previous address, unresolved. */ external fun reviewShieldedTip(manager: Long, walletId: ByteArray, username: String): ByteArray external fun abandonUnresolvedShieldedTips(manager: Long, walletId: ByteArray, acknowledge: Boolean): Int + external fun refreshShieldedAccounts(manager: Long, walletId: ByteArray, resolver: Long) + /** `[state, account, credits]`; state 0 unverified, 1 unavailable, 2 available. */ + external fun identityTipBalance(manager: Long, walletId: ByteArray, identityId: ByteArray): LongArray /** `[account, verified]` for a wallet identity's dedicated tip account. */ external fun identityTipAccount(wallet: Long, identityId: ByteArray): IntArray diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipient.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipient.kt index ada10346611..54e9a0607ad 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipient.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipient.kt @@ -98,3 +98,23 @@ class ShieldedTipReview( * when [verified]: unverified, the account may belong to another identity. */ data class IdentityTipAccount(val account: Int, val verified: Boolean) + +/** An identity's tip balance as the wallet can vouch for it; decided by Rust. */ +sealed class IdentityTipBalance { + /** This session has not proved the identity holds its slot. */ + object Unverified : IdentityTipBalance() + + /** Verified, but the account's ledger is not established yet. */ + data class Unavailable(val account: Int) : IdentityTipBalance() + + /** Spendable credits of the identity's tip account. */ + data class Available(val account: Int, val credits: Long) : IdentityTipBalance() + + internal companion object { + fun fromNative(values: LongArray): IdentityTipBalance = when (values[0]) { + 2L -> Available(values[1].toInt(), values[2]) + 1L -> Unavailable(values[1].toInt()) + else -> Unverified + } + } +} diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 1685ca4b869..96dfade033d 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -2026,6 +2026,25 @@ class PlatformWalletManager( mapNativeErrors { FundingNative.prepareShieldedTipAddress(managerHandle, walletId, mnemonicResolver.nativeHandle, identityId) } } + /** + * Rebind [walletId]'s shielded accounts after identity discovery: Rust keeps the wallet's + * ordinary accounts and adds the tip accounts of newly discovered identities. + */ + suspend fun refreshShieldedAccounts(walletId: ByteArray) = teardownGate.op { + mapNativeErrors { + FundingNative.refreshShieldedAccounts(managerHandle, walletId, mnemonicResolver.nativeHandle) + } + } + + /** An identity's tip balance as the wallet can vouch for it. */ + suspend fun identityTipBalance( + walletId: ByteArray, identityId: ByteArray, + ): org.dashfoundation.dashsdk.tokens.IdentityTipBalance = teardownGate.op { + val values = mapNativeErrors { FundingNative.identityTipBalance(managerHandle, walletId, identityId) } + check(values.size == 3) { "Invalid tip balance response" } + org.dashfoundation.dashsdk.tokens.IdentityTipBalance.fromNative(values) + } + /** * Allow new tips although earlier ones from this wallet are unresolved. Pass the user's * acknowledgement that those tips may still execute; returns how many were released. diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index fc42850a127..77e9c4ce322 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -54,8 +54,8 @@ use dpp::ProtocolError; use platform_wallet::wallet::asset_lock::AssetLockFunding; use platform_wallet::wallet::shielded::CachedOrchardProver; use platform_wallet::{ - PlatformWalletError, ShieldedTipDisposition, ShieldedTipRecipientChange, ShieldedTipReview, - ShieldedTipSource, + IdentityTipBalance, PlatformWalletError, ShieldedTipDisposition, ShieldedTipRecipientChange, + ShieldedTipReview, ShieldedTipSource, }; use rs_sdk_ffi::{ MnemonicResolverCoreSigner, MnemonicResolverHandle, SignerHandle, VTableSigner, @@ -2663,6 +2663,76 @@ pub unsafe extern "C" fn platform_wallet_manager_abandon_unresolved_shielded_tip result } +/// `out_state` of an identity tip balance: the identity's slot is not +/// verified this session. +pub const IDENTITY_TIP_BALANCE_UNVERIFIED: u32 = 0; +/// `out_state` of an identity tip balance: verified, but the account's ledger +/// is not established. +pub const IDENTITY_TIP_BALANCE_UNAVAILABLE: u32 = 1; +/// `out_state` of an identity tip balance: `out_credits` is the spendable +/// balance. +pub const IDENTITY_TIP_BALANCE_AVAILABLE: u32 = 2; + +/// The tip balance of identity `identity_id` (32 readable bytes) in wallet +/// `wallet_id_bytes` (32 readable bytes); see `PlatformWallet::identity_tip_balance`. +/// `out_account` is set unless the state is unverified, `out_credits` only +/// when available; all outputs are zero on error. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_identity_tip_balance( + handle: Handle, + wallet_id_bytes: *const u8, + identity_id: *const u8, + out_state: *mut u32, + out_account: *mut u32, + out_credits: *mut u64, +) -> PlatformWalletFFIResult { + check_ptr!(out_state); + check_ptr!(out_account); + check_ptr!(out_credits); + *out_state = IDENTITY_TIP_BALANCE_UNVERIFIED; + *out_account = 0; + *out_credits = 0; + check_ptr!(wallet_id_bytes); + let wallet_id: [u8; 32] = std::slice::from_raw_parts(wallet_id_bytes, 32) + .try_into() + .expect("wallet_id slice is exactly 32 bytes by construction"); + let identity_id = match crate::types::read_identifier(identity_id) { + Ok(id) => id, + Err(e) => return e.into(), + }; + let mut balance = None; + let result = catch_query_panic("identity tip balance", || { + let (wallet, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) { + Ok(value) => value, + Err(e) => return e, + }; + match block_on_worker(async move { + wallet + .identity_tip_balance(&coordinator, &identity_id) + .await + }) { + Ok(value) => { + balance = Some(value); + PlatformWalletFFIResult::ok() + } + Err(e) => e.into(), + } + }); + match balance { + Some(IdentityTipBalance::Unverified) | None => {} + Some(IdentityTipBalance::Unavailable { account }) => { + *out_state = IDENTITY_TIP_BALANCE_UNAVAILABLE; + *out_account = account; + } + Some(IdentityTipBalance::Available { account, credits }) => { + *out_state = IDENTITY_TIP_BALANCE_AVAILABLE; + *out_account = account; + *out_credits = credits; + } + } + result +} + /// The dedicated tip account of a wallet identity's slot (`identity_id`: 32 /// readable bytes), and whether this session proved the identity holds that /// slot. Show the account's balance as the identity's only when verified. diff --git a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs index f14e191049d..76a9b7e6a67 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs @@ -372,6 +372,56 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_sync_sync_now( // Bind shielded // --------------------------------------------------------------------------- +/// Rebind wallet `wallet_id_bytes`' shielded accounts after identity discovery, +/// keeping its ordinary accounts and adding newly discovered identities' tip +/// accounts; see `PlatformWallet::refresh_shielded_accounts`. The mnemonic is +/// resolved through `mnemonic_resolver_handle` only when persisted viewing +/// keys cannot cover the request. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_refresh_shielded_accounts( + handle: Handle, + wallet_id_bytes: *const u8, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + check_ptr!(mnemonic_resolver_handle); + let mut wallet_id = [0u8; 32]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); + catch_query_panic("shielded account refresh", || { + let (wallet, coordinator) = + match crate::shielded_send::resolve_wallet_and_coordinator(handle, &wallet_id) { + Ok(value) => value, + Err(e) => return e, + }; + let seedless = { + let (wallet, coordinator) = (wallet.clone(), coordinator.clone()); + block_on_worker( + async move { wallet.refresh_shielded_accounts(None, &coordinator).await }, + ) + }; + match seedless { + Ok(true) => return PlatformWalletFFIResult::ok(), + Ok(false) => {} + Err(e) => return map_shielded_error(e, "refresh_shielded_accounts"), + } + let seed = match crate::identity_keys_from_mnemonic::resolve_seed_from_resolver( + mnemonic_resolver_handle, + &wallet_id, + ) { + Ok(seed) => seed, + Err(e) => return e, + }; + match block_on_worker(async move { + wallet + .refresh_shielded_accounts(Some(seed.as_ref()), &coordinator) + .await + }) { + Ok(_) => PlatformWalletFFIResult::ok(), + Err(e) => map_shielded_error(e, "refresh_shielded_accounts"), + } + }) +} + /// Bind the given wallet's Orchard accounts on the network-scoped /// shielded coordinator — from persisted viewing keys when the host /// persister has them, falling back to a mnemonic-resolver seed diff --git a/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md b/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md index 080092f85ab..060b49209d2 100644 --- a/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md +++ b/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md @@ -53,8 +53,12 @@ receive, balance, and automatic spending choices, as the example apps do. `identity_tip_account(identity_id)` returns an identity's tip account and whether this session proved the identity holds its slot (discovery, loading, or a seed check in preparation or a tip send). A restored index is unverified until then, because -it may be a host placeholder whose account belongs to another identity; show the tip -balance only when verified. +it may be a host placeholder whose account belongs to another identity. +`identity_tip_balance(coordinator, identity_id)` combines that with the ledger: it is +`Unverified`, `Unavailable` (the account is not bound, or its ledger is not restored or +scanned yet, as after a shielded Clear), or `Available` with the spendable credits. Show +that, not a sum of persisted notes, which cannot tell an empty account from a missing +ledger. `shielded_balances()` returns all bound accounts separately, including tip accounts; it does not apply this policy. Spending a tip balance is an explicit host action. @@ -75,7 +79,9 @@ shielded accounts. Seed-backed `bind_shielded` adds each discovered identity's t account even if the caller supplies only account zero. This does not depend on the current profile: removal or replacement of a published address does not remove received funds from recovery. After discovering more identities in an existing -session, bind again. Newly bound accounts start with their own scan watermark at +session, call `refresh_shielded_accounts`: it rebinds the ordinary accounts already bound +or persisted (account 0 when there are none) and adds the new identities' tip accounts, +under the account-set lock. Newly bound accounts start with their own scan watermark at zero, so the next synchronization scans their history while retaining the shared commitment tree and previously synchronized accounts. diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index c17d0419092..7f06e1e12df 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -123,6 +123,6 @@ pub use wallet::identity::types::dashpay::profile::{ #[cfg(feature = "shielded")] pub use wallet::shielded::tips::{ is_shielded_tip_account, parse_shielded_tip_amount, shielded_tip_account_index, - IdentityTipAccount, ShieldedTipDisposition, ShieldedTipRecipientChange, ShieldedTipReview, - ShieldedTipSource, SHIELDED_TIP_ACCOUNT_BASE, + IdentityTipAccount, IdentityTipBalance, ShieldedTipDisposition, ShieldedTipRecipientChange, + ShieldedTipReview, ShieldedTipSource, SHIELDED_TIP_ACCOUNT_BASE, }; diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 88d9de50ea4..68ab010b28e 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -981,8 +981,76 @@ impl PlatformWallet { accounts: &[u32], coordinator: &Arc, ) -> Result { - use super::shielded::{AccountViewingKeys, SubwalletId}; let _config = self.shielded_config_lock.lock().await; + self.bind_shielded_from_persisted_locked(accounts, coordinator) + .await + } + + /// Rebind the shielded accounts this wallet already uses, adding the tip + /// accounts of identities discovered since. The request keeps every + /// ordinary account bound now or persisted from an earlier bind (account + /// 0 when there are none); tip accounts are added by the bind itself, so + /// they never count against a host's request. Tries persisted viewing + /// keys first and returns `Ok(false)` when the seed is needed and `seed` + /// is `None`. The request is computed and bound under the account-set + /// lock, so a concurrent bind cannot be undone by a stale request. + #[cfg(feature = "shielded")] + pub async fn refresh_shielded_accounts( + &self, + seed: Option<&[u8]>, + coordinator: &Arc, + ) -> Result { + let _config = self.shielded_config_lock.lock().await; + let mut request: std::collections::BTreeSet = self + .shielded_account_indices() + .await + .into_iter() + .filter(|account| !super::shielded::is_shielded_tip_account(*account)) + .collect(); + let start = self + .persister + .load() + .map_err(|e| PlatformWalletError::Persistence(e.to_string()))?; + request.extend( + start + .shielded + .viewing_keys + .keys() + .filter(|id| { + id.wallet_id == self.wallet_id + && !super::shielded::is_shielded_tip_account(id.account_index) + }) + .map(|id| id.account_index), + ); + if request.is_empty() { + request.insert(0); + } + let request: Vec = request.into_iter().collect(); + if self + .bind_shielded_from_persisted_locked(&request, coordinator) + .await? + { + return Ok(true); + } + match seed { + Some(seed) => { + self.bind_shielded_locked(seed, &request, coordinator) + .await?; + Ok(true) + } + None => Ok(false), + } + } + + /// [`Self::bind_shielded_from_persisted`] for a caller already holding + /// `shielded_config_lock`. + #[cfg(feature = "shielded")] + async fn bind_shielded_from_persisted_locked( + &self, + accounts: &[u32], + coordinator: &Arc, + ) -> Result { + use super::shielded::{AccountViewingKeys, SubwalletId}; let required = crate::changeset::PersistenceCapabilities::SHIELDED_FVK_RESTART; let capabilities = self.persister.persistence_capabilities(); if !capabilities.contains(required) { diff --git a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs index 964f0d4db77..e67dcd1f08c 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs @@ -51,8 +51,8 @@ pub mod sync; pub mod tips; pub use tips::{ is_shielded_tip_account, parse_shielded_tip_amount, shielded_tip_account_index, - IdentityTipAccount, ShieldedTipDisposition, ShieldedTipRecipientChange, ShieldedTipReview, - ShieldedTipSource, SHIELDED_TIP_ACCOUNT_BASE, + IdentityTipAccount, IdentityTipBalance, ShieldedTipDisposition, ShieldedTipRecipientChange, + ShieldedTipReview, ShieldedTipSource, SHIELDED_TIP_ACCOUNT_BASE, }; #[cfg(test)] mod viewing_key_bind_tests; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/tips.rs b/packages/rs-platform-wallet/src/wallet/shielded/tips.rs index 1346d3ea00a..48fb2ccef8b 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/tips.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/tips.rs @@ -55,6 +55,21 @@ pub struct IdentityTipAccount { pub verified: bool, } +/// An identity's tip balance as the wallet can vouch for it; see +/// [`PlatformWallet::identity_tip_balance`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IdentityTipBalance { + /// This session has not proved the identity holds its slot; the account + /// may belong to another identity. + Unverified, + /// The slot is verified, but the account's ledger is not established + /// (not bound, or not yet restored or scanned). + Unavailable { account: u32 }, + /// Spendable credits of the identity's tip account, excluding notes + /// reserved by pending spends. + Available { account: u32, credits: u64 }, +} + /// How a tip recipient compares with the one last confirmed for the same /// name from this wallet. #[derive(Debug, Clone, PartialEq, Eq)] @@ -485,6 +500,36 @@ impl PlatformWallet { } } + /// The identity's tip balance: unverified until this session proves its + /// slot, unavailable until the account's ledger is established, and only + /// then the spendable credits. Hosts show these states instead of + /// summing persisted notes, which cannot tell an empty account from one + /// whose ledger was cleared or never restored. + pub async fn identity_tip_balance( + &self, + coordinator: &Arc, + identity_id: &Identifier, + ) -> Result { + let tip = self.identity_tip_account(identity_id).await?; + if !tip.verified { + return Ok(IdentityTipBalance::Unverified); + } + let ledger = coordinator.local_balance_snapshot(self.wallet_id()).await?; + let balance = match ledger { + super::balance::ShieldedLocalBalanceState::Ready(snapshot) => snapshot + .accounts + .get(&tip.account) + .map(|account| IdentityTipBalance::Available { + account: tip.account, + credits: account.spendable_credits, + }), + _ => None, + }; + Ok(balance.unwrap_or(IdentityTipBalance::Unavailable { + account: tip.account, + })) + } + /// Resolve `username` for the user to confirm a tip: its current /// recipient, how that compares with the recipient last confirmed for the /// name from this wallet, and whether an earlier payment to that address @@ -494,6 +539,7 @@ impl PlatformWallet { coordinator: &Arc, username: &str, ) -> Result { + let username = username.trim(); let recipient = self .identity() .dashpay() @@ -578,6 +624,7 @@ impl PlatformWallet { // A bind cannot drop or re-key the paying account while its tip is // between note selection and recording its barrier. let _config = self.shielded_config_lock.lock().await; + let username = username.trim(); let account = self.tip_source_account(seed, source).await?; let current = self .identity() diff --git a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs index 7a851ff0210..6b230aca580 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs @@ -2054,3 +2054,75 @@ async fn should_refuse_a_second_tip_while_one_is_being_sent() { "{refused:?}" ); } + +/// A refresh after discovery keeps the ordinary accounts a host bound and adds +/// the tip accounts of the wallet's identities. Without persisted viewing keys +/// covering the request (this fixture serves none) it asks for the seed and +/// changes nothing. +#[tokio::test] +async fn should_refresh_shielded_accounts_keeping_ordinary_ones() { + use crate::shielded_tip_account_index; + let (wallet, seed, _id, coordinator) = tip_race_fixture("tips_refresh_accounts").await; + wallet + .bind_shielded(&seed, &[0, 1], &coordinator) + .await + .unwrap(); + let before = wallet.shielded_account_indices().await; + assert!(!wallet + .refresh_shielded_accounts(None, &coordinator) + .await + .unwrap()); + assert_eq!(wallet.shielded_account_indices().await, before); + assert!(wallet + .refresh_shielded_accounts(Some(&seed), &coordinator) + .await + .unwrap()); + let bound = wallet.shielded_account_indices().await; + for account in [0, 1, shielded_tip_account_index(2).unwrap()] { + assert!( + bound.contains(&account), + "account {account} bound: {bound:?}" + ); + } +} + +/// The tip balance is unverified until the seed proves the identity's slot, +/// and unavailable, not zero, once Clear removed the account's ledger. +#[tokio::test] +async fn should_report_a_tip_balance_only_for_a_verified_slot_with_a_ledger() { + use crate::shielded_tip_account_index; + use crate::IdentityTipBalance; + let (wallet, seed, id, coordinator) = tip_race_fixture("tips_balance_state").await; + let tip = shielded_tip_account_index(2).unwrap(); + assert_eq!( + wallet + .identity_tip_balance(&coordinator, &id) + .await + .unwrap(), + IdentityTipBalance::Unverified + ); + wallet + .prepare_shielded_tip_address(&seed, &id, &coordinator) + .await + .unwrap(); + let bound = wallet + .identity_tip_balance(&coordinator, &id) + .await + .unwrap(); + assert!( + matches!( + bound, + IdentityTipBalance::Unavailable { account } | IdentityTipBalance::Available { account, credits: 0 } + if account == tip + ), + "{bound:?}" + ); + coordinator.clear().await.unwrap(); + assert_eq!( + wallet + .identity_tip_balance(&coordinator, &id) + .await + .unwrap(), + IdentityTipBalance::Unavailable { account: tip } + ); +} diff --git a/packages/rs-unified-sdk-jni/src/funding.rs b/packages/rs-unified-sdk-jni/src/funding.rs index 39195b90025..f1a86d57f94 100644 --- a/packages/rs-unified-sdk-jni/src/funding.rs +++ b/packages/rs-unified-sdk-jni/src/funding.rs @@ -1501,6 +1501,83 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_abandon }) } +/// Rebind the wallet's shielded accounts after identity discovery; see +/// `platform_wallet_manager_refresh_shielded_accounts`. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_refreshShieldedAccounts( + mut env: JNIEnv, + _class: JClass, + manager: jlong, + wallet_id: JByteArray, + resolver: jlong, +) { + guard(&mut env, (), |env| { + let Some(wid) = read_id32(env, &wallet_id, "walletId") else { + return; + }; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_refresh_shielded_accounts( + manager as Handle, + wid.as_ptr(), + resolver as *mut MnemonicResolverHandle, + ) + }; + let _ = take_pwffi_error(env, result); + }) +} + +/// `[state, account, credits]` for an identity's tip balance, `state` being +/// one of the `IDENTITY_TIP_BALANCE_*` values. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_identityTipBalance( + mut env: JNIEnv, + _class: JClass, + manager: jlong, + wallet_id: JByteArray, + identity_id: JByteArray, +) -> jni::sys::jlongArray { + guard(&mut env, ptr::null_mut(), |env| { + let Some(wid) = read_id32(env, &wallet_id, "walletId") else { + return ptr::null_mut(); + }; + let Some(id) = read_id32(env, &identity_id, "identityId") else { + return ptr::null_mut(); + }; + let (mut state, mut account, mut credits) = (0u32, 0u32, 0u64); + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_identity_tip_balance( + manager as Handle, + wid.as_ptr(), + id.as_ptr(), + &mut state, + &mut account, + &mut credits, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + let Ok(credits) = jlong::try_from(credits) else { + throw_sdk_exception(env, 2, "Tip balance exceeds the largest supported value"); + return ptr::null_mut(); + }; + let Ok(array) = env.new_long_array(3) else { + return ptr::null_mut(); + }; + if env + .set_long_array_region( + &array, + 0, + &[jlong::from(state), jlong::from(account), credits], + ) + .is_err() + { + return ptr::null_mut(); + } + array.into_raw() + }) +} + /// `[account, verified]` for a wallet identity's dedicated tip account; /// `verified` is 1 when this session proved the identity holds its slot. #[no_mangle] diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index f45fb5774bf..023bc6d0939 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -1647,6 +1647,16 @@ public struct ShieldedTipReview: Sendable, Equatable { } } +/// An identity's tip balance as the wallet can vouch for it; decided by Rust. +public enum IdentityTipBalance: Sendable, Equatable { + /// This session has not proved the identity holds its slot. + case unverified + /// Verified, but the account's ledger is not established yet. + case unavailable(account: UInt32) + /// Spendable credits of the identity's tip account. + case available(account: UInt32, credits: UInt64) +} + /// A wallet identity's dedicated tip account. Show its balance as the /// identity's only when `verified`: unverified, the account may belong to /// another identity. @@ -1719,6 +1729,56 @@ extension PlatformWalletManager { }.value } + /// Rebind the wallet's shielded accounts after identity discovery: Rust + /// keeps the wallet's ordinary accounts and adds the tip accounts of newly + /// discovered identities. + public func refreshShieldedAccounts(walletId: Data, resolver: MnemonicResolver) throws { + try ensureShieldedLifecycleAllowed("refreshShieldedAccounts") + try ensureNoShieldedStopInFlight(before: "refreshing shielded accounts") + guard walletId.count == 32, let resolverHandle = resolver.handle else { + throw PlatformWalletError.invalidParameter("Expected a 32-byte wallet ID and a mnemonic resolver") + } + try withShieldedLocalBalanceBind { + try walletId.withUnsafeBytes { wallet in + try platform_wallet_manager_refresh_shielded_accounts( + handle, wallet.baseAddress!.assumingMemoryBound(to: UInt8.self), resolverHandle + ).check() + } + } + } + + /// An identity's tip balance as the wallet can vouch for it. + public func identityTipBalance(walletId: Data, identityId: Data) async throws -> IdentityTipBalance { + guard walletId.count == 32, identityId.count == 32 else { + throw PlatformWalletError.invalidParameter("Expected 32-byte wallet and identity IDs") + } + try admitNativeOp("identityTipBalance") + defer { finishNativeOp() } + let handle = self.handle + return try await Task.detached(priority: .userInitiated) { + var state: UInt32 = 0 + var account: UInt32 = 0 + var credits: UInt64 = 0 + try walletId.withUnsafeBytes { wallet in + try identityId.withUnsafeBytes { identity in + try platform_wallet_manager_identity_tip_balance( + handle, wallet.baseAddress!.assumingMemoryBound(to: UInt8.self), + identity.baseAddress!.assumingMemoryBound(to: UInt8.self), + &state, &account, &credits + ).check() + } + } + switch state { + case UInt32(IDENTITY_TIP_BALANCE_AVAILABLE): + return .available(account: account, credits: credits) + case UInt32(IDENTITY_TIP_BALANCE_UNAVAILABLE): + return .unavailable(account: account) + default: + return .unverified + } + }.value + } + /// Allow new tips although earlier ones from this wallet are unresolved. /// Pass the user's acknowledgement that those tips may still execute; /// returns how many were released. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md index 8ec1334f519..92af86f017e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md @@ -723,15 +723,14 @@ call `sendShieldedTip` with that recipient and a `ShieldedTipSource`: `.identity(id)` pays from that identity's tip account, and `.account(n)` from an ordinary account or a retired identity's tip account. The send verifies that fresh resolution still matches the confirmed recipient. A native failure is thrown as -`ShieldedTipFailure` (also when it fails before the native call, as `.notSent`); send -again only when its `disposition` is `.notSent`, and review -again first, because a refusal for an unresolved earlier tip is also `.notSent`. An -unresolved tip whose outcome stays unknown keeps blocking its recipient; after the -user acknowledges it may still be paid, `abandonUnresolvedShieldedTips` allows new -tips. The -wallet records each tip as unresolved before broadcasting it, so this holds across -relaunches. Show an identity's tip balance only when `identityTipAccount` reports -it `verified`. +`ShieldedTipFailure` (also when it fails before the native call, as `.notSent`). Send +again only when its `disposition` is `.notSent`, and review again first, because a +refusal for an unresolved earlier tip is also `.notSent`. The wallet records each tip as +unresolved before broadcasting it, so this holds across relaunches. A tip whose outcome +stays unknown keeps blocking its recipient until the user acknowledges it may still be +paid; `abandonUnresolvedShieldedTips` then allows new tips. Show an identity's tip +balance from `identityTipBalance`, and call `refreshShieldedAccounts` after identity +discovery. The published address is publicly associated with the username. Dedicated accounts isolate viewing keys and ordinary receiving activity; transfers between From c293c949a4a3010b32a2b7a797d1052d18e1fdc7 Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 1 Nov 2026 16:54:19 -0600 Subject: [PATCH 08/21] fix(platform-wallet): report an unscanned tip account as unavailable, and never wait behind a scan identity_tip_balance read a bound account's ledger as Available once the wallet was hydrated, so a tip account bound after a restore showed zero until its first scan. An account with no restored or scanned history is now Unavailable. The FFI read gets the same 100 ms store budget as the local balance snapshot and fails as busy instead. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/wallet/PlatformWalletManager.kt | 5 ++- .../src/shielded_send.rs | 21 ++++++++---- .../src/wallet/shielded/tips.rs | 9 ++++-- .../wallet/shielded/viewing_key_bind_tests.rs | 32 +++++++++++++------ .../PlatformWalletManagerShieldedSync.swift | 4 ++- 5 files changed, 50 insertions(+), 21 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 96dfade033d..2f6df28844b 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -2036,7 +2036,10 @@ class PlatformWalletManager( } } - /** An identity's tip balance as the wallet can vouch for it. */ + /** + * An identity's tip balance as the wallet can vouch for it. Throws (busy) instead of + * waiting while a scan holds the shielded store; read it again after sync. + */ suspend fun identityTipBalance( walletId: ByteArray, identityId: ByteArray, ): org.dashfoundation.dashsdk.tokens.IdentityTipBalance = teardownGate.op { diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 77e9c4ce322..0bd8f5c1644 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -41,6 +41,7 @@ use std::ffi::CStr; use std::os::raw::c_char; +use std::time::Duration; use dashcore::hashes::Hash; use dpp::address_funds::{OrchardAddress, PlatformAddress}; @@ -2676,7 +2677,9 @@ pub const IDENTITY_TIP_BALANCE_AVAILABLE: u32 = 2; /// The tip balance of identity `identity_id` (32 readable bytes) in wallet /// `wallet_id_bytes` (32 readable bytes); see `PlatformWallet::identity_tip_balance`. /// `out_account` is set unless the state is unverified, `out_credits` only -/// when available; all outputs are zero on error. +/// when available; all outputs are zero on error. Like +/// `platform_wallet_manager_local_shielded_balance_snapshot`, it fails as busy +/// instead of waiting behind a scan that holds the store. #[no_mangle] pub unsafe extern "C" fn platform_wallet_manager_identity_tip_balance( handle: Handle, @@ -2707,15 +2710,21 @@ pub unsafe extern "C" fn platform_wallet_manager_identity_tip_balance( Err(e) => return e, }; match block_on_worker(async move { - wallet - .identity_tip_balance(&coordinator, &identity_id) - .await + tokio::time::timeout( + Duration::from_millis(100), + wallet.identity_tip_balance(&coordinator, &identity_id), + ) + .await }) { - Ok(value) => { + Ok(Ok(value)) => { balance = Some(value); PlatformWalletFFIResult::ok() } - Err(e) => e.into(), + Ok(Err(e)) => e.into(), + Err(_) => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "identity tip balance busy; retry after sync releases its locks", + ), } }); match balance { diff --git a/packages/rs-platform-wallet/src/wallet/shielded/tips.rs b/packages/rs-platform-wallet/src/wallet/shielded/tips.rs index 48fb2ccef8b..ed95f7e6983 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/tips.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/tips.rs @@ -8,6 +8,7 @@ //! reconstructs it even after publication was removed. Address rotation uses //! another diversifier in the same account, so all old addresses remain covered. +use super::balance::{ShieldedBalanceSource, ShieldedLocalBalanceState}; use super::store::{ShieldedStore, TipBarrierStatus}; use super::NetworkShieldedCoordinator; use crate::wallet::platform_wallet::{PlatformWallet, WalletId}; @@ -501,8 +502,9 @@ impl PlatformWallet { } /// The identity's tip balance: unverified until this session proves its - /// slot, unavailable until the account's ledger is established, and only - /// then the spendable credits. Hosts show these states instead of + /// slot, unavailable until the account's ledger is established (restored + /// or scanned; a freshly bound account has neither), and only then the + /// spendable credits. Hosts show these states instead of /// summing persisted notes, which cannot tell an empty account from one /// whose ledger was cleared or never restored. pub async fn identity_tip_balance( @@ -516,9 +518,10 @@ impl PlatformWallet { } let ledger = coordinator.local_balance_snapshot(self.wallet_id()).await?; let balance = match ledger { - super::balance::ShieldedLocalBalanceState::Ready(snapshot) => snapshot + ShieldedLocalBalanceState::Ready(snapshot) => snapshot .accounts .get(&tip.account) + .filter(|account| account.source != ShieldedBalanceSource::NoHistory) .map(|account| IdentityTipBalance::Available { account: tip.account, credits: account.spendable_credits, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs index 6b230aca580..df0f826161b 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs @@ -2087,7 +2087,8 @@ async fn should_refresh_shielded_accounts_keeping_ordinary_ones() { } /// The tip balance is unverified until the seed proves the identity's slot, -/// and unavailable, not zero, once Clear removed the account's ledger. +/// unavailable, not zero, until the account is scanned, and unavailable again +/// once Clear removed the account's ledger. #[tokio::test] async fn should_report_a_tip_balance_only_for_a_verified_slot_with_a_ledger() { use crate::shielded_tip_account_index; @@ -2105,17 +2106,28 @@ async fn should_report_a_tip_balance_only_for_a_verified_slot_with_a_ledger() { .prepare_shielded_tip_address(&seed, &id, &coordinator) .await .unwrap(); - let bound = wallet - .identity_tip_balance(&coordinator, &id) + assert_eq!( + wallet + .identity_tip_balance(&coordinator, &id) + .await + .unwrap(), + IdentityTipBalance::Unavailable { account: tip } + ); + coordinator + .store() + .write() .await + .set_last_synced_note_index(SubwalletId::new(wallet.wallet_id(), tip), 0) .unwrap(); - assert!( - matches!( - bound, - IdentityTipBalance::Unavailable { account } | IdentityTipBalance::Available { account, credits: 0 } - if account == tip - ), - "{bound:?}" + assert_eq!( + wallet + .identity_tip_balance(&coordinator, &id) + .await + .unwrap(), + IdentityTipBalance::Available { + account: tip, + credits: 0 + } ); coordinator.clear().await.unwrap(); assert_eq!( diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index 023bc6d0939..8d282165741 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -1747,7 +1747,9 @@ extension PlatformWalletManager { } } - /// An identity's tip balance as the wallet can vouch for it. + /// An identity's tip balance as the wallet can vouch for it. Throws + /// (busy) instead of waiting while a scan holds the shielded store; read + /// it again after sync. public func identityTipBalance(walletId: Data, identityId: Data) async throws -> IdentityTipBalance { guard walletId.count == 32, identityId.count == 32 else { throw PlatformWalletError.invalidParameter("Expected 32-byte wallet and identity IDs") From 84cfd418b14c3a7dce7e36a91c9ffe4236c6d73a Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 1 Nov 2026 17:16:54 -0600 Subject: [PATCH 09/21] fix(platform-wallet): refuse Clear during a tip send, and never release a tip on a re-drive rejection A tip send is now admitted under the coordinator's lifecycle lock before it selects notes. Clear, and a bind dropping or re-keying the wallet's accounts, is refused while one is admitted, so a Clear can no longer land before the tip's barrier exists and purge what it relies on. The sync re-drive treated a node's consensus rejection of a re-broadcast tip as definitive: it released the notes, dropped the recovery record and marked the activity failed. A rejection is unauthenticated, so for a tip whose barrier is unsettled it now counts as inconclusive. Also: - a tip settlement whose write fails is retried by later mark_spent calls instead of leaving the barrier pending; - the recipient-history key uses the SDK's normalize_dpns_label; - Swift refuses a username with an embedded NUL, as JNI does; - tests cover a host that cannot store or flush the tip's activity row. Co-Authored-By: Claude Opus 5.5 --- .../rs-platform-wallet/docs/SHIELDED_TIPS.md | 8 +- .../src/wallet/shielded/coordinator.rs | 95 ++++++++- .../src/wallet/shielded/file_store.rs | 95 ++++++++- .../src/wallet/shielded/operations.rs | 181 ++++++++++++++++-- .../src/wallet/shielded/tips.rs | 61 ++++-- .../wallet/shielded/viewing_key_bind_tests.rs | 77 ++++++++ .../rs-sdk/src/platform/dpns_usernames/mod.rs | 4 +- .../PlatformWalletManagerShieldedSync.swift | 13 +- 8 files changed, 478 insertions(+), 56 deletions(-) diff --git a/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md b/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md index 060b49209d2..5eaacb3b253 100644 --- a/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md +++ b/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md @@ -130,12 +130,16 @@ whose wallet cannot be read blocks every wallet's tips. A barrier settles as executed when the store sees one of its notes spent, in the same step that resolves the note and before its recovery record goes. It settles as failed only when the tip never left the device (no node address was available): a rejection -a node reports, at broadcast or in the wait for the result, is unauthenticated and -leaves the tip unresolved. A tip whose outcome stays unknown therefore stays pending +a node reports, at broadcast, in the wait for the result or to a sync re-drive, is +unauthenticated and leaves the tip unresolved. A failed settlement write is retried at +the next sync pass. A tip whose outcome stays unknown therefore stays pending until its notes are seen spent, or until the user, acknowledging that it may still execute, calls `abandon_unresolved_shielded_tips`, which also drops that wallet's unreadable barriers. While a tip is pending, a bind that would drop or re-key its paying account and a shielded Clear are refused; a refused Clear leaves background sync running. +The same refusals apply from the moment a send is admitted, before its notes are +selected, so a Clear cannot land before the barrier exists. Removing the wallet waits for +its send to end. After a failure, `ShieldedTipDisposition::of(error)` decides whether the tip may be sent again: `NotSent` (nothing broadcast, or definitively rejected) or `Unresolved` (do not diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index 98e3f88b3fa..995c4b70411 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -263,6 +263,41 @@ pub struct NetworkShieldedCoordinator { /// counter before the load and re-reading it inside the transaction /// makes that snapshot detectably stale. clear_generation: std::sync::atomic::AtomicU64, + + /// Tip sends in flight per wallet, from admission to their outcome. + /// Admission takes `lifecycle`, so a send is never admitted during a + /// Clear, and Clear (and a bind dropping or re-keying the wallet's + /// accounts) is refused while one is admitted: before its barrier + /// exists, nothing else marks its notes and account as in use. + tip_sends: std::sync::Mutex>, + /// Notified whenever an admitted tip send ends. + tip_send_ended: tokio::sync::Notify, +} + +/// A tip send's admission; see +/// [`admit_tip_send`](NetworkShieldedCoordinator::admit_tip_send). Dropping +/// it ends the send's claim. +pub(crate) struct TipSendAdmission<'a> { + coordinator: &'a NetworkShieldedCoordinator, + wallet_id: WalletId, +} + +impl Drop for TipSendAdmission<'_> { + fn drop(&mut self) { + let mut sends = self + .coordinator + .tip_sends + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(count) = sends.get_mut(&self.wallet_id) { + *count -= 1; + if *count == 0 { + sends.remove(&self.wallet_id); + } + } + drop(sends); + self.coordinator.tip_send_ended.notify_waiters(); + } } /// Exclusive handle on one wallet's shielded install transaction, @@ -401,6 +436,36 @@ impl NetworkShieldedCoordinator { lifecycle: tokio::sync::Mutex::new(()), hydrated: RwLock::new(std::collections::BTreeSet::new()), clear_generation: std::sync::atomic::AtomicU64::new(0), + tip_sends: std::sync::Mutex::new(BTreeMap::new()), + tip_send_ended: tokio::sync::Notify::new(), + } + } + + /// Admit a tip send for `wallet_id`, waiting out any Clear or bind + /// transaction in progress. Hold the admission until the send returns. + pub(crate) async fn admit_tip_send(&self, wallet_id: WalletId) -> TipSendAdmission<'_> { + let _lifecycle = self.lifecycle.lock().await; + *self + .tip_sends + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .entry(wallet_id) + .or_default() += 1; + TipSendAdmission { + coordinator: self, + wallet_id, + } + } + + /// Whether a tip send is admitted for `wallet_id`, or for any wallet. + fn tip_send_in_flight(&self, wallet_id: Option) -> bool { + let sends = self + .tip_sends + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match wallet_id { + Some(wallet_id) => sends.contains_key(&wallet_id), + None => !sends.is_empty(), } } @@ -693,6 +758,9 @@ impl NetworkShieldedCoordinator { // may not yet be in the registry. An initial registration must cover // those durable owners. Later binds that only retain/add accounts cannot // displace an owner, so they keep their store-lock-free fast path. + if !stale.is_empty() && self.tip_send_in_flight(Some(wallet_id)) { + return Err(PlatformWalletError::ShieldedTipPending); + } if !stale.is_empty() || registered_count == 0 { let store = self.store.read().await; // A pending tip settles only through its paying account's scan @@ -890,7 +958,8 @@ impl NetworkShieldedCoordinator { /// checkpoints). The shared commitment tree is left intact — /// it's a chain-wide structure, not per-wallet. No-op for /// parts that weren't present. Called when a wallet is - /// removed from the manager. + /// removed from the manager. Waits for the wallet's tip sends + /// in flight to end first. /// /// Purging the store watermark matters: without it, a later /// re-bind of the same wallet would resume from the stale @@ -904,6 +973,18 @@ impl NetworkShieldedCoordinator { // restore could repopulate (and re-mark hydrated) the state // purged below after the purge ran. let _lifecycle = self.lifecycle.lock().await; + // Wait out the wallet's admitted tip sends (none is admitted while + // `lifecycle` is held): one finishing after the purge would record a + // barrier nothing can settle, blocking Clear for good. + loop { + let ended = self.tip_send_ended.notified(); + tokio::pin!(ended); + ended.as_mut().enable(); + if !self.tip_send_in_flight(Some(wallet_id)) { + break; + } + ended.await; + } self.accounts .write() .await @@ -1159,9 +1240,12 @@ impl NetworkShieldedCoordinator { Ok(()) } - /// Whether any wallet holds a pending (or unreadable) shielded tip; - /// [`Self::clear`] is refused while one does. + /// Whether any wallet is sending a tip or holds a pending (or unreadable) + /// one; [`Self::clear`] is refused while one does. pub async fn has_pending_tips(&self) -> Result { + if self.tip_send_in_flight(None) { + return Ok(true); + } self.store .read() .await @@ -1229,6 +1313,11 @@ impl NetworkShieldedCoordinator { // Taken before the store lock to keep the `lifecycle` → `store` // order. let _lifecycle = self.lifecycle.lock().await; + // A send admitted before this Clear may not have recorded its barrier + // yet; purging under it would lose the tip's notes and recovery. + if self.tip_send_in_flight(None) { + return Err(crate::error::PlatformWalletError::ShieldedTipPending); + } // Reset the persistent store FIRST and bail before mutating any // coordinator registries if it fails. Clearing `accounts` / `persisters` diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index beeb22f2a1f..79f48133942 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -11,7 +11,7 @@ //! tree (or, when the host persister is wired up, restored from //! SwiftData before sync runs). -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::error::Error as StdError; use std::fmt; use std::path::{Path, PathBuf}; @@ -123,6 +123,10 @@ pub struct FileBackedShieldedStore { /// is unknown, so it blocks every wallet's tips and no wallet's /// acknowledgement releases it. unattributed_tip_barrier_damage: bool, + /// Executed tips whose settlement did not persist. The scan may not see + /// their nullifiers again, so each watermark advance retries them; the + /// barrier stays pending (refusing) until one succeeds. + unsettled_tip_executions: BTreeSet<(WalletId, [u8; 32])>, } impl FileBackedShieldedStore { @@ -276,6 +280,7 @@ impl FileBackedShieldedStore { tip_barriers: BTreeMap::new(), damaged_tip_barrier_rows: BTreeMap::new(), unattributed_tip_barrier_damage: false, + unsettled_tip_executions: BTreeSet::new(), }; store.rehydrate_pending_spends()?; store.load_tip_barriers()?; @@ -509,6 +514,20 @@ impl FileBackedShieldedStore { /// Write `status` for `wallet_id`'s barrier `activity_id` (unless it is /// executed already): durably first, then in memory. + /// Settle each tip as executed, keeping those whose write fails for a + /// retry. + fn settle_executed_tips(&mut self, tips: Vec<(WalletId, [u8; 32])>) { + for (wallet_id, activity_id) in tips { + if let Err(e) = + self.set_tip_barrier_status(wallet_id, &activity_id, TipBarrierStatus::Executed) + { + tracing::warn!(error = %e, "tip barrier settlement not persisted; retrying later"); + self.unsettled_tip_executions + .insert((wallet_id, activity_id)); + } + } + } + fn set_tip_barrier_status( &mut self, wallet_id: WalletId, @@ -723,14 +742,13 @@ impl ShieldedStore for FileBackedShieldedStore { fn mark_spent(&mut self, id: SubwalletId, nullifier: &[u8; 32]) -> Result { // Settle the tip barrier before the recovery row below goes, so a // crash between them never loses an executed tip's record. A failed - // write leaves it pending (fail closed). - for activity_id in tip_barriers_spending(&self.tip_barriers, id.wallet_id, nullifier) { - if let Err(e) = - self.set_tip_barrier_status(id.wallet_id, &activity_id, TipBarrierStatus::Executed) - { - tracing::warn!(error = %e, "tip barrier settlement not persisted; it stays pending"); - } - } + // write leaves it pending (fail closed) and is retried at the next + // watermark advance. + let executed = tip_barriers_spending(&self.tip_barriers, id.wallet_id, nullifier) + .into_iter() + .map(|activity_id| (id.wallet_id, activity_id)) + .collect(); + self.settle_executed_tips(executed); let Some(sw) = self.subwallets.get_mut(&id) else { return Ok(false); }; @@ -1058,6 +1076,10 @@ impl ShieldedStore for FileBackedShieldedStore { id: SubwalletId, index: u64, ) -> Result<(), Self::Error> { + // Once per pass, not per scanned nullifier, so a persistent write + // fault does not multiply failing updates. + let retries = std::mem::take(&mut self.unsettled_tip_executions); + self.settle_executed_tips(retries.into_iter().collect()); let state = self.subwallets.entry(id).or_default(); state.last_synced_index = index; state.last_scanned_index = Some(index); @@ -1104,6 +1126,8 @@ impl ShieldedStore for FileBackedShieldedStore { self.tip_barriers .retain(|(wallet, _), _| *wallet != wallet_id); self.damaged_tip_barrier_rows.remove(&wallet_id); + self.unsettled_tip_executions + .retain(|(wallet, _)| *wallet != wallet_id); Ok(()) } @@ -1848,6 +1872,59 @@ mod tests { let _ = std::fs::remove_file(&path); } + /// A confirmation whose settlement write fails is retried at the next + /// watermark advance, even when the scan never sees that nullifier again, + /// so the executed tip settles without the user abandoning it. + #[test] + fn a_failed_tip_settlement_is_retried_until_it_persists() { + let path = temp_tree_path("tip_settlement_retry"); + let wallet = [0xC5; 32]; + let id = SubwalletId::new(wallet, 0); + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); + store + .record_tip_barrier( + wallet, + &TipBarrier { + activity_id: [1; 32], + account_index: 0, + recipient: [7; 43], + nullifiers: vec![[0x11; 32]], + anchor: [9; 32], + status: TipBarrierStatus::Pending, + }, + ) + .unwrap(); + store + .pending_conn + .lock() + .unwrap() + .execute_batch( + "CREATE TRIGGER reject_settlement BEFORE UPDATE ON shielded_tip_barriers \ + BEGIN SELECT RAISE(FAIL, 'injected settlement failure'); END;", + ) + .unwrap(); + store.mark_spent(id, &[0x11; 32]).unwrap(); + assert!(store.has_pending_tip_barriers().unwrap()); + store + .pending_conn + .lock() + .unwrap() + .execute_batch("DROP TRIGGER reject_settlement;") + .unwrap(); + // An unrelated nullifier does not retry; the pass's watermark does. + store.mark_spent(id, &[0x99; 32]).unwrap(); + assert!(store.has_pending_tip_barriers().unwrap()); + store.set_last_synced_note_index(id, 1).unwrap(); + assert!(!store.has_pending_tip_barriers().unwrap()); + drop(store); + let store = FileBackedShieldedStore::open_path(&path, 100).expect("reopen"); + assert_eq!( + store.tip_barriers(wallet).unwrap()[0].status, + TipBarrierStatus::Executed + ); + let _ = std::fs::remove_file(&path); + } + /// A tip barrier survives a reopen and the purges that rebuild account /// state, settles durably when its note is seen spent or its own send /// failed definitively (a released reservation does not settle it), goes diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index d35df803f88..95086d90a23 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -3282,21 +3282,56 @@ enum RedriveBroadcastOutcome { /// `NullifierAlreadySpent`: the ORIGINAL broadcast executed — a /// success signal, never a failure. AlreadyExecuted, - /// Any other consensus verdict: the transition can never execute. + /// Any other consensus verdict: the transition can never execute. Never + /// for a guarded tip, whose outcome only the scan proves; while a + /// wallet's barriers are unreadable that holds for all its re-drives, + /// which then wait for the prune backstop. DefinitiveRejection, /// Transport noise / `AlreadyExists` / anything non-definitive. Inconclusive, } -fn classify_redrive_broadcast(result: &Result<(), dash_sdk::Error>) -> RedriveBroadcastOutcome { +/// `guarded_tip`: the re-driven spend is a tip whose barrier is unsettled. A +/// node's rejection is unauthenticated (an endpoint may have relayed the +/// original and fabricated it, or lag one that accepted it), so for a tip it +/// releases nothing: reservations, recovery, activity and barrier stay until +/// the scan settles the tip or the user abandons it. +fn classify_redrive_broadcast( + result: &Result<(), dash_sdk::Error>, + guarded_tip: bool, +) -> RedriveBroadcastOutcome { match result { Ok(()) => RedriveBroadcastOutcome::Accepted, Err(e) if is_nullifier_already_spent(e) => RedriveBroadcastOutcome::AlreadyExecuted, - Err(e) if carries_consensus_rejection(e) => RedriveBroadcastOutcome::DefinitiveRejection, + Err(e) if !guarded_tip && carries_consensus_rejection(e) => { + RedriveBroadcastOutcome::DefinitiveRejection + } Err(_) => RedriveBroadcastOutcome::Inconclusive, } } +/// Whether `activity_id` is a tip with an unsettled barrier (pending or +/// abandoned). An unreadable barrier set counts as one, failing closed. +fn is_guarded_tip( + store: &S, + wallet_id: WalletId, + activity_id: &[u8; 32], +) -> bool { + match store.tip_barriers(wallet_id) { + Ok(barriers) => barriers.iter().any(|barrier| { + barrier.activity_id == *activity_id + && matches!( + barrier.status, + TipBarrierStatus::Pending | TipBarrierStatus::Abandoned + ) + }), + Err(e) => { + warn!(error = %e, "redrive: tip barriers unreadable; treating the spend as a guarded tip"); + true + } + } +} + /// Bump a redrive attempt counter, logging (rather than discarding) a /// persistence failure. On `Err` the durable counter did not advance and /// the file store's persist-first ordering leaves memory untouched, so @@ -3473,13 +3508,14 @@ pub(super) async fn redrive_pending_spends( continue; } }; + let guarded_tip = is_guarded_tip(&*store.read().await, wallet_id, &redrive.activity_id); let broadcast_result = st.broadcast(sdk, None).await; let err_display = broadcast_result .as_ref() .err() .map(|e| e.to_string()) .unwrap_or_default(); - match classify_redrive_broadcast(&broadcast_result) { + match classify_redrive_broadcast(&broadcast_result, guarded_tip) { RedriveBroadcastOutcome::Accepted => { let attempts = bump_redrive_attempts_logged(store, id, &redrive.activity_id).await; info!( @@ -4034,13 +4070,46 @@ mod redrive_tests { assert!(!is_nullifier_already_spent(&other)); } + /// A re-driven spend is a guarded tip while its barrier is pending or + /// abandoned, or when barriers are unreadable; a failed or executed + /// barrier, or none, leaves the ordinary rejection handling. + #[test] + fn should_guard_a_redriven_tip_until_its_barrier_settles() { + let mut store = InMemoryShieldedStore::new(); + let wallet_id = [0x31; 32]; + let activity_id = [0x41; 32]; + assert!(!is_guarded_tip(&store, wallet_id, &activity_id)); + store + .record_tip_barrier( + wallet_id, + &TipBarrier { + activity_id, + account_index: 0, + recipient: [0; 43], + nullifiers: vec![[0x51; 32]], + anchor: [0x61; 32], + status: TipBarrierStatus::Pending, + }, + ) + .unwrap(); + assert!(is_guarded_tip(&store, wallet_id, &activity_id)); + assert!(!is_guarded_tip(&store, wallet_id, &[0x42; 32])); + assert!(!is_guarded_tip(&store, [0x32; 32], &activity_id)); + store.abandon_tip_barriers(wallet_id).unwrap(); + assert!(is_guarded_tip(&store, wallet_id, &activity_id)); + store + .settle_tip_barrier(wallet_id, &activity_id, TipBarrierStatus::Failed) + .unwrap(); + assert!(!is_guarded_tip(&store, wallet_id, &activity_id)); + } + /// The re-broadcast outcome classifier, arm order included: /// `NullifierAlreadySpent` is itself a consensus error, so the /// `AlreadyExecuted` arm must win over `DefinitiveRejection`. #[test] fn redrive_broadcast_classification_matrix() { assert_eq!( - classify_redrive_broadcast(&Ok(())), + classify_redrive_broadcast(&Ok(()), false), RedriveBroadcastOutcome::Accepted ); @@ -4054,35 +4123,41 @@ mod redrive_tests { cause: Some(already_spent), }); assert_eq!( - classify_redrive_broadcast(&Err(already_spent_err)), + classify_redrive_broadcast(&Err(already_spent_err), false), RedriveBroadcastOutcome::AlreadyExecuted, "already-spent must classify as success BEFORE the generic rejection arm" ); - let other_rejection = ConsensusError::BasicError( - dpp::consensus::basic::BasicError::ProtocolVersionParsingError( - dpp::consensus::basic::decode::ProtocolVersionParsingError::new( - "bad version".to_string(), - ), - ), - ); - let rejection_err = + let rejection_err = || { dash_sdk::Error::StateTransitionBroadcastError(StateTransitionBroadcastError { code: 1, message: "state error".to_string(), - cause: Some(other_rejection), - }); + cause: Some(ConsensusError::BasicError( + dpp::consensus::basic::BasicError::ProtocolVersionParsingError( + dpp::consensus::basic::decode::ProtocolVersionParsingError::new( + "bad version".to_string(), + ), + ), + )), + }) + }; assert_eq!( - classify_redrive_broadcast(&Err(rejection_err)), + classify_redrive_broadcast(&Err(rejection_err()), false), RedriveBroadcastOutcome::DefinitiveRejection ); + // A guarded tip's rejection claim proves nothing; already-spent still + // reads as executed. + assert_eq!( + classify_redrive_broadcast(&Err(rejection_err()), true), + RedriveBroadcastOutcome::Inconclusive + ); let timeout = dash_sdk::Error::TimeoutReached( std::time::Duration::from_secs(1), "waiting".to_string(), ); assert_eq!( - classify_redrive_broadcast(&Err(timeout)), + classify_redrive_broadcast(&Err(timeout), false), RedriveBroadcastOutcome::Inconclusive ); } @@ -5111,6 +5186,76 @@ mod parked_spend_guard_tests { assert_eq!(barriers[0].nullifiers, vec![[5; 32]]); } + /// A host that cannot store or flush the tip's activity row refuses the + /// tip before broadcast: nothing is armed, and a failed store is not + /// followed by a flush. + #[tokio::test] + async fn should_refuse_a_tip_whose_activity_row_cannot_be_persisted() { + use crate::changeset::{ClientStartState, PersistenceError, PlatformWalletPersistence}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct Failing { + fail_store: bool, + flushes: AtomicUsize, + } + impl PlatformWalletPersistence for Failing { + fn store( + &self, + _wallet_id: WalletId, + _changeset: PlatformWalletChangeSet, + ) -> Result<(), crate::changeset::PersistenceError> { + if self.fail_store { + Err(PersistenceError::backend(std::io::Error::other("store"))) + } else { + Ok(()) + } + } + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + self.flushes.fetch_add(1, Ordering::SeqCst); + Err(PersistenceError::backend(std::io::Error::other("flush"))) + } + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + + for fail_store in [true, false] { + let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); + let id = SubwalletId::new([1; 32], 0); + let backend = Arc::new(Failing { + fail_store, + flushes: AtomicUsize::new(0), + }); + let persister = WalletPersister::new(id.wallet_id, backend.clone()); + let refused = arm_parked_spend_guard( + &store, + Some(&persister), + id, + &Some(sent_entry()), + &[4; 43], + [3; 32], + &[spent_note()], + &transfer(), + ) + .await; + assert!( + matches!(refused, Err(PlatformWalletError::Persistence(_))), + "{refused:?}" + ); + assert_eq!( + backend.flushes.load(Ordering::SeqCst), + usize::from(!fail_store) + ); + assert!(store.read().await.pending_redrives(id).unwrap().is_empty()); + assert!(store + .read() + .await + .tip_barriers(id.wallet_id) + .unwrap() + .is_empty()); + } + } + /// An ambiguous result opens a still-armed guard to the re-drive, and /// re-creates nothing once a scan has dropped it. #[tokio::test] diff --git a/packages/rs-platform-wallet/src/wallet/shielded/tips.rs b/packages/rs-platform-wallet/src/wallet/shielded/tips.rs index ed95f7e6983..a26f3e34a6e 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/tips.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/tips.rs @@ -217,16 +217,11 @@ fn has_unresolved_tip_to( } } -/// The key a confirmed recipient is stored under: the DPNS label with an -/// optional `.dash` suffix removed, in homograph-safe form, as name -/// resolution matches it. +/// The key a confirmed recipient is stored under: the trimmed name +/// normalized by the SDK's own DPNS resolver normalization, so the key always +/// names what resolution matched. The tip calls trim the username first. fn tip_recipient_label(username: &str) -> String { - let name = username.trim(); - let label = match name.rfind('.') { - Some(dot) if name[dot..].eq_ignore_ascii_case(".dash") => &name[..dot], - _ => name, - }; - dash_sdk::platform::dpns_usernames::convert_to_homograph_safe_chars(label) + dash_sdk::platform::dpns_usernames::normalize_dpns_label(username.trim()) } /// Prove `identity_index` with the seed: the identity must carry the MASTER @@ -623,6 +618,11 @@ impl PlatformWallet { .tip_send_in_progress .try_lock() .map_err(|_| PlatformWalletError::ShieldedTipPending)?; + // Clear is refused from here until the send returns; afterwards an + // unresolved tip's barrier keeps refusing it. + let _admission = coordinator.admit_tip_send(self.wallet_id()).await; + #[cfg(test)] + test_hooks::reach(self, test_hooks::Point::AfterTipAdmission).await; let _placement = self.identity.identity_placement.read().await; // A bind cannot drop or re-key the paying account while its tip is // between note selection and recording its barrier. @@ -678,9 +678,9 @@ impl PlatformWallet { } } -/// Test-only pause point between tip preparation's account snapshot and -/// its bind, keyed by wallet instance (shared by its clones) so parallel -/// tests built from the same mnemonic do not interfere. +/// Test-only pause points in tip preparation and sending, keyed by wallet +/// instance (shared by its clones) so parallel tests built from the same +/// mnemonic do not interfere. #[cfg(test)] pub(crate) mod test_hooks { use super::PlatformWallet; @@ -688,39 +688,58 @@ pub(crate) mod test_hooks { use std::sync::{Arc, Mutex}; use tokio::sync::oneshot; + #[derive(Clone, Copy, PartialEq, Eq, Hash)] + pub(crate) enum Point { + /// Between preparation's account snapshot and its bind. + AfterSnapshot, + /// A tip send just after its admission, before any resolution. + AfterTipAdmission, + } + type Hook = (oneshot::Sender<()>, oneshot::Receiver<()>); - static AFTER_SNAPSHOT: Mutex>> = Mutex::new(None); + static HOOKS: Mutex>> = Mutex::new(None); fn key(wallet: &PlatformWallet) -> usize { Arc::as_ptr(&wallet.shielded_config_lock) as usize } - /// Arm the pause for `wallet`. Returns a receiver that fires when - /// preparation reaches the pause, and the sender that resumes it. - pub(crate) fn pause_after_snapshot( + /// Arm `point` for `wallet`. Returns a receiver that fires when the + /// operation reaches it, and the sender that resumes it. + pub(crate) fn pause_at( wallet: &PlatformWallet, + point: Point, ) -> (oneshot::Receiver<()>, oneshot::Sender<()>) { let (reached_tx, reached_rx) = oneshot::channel(); let (resume_tx, resume_rx) = oneshot::channel(); - AFTER_SNAPSHOT + HOOKS .lock() .unwrap() .get_or_insert_with(HashMap::new) - .insert(key(wallet), (reached_tx, resume_rx)); + .insert((key(wallet), point), (reached_tx, resume_rx)); (reached_rx, resume_tx) } - pub(crate) async fn after_snapshot(wallet: &PlatformWallet) { - let hook = AFTER_SNAPSHOT + pub(crate) fn pause_after_snapshot( + wallet: &PlatformWallet, + ) -> (oneshot::Receiver<()>, oneshot::Sender<()>) { + pause_at(wallet, Point::AfterSnapshot) + } + + pub(crate) async fn reach(wallet: &PlatformWallet, point: Point) { + let hook = HOOKS .lock() .unwrap() .as_mut() - .and_then(|hooks| hooks.remove(&key(wallet))); + .and_then(|hooks| hooks.remove(&(key(wallet), point))); if let Some((reached, resume)) = hook { let _ = reached.send(()); let _ = resume.await; } } + + pub(crate) async fn after_snapshot(wallet: &PlatformWallet) { + reach(wallet, Point::AfterSnapshot).await; + } } #[cfg(test)] diff --git a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs index df0f826161b..78feafad53c 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs @@ -2055,6 +2055,83 @@ async fn should_refuse_a_second_tip_while_one_is_being_sent() { ); } +/// A tip send is admitted before it selects notes, so a Clear landing before +/// its barrier exists is refused rather than purging what the send relies +/// on; so is a bind dropping the wallet's accounts. Both proceed once the +/// send ends. +#[tokio::test] +async fn should_refuse_clear_and_account_drops_while_a_tip_send_is_admitted() { + use super::tips::test_hooks::{pause_at, Point}; + let (wallet, seed, _id, coordinator) = tip_race_fixture("tips_clear_admission").await; + wallet + .bind_shielded(&seed, &[0, 1], &coordinator) + .await + .unwrap(); + let (reached, _resume) = pause_at(&wallet, Point::AfterTipAdmission); + let send = tokio::spawn({ + let (wallet, coordinator) = (wallet.clone(), Arc::clone(&coordinator)); + async move { + wallet + .send_shielded_tip( + &coordinator, + &seed, + crate::ShieldedTipSource::Account(0), + "recipient.dash", + &crate::ShieldedTipRecipient { + identity_id: dpp::prelude::Identifier::from([0x77; 32]), + address: [0; 43], + }, + 1_000, + [0; 36], + &super::CachedOrchardProver::new(), + ) + .await + } + }); + reached.await.unwrap(); + let bound = wallet.shielded_account_indices().await; + + assert!(coordinator.has_pending_tips().await.unwrap()); + let cleared = coordinator.clear().await; + assert!( + matches!(cleared, Err(crate::PlatformWalletError::ShieldedTipPending)), + "{cleared:?}" + ); + let dropped = wallet.bind_shielded(&seed, &[0], &coordinator).await; + assert!( + matches!(dropped, Err(crate::PlatformWalletError::ShieldedTipPending)), + "{dropped:?}" + ); + assert_eq!(wallet.shielded_account_indices().await, bound); + + send.abort(); + let _ = send.await; + assert!(!coordinator.has_pending_tips().await.unwrap()); + coordinator.clear().await.unwrap(); +} + +/// Removing a wallet waits for its admitted tip send to end, so the send +/// cannot record a barrier after the purge that nothing could settle. +#[tokio::test] +async fn should_remove_a_wallet_only_after_its_tip_send_ends() { + let (wallet, _seed, _id, coordinator) = tip_race_fixture("tips_remove_admission").await; + let wallet_id = wallet.wallet_id(); + let admission = coordinator.admit_tip_send(wallet_id).await; + let removal = tokio::spawn({ + let coordinator = Arc::clone(&coordinator); + async move { coordinator.unregister_wallet(wallet_id).await } + }); + for _ in 0..10 { + tokio::task::yield_now().await; + } + assert!(!removal.is_finished()); + drop(admission); + tokio::time::timeout(std::time::Duration::from_secs(5), removal) + .await + .expect("removal proceeds once the send ends") + .unwrap(); +} + /// A refresh after discovery keeps the ordinary accounts a host bound and adds /// the tip accounts of the wallet's identities. Without persisted viewing keys /// covering the request (this fixture serves none) it asks for the seed and diff --git a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs index d4677138e99..574bf53140b 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs @@ -51,8 +51,8 @@ fn extract_dpns_label(name: &str) -> &str { /// /// Accepts either a bare label (e.g. `"alice"`) or a full DPNS name /// (e.g. `"alice.dash"`, `"Alice.DASH"`) and returns the normalized label -/// (e.g. `"a11ce"`). -fn normalize_dpns_label(input: &str) -> String { +/// (e.g. `"a11ce"`). Surrounding whitespace is kept; callers trim first. +pub fn normalize_dpns_label(input: &str) -> String { convert_to_homograph_safe_chars(extract_dpns_label(input)) } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index 8d282165741..35901ca45c6 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -1689,6 +1689,7 @@ extension ManagedPlatformWallet { /// Fetch and verify the current DPNS identity and shielded tip address. public func resolveShieldedTip(username: String) async throws -> ShieldedTipRecipient { + try requireNoEmbeddedNul(username) // `deinit` destroys `handle`, so the wallet stays alive for the native call. return try await Task.detached(priority: .userInitiated) { [self] in try withExtendedLifetime(self) { @@ -1810,6 +1811,7 @@ extension PlatformWalletManager { guard walletId.count == 32 else { throw PlatformWalletError.invalidParameter("Expected a 32-byte wallet ID") } + try requireNoEmbeddedNul(username) try admitNativeOp("reviewShieldedTip") defer { finishNativeOp() } let handle = self.handle @@ -1833,9 +1835,10 @@ extension PlatformWalletManager { public func sendShieldedTip(walletId: Data, resolver: MnemonicResolver, source: ShieldedTipSource, username: String, recipient: ShieldedTipRecipient, amount: UInt64) async throws { guard walletId.count == 32, recipient.identityId.count == 32, recipient.address.count == 43, + !username.utf8.contains(0), let sourceFFI = source.ffi, let resolverHandle = resolver.handle else { throw ShieldedTipFailure(disposition: .notSent, underlying: - PlatformWalletError.invalidParameter("Invalid shielded tip source, recipient or wallet")) + PlatformWalletError.invalidParameter("Invalid shielded tip source, recipient, username or wallet")) } do { try admitNativeOp("sendShieldedTip") @@ -1871,6 +1874,14 @@ extension PlatformWalletManager { } } +/// The C exports read a username up to its first NUL; refuse it rather than +/// resolve a prefix (JNI refuses the same input). +private func requireNoEmbeddedNul(_ username: String) throws { + guard !username.utf8.contains(0) else { + throw PlatformWalletError.invalidParameter("Username contains an embedded NUL") + } +} + extension PlatformWalletManager { /// Exact credits for a tip amount typed in DASH, parsed by Rust; throws on /// ambiguous input (signs, grouping, exponents, sub-credit fractions). From 0dfe1513b6f7ca94c8857efb5efee99be7fd78bc Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 1 Nov 2026 17:35:35 -0600 Subject: [PATCH 10/21] fix(platform-wallet): hold identity placement through a tip-balance read identity_tip_balance chose the identity's account and then waited for the ledger without the placement lock, so a discovery or load could move the identity in between and the balance would describe an account it no longer held. It now holds the placement read guard across both steps. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/shielded/tips.rs | 3 ++ .../wallet/shielded/viewing_key_bind_tests.rs | 32 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/tips.rs b/packages/rs-platform-wallet/src/wallet/shielded/tips.rs index a26f3e34a6e..952ce2487e2 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/tips.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/tips.rs @@ -507,6 +507,9 @@ impl PlatformWallet { coordinator: &Arc, identity_id: &Identifier, ) -> Result { + // The identity cannot change slot (or lose verification) between + // choosing its account and reading that account's ledger. + let _placement = self.identity.identity_placement.read().await; let tip = self.identity_tip_account(identity_id).await?; if !tip.verified { return Ok(IdentityTipBalance::Unverified); diff --git a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs index 78feafad53c..069a654d99c 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs @@ -2132,6 +2132,38 @@ async fn should_remove_a_wallet_only_after_its_tip_send_ends() { .unwrap(); } +/// An identity move waits while a tip-balance read is between choosing the +/// identity's account and reading its ledger, so the two describe one state. +#[tokio::test] +async fn should_hold_identity_placement_through_a_tip_balance_read() { + let (wallet, seed, id, coordinator) = tip_race_fixture("tips_balance_placement").await; + wallet + .prepare_shielded_tip_address(&seed, &id, &coordinator) + .await + .unwrap(); + let store = coordinator.store().write().await; + let read = tokio::spawn({ + let (wallet, coordinator) = (wallet.clone(), Arc::clone(&coordinator)); + async move { wallet.identity_tip_balance(&coordinator, &id).await } + }); + let mut blocked = false; + for _ in 0..50 { + tokio::task::yield_now().await; + if wallet.identity.identity_placement.try_write().is_err() { + blocked = true; + break; + } + } + assert!( + blocked, + "the balance read holds placement while the ledger waits" + ); + assert!(!read.is_finished()); + drop(store); + read.await.unwrap().unwrap(); + assert!(wallet.identity.identity_placement.try_write().is_ok()); +} + /// A refresh after discovery keeps the ordinary accounts a host bound and adds /// the tip accounts of the wallet's identities. Without persisted viewing keys /// covering the request (this fixture serves none) it asks for the seed and From ca92a5846cab8914f2b254a428ac35a2fc1cedb1 Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 1 Nov 2026 18:05:59 -0600 Subject: [PATCH 11/21] fix(platform-wallet): drain tip sends outside the lifecycle lock, refuse removed handles, keep abandoned barriers Wallet removal waited for admitted tip sends while holding the coordinator's lifecycle lock. A send waiting for a bind's config lock, with that bind waiting for lifecycle, deadlocked all three. Removal now drains without the lock, then takes it and re-checks before purging. Admission now refuses a wallet handle already removed from the manager, checked under the lifecycle lock, so a stale handle cannot write recipient history or a barrier after the purge. A replacement tip deleted its recipient's abandoned barrier, so the old tip's re-drive lost its guarded-tip handling. Only executed and failed barriers are pruned now. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/shielded/coordinator.rs | 53 ++++++++--- .../src/wallet/shielded/file_store.rs | 53 ++++++++++- .../src/wallet/shielded/operations.rs | 15 +++ .../src/wallet/shielded/store.rs | 15 ++- .../src/wallet/shielded/tips.rs | 4 +- .../wallet/shielded/viewing_key_bind_tests.rs | 94 ++++++++++++++++++- 6 files changed, 213 insertions(+), 21 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index 995c4b70411..12c37612cc0 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -443,17 +443,44 @@ impl NetworkShieldedCoordinator { /// Admit a tip send for `wallet_id`, waiting out any Clear or bind /// transaction in progress. Hold the admission until the send returns. - pub(crate) async fn admit_tip_send(&self, wallet_id: WalletId) -> TipSendAdmission<'_> { + /// Refused once the wallet handle is `detached` (removed from the + /// manager): checked under `lifecycle`, so removal either sees the + /// admission and waits for it, or the send sees the removal. + pub(crate) async fn admit_tip_send( + &self, + wallet_id: WalletId, + detached: &std::sync::atomic::AtomicBool, + ) -> Result, PlatformWalletError> { let _lifecycle = self.lifecycle.lock().await; + if detached.load(std::sync::atomic::Ordering::Acquire) { + return Err(PlatformWalletError::WalletNotFound(format!( + "{} was removed from the manager; tip refused", + hex::encode(wallet_id) + ))); + } *self .tip_sends .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .entry(wallet_id) .or_default() += 1; - TipSendAdmission { + Ok(TipSendAdmission { coordinator: self, wallet_id, + }) + } + + /// Wait until no tip send is admitted for `wallet_id`. Takes no lock: an + /// admitted send may be waiting on a bind that needs `lifecycle`. + async fn wait_for_tip_sends(&self, wallet_id: WalletId) { + loop { + let ended = self.tip_send_ended.notified(); + tokio::pin!(ended); + ended.as_mut().enable(); + if !self.tip_send_in_flight(Some(wallet_id)) { + return; + } + ended.await; } } @@ -972,19 +999,19 @@ impl NetworkShieldedCoordinator { // persister this removal just deleted, and an in-flight bind's // restore could repopulate (and re-mark hydrated) the state // purged below after the purge ran. - let _lifecycle = self.lifecycle.lock().await; - // Wait out the wallet's admitted tip sends (none is admitted while - // `lifecycle` is held): one finishing after the purge would record a - // barrier nothing can settle, blocking Clear for good. - loop { - let ended = self.tip_send_ended.notified(); - tokio::pin!(ended); - ended.as_mut().enable(); + // + // First wait out the wallet's admitted tip sends, without `lifecycle`: + // one finishing after the purge would record a barrier nothing can + // settle, and one may be waiting on a bind that needs `lifecycle`. + // The handle is detached before this call, so admissions stop; one + // admitted just before that is caught by the re-check under the lock. + let _lifecycle = loop { + self.wait_for_tip_sends(wallet_id).await; + let lifecycle = self.lifecycle.lock().await; if !self.tip_send_in_flight(Some(wallet_id)) { - break; + break lifecycle; } - ended.await; - } + }; self.accounts .write() .await diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index 79f48133942..6162d964ba3 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -1145,8 +1145,13 @@ impl ShieldedStore for FileBackedShieldedStore { let conn = self.pending_conn.lock().expect("pending_conn mutex"); conn.execute( "DELETE FROM shielded_tip_barriers \ - WHERE wallet_id = ?1 AND recipient = ?2 AND status != 0", - rusqlite::params![wallet_id.as_slice(), barrier.recipient.as_slice()], + WHERE wallet_id = ?1 AND recipient = ?2 AND status IN (?3, ?4)", + rusqlite::params![ + wallet_id.as_slice(), + barrier.recipient.as_slice(), + tip_barrier_status_code(TipBarrierStatus::Executed), + tip_barrier_status_code(TipBarrierStatus::Failed) + ], ) .map_err(|e| FileShieldedStoreError::Storage(format!("prune tip barriers: {e}")))?; conn.execute( @@ -1168,7 +1173,7 @@ impl ShieldedStore for FileBackedShieldedStore { self.tip_barriers.retain(|(wallet, _), existing| { *wallet != wallet_id || existing.recipient != barrier.recipient - || existing.status == TipBarrierStatus::Pending + || !existing.status.is_settled() }); self.tip_barriers .insert((wallet_id, barrier.activity_id), barrier.clone()); @@ -1872,6 +1877,48 @@ mod tests { let _ = std::fs::remove_file(&path); } + /// A replacement tip prunes only its recipient's executed and failed + /// barriers, durably: an abandoned tip may still execute and stays. + #[test] + fn a_replacement_tip_keeps_an_abandoned_barrier() { + let path = temp_tree_path("tip_barrier_replacement"); + let wallet = [0xC7; 32]; + let barrier = |activity: u8| TipBarrier { + activity_id: [activity; 32], + account_index: 0, + recipient: [7; 43], + nullifiers: vec![[activity; 32]], + anchor: [9; 32], + status: TipBarrierStatus::Pending, + }; + { + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); + store.record_tip_barrier(wallet, &barrier(1)).unwrap(); + store + .settle_tip_barrier(wallet, &[1; 32], TipBarrierStatus::Failed) + .unwrap(); + store.record_tip_barrier(wallet, &barrier(2)).unwrap(); + store.abandon_tip_barriers(wallet).unwrap(); + store.record_tip_barrier(wallet, &barrier(3)).unwrap(); + } + let store = FileBackedShieldedStore::open_path(&path, 100).expect("reopen"); + let mut kept: Vec<_> = store + .tip_barriers(wallet) + .unwrap() + .into_iter() + .map(|b| (b.activity_id[0], b.status)) + .collect(); + kept.sort_by_key(|(activity, _)| *activity); + assert_eq!( + kept, + vec![ + (2, TipBarrierStatus::Abandoned), + (3, TipBarrierStatus::Pending) + ] + ); + let _ = std::fs::remove_file(&path); + } + /// A confirmation whose settlement write fails is retried at the next /// watermark advance, even when the scan never sees that nullifier again, /// so the executed tip settles without the user abandoning it. diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 95086d90a23..8fe8791d728 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -4097,6 +4097,21 @@ mod redrive_tests { assert!(!is_guarded_tip(&store, [0x32; 32], &activity_id)); store.abandon_tip_barriers(wallet_id).unwrap(); assert!(is_guarded_tip(&store, wallet_id, &activity_id)); + // A replacement tip to the same recipient keeps the abandoned one. + store + .record_tip_barrier( + wallet_id, + &TipBarrier { + activity_id: [0x43; 32], + account_index: 0, + recipient: [0; 43], + nullifiers: vec![[0x52; 32]], + anchor: [0x61; 32], + status: TipBarrierStatus::Pending, + }, + ) + .unwrap(); + assert!(is_guarded_tip(&store, wallet_id, &activity_id)); store .settle_tip_barrier(wallet_id, &activity_id, TipBarrierStatus::Failed) .unwrap(); diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index e0a9cf4bb61..3a09b28a8f8 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -150,6 +150,13 @@ pub enum TipBarrierStatus { Abandoned, } +impl TipBarrierStatus { + /// Executed or failed: the tip's outcome is known. + pub fn is_settled(self) -> bool { + matches!(self, Self::Executed | Self::Failed) + } +} + /// The durable record of one shielded tip, kept apart from note and /// recovery state so that rebuilding those (an account purge, a Clear, a /// scan) cannot lose it. A tip to `recipient` is refused while a barrier for @@ -545,8 +552,10 @@ pub trait ShieldedStore: Send + Sync { // ── Tip payment barriers (per wallet) ────────────────────────────── /// Durably record `barrier` before its tip is broadcast. Replaces any - /// settled barrier of the same wallet and recipient, so the table holds - /// at most one settled row per recipient. + /// executed or failed barrier of the same wallet and recipient, so the + /// table holds at most one such row per recipient. Pending and abandoned + /// barriers stay: an abandoned tip may still execute, and its recovery + /// keeps treating it as a tip until it settles. fn record_tip_barrier( &mut self, wallet_id: WalletId, @@ -1251,7 +1260,7 @@ impl ShieldedStore for InMemoryShieldedStore { self.tip_barriers.retain(|(wallet, _), existing| { *wallet != wallet_id || existing.recipient != barrier.recipient - || existing.status == TipBarrierStatus::Pending + || !existing.status.is_settled() }); self.tip_barriers .insert((wallet_id, barrier.activity_id), barrier.clone()); diff --git a/packages/rs-platform-wallet/src/wallet/shielded/tips.rs b/packages/rs-platform-wallet/src/wallet/shielded/tips.rs index 952ce2487e2..1b24a1c4c86 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/tips.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/tips.rs @@ -623,7 +623,9 @@ impl PlatformWallet { .map_err(|_| PlatformWalletError::ShieldedTipPending)?; // Clear is refused from here until the send returns; afterwards an // unresolved tip's barrier keeps refusing it. - let _admission = coordinator.admit_tip_send(self.wallet_id()).await; + let _admission = coordinator + .admit_tip_send(self.wallet_id(), &self.shielded_detached) + .await?; #[cfg(test)] test_hooks::reach(self, test_hooks::Point::AfterTipAdmission).await; let _placement = self.identity.identity_placement.read().await; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs index 069a654d99c..a1c81097d3a 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs @@ -2110,13 +2110,105 @@ async fn should_refuse_clear_and_account_drops_while_a_tip_send_is_admitted() { coordinator.clear().await.unwrap(); } +/// Removal drains an admitted send without holding the lifecycle lock: the +/// send may be waiting on a bind that needs it. Here the send is paused after +/// admission while removal drains, and an install transaction must still be +/// able to start. +#[tokio::test] +async fn should_let_a_bind_proceed_while_removal_drains_a_tip_send() { + use super::tips::test_hooks::{pause_at, Point}; + let (wallet, seed, _id, coordinator) = tip_race_fixture("tips_remove_drain").await; + let wallet_id = wallet.wallet_id(); + let (reached, _resume) = pause_at(&wallet, Point::AfterTipAdmission); + let send = tokio::spawn({ + let (wallet, coordinator) = (wallet.clone(), Arc::clone(&coordinator)); + async move { + wallet + .send_shielded_tip( + &coordinator, + &seed, + crate::ShieldedTipSource::Account(0), + "recipient.dash", + &crate::ShieldedTipRecipient { + identity_id: dpp::prelude::Identifier::from([0x77; 32]), + address: [0; 43], + }, + 1_000, + [0; 36], + &super::CachedOrchardProver::new(), + ) + .await + } + }); + reached.await.unwrap(); + wallet.mark_shielded_detached(); + let removal = tokio::spawn({ + let coordinator = Arc::clone(&coordinator); + async move { coordinator.unregister_wallet(wallet_id).await } + }); + for _ in 0..10 { + tokio::task::yield_now().await; + } + assert!(!removal.is_finished()); + let install = tokio::time::timeout( + std::time::Duration::from_secs(5), + coordinator.begin_install(wallet_id), + ) + .await + .expect("removal must not hold lifecycle while draining"); + drop(install); + send.abort(); + let _ = send.await; + tokio::time::timeout(std::time::Duration::from_secs(5), removal) + .await + .expect("removal completes once the send ends") + .unwrap(); +} + +/// A handle that outlived its wallet's removal cannot start a tip: admission +/// refuses before any recipient confirmation or barrier is written. +#[tokio::test] +async fn should_refuse_a_tip_from_a_removed_wallet_handle() { + let (wallet, seed, _id, coordinator) = tip_race_fixture("tips_detached_handle").await; + wallet.mark_shielded_detached(); + coordinator.unregister_wallet(wallet.wallet_id()).await; + let refused = wallet + .send_shielded_tip( + &coordinator, + &seed, + crate::ShieldedTipSource::Account(0), + "recipient.dash", + &crate::ShieldedTipRecipient { + identity_id: dpp::prelude::Identifier::from([0x77; 32]), + address: [0; 43], + }, + 1_000, + [0; 36], + &super::CachedOrchardProver::new(), + ) + .await; + assert!( + matches!(refused, Err(crate::PlatformWalletError::WalletNotFound(_))), + "{refused:?}" + ); + let store = coordinator.store().read().await; + assert!(store.tip_barriers(wallet.wallet_id()).unwrap().is_empty()); + assert!(store + .confirmed_tip_recipient(wallet.wallet_id(), "recipient") + .unwrap() + .is_none()); +} + /// Removing a wallet waits for its admitted tip send to end, so the send /// cannot record a barrier after the purge that nothing could settle. #[tokio::test] async fn should_remove_a_wallet_only_after_its_tip_send_ends() { let (wallet, _seed, _id, coordinator) = tip_race_fixture("tips_remove_admission").await; let wallet_id = wallet.wallet_id(); - let admission = coordinator.admit_tip_send(wallet_id).await; + let admission = coordinator + .admit_tip_send(wallet_id, &wallet.shielded_detached) + .await + .unwrap(); let removal = tokio::spawn({ let coordinator = Arc::clone(&coordinator); async move { coordinator.unregister_wallet(wallet_id).await } From 9fb9160f9fa5d9cfd4d3c1d17fd39c4b576362bd Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 1 Nov 2026 18:42:33 -0600 Subject: [PATCH 12/21] fix(platform-wallet): fence tip abandonment against removed wallets, and keep repaired tips guarded Abandoning unresolved tips took only the handle's send lock, so a handle kept past its wallet's removal could release a recreated wallet's pending barrier. Abandonment is now admitted like a send: refused for a detached handle under the lifecycle lock, and drained by removal. Repairing damaged barriers deleted the unreadable rows, so a still-armed recovery operation lost its guarded-tip handling and a later re-drive rejection could release its notes. Before deleting them, every unclaimed note-spending recovery operation of the wallet now gets an abandoned stand-in barrier. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/shielded/file_store.rs | 117 +++++++++++++++++- .../src/wallet/shielded/store.rs | 3 + .../src/wallet/shielded/tips.rs | 6 + .../wallet/shielded/viewing_key_bind_tests.rs | 41 ++++++ 4 files changed, 162 insertions(+), 5 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index 6162d964ba3..321ae7dec90 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -1215,7 +1215,55 @@ impl ShieldedStore for FileBackedShieldedStore { } fn abandon_tip_barriers(&mut self, wallet_id: WalletId) -> Result { + let damaged = self + .damaged_tip_barrier_rows + .get(&wallet_id) + .cloned() + .unwrap_or_default(); + // A damaged row may have marked a still-armed recovery operation as a + // tip, whose re-drive must not trust a rejection. Before deleting the + // rows, give every note-spending recovery operation no readable + // barrier claims an abandoned stand-in (recipient unknown, so zero); + // an ordinary spend among them only waits for the prune backstop. + let stand_ins: Vec = if damaged.is_empty() { + Vec::new() + } else { + self.pending_redrives_for_wallet(wallet_id)? + .into_iter() + .filter(|(_, redrive)| { + !redrive.nullifiers.is_empty() + && !self + .tip_barriers + .contains_key(&(wallet_id, redrive.activity_id)) + }) + .map(|(id, redrive)| TipBarrier { + activity_id: redrive.activity_id, + account_index: id.account_index, + recipient: [0; 43], + nullifiers: redrive.nullifiers, + anchor: redrive.anchor, + status: TipBarrierStatus::Abandoned, + }) + .collect() + }; let conn = self.pending_conn.lock().expect("pending_conn mutex"); + for barrier in &stand_ins { + conn.execute( + "INSERT OR REPLACE INTO shielded_tip_barriers \ + (wallet_id, activity_id, account_index, recipient, nullifiers, anchor, status) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![ + wallet_id.as_slice(), + barrier.activity_id.as_slice(), + barrier.account_index, + barrier.recipient.as_slice(), + barrier.nullifiers.concat(), + barrier.anchor.as_slice(), + tip_barrier_status_code(barrier.status) + ], + ) + .map_err(|e| FileShieldedStoreError::Storage(format!("keep tip recovery: {e}")))?; + } conn.execute( "UPDATE shielded_tip_barriers SET status = 3 WHERE wallet_id = ?1 AND status = 0", rusqlite::params![wallet_id.as_slice()], @@ -1223,11 +1271,6 @@ impl ShieldedStore for FileBackedShieldedStore { .map_err(|e| FileShieldedStoreError::Storage(format!("abandon tip barriers: {e}")))?; // Unreadable rows of this wallet cannot be settled; the // acknowledgement covers them. Rows of an unknown wallet stay. - let damaged = self - .damaged_tip_barrier_rows - .get(&wallet_id) - .cloned() - .unwrap_or_default(); for rowid in &damaged { conn.execute( "DELETE FROM shielded_tip_barriers WHERE rowid = ?1 AND wallet_id = ?2", @@ -1239,6 +1282,10 @@ impl ShieldedStore for FileBackedShieldedStore { } drop(conn); self.damaged_tip_barrier_rows.remove(&wallet_id); + for barrier in stand_ins { + self.tip_barriers + .insert((wallet_id, barrier.activity_id), barrier); + } let mut abandoned = 0; for ((wallet, _), barrier) in self.tip_barriers.iter_mut() { if *wallet == wallet_id && barrier.status == TipBarrierStatus::Pending { @@ -1877,6 +1924,66 @@ mod tests { let _ = std::fs::remove_file(&path); } + /// Repairing a damaged barrier keeps an abandoned stand-in for the armed + /// recovery operation it may have marked, so a re-drive of that tip still + /// distrusts a rejection after the acknowledgement and a reopen. + #[test] + fn repairing_a_damaged_barrier_keeps_its_recovery_marked_as_a_tip() { + let path = temp_tree_path("tip_barrier_repair_recovery"); + let wallet = [0xC9; 32]; + let id = SubwalletId::new(wallet, 0); + let activity_id = [0x21; 32]; + let redrive = PendingRedrive { + activity_id, + anchor: [0x31; 32], + nullifiers: vec![[0x41; 32]], + st_bytes: vec![0x51; 24], + attempts: 0, + identity_nonce_finalized: false, + identity_user_abandoned: false, + }; + { + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); + store.arm_redrive(id, redrive.clone()).unwrap(); + store + .record_tip_barrier( + wallet, + &TipBarrier { + activity_id, + account_index: 0, + recipient: [7; 43], + nullifiers: redrive.nullifiers.clone(), + anchor: redrive.anchor, + status: TipBarrierStatus::Pending, + }, + ) + .unwrap(); + store + .pending_conn + .lock() + .unwrap() + .execute( + "UPDATE shielded_tip_barriers SET account_index = -1 WHERE activity_id = ?1", + [activity_id.as_slice()], + ) + .unwrap(); + } + { + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("reopen"); + assert!(store.tip_barriers(wallet).is_err()); + assert_eq!(store.pending_redrives(id).unwrap(), vec![redrive.clone()]); + store.abandon_tip_barriers(wallet).unwrap(); + } + let store = FileBackedShieldedStore::open_path(&path, 100).expect("reopen repaired"); + let kept = store.tip_barriers(wallet).unwrap(); + assert_eq!(kept.len(), 1); + assert_eq!(kept[0].activity_id, activity_id); + assert_eq!(kept[0].status, TipBarrierStatus::Abandoned); + assert_eq!(kept[0].nullifiers, redrive.nullifiers); + assert!(!store.has_pending_tip_barriers().unwrap()); + let _ = std::fs::remove_file(&path); + } + /// A replacement tip prunes only its recipient's executed and failed /// barriers, durably: an abandoned tip may still execute and stays. #[test] diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index 3a09b28a8f8..0672405e885 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -580,6 +580,9 @@ pub trait ShieldedStore: Send + Sync { /// Mark every pending barrier of `wallet_id` abandoned, and drop its /// unreadable ones, after the user accepted those tips may still execute. + /// Before dropping unreadable ones, give each of the wallet's + /// note-spending recovery operations that no readable barrier claims an + /// abandoned stand-in, so its re-drive keeps distrusting rejections. /// A barrier whose wallet is unreadable is not this wallet's to release /// and stays. Returns how many pending barriers it abandoned. fn abandon_tip_barriers(&mut self, wallet_id: WalletId) -> Result; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/tips.rs b/packages/rs-platform-wallet/src/wallet/shielded/tips.rs index 1b24a1c4c86..8adcba71030 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/tips.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/tips.rs @@ -589,6 +589,12 @@ impl PlatformWallet { .tip_send_in_progress .try_lock() .map_err(|_| PlatformWalletError::ShieldedTipPending)?; + // Admitted like a send: a handle whose wallet was removed is refused, + // and removal waits for this release, so a stale handle can never + // release a recreated wallet's barriers. + let _admission = coordinator + .admit_tip_send(self.wallet_id(), &self.shielded_detached) + .await?; coordinator .store() .write() diff --git a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs index a1c81097d3a..2e9f287ec4b 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs @@ -2165,6 +2165,47 @@ async fn should_let_a_bind_proceed_while_removal_drains_a_tip_send() { .unwrap(); } +/// A handle that outlived its wallet's removal cannot release tips either: +/// a recreated wallet with the same id keeps its pending barrier. +#[tokio::test] +async fn should_refuse_abandonment_from_a_removed_wallet_handle() { + use super::store::{TipBarrier, TipBarrierStatus}; + let (wallet, _seed, _id, coordinator) = tip_race_fixture("tips_detached_abandon").await; + wallet.mark_shielded_detached(); + coordinator.unregister_wallet(wallet.wallet_id()).await; + // The recreated wallet's pending tip. + coordinator + .store() + .write() + .await + .record_tip_barrier( + wallet.wallet_id(), + &TipBarrier { + activity_id: [0x24; 32], + account_index: 0, + recipient: [7; 43], + nullifiers: vec![[0x34; 32]], + anchor: [0x44; 32], + status: TipBarrierStatus::Pending, + }, + ) + .unwrap(); + let refused = wallet + .abandon_unresolved_shielded_tips(&coordinator, true) + .await; + assert!( + matches!(refused, Err(crate::PlatformWalletError::WalletNotFound(_))), + "{refused:?}" + ); + let barriers = coordinator + .store() + .read() + .await + .tip_barriers(wallet.wallet_id()) + .unwrap(); + assert_eq!(barriers[0].status, TipBarrierStatus::Pending); +} + /// A handle that outlived its wallet's removal cannot start a tip: admission /// refuses before any recipient confirmation or barrier is written. #[tokio::test] From ba68897ec91f26959cd5f9083727921b0348ae49 Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 1 Nov 2026 18:46:14 -0600 Subject: [PATCH 13/21] feat(swift-sdk,kotlin-sdk): refresh shielded accounts off the main actor, and bind tip-account classification for Kotlin The Swift refreshShieldedAccounts blocked the main actor while the bind waited for a shielded scan to release the store. It is now async: the native call runs detached, admitted like the other tip calls, and local-balance reads delivered meanwhile are marked obsolete. Kotlin gains PlatformWalletManager.isShieldedTipAccount over the existing C export, so screens can exclude tip accounts without hardcoding the range. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/ffi/FundingNative.kt | 1 + .../dashsdk/wallet/PlatformWalletManager.kt | 6 ++++ packages/rs-unified-sdk-jni/src/funding.rs | 18 +++++++++++ .../PlatformWalletManagerShieldedSync.swift | 31 ++++++++++++++----- 4 files changed, 49 insertions(+), 7 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt index 18c075825a5..72ef27c11c3 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt @@ -18,6 +18,7 @@ package org.dashfoundation.dashsdk.ffi internal object FundingNative { external fun tipAccountIndex(identityIndex: Int): Int external fun parseShieldedTipAmount(text: String): Long + external fun isShieldedTipAccount(account: Int): Boolean external fun prepareShieldedTipAddress( manager: Long, walletId: ByteArray, resolver: Long, identityId: ByteArray, ): ByteArray diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 2f6df28844b..1b8f9fb292b 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -51,6 +51,12 @@ data class PlatformWalletPersistenceCapabilities( fun contains(capability: Long): Boolean = bits and capability == capability companion object { + /** + * Whether [account] is a dedicated tip account, as Rust classifies it + * (Swift `PlatformWalletManager.isShieldedTipAccount`). Shielded builds only. + */ + fun isShieldedTipAccount(account: Int): Boolean = FundingNative.isShieldedTipAccount(account) + const val VERSION_1: Int = 1 const val ATOMIC_CHANGESETS: Long = 1L shl 0 const val INVITATIONS: Long = 1L shl 1 diff --git a/packages/rs-unified-sdk-jni/src/funding.rs b/packages/rs-unified-sdk-jni/src/funding.rs index f1a86d57f94..7d07c76bdac 100644 --- a/packages/rs-unified-sdk-jni/src/funding.rs +++ b/packages/rs-unified-sdk-jni/src/funding.rs @@ -1617,6 +1617,24 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_identit }) } +/// Whether `account` is a dedicated tip account, as Rust classifies it. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_isShieldedTipAccount( + mut env: JNIEnv, + _class: JClass, + account: jint, +) -> jboolean { + guard(&mut env, JNI_FALSE, |_env| { + if u32::try_from(account).is_ok_and(|account| { + platform_wallet_ffi::platform_wallet_is_shielded_tip_account(account) + }) { + JNI_TRUE + } else { + JNI_FALSE + } + }) +} + /// Exact credits for a tip amount typed in DASH. #[no_mangle] pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_parseShieldedTipAmount( diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index 35901ca45c6..97eb174496d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -1733,18 +1733,35 @@ extension PlatformWalletManager { /// Rebind the wallet's shielded accounts after identity discovery: Rust /// keeps the wallet's ordinary accounts and adds the tip accounts of newly /// discovered identities. - public func refreshShieldedAccounts(walletId: Data, resolver: MnemonicResolver) throws { + /// + /// Runs off the main actor: the bind can wait for a shielded scan to + /// release the store. Local-balance reads delivered meanwhile are marked + /// obsolete, as after any bind. + public func refreshShieldedAccounts(walletId: Data, resolver: MnemonicResolver) async throws { try ensureShieldedLifecycleAllowed("refreshShieldedAccounts") try ensureNoShieldedStopInFlight(before: "refreshing shielded accounts") guard walletId.count == 32, let resolverHandle = resolver.handle else { throw PlatformWalletError.invalidParameter("Expected a 32-byte wallet ID and a mnemonic resolver") } - try withShieldedLocalBalanceBind { - try walletId.withUnsafeBytes { wallet in - try platform_wallet_manager_refresh_shielded_accounts( - handle, wallet.baseAddress!.assumingMemoryBound(to: UInt8.self), resolverHandle - ).check() - } + // Admitted, so `shutdown()` waits for this call before destroying the handle. + try admitNativeOp("refreshShieldedAccounts") + defer { finishNativeOp() } + let handle = self.handle + shieldedLocalBalanceBindGeneration.bump() + do { + try await Task.detached(priority: .userInitiated) { + try withExtendedLifetime(resolver) { + try walletId.withUnsafeBytes { wallet in + try platform_wallet_manager_refresh_shielded_accounts( + handle, wallet.baseAddress!.assumingMemoryBound(to: UInt8.self), resolverHandle + ).check() + } + } + }.value + shieldedLocalBalanceBindGeneration.bump() + } catch { + shieldedLocalBalanceGeneration.bump() + throw error } } From bc99e1e8e463ba223a03c8446b636a08c90d9b3d Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 1 Nov 2026 18:51:34 -0600 Subject: [PATCH 14/21] fix(kotlin-sdk): put isShieldedTipAccount on PlatformWalletManager's companion It landed on PlatformWalletPersistenceCapabilities' companion, so the example app could not resolve PlatformWalletManager.isShieldedTipAccount. The manager's companion is now public with its constants kept private. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/wallet/PlatformWalletManager.kt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 1b8f9fb292b..b16a19c039f 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -51,12 +51,6 @@ data class PlatformWalletPersistenceCapabilities( fun contains(capability: Long): Boolean = bits and capability == capability companion object { - /** - * Whether [account] is a dedicated tip account, as Rust classifies it - * (Swift `PlatformWalletManager.isShieldedTipAccount`). Shielded builds only. - */ - fun isShieldedTipAccount(account: Int): Boolean = FundingNative.isShieldedTipAccount(account) - const val VERSION_1: Int = 1 const val ATOMIC_CHANGESETS: Long = 1L shl 0 const val INVITATIONS: Long = 1L shl 1 @@ -2922,12 +2916,18 @@ class PlatformWalletManager( runBlocking { closeSuspending() } } - private companion object { + companion object { /** SPV progress poll cadence — matches Swift's 1 Hz `startProgressPolling`. */ - const val POLL_INTERVAL_MS = 1_000L + private const val POLL_INTERVAL_MS = 1_000L /** De-offset `PlatformWalletFFIResultCode::ErrorInvalidParameter`. */ - const val PWFFI_INVALID_PARAMETER = 2 + private const val PWFFI_INVALID_PARAMETER = 2 + + /** + * Whether [account] is a dedicated tip account, as Rust classifies it + * (Swift `PlatformWalletManager.isShieldedTipAccount`). Shielded builds only. + */ + fun isShieldedTipAccount(account: Int): Boolean = FundingNative.isShieldedTipAccount(account) } } From ffcabad74ccd7244d158c09cee70bc0f28bcebf6 Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 1 Nov 2026 19:33:03 -0600 Subject: [PATCH 15/21] fix(platform-wallet): refuse dropping accounts while tip barriers are unreadable register_locked read a failed barrier lookup as "no pending tips", so a damaged row let a bind drop the account paying a readable pending tip and purge its recovery and reservations. A bind that drops or re-keys an account is now refused while the wallet's barriers are unreadable; an initial registration, which purges nothing, still proceeds. Also load the native library before Kotlin's static PlatformWalletManager.isShieldedTipAccount calls JNI. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/wallet/PlatformWalletManager.kt | 6 +- .../src/wallet/shielded/coordinator.rs | 11 +++- .../src/wallet/shielded/file_store.rs | 10 ++++ .../wallet/shielded/viewing_key_bind_tests.rs | 55 +++++++++++++++++++ 4 files changed, 78 insertions(+), 4 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index b16a19c039f..6a4e802e429 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -25,6 +25,7 @@ import org.dashfoundation.dashsdk.errors.mapNativeErrors import org.dashfoundation.dashsdk.ffi.DashpayNative import org.dashfoundation.dashsdk.ffi.DpnsMarketplaceNative import org.dashfoundation.dashsdk.ffi.FundingNative +import org.dashfoundation.dashsdk.ffi.NativeLoader import org.dashfoundation.dashsdk.ffi.NativePersistenceBridge import org.dashfoundation.dashsdk.ffi.NativeWalletEventBridge import org.dashfoundation.dashsdk.ffi.WalletManagerNative @@ -2927,7 +2928,10 @@ class PlatformWalletManager( * Whether [account] is a dedicated tip account, as Rust classifies it * (Swift `PlatformWalletManager.isShieldedTipAccount`). Shielded builds only. */ - fun isShieldedTipAccount(account: Int): Boolean = FundingNative.isShieldedTipAccount(account) + fun isShieldedTipAccount(account: Int): Boolean { + NativeLoader.ensureLoaded() + return FundingNative.isShieldedTipAccount(account) + } } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index 12c37612cc0..28e480a233a 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -792,9 +792,14 @@ impl NetworkShieldedCoordinator { let store = self.store.read().await; // A pending tip settles only through its paying account's scan // and prune passes, so that account must stay bound, with its keys. - // Unreadable barriers cannot settle that way and keep blocking - // tips and Clear on their own, so they do not block the bind. - let tips = store.tip_barriers(wallet_id).unwrap_or_default(); + // While barriers are unreadable the owners are unknown (a damaged + // row hides readable pending ones too), so only an initial + // registration, which purges nothing, may go ahead. + let tips = match store.tip_barriers(wallet_id) { + Ok(tips) => tips, + Err(_) if stale.is_empty() => Vec::new(), + Err(_) => return Err(PlatformWalletError::ShieldedTipPending), + }; for tip in tips .iter() .filter(|tip| tip.status == super::store::TipBarrierStatus::Pending) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index 321ae7dec90..c639dc0182c 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -130,6 +130,16 @@ pub struct FileBackedShieldedStore { } impl FileBackedShieldedStore { + /// Make `wallet_id`'s barrier reads fail as if a stored row were + /// unreadable. + #[cfg(test)] + pub(crate) fn damage_tip_barriers_for_test(&mut self, wallet_id: WalletId) { + self.damaged_tip_barrier_rows + .entry(wallet_id) + .or_default() + .push(-1); + } + /// Called under the coordinator's store read guard so amount and provenance /// cannot straddle a sync or a pending-spend reservation. pub(super) fn local_account_balance( diff --git a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs index 2e9f287ec4b..a6307b0a415 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs @@ -2165,6 +2165,61 @@ async fn should_let_a_bind_proceed_while_removal_drains_a_tip_send() { .unwrap(); } +/// While the wallet's barriers are unreadable their owners are unknown, so a +/// bind that drops an account is refused: a damaged row must not hide a +/// readable pending tip whose paying account and recovery would be purged. +#[tokio::test] +async fn should_refuse_dropping_an_account_while_tip_barriers_are_unreadable() { + use super::store::{TipBarrier, TipBarrierStatus}; + let (wallet, seed, _id, coordinator) = tip_race_fixture("tips_damaged_bind").await; + wallet + .bind_shielded(&seed, &[0, 1], &coordinator) + .await + .unwrap(); + let paying = SubwalletId::new(wallet.wallet_id(), 0); + let redrive = PendingRedrive { + activity_id: [0x26; 32], + anchor: [0x36; 32], + nullifiers: vec![[0x46; 32]], + st_bytes: vec![0x56; 24], + attempts: 0, + identity_nonce_finalized: false, + identity_user_abandoned: false, + }; + { + let mut store = coordinator.store().write().await; + store.arm_redrive(paying, redrive.clone()).unwrap(); + store + .record_tip_barrier( + wallet.wallet_id(), + &TipBarrier { + activity_id: redrive.activity_id, + account_index: 0, + recipient: [7; 43], + nullifiers: redrive.nullifiers.clone(), + anchor: redrive.anchor, + status: TipBarrierStatus::Pending, + }, + ) + .unwrap(); + store.damage_tip_barriers_for_test(wallet.wallet_id()); + } + let dropped = wallet.bind_shielded(&seed, &[1], &coordinator).await; + assert!( + matches!(dropped, Err(crate::PlatformWalletError::ShieldedTipPending)), + "{dropped:?}" + ); + assert_eq!( + coordinator + .store() + .read() + .await + .pending_redrives(paying) + .unwrap(), + vec![redrive] + ); +} + /// A handle that outlived its wallet's removal cannot release tips either: /// a recreated wallet with the same id keeps its pending barrier. #[tokio::test] From 21d3110ed621c834a42f3b924a138ecb17117f4c Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 1 Nov 2026 20:27:05 -0600 Subject: [PATCH 16/21] fix(platform-wallet): keep an executed tip's notes spent across a stale restore, and retry every settlement An executed barrier was not applied to a restored host snapshot, so a process stopping between mark_spent and the host persisting the spent marks brought the tip's notes back as spendable. Restore now marks every executed barrier's notes of the account spent and sends the host those spent marks. A replacement tip prunes only failed barriers, keeping executed ones as that evidence. A failed (not-sent) settlement whose write failed was never retried; the retry set now keeps both kinds, executed winning. Clear's pending-tip check waits at most 100 ms for the store and fails as busy instead of waiting out a scan. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/shielded/balance_tests.rs | 83 +++++++++++ .../src/wallet/shielded/coordinator.rs | 36 ++++- .../src/wallet/shielded/file_store.rs | 132 ++++++++++++++---- .../src/wallet/shielded/store.rs | 18 +-- 4 files changed, 226 insertions(+), 43 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/balance_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/balance_tests.rs index 30a459ce40f..2c6e5872eb5 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/balance_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/balance_tests.rs @@ -87,6 +87,89 @@ async fn ready( } } +/// A tip settled as executed keeps its notes spent when the host's snapshot +/// predates recording that (the process stopped in between): they are +/// neither counted nor selectable after the restore. +#[tokio::test] +async fn an_executed_tip_keeps_its_notes_spent_across_a_stale_host_snapshot() { + use super::store::{TipBarrier, TipBarrierStatus}; + let path = tree_path(); + let coordinator = coordinator(&path); + let wallet = [0x5A; 32]; + let id = SubwalletId::new(wallet, 0); + register(&coordinator, wallet, &[0]).await; + coordinator + .store() + .write() + .await + .record_tip_barrier( + wallet, + &TipBarrier { + activity_id: [0x15; 32], + account_index: 0, + recipient: [7; 43], + nullifiers: vec![[5; 32]], + anchor: [9; 32], + status: TipBarrierStatus::Executed, + }, + ) + .unwrap(); + let snapshot = ShieldedSyncStartState { + per_subwallet: BTreeMap::from([( + id, + ShieldedSubwalletStartState { + notes: vec![note(5, 900, false), note(6, 100, false)], + has_sync_state: true, + last_synced_index: 7, + ..Default::default() + }, + )]), + ..Default::default() + }; + coordinator + .restore_for_wallet(wallet, &snapshot) + .await + .unwrap(); + coordinator.mark_hydrated(wallet, true).await; + assert_eq!( + ready(&coordinator, wallet).await.accounts[&0].spendable_credits, + 100 + ); + let unspent: Vec<_> = coordinator + .store() + .read() + .await + .get_unspent_notes(id) + .unwrap() + .into_iter() + .map(|note| note.nullifier) + .collect(); + assert_eq!(unspent, vec![[6; 32]]); + let _ = std::fs::remove_file(&path); +} + +/// Clear's pending-tip check fails as busy instead of waiting out a scan +/// that holds the store, and never reads contention as no pending tips. +#[tokio::test] +async fn the_pending_tip_check_fails_busy_behind_a_scan() { + let path = tree_path(); + let coordinator = coordinator(&path); + let store = coordinator.store().write().await; + let checked = tokio::time::timeout( + std::time::Duration::from_secs(5), + coordinator.has_pending_tips(), + ) + .await + .expect("bounded"); + assert!( + matches!(checked, Err(PlatformWalletError::ShieldedStoreError(_))), + "{checked:?}" + ); + drop(store); + assert!(!coordinator.has_pending_tips().await.unwrap()); + let _ = std::fs::remove_file(&path); +} + #[tokio::test] async fn local_balance_distinguishes_availability_and_explicit_empty_scan() { let path = tree_path(); diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index 28e480a233a..e60215b220f 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -1168,6 +1168,8 @@ impl NetworkShieldedCoordinator { } let mut store = self.store.write().await; + // Spent marks for the host, queued once the store is released. + let mut executed_spends = crate::changeset::ShieldedChangeSet::default(); for (id, sub) in &snapshot.per_subwallet { // Only restore subwallets that are registered on this // coordinator — `registered` holds `wallet_id`'s alone, so @@ -1221,6 +1223,27 @@ impl NetworkShieldedCoordinator { })?; } } + // A tip settled as executed spent its notes even when this + // snapshot predates the host recording it (the process stopped + // in between): the barrier is the durable evidence. Unreadable + // barriers give none; they keep tips blocked until repaired. + let executed: Vec<[u8; 32]> = store + .tip_barriers(wallet_id) + .unwrap_or_default() + .into_iter() + .filter(|barrier| { + barrier.status == super::store::TipBarrierStatus::Executed + && barrier.account_index == id.account_index + }) + .flat_map(|barrier| barrier.nullifiers) + .collect(); + for nullifier in executed { + if store.mark_spent(*id, &nullifier).map_err(|e| { + crate::error::PlatformWalletError::ShieldedStoreError(e.to_string()) + })? { + executed_spends.record_nullifier_spent(*id, nullifier); + } + } // Rehydrate recovered outgoing (sent) notes so send history // survives a cold start without re-OVK-recovering. Idempotent // by `cmx`, so a later re-scan that re-recovers the same note @@ -1269,18 +1292,27 @@ impl NetworkShieldedCoordinator { !sub.notes.is_empty(), ); } + drop(store); + let persister = self.persisters.read().await.get(&wallet_id).cloned(); + super::operations::queue_shielded_changeset(persister.as_ref(), wallet_id, executed_spends); Ok(()) } /// Whether any wallet is sending a tip or holds a pending (or unreadable) /// one; [`Self::clear`] is refused while one does. + /// Waits at most 100 ms for the store: a scan holds it for minutes, and a + /// busy store fails rather than reading as no pending tips. pub async fn has_pending_tips(&self) -> Result { if self.tip_send_in_flight(None) { return Ok(true); } - self.store - .read() + tokio::time::timeout(std::time::Duration::from_millis(100), self.store.read()) .await + .map_err(|_| { + PlatformWalletError::ShieldedStoreError( + "shielded store busy with a sync pass; retry Clear after it".to_string(), + ) + })? .has_pending_tip_barriers() .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string())) } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index c639dc0182c..6df0ae4ffc7 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -11,7 +11,7 @@ //! tree (or, when the host persister is wired up, restored from //! SwiftData before sync runs). -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; use std::error::Error as StdError; use std::fmt; use std::path::{Path, PathBuf}; @@ -123,10 +123,12 @@ pub struct FileBackedShieldedStore { /// is unknown, so it blocks every wallet's tips and no wallet's /// acknowledgement releases it. unattributed_tip_barrier_damage: bool, - /// Executed tips whose settlement did not persist. The scan may not see - /// their nullifiers again, so each watermark advance retries them; the - /// barrier stays pending (refusing) until one succeeds. - unsettled_tip_executions: BTreeSet<(WalletId, [u8; 32])>, + /// Tip settlements (executed or failed) that did not persist. Nothing + /// may bring them up again (a scan need not see an executed tip's + /// nullifiers twice, and a failed tip's are never spent), so each + /// watermark advance retries them; the barrier stays pending (refusing) + /// until one succeeds. Executed wins over failed. + unsettled_tip_settlements: BTreeMap<(WalletId, [u8; 32]), TipBarrierStatus>, } impl FileBackedShieldedStore { @@ -290,7 +292,7 @@ impl FileBackedShieldedStore { tip_barriers: BTreeMap::new(), damaged_tip_barrier_rows: BTreeMap::new(), unattributed_tip_barrier_damage: false, - unsettled_tip_executions: BTreeSet::new(), + unsettled_tip_settlements: BTreeMap::new(), }; store.rehydrate_pending_spends()?; store.load_tip_barriers()?; @@ -522,22 +524,34 @@ impl FileBackedShieldedStore { Ok(()) } - /// Write `status` for `wallet_id`'s barrier `activity_id` (unless it is - /// executed already): durably first, then in memory. - /// Settle each tip as executed, keeping those whose write fails for a - /// retry. - fn settle_executed_tips(&mut self, tips: Vec<(WalletId, [u8; 32])>) { - for (wallet_id, activity_id) in tips { - if let Err(e) = - self.set_tip_barrier_status(wallet_id, &activity_id, TipBarrierStatus::Executed) - { + /// Settle each tip, keeping those whose write fails for a retry. + fn settle_tips(&mut self, tips: Vec<((WalletId, [u8; 32]), TipBarrierStatus)>) { + for ((wallet_id, activity_id), status) in tips { + if let Err(e) = self.set_tip_barrier_status(wallet_id, &activity_id, status) { tracing::warn!(error = %e, "tip barrier settlement not persisted; retrying later"); - self.unsettled_tip_executions - .insert((wallet_id, activity_id)); + self.retry_tip_settlement(wallet_id, activity_id, status); } } } + /// Keep a settlement for the next watermark advance; executed wins. + fn retry_tip_settlement( + &mut self, + wallet_id: WalletId, + activity_id: [u8; 32], + status: TipBarrierStatus, + ) { + let pending = self + .unsettled_tip_settlements + .entry((wallet_id, activity_id)) + .or_insert(status); + if status == TipBarrierStatus::Executed { + *pending = status; + } + } + + /// Write `status` for `wallet_id`'s barrier `activity_id` (unless it is + /// executed already): durably first, then in memory. fn set_tip_barrier_status( &mut self, wallet_id: WalletId, @@ -756,9 +770,9 @@ impl ShieldedStore for FileBackedShieldedStore { // watermark advance. let executed = tip_barriers_spending(&self.tip_barriers, id.wallet_id, nullifier) .into_iter() - .map(|activity_id| (id.wallet_id, activity_id)) + .map(|activity_id| ((id.wallet_id, activity_id), TipBarrierStatus::Executed)) .collect(); - self.settle_executed_tips(executed); + self.settle_tips(executed); let Some(sw) = self.subwallets.get_mut(&id) else { return Ok(false); }; @@ -1088,8 +1102,8 @@ impl ShieldedStore for FileBackedShieldedStore { ) -> Result<(), Self::Error> { // Once per pass, not per scanned nullifier, so a persistent write // fault does not multiply failing updates. - let retries = std::mem::take(&mut self.unsettled_tip_executions); - self.settle_executed_tips(retries.into_iter().collect()); + let retries = std::mem::take(&mut self.unsettled_tip_settlements); + self.settle_tips(retries.into_iter().collect()); let state = self.subwallets.entry(id).or_default(); state.last_synced_index = index; state.last_scanned_index = Some(index); @@ -1136,8 +1150,8 @@ impl ShieldedStore for FileBackedShieldedStore { self.tip_barriers .retain(|(wallet, _), _| *wallet != wallet_id); self.damaged_tip_barrier_rows.remove(&wallet_id); - self.unsettled_tip_executions - .retain(|(wallet, _)| *wallet != wallet_id); + self.unsettled_tip_settlements + .retain(|(wallet, _), _| *wallet != wallet_id); Ok(()) } @@ -1155,11 +1169,10 @@ impl ShieldedStore for FileBackedShieldedStore { let conn = self.pending_conn.lock().expect("pending_conn mutex"); conn.execute( "DELETE FROM shielded_tip_barriers \ - WHERE wallet_id = ?1 AND recipient = ?2 AND status IN (?3, ?4)", + WHERE wallet_id = ?1 AND recipient = ?2 AND status = ?3", rusqlite::params![ wallet_id.as_slice(), barrier.recipient.as_slice(), - tip_barrier_status_code(TipBarrierStatus::Executed), tip_barrier_status_code(TipBarrierStatus::Failed) ], ) @@ -1183,7 +1196,7 @@ impl ShieldedStore for FileBackedShieldedStore { self.tip_barriers.retain(|(wallet, _), existing| { *wallet != wallet_id || existing.recipient != barrier.recipient - || !existing.status.is_settled() + || existing.status != TipBarrierStatus::Failed }); self.tip_barriers .insert((wallet_id, barrier.activity_id), barrier.clone()); @@ -1222,6 +1235,7 @@ impl ShieldedStore for FileBackedShieldedStore { status: TipBarrierStatus, ) -> Result<(), Self::Error> { self.set_tip_barrier_status(wallet_id, activity_id, status) + .inspect_err(|_| self.retry_tip_settlement(wallet_id, *activity_id, status)) } fn abandon_tip_barriers(&mut self, wallet_id: WalletId) -> Result { @@ -1934,6 +1948,58 @@ mod tests { let _ = std::fs::remove_file(&path); } + /// A failed settlement whose write fails is retried at the next watermark + /// advance too: a tip that never left the device has no nullifier the + /// scan could see spent. + #[test] + fn a_failed_not_sent_settlement_is_retried_until_it_persists() { + let path = temp_tree_path("tip_failed_settlement_retry"); + let wallet = [0xCB; 32]; + let id = SubwalletId::new(wallet, 0); + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); + store + .record_tip_barrier( + wallet, + &TipBarrier { + activity_id: [2; 32], + account_index: 0, + recipient: [7; 43], + nullifiers: vec![[0x12; 32]], + anchor: [9; 32], + status: TipBarrierStatus::Pending, + }, + ) + .unwrap(); + store + .pending_conn + .lock() + .unwrap() + .execute_batch( + "CREATE TRIGGER reject_settlement BEFORE UPDATE ON shielded_tip_barriers \ + BEGIN SELECT RAISE(FAIL, 'injected settlement failure'); END;", + ) + .unwrap(); + assert!(store + .settle_tip_barrier(wallet, &[2; 32], TipBarrierStatus::Failed) + .is_err()); + assert!(store.has_pending_tip_barriers().unwrap()); + store + .pending_conn + .lock() + .unwrap() + .execute_batch("DROP TRIGGER reject_settlement;") + .unwrap(); + store.set_last_synced_note_index(id, 1).unwrap(); + assert!(!store.has_pending_tip_barriers().unwrap()); + drop(store); + let store = FileBackedShieldedStore::open_path(&path, 100).expect("reopen"); + assert_eq!( + store.tip_barriers(wallet).unwrap()[0].status, + TipBarrierStatus::Failed + ); + let _ = std::fs::remove_file(&path); + } + /// Repairing a damaged barrier keeps an abandoned stand-in for the armed /// recovery operation it may have marked, so a re-drive of that tip still /// distrusts a rejection after the acknowledgement and a reopen. @@ -2145,9 +2211,16 @@ mod tests { ] ); assert!(!store.has_pending_tip_barriers().unwrap()); - // A new tip to the recipient replaces its settled rows. + // A new tip to the recipient replaces its failed row; the + // executed one stays as the record of the notes it spent. store.record_tip_barrier(wallet, &barrier(3, 0x33)).unwrap(); - assert_eq!(statuses(&store), vec![(3, TipBarrierStatus::Pending)]); + assert_eq!( + statuses(&store), + vec![ + (1, TipBarrierStatus::Executed), + (3, TipBarrierStatus::Pending) + ] + ); assert!(store .record_tip_barrier( wallet, @@ -2189,7 +2262,8 @@ mod tests { store.abandon_tip_barriers([0xC2; 32]).unwrap(); assert!(store.tip_barriers(wallet).is_err()); assert_eq!(store.abandon_tip_barriers(wallet).unwrap(), 0); - assert!(store.tip_barriers(wallet).unwrap().is_empty()); + // The damaged rows are gone; the executed tip's record stays. + assert_eq!(statuses(&store), vec![(1, TipBarrierStatus::Executed)]); assert!(!store.has_pending_tip_barriers().unwrap()); store.record_tip_barrier(wallet, &barrier(6, 0x66)).unwrap(); store.purge_wallet(wallet).unwrap(); diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index 0672405e885..3fd20c3cb0b 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -150,13 +150,6 @@ pub enum TipBarrierStatus { Abandoned, } -impl TipBarrierStatus { - /// Executed or failed: the tip's outcome is known. - pub fn is_settled(self) -> bool { - matches!(self, Self::Executed | Self::Failed) - } -} - /// The durable record of one shielded tip, kept apart from note and /// recovery state so that rebuilding those (an account purge, a Clear, a /// scan) cannot lose it. A tip to `recipient` is refused while a barrier for @@ -552,10 +545,11 @@ pub trait ShieldedStore: Send + Sync { // ── Tip payment barriers (per wallet) ────────────────────────────── /// Durably record `barrier` before its tip is broadcast. Replaces any - /// executed or failed barrier of the same wallet and recipient, so the - /// table holds at most one such row per recipient. Pending and abandoned - /// barriers stay: an abandoned tip may still execute, and its recovery - /// keeps treating it as a tip until it settles. + /// failed barrier of the same wallet and recipient, so the table holds + /// at most one such row per recipient. Pending and abandoned barriers + /// stay (an abandoned tip may still execute, and its recovery keeps + /// treating it as a tip until it settles), and executed ones stay as the + /// durable record of the notes the tip spent. fn record_tip_barrier( &mut self, wallet_id: WalletId, @@ -1263,7 +1257,7 @@ impl ShieldedStore for InMemoryShieldedStore { self.tip_barriers.retain(|(wallet, _), existing| { *wallet != wallet_id || existing.recipient != barrier.recipient - || !existing.status.is_settled() + || existing.status != TipBarrierStatus::Failed }); self.tip_barriers .insert((wallet_id, barrier.activity_id), barrier.clone()); From c2b4ca9293dc6272dc4989ea6d3b8c37cf58cc74 Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 1 Nov 2026 21:05:42 -0600 Subject: [PATCH 17/21] fix(platform-wallet): record each observed spend, not a tip's whole input set The restore overlay read executed barriers through the aggregate barrier lookup, so one damaged row dropped all the evidence. It also marked every input of an executed barrier spent, although an executed barrier can come from a failed tip whose one input a later payment spent. mark_spent now records the exact nullifier in a new shielded_known_spent table whenever it matches a tip barrier or drops a recovery operation, and keeps the recovery rows if that write fails. Restore marks exactly those notes spent and sends the host the marks. A replacement tip again prunes executed barriers. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/shielded/balance_tests.rs | 88 ++++++++++---- .../src/wallet/shielded/coordinator.rs | 29 ++--- .../src/wallet/shielded/file_store.rs | 115 +++++++++++++++--- .../src/wallet/shielded/store.rs | 49 ++++++-- 4 files changed, 214 insertions(+), 67 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/balance_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/balance_tests.rs index 2c6e5872eb5..ca3ccb0066c 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/balance_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/balance_tests.rs @@ -87,40 +87,84 @@ async fn ready( } } -/// A tip settled as executed keeps its notes spent when the host's snapshot -/// predates recording that (the process stopped in between): they are -/// neither counted nor selectable after the restore. +/// A spend the store observed while it held the note's durable recovery +/// state stays applied when the host's snapshot predates recording it (the +/// process stopped in between): after a reopen and restore, exactly that +/// note is neither counted nor selectable. A two-input tip that failed, whose +/// first input an ordinary payment spent later, leaves the second counted. +/// A damaged barrier elsewhere in the wallet does not hide the evidence. #[tokio::test] -async fn an_executed_tip_keeps_its_notes_spent_across_a_stale_host_snapshot() { - use super::store::{TipBarrier, TipBarrierStatus}; +async fn an_observed_spend_stays_applied_across_a_stale_host_snapshot() { + use super::store::{PendingRedrive, TipBarrier, TipBarrierStatus}; let path = tree_path(); - let coordinator = coordinator(&path); let wallet = [0x5A; 32]; let id = SubwalletId::new(wallet, 0); - register(&coordinator, wallet, &[0]).await; + { + let coordinator = coordinator(&path); + register(&coordinator, wallet, &[0]).await; + let mut store = coordinator.store().write().await; + // A failed two-input tip: its barrier remains, its reservations went. + store + .record_tip_barrier( + wallet, + &TipBarrier { + activity_id: [0x15; 32], + account_index: 0, + recipient: [7; 43], + nullifiers: vec![[5; 32], [6; 32]], + anchor: [9; 32], + status: TipBarrierStatus::Pending, + }, + ) + .unwrap(); + store + .settle_tip_barrier(wallet, &[0x15; 32], TipBarrierStatus::Failed) + .unwrap(); + // An ordinary payment, guarded by a recovery row, spends note 7. + store.save_note(id, ¬e(7, 300, false)).unwrap(); + store + .arm_redrive( + id, + PendingRedrive { + activity_id: [0x17; 32], + anchor: [9; 32], + nullifiers: vec![[7; 32]], + st_bytes: vec![1; 8], + attempts: 0, + identity_nonce_finalized: false, + identity_user_abandoned: false, + }, + ) + .unwrap(); + for n in [5u8, 6, 7] { + store + .save_note(id, ¬e(n, 100 * n as u64, false)) + .unwrap(); + } + // The scan sees 5 (the failed tip's first input) and 7 spent. + store.mark_spent(id, &[5; 32]).unwrap(); + store.mark_spent(id, &[7; 32]).unwrap(); + } + // Reopen; an unrelated barrier is damaged, and the host's snapshot + // still shows all three notes unspent. + let coordinator = coordinator(&path); coordinator .store() .write() .await - .record_tip_barrier( - wallet, - &TipBarrier { - activity_id: [0x15; 32], - account_index: 0, - recipient: [7; 43], - nullifiers: vec![[5; 32]], - anchor: [9; 32], - status: TipBarrierStatus::Executed, - }, - ) - .unwrap(); + .damage_tip_barriers_for_test(wallet); + register(&coordinator, wallet, &[0]).await; let snapshot = ShieldedSyncStartState { per_subwallet: BTreeMap::from([( id, ShieldedSubwalletStartState { - notes: vec![note(5, 900, false), note(6, 100, false)], + notes: vec![ + note(5, 500, false), + note(6, 600, false), + note(7, 700, false), + ], has_sync_state: true, - last_synced_index: 7, + last_synced_index: 9, ..Default::default() }, )]), @@ -133,7 +177,7 @@ async fn an_executed_tip_keeps_its_notes_spent_across_a_stale_host_snapshot() { coordinator.mark_hydrated(wallet, true).await; assert_eq!( ready(&coordinator, wallet).await.accounts[&0].spendable_credits, - 100 + 600 ); let unspent: Vec<_> = coordinator .store() diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index e60215b220f..bd95850c283 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -1169,7 +1169,7 @@ impl NetworkShieldedCoordinator { let mut store = self.store.write().await; // Spent marks for the host, queued once the store is released. - let mut executed_spends = crate::changeset::ShieldedChangeSet::default(); + let mut known_spends = crate::changeset::ShieldedChangeSet::default(); for (id, sub) in &snapshot.per_subwallet { // Only restore subwallets that are registered on this // coordinator — `registered` holds `wallet_id`'s alone, so @@ -1223,25 +1223,18 @@ impl NetworkShieldedCoordinator { })?; } } - // A tip settled as executed spent its notes even when this - // snapshot predates the host recording it (the process stopped - // in between): the barrier is the durable evidence. Unreadable - // barriers give none; they keep tips blocked until repaired. - let executed: Vec<[u8; 32]> = store - .tip_barriers(wallet_id) - .unwrap_or_default() - .into_iter() - .filter(|barrier| { - barrier.status == super::store::TipBarrierStatus::Executed - && barrier.account_index == id.account_index - }) - .flat_map(|barrier| barrier.nullifiers) - .collect(); - for nullifier in executed { + // Notes the store saw spent while it still held their durable + // recovery state stay spent even when this snapshot predates the + // host recording it (the process stopped in between). Each is + // the exact nullifier observed, not a tip's whole input set. + let known_spent = store.known_spent_nullifiers(*id).map_err(|e| { + crate::error::PlatformWalletError::ShieldedStoreError(e.to_string()) + })?; + for nullifier in known_spent { if store.mark_spent(*id, &nullifier).map_err(|e| { crate::error::PlatformWalletError::ShieldedStoreError(e.to_string()) })? { - executed_spends.record_nullifier_spent(*id, nullifier); + known_spends.record_nullifier_spent(*id, nullifier); } } // Rehydrate recovered outgoing (sent) notes so send history @@ -1294,7 +1287,7 @@ impl NetworkShieldedCoordinator { } drop(store); let persister = self.persisters.read().await.get(&wallet_id).cloned(); - super::operations::queue_shielded_changeset(persister.as_ref(), wallet_id, executed_spends); + super::operations::queue_shielded_changeset(persister.as_ref(), wallet_id, known_spends); Ok(()) } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index 6df0ae4ffc7..c7f4b2e5dbe 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -262,6 +262,19 @@ impl FileBackedShieldedStore { .map_err(|e| { FileShieldedStoreError::Storage(format!("create tip_recipients table: {e}")) })?; + pending_conn + .execute( + "CREATE TABLE IF NOT EXISTS shielded_known_spent ( + wallet_id BLOB NOT NULL, + account_index INTEGER NOT NULL, + nullifier BLOB NOT NULL, + PRIMARY KEY (wallet_id, account_index, nullifier) + )", + [], + ) + .map_err(|e| { + FileShieldedStoreError::Storage(format!("create known_spent table: {e}")) + })?; // Additive, idempotent migration: old guards remain active. let columns = { let mut statement = pending_conn @@ -524,6 +537,28 @@ impl FileBackedShieldedStore { Ok(()) } + /// Durably note that `id`'s `nullifier` was seen spent. + fn record_known_spent( + &self, + id: SubwalletId, + nullifier: &[u8; 32], + ) -> Result<(), FileShieldedStoreError> { + self.pending_conn + .lock() + .expect("pending_conn mutex") + .execute( + "INSERT OR IGNORE INTO shielded_known_spent (wallet_id, account_index, nullifier) \ + VALUES (?1, ?2, ?3)", + rusqlite::params![ + id.wallet_id.as_slice(), + id.account_index, + nullifier.as_slice() + ], + ) + .map(|_| ()) + .map_err(|e| FileShieldedStoreError::Storage(format!("record known spend: {e}"))) + } + /// Settle each tip, keeping those whose write fails for a retry. fn settle_tips(&mut self, tips: Vec<((WalletId, [u8; 32]), TipBarrierStatus)>) { for ((wallet_id, activity_id), status) in tips { @@ -768,15 +803,38 @@ impl ShieldedStore for FileBackedShieldedStore { // crash between them never loses an executed tip's record. A failed // write leaves it pending (fail closed) and is retried at the next // watermark advance. - let executed = tip_barriers_spending(&self.tip_barriers, id.wallet_id, nullifier) - .into_iter() - .map(|activity_id| ((id.wallet_id, activity_id), TipBarrierStatus::Executed)) + let tips = tip_barriers_spending(&self.tip_barriers, id.wallet_id, nullifier); + let executed = tips + .iter() + .map(|activity_id| ((id.wallet_id, *activity_id), TipBarrierStatus::Executed)) .collect(); self.settle_tips(executed); let Some(sw) = self.subwallets.get_mut(&id) else { + if !tips.is_empty() { + if let Err(e) = self.record_known_spent(id, nullifier) { + tracing::warn!(error = %e, "tip input spend not recorded"); + } + } return Ok(false); }; let outcome = sw.mark_spent(nullifier); + // A host snapshot written before this spend still shows the note + // unspent, and the recovery row below was the durable reservation. + // Record this exact nullifier first; if that fails, keep the rows + // so a restart still reserves the note. + if (!tips.is_empty() || !outcome.dropped_redrives.is_empty()) + && self + .record_known_spent(id, nullifier) + .inspect_err(|e| { + tracing::warn!( + error = %e, + "spend not recorded; keeping its recovery row until a restart reconciles it" + ) + }) + .is_err() + { + return Ok(outcome.newly_spent); + } // Mirror the durable deletion whenever the in-memory drop // happened — keyed on the returned activity ids, NOT on // `newly_spent`. A note restored already-spent still resolves a @@ -1142,6 +1200,13 @@ impl ShieldedStore for FileBackedShieldedStore { .map_err(|e| { FileShieldedStoreError::Storage(format!("purge tip barriers for wallet: {e}")) })?; + conn.execute( + "DELETE FROM shielded_known_spent WHERE wallet_id = ?1", + rusqlite::params![wallet_id.as_slice()], + ) + .map_err(|e| { + FileShieldedStoreError::Storage(format!("purge known spends for wallet: {e}")) + })?; } // Per-subwallet note / watermark / checkpoint state is // in-memory only (`subwallets`); the commitment tree in @@ -1169,10 +1234,11 @@ impl ShieldedStore for FileBackedShieldedStore { let conn = self.pending_conn.lock().expect("pending_conn mutex"); conn.execute( "DELETE FROM shielded_tip_barriers \ - WHERE wallet_id = ?1 AND recipient = ?2 AND status = ?3", + WHERE wallet_id = ?1 AND recipient = ?2 AND status IN (?3, ?4)", rusqlite::params![ wallet_id.as_slice(), barrier.recipient.as_slice(), + tip_barrier_status_code(TipBarrierStatus::Executed), tip_barrier_status_code(TipBarrierStatus::Failed) ], ) @@ -1196,7 +1262,10 @@ impl ShieldedStore for FileBackedShieldedStore { self.tip_barriers.retain(|(wallet, _), existing| { *wallet != wallet_id || existing.recipient != barrier.recipient - || existing.status != TipBarrierStatus::Failed + || !matches!( + existing.status, + TipBarrierStatus::Executed | TipBarrierStatus::Failed + ) }); self.tip_barriers .insert((wallet_id, barrier.activity_id), barrier.clone()); @@ -1320,6 +1389,28 @@ impl ShieldedStore for FileBackedShieldedStore { Ok(abandoned) } + fn known_spent_nullifiers(&self, id: SubwalletId) -> Result, Self::Error> { + let conn = self.pending_conn.lock().expect("pending_conn mutex"); + let mut statement = conn + .prepare( + "SELECT nullifier FROM shielded_known_spent \ + WHERE wallet_id = ?1 AND account_index = ?2", + ) + .map_err(|e| FileShieldedStoreError::Storage(format!("read known spends: {e}")))?; + let rows = statement + .query_map( + rusqlite::params![id.wallet_id.as_slice(), id.account_index], + |row| row.get::<_, Vec>(0), + ) + .map_err(|e| FileShieldedStoreError::Storage(format!("read known spends: {e}")))?; + rows.map(|row| { + row.map_err(|e| FileShieldedStoreError::Storage(format!("read known spend: {e}")))? + .try_into() + .map_err(|_| FileShieldedStoreError::Storage("known spend is not 32 bytes".into())) + }) + .collect() + } + fn confirmed_tip_recipient( &self, wallet_id: WalletId, @@ -2211,16 +2302,9 @@ mod tests { ] ); assert!(!store.has_pending_tip_barriers().unwrap()); - // A new tip to the recipient replaces its failed row; the - // executed one stays as the record of the notes it spent. + // A new tip to the recipient replaces its settled rows. store.record_tip_barrier(wallet, &barrier(3, 0x33)).unwrap(); - assert_eq!( - statuses(&store), - vec![ - (1, TipBarrierStatus::Executed), - (3, TipBarrierStatus::Pending) - ] - ); + assert_eq!(statuses(&store), vec![(3, TipBarrierStatus::Pending)]); assert!(store .record_tip_barrier( wallet, @@ -2262,8 +2346,7 @@ mod tests { store.abandon_tip_barriers([0xC2; 32]).unwrap(); assert!(store.tip_barriers(wallet).is_err()); assert_eq!(store.abandon_tip_barriers(wallet).unwrap(), 0); - // The damaged rows are gone; the executed tip's record stays. - assert_eq!(statuses(&store), vec![(1, TipBarrierStatus::Executed)]); + assert!(store.tip_barriers(wallet).unwrap().is_empty()); assert!(!store.has_pending_tip_barriers().unwrap()); store.record_tip_barrier(wallet, &barrier(6, 0x66)).unwrap(); store.purge_wallet(wallet).unwrap(); diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index 3fd20c3cb0b..2339d7fdc4c 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -545,11 +545,10 @@ pub trait ShieldedStore: Send + Sync { // ── Tip payment barriers (per wallet) ────────────────────────────── /// Durably record `barrier` before its tip is broadcast. Replaces any - /// failed barrier of the same wallet and recipient, so the table holds - /// at most one such row per recipient. Pending and abandoned barriers - /// stay (an abandoned tip may still execute, and its recovery keeps - /// treating it as a tip until it settles), and executed ones stay as the - /// durable record of the notes the tip spent. + /// executed or failed barrier of the same wallet and recipient, so the + /// table holds at most one such row per recipient. Pending and abandoned + /// barriers stay: an abandoned tip may still execute, and its recovery + /// keeps treating it as a tip until it settles. fn record_tip_barrier( &mut self, wallet_id: WalletId, @@ -581,6 +580,13 @@ pub trait ShieldedStore: Send + Sync { /// and stays. Returns how many pending barriers it abandoned. fn abandon_tip_barriers(&mut self, wallet_id: WalletId) -> Result; + /// Nullifiers of `id` this store saw spent while a tip barrier or a + /// recovery operation tracked them. `mark_spent` must record one before + /// dropping that durable recovery state, since a host snapshot written + /// earlier still shows the note unspent; restore marks these spent. + /// Kept until `purge_wallet`. + fn known_spent_nullifiers(&self, id: SubwalletId) -> Result, Self::Error>; + // ── Confirmed tip recipients (per wallet) ────────────────────────── /// The recipient `(identity id, raw Orchard address)` the user last @@ -972,6 +978,8 @@ pub struct InMemoryShieldedStore { tip_recipients: BTreeMap<(WalletId, String), ConfirmedTipRecipient>, /// Tip payment barriers by `(wallet, activity id)`. tip_barriers: BTreeMap<(WalletId, [u8; 32]), TipBarrier>, + /// See [`ShieldedStore::known_spent_nullifiers`]. + known_spent: BTreeMap>, } impl InMemoryShieldedStore { @@ -1013,19 +1021,26 @@ impl ShieldedStore for InMemoryShieldedStore { } fn mark_spent(&mut self, id: SubwalletId, nullifier: &[u8; 32]) -> Result { - for activity_id in tip_barriers_spending(&self.tip_barriers, id.wallet_id, nullifier) { - if let Some(barrier) = self.tip_barriers.get_mut(&(id.wallet_id, activity_id)) { + let tips = tip_barriers_spending(&self.tip_barriers, id.wallet_id, nullifier); + for activity_id in &tips { + if let Some(barrier) = self.tip_barriers.get_mut(&(id.wallet_id, *activity_id)) { barrier.status = TipBarrierStatus::Executed; } } // In-memory store: the redrive map lives in the same // `SubwalletState`, so `mark_spent` already dropped any resolved // redrives — nothing durable to mirror. Surface `newly_spent`. - Ok(self + let outcome = self .subwallets .get_mut(&id) - .map(|sw| sw.mark_spent(nullifier).newly_spent) - .unwrap_or(false)) + .map(|sw| sw.mark_spent(nullifier)); + let tracked = outcome + .as_ref() + .is_some_and(|outcome| !outcome.dropped_redrives.is_empty()); + if !tips.is_empty() || tracked { + self.known_spent.entry(id).or_default().insert(*nullifier); + } + Ok(outcome.is_some_and(|outcome| outcome.newly_spent)) } fn mark_pending(&mut self, id: SubwalletId, nullifier: &[u8; 32]) -> Result { @@ -1241,9 +1256,18 @@ impl ShieldedStore for InMemoryShieldedStore { .retain(|(wallet, _), _| *wallet != wallet_id); self.tip_barriers .retain(|(wallet, _), _| *wallet != wallet_id); + self.known_spent.retain(|id, _| id.wallet_id != wallet_id); Ok(()) } + fn known_spent_nullifiers(&self, id: SubwalletId) -> Result, Self::Error> { + Ok(self + .known_spent + .get(&id) + .map(|spent| spent.iter().copied().collect()) + .unwrap_or_default()) + } + fn record_tip_barrier( &mut self, wallet_id: WalletId, @@ -1257,7 +1281,10 @@ impl ShieldedStore for InMemoryShieldedStore { self.tip_barriers.retain(|(wallet, _), existing| { *wallet != wallet_id || existing.recipient != barrier.recipient - || existing.status != TipBarrierStatus::Failed + || !matches!( + existing.status, + TipBarrierStatus::Executed | TipBarrierStatus::Failed + ) }); self.tip_barriers .insert((wallet_id, barrier.activity_id), barrier.clone()); From 1b300991f0725701c2ca979837d0a720f64cf7a0 Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 1 Nov 2026 21:39:56 -0600 Subject: [PATCH 18/21] fix(platform-wallet): record every owned spend, and keep a recovery row until all its inputs are A two-input tip's second input was never recorded as spent: marking the first settled the barrier and dropped the shared recovery operation, so the second matched neither. A stop between the two marks also lost the second input's durable reservation. mark_spent now records each owned note it sees newly spent, plus tracked ones, and keeps a recovery operation's durable row until every input it reserves is recorded; a restart in between rehydrates it and keeps the rest reserved. Known spends are loaded at open, skipping a damaged row rather than failing the others, and restore reads them before restoring any note. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/shielded/balance_tests.rs | 115 ++++++++++++++ .../src/wallet/shielded/coordinator.rs | 15 +- .../src/wallet/shielded/file_store.rs | 146 +++++++++++++----- .../src/wallet/shielded/store.rs | 14 +- 4 files changed, 236 insertions(+), 54 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/balance_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/balance_tests.rs index ca3ccb0066c..6a3e1ae002c 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/balance_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/balance_tests.rs @@ -192,6 +192,121 @@ async fn an_observed_spend_stays_applied_across_a_stale_host_snapshot() { let _ = std::fs::remove_file(&path); } +/// A successful two-input tip: the scan sees its first input spent, and the +/// process stops before the second (and before the host records either). +/// After a reopen the recovery row still reserves the second input, so with +/// a stale snapshot neither is counted or selectable. Once the second is seen +/// spent too, the row goes, and a later stale restore still excludes both. +#[tokio::test] +async fn a_two_input_tip_keeps_both_inputs_excluded_across_interruptions() { + use super::store::{PendingRedrive, TipBarrier, TipBarrierStatus}; + let path = tree_path(); + let wallet = [0x5B; 32]; + let id = SubwalletId::new(wallet, 0); + let redrive = PendingRedrive { + activity_id: [0x18; 32], + anchor: [9; 32], + nullifiers: vec![[1; 32], [2; 32]], + st_bytes: vec![1; 8], + attempts: 0, + identity_nonce_finalized: false, + identity_user_abandoned: false, + }; + let stale = ShieldedSyncStartState { + per_subwallet: BTreeMap::from([( + id, + ShieldedSubwalletStartState { + notes: vec![ + note(1, 100, false), + note(2, 200, false), + note(3, 300, false), + ], + has_sync_state: true, + last_synced_index: 9, + ..Default::default() + }, + )]), + ..Default::default() + }; + let unspent = |coordinator: &NetworkShieldedCoordinator| { + let store = coordinator.store().try_read().unwrap(); + store + .get_unspent_notes(id) + .unwrap() + .into_iter() + .map(|note| note.nullifier) + .collect::>() + }; + { + let coordinator = coordinator(&path); + register(&coordinator, wallet, &[0]).await; + let mut store = coordinator.store().write().await; + for n in [1u8, 2, 3] { + store + .save_note(id, ¬e(n, 100 * n as u64, false)) + .unwrap(); + } + store + .record_tip_barrier( + wallet, + &TipBarrier { + activity_id: redrive.activity_id, + account_index: 0, + recipient: [7; 43], + nullifiers: redrive.nullifiers.clone(), + anchor: redrive.anchor, + status: TipBarrierStatus::Pending, + }, + ) + .unwrap(); + store.arm_redrive(id, redrive.clone()).unwrap(); + store.mark_spent(id, &[1; 32]).unwrap(); + } + { + let coordinator = coordinator(&path); + register(&coordinator, wallet, &[0]).await; + coordinator + .restore_for_wallet(wallet, &stale) + .await + .unwrap(); + coordinator.mark_hydrated(wallet, true).await; + assert_eq!(unspent(&coordinator), vec![[3; 32]]); + assert_eq!( + ready(&coordinator, wallet).await.accounts[&0].spendable_credits, + 300 + ); + coordinator + .store() + .write() + .await + .mark_spent(id, &[2; 32]) + .unwrap(); + assert!(coordinator + .store() + .read() + .await + .pending_redrives(id) + .unwrap() + .is_empty()); + } + let coordinator = coordinator(&path); + register(&coordinator, wallet, &[0]).await; + coordinator + .restore_for_wallet(wallet, &stale) + .await + .unwrap(); + coordinator.mark_hydrated(wallet, true).await; + assert!(coordinator + .store() + .read() + .await + .pending_redrives(id) + .unwrap() + .is_empty()); + assert_eq!(unspent(&coordinator), vec![[3; 32]]); + let _ = std::fs::remove_file(&path); +} + /// Clear's pending-tip check fails as busy instead of waiting out a scan /// that holds the store, and never reads contention as no pending tips. #[tokio::test] diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index bd95850c283..18e12749c06 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -1193,6 +1193,10 @@ impl NetworkShieldedCoordinator { continue; } } + // Spent evidence first, so a failed read restores nothing. + let known_spent = store.known_spent_nullifiers(*id).map_err(|e| { + crate::error::PlatformWalletError::ShieldedStoreError(e.to_string()) + })?; // Nullifiers the store already tracks for this subwallet. // The in-memory state is always at least as fresh as the // host snapshot (the snapshot's rows were produced from @@ -1223,13 +1227,10 @@ impl NetworkShieldedCoordinator { })?; } } - // Notes the store saw spent while it still held their durable - // recovery state stay spent even when this snapshot predates the - // host recording it (the process stopped in between). Each is - // the exact nullifier observed, not a tip's whole input set. - let known_spent = store.known_spent_nullifiers(*id).map_err(|e| { - crate::error::PlatformWalletError::ShieldedStoreError(e.to_string()) - })?; + // Notes the store saw spent stay spent even when this snapshot + // predates the host recording it (the process stopped in + // between). Each is the exact nullifier observed, not a tip's + // whole input set; read before any note is restored. for nullifier in known_spent { if store.mark_spent(*id, &nullifier).map_err(|e| { crate::error::PlatformWalletError::ShieldedStoreError(e.to_string()) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index c7f4b2e5dbe..8a31406be19 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -129,6 +129,12 @@ pub struct FileBackedShieldedStore { /// watermark advance retries them; the barrier stays pending (refusing) /// until one succeeds. Executed wins over failed. unsettled_tip_settlements: BTreeMap<(WalletId, [u8; 32]), TipBarrierStatus>, + /// The `shielded_known_spent` table, loaded at open and written through. + known_spent: std::collections::BTreeSet<(SubwalletId, [u8; 32])>, + /// Recovery rows resolved in memory but kept durably, by activity id, + /// with their inputs: a row goes once every input is known spent, so a + /// restart in between still reserves the rest. + lingering_redrive_rows: BTreeMap<(SubwalletId, [u8; 32]), Vec<[u8; 32]>>, } impl FileBackedShieldedStore { @@ -306,9 +312,12 @@ impl FileBackedShieldedStore { damaged_tip_barrier_rows: BTreeMap::new(), unattributed_tip_barrier_damage: false, unsettled_tip_settlements: BTreeMap::new(), + known_spent: std::collections::BTreeSet::new(), + lingering_redrive_rows: BTreeMap::new(), }; store.rehydrate_pending_spends()?; store.load_tip_barriers()?; + store.load_known_spent()?; Ok(store) } @@ -537,12 +546,53 @@ impl FileBackedShieldedStore { Ok(()) } + /// Load `shielded_known_spent`. Rows are only ever written with exact + /// sizes, so one that does not decode is external damage: it is skipped + /// (with an error) rather than discarding the others. + fn load_known_spent(&mut self) -> Result<(), FileShieldedStoreError> { + let conn = self.pending_conn.lock().expect("pending_conn mutex"); + let mut statement = conn + .prepare("SELECT wallet_id, account_index, nullifier FROM shielded_known_spent") + .map_err(|e| FileShieldedStoreError::Storage(format!("read known spends: {e}")))?; + let rows = statement + .query_map([], |row| { + Ok(( + row.get::<_, Vec>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, Vec>(2)?, + )) + }) + .map_err(|e| FileShieldedStoreError::Storage(format!("read known spends: {e}")))?; + let mut known = std::collections::BTreeSet::new(); + for row in rows { + let (wallet_id, account_index, nullifier) = + row.map_err(|e| FileShieldedStoreError::Storage(format!("read known spend: {e}")))?; + match ( + <[u8; 32]>::try_from(wallet_id), + u32::try_from(account_index), + <[u8; 32]>::try_from(nullifier), + ) { + (Ok(wallet_id), Ok(account_index), Ok(nullifier)) => { + known.insert((SubwalletId::new(wallet_id, account_index), nullifier)); + } + _ => tracing::error!("skipping an unreadable known-spend row"), + } + } + drop(statement); + drop(conn); + self.known_spent = known; + Ok(()) + } + /// Durably note that `id`'s `nullifier` was seen spent. fn record_known_spent( - &self, + &mut self, id: SubwalletId, nullifier: &[u8; 32], ) -> Result<(), FileShieldedStoreError> { + if self.known_spent.contains(&(id, *nullifier)) { + return Ok(()); + } self.pending_conn .lock() .expect("pending_conn mutex") @@ -555,8 +605,9 @@ impl FileBackedShieldedStore { nullifier.as_slice() ], ) - .map(|_| ()) - .map_err(|e| FileShieldedStoreError::Storage(format!("record known spend: {e}"))) + .map_err(|e| FileShieldedStoreError::Storage(format!("record known spend: {e}")))?; + self.known_spent.insert((id, *nullifier)); + Ok(()) } /// Settle each tip, keeping those whose write fails for a retry. @@ -817,41 +868,65 @@ impl ShieldedStore for FileBackedShieldedStore { } return Ok(false); }; + // The recovery operations this spend resolves in memory, with every + // input each reserves. + let resolved: Vec<([u8; 32], Vec<[u8; 32]>)> = sw + .redrives + .iter() + .filter(|(_, redrive)| redrive.nullifiers.contains(nullifier)) + .map(|(activity_id, redrive)| (*activity_id, redrive.nullifiers.clone())) + .collect(); let outcome = sw.mark_spent(nullifier); // A host snapshot written before this spend still shows the note - // unspent, and the recovery row below was the durable reservation. - // Record this exact nullifier first; if that fails, keep the rows - // so a restart still reserves the note. - if (!tips.is_empty() || !outcome.dropped_redrives.is_empty()) + // unspent. Record the exact nullifier of each owned note seen spent, + // and of tracked ones, before any recovery row goes; if that fails, + // keep the rows so a restart still reserves the note. + if (outcome.newly_spent || !resolved.is_empty() || !tips.is_empty()) && self .record_known_spent(id, nullifier) .inspect_err(|e| { tracing::warn!( error = %e, - "spend not recorded; keeping its recovery row until a restart reconciles it" + "spend not recorded; keeping its recovery rows until a restart reconciles it" ) }) .is_err() { + for (activity_id, inputs) in resolved { + self.lingering_redrive_rows.insert((id, activity_id), inputs); + } return Ok(outcome.newly_spent); } - // Mirror the durable deletion whenever the in-memory drop - // happened — keyed on the returned activity ids, NOT on - // `newly_spent`. A note restored already-spent still resolves a - // rehydrated redrive here (`newly_spent == false`), and leaving - // the SQLite row would resurrect the reservation on the next - // open. Targeting the exact activity ids means the common case - // (no armed redrive for this note) issues zero SQLite work. The - // in-memory transition already happened, so a SQLite failure - // only warns — a surviving row rehydrates and self-heals via the - // reconcile / prune passes. - for activity_id in &outcome.dropped_redrives { - if let Err(e) = self.delete_redrive_row(id, activity_id) { - tracing::warn!( + for (activity_id, inputs) in resolved { + self.lingering_redrive_rows + .insert((id, activity_id), inputs); + } + // A recovery row goes once every input it reserves is known spent: + // one observed input does not prove the others spent (a competing + // payment may have taken it), and until then a restart rehydrates + // the row and keeps the rest reserved. A row left behind by an input + // never seen spent is released by the re-drive and prune passes. + let finished: Vec<[u8; 32]> = self + .lingering_redrive_rows + .iter() + .filter(|((owner, _), inputs)| { + *owner == id + && inputs + .iter() + .all(|input| self.known_spent.contains(&(id, *input))) + }) + .map(|((_, activity_id), _)| *activity_id) + .collect(); + for activity_id in finished { + match self.delete_redrive_row(id, &activity_id) { + Ok(()) => { + self.lingering_redrive_rows.remove(&(id, activity_id)); + } + Err(e) => tracing::warn!( error = %e, "redrive row deletion failed after mark_spent; a stale row may \ rehydrate on the next open (self-heals via reconcile/prune)" - ); + ), } } Ok(outcome.newly_spent) @@ -1217,6 +1292,9 @@ impl ShieldedStore for FileBackedShieldedStore { self.damaged_tip_barrier_rows.remove(&wallet_id); self.unsettled_tip_settlements .retain(|(wallet, _), _| *wallet != wallet_id); + self.known_spent.retain(|(id, _)| id.wallet_id != wallet_id); + self.lingering_redrive_rows + .retain(|(id, _), _| id.wallet_id != wallet_id); Ok(()) } @@ -1390,25 +1468,11 @@ impl ShieldedStore for FileBackedShieldedStore { } fn known_spent_nullifiers(&self, id: SubwalletId) -> Result, Self::Error> { - let conn = self.pending_conn.lock().expect("pending_conn mutex"); - let mut statement = conn - .prepare( - "SELECT nullifier FROM shielded_known_spent \ - WHERE wallet_id = ?1 AND account_index = ?2", - ) - .map_err(|e| FileShieldedStoreError::Storage(format!("read known spends: {e}")))?; - let rows = statement - .query_map( - rusqlite::params![id.wallet_id.as_slice(), id.account_index], - |row| row.get::<_, Vec>(0), - ) - .map_err(|e| FileShieldedStoreError::Storage(format!("read known spends: {e}")))?; - rows.map(|row| { - row.map_err(|e| FileShieldedStoreError::Storage(format!("read known spend: {e}")))? - .try_into() - .map_err(|_| FileShieldedStoreError::Storage("known spend is not 32 bytes".into())) - }) - .collect() + Ok(self + .known_spent + .range((id, [0; 32])..=(id, [0xFF; 32])) + .map(|(_, nullifier)| *nullifier) + .collect()) } fn confirmed_tip_recipient( diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index 2339d7fdc4c..572b198eff9 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -580,11 +580,13 @@ pub trait ShieldedStore: Send + Sync { /// and stays. Returns how many pending barriers it abandoned. fn abandon_tip_barriers(&mut self, wallet_id: WalletId) -> Result; - /// Nullifiers of `id` this store saw spent while a tip barrier or a - /// recovery operation tracked them. `mark_spent` must record one before - /// dropping that durable recovery state, since a host snapshot written - /// earlier still shows the note unspent; restore marks these spent. - /// Kept until `purge_wallet`. + /// Nullifiers of `id` this store saw spent: owned notes newly spent, and + /// ones a tip barrier or recovery operation tracked. `mark_spent` must + /// record each exact nullifier before dropping durable recovery state, + /// and keep a recovery operation's durable state until every input it + /// reserves is recorded, since a host snapshot written earlier still + /// shows the notes unspent; restore marks these spent. Kept until + /// `purge_wallet`. fn known_spent_nullifiers(&self, id: SubwalletId) -> Result, Self::Error>; // ── Confirmed tip recipients (per wallet) ────────────────────────── @@ -1036,7 +1038,7 @@ impl ShieldedStore for InMemoryShieldedStore { .map(|sw| sw.mark_spent(nullifier)); let tracked = outcome .as_ref() - .is_some_and(|outcome| !outcome.dropped_redrives.is_empty()); + .is_some_and(|outcome| outcome.newly_spent || !outcome.dropped_redrives.is_empty()); if !tips.is_empty() || tracked { self.known_spent.entry(id).or_default().insert(*nullifier); } From 50c24fbf6e5aab0af1cea8521542b576cd851a73 Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 1 Nov 2026 22:34:16 -0600 Subject: [PATCH 19/21] fix(platform-wallet): keep partly confirmed spends bound, and refuse restores over damaged spend evidence A bind could drop the account of a recovery operation whose first input was confirmed and second not; the purge deleted the row still reserving the second, and a stale host snapshot then offered it. Such a bind is now refused until the rest is confirmed. A known-spend row that did not decode was skipped, so the consumed note it recorded came back spendable. Its wallet (every wallet, when the wallet id is unreadable) now fails restores until a shielded Clear, which rebuilds spends from the chain, removes the evidence. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/shielded/balance_tests.rs | 68 ++++++++++ .../src/wallet/shielded/coordinator.rs | 14 ++ .../src/wallet/shielded/file_store.rs | 128 +++++++++++++++++- .../src/wallet/shielded/store.rs | 11 ++ 4 files changed, 216 insertions(+), 5 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/balance_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/balance_tests.rs index 6a3e1ae002c..319019c5464 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/balance_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/balance_tests.rs @@ -275,6 +275,37 @@ async fn a_two_input_tip_keeps_both_inputs_excluded_across_interruptions() { ready(&coordinator, wallet).await.accounts[&0].spendable_credits, 300 ); + // Until the second input is confirmed, the account cannot be dropped: + // purging it would release that input's last durable reservation. + let views = [1u32] + .into_iter() + .map(|account| { + ( + account, + OrchardKeySet::from_seed(&[42; 64], dashcore::Network::Testnet, account) + .unwrap() + .viewing_keys(), + ) + }) + .collect(); + let dropped = coordinator + .register_wallet( + wallet, + views, + WalletPersister::new(wallet, Arc::new(NoPlatformPersistence)), + ) + .await; + assert!( + matches!( + dropped, + Err(PlatformWalletError::ShieldedRecoveryKeysRequired { + account_index: 0, + .. + }) + ), + "{dropped:?}" + ); + assert_eq!(unspent(&coordinator), vec![[3; 32]]); coordinator .store() .write() @@ -307,6 +338,43 @@ async fn a_two_input_tip_keeps_both_inputs_excluded_across_interruptions() { let _ = std::fs::remove_file(&path); } +/// Damaged spend evidence fails the restore before any note is restored, so +/// a consumed note whose record is unreadable is never offered. +#[tokio::test] +async fn damaged_spend_evidence_fails_the_restore_before_publishing_notes() { + let path = tree_path(); + let coordinator = coordinator(&path); + let wallet = [0x5C; 32]; + let id = SubwalletId::new(wallet, 0); + register(&coordinator, wallet, &[0]).await; + coordinator + .store() + .write() + .await + .damage_known_spent_for_test(wallet); + let snapshot = ShieldedSyncStartState { + per_subwallet: BTreeMap::from([( + id, + ShieldedSubwalletStartState { + notes: vec![note(4, 400, false)], + has_sync_state: true, + last_synced_index: 9, + ..Default::default() + }, + )]), + ..Default::default() + }; + assert!(coordinator + .restore_for_wallet(wallet, &snapshot) + .await + .is_err()); + let store = coordinator.store().read().await; + assert!(store.get_unspent_notes(id).unwrap().is_empty()); + assert_eq!(store.spendable_balance(id).unwrap(), 0); + drop(store); + let _ = std::fs::remove_file(&path); +} + /// Clear's pending-tip check fails as busy instead of waiting out a scan /// that holds the store, and never reads contention as no pending tips. #[tokio::test] diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index 18e12749c06..0f00f83cb30 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -790,6 +790,20 @@ impl NetworkShieldedCoordinator { } if !stale.is_empty() || registered_count == 0 { let store = self.store.read().await; + // A partly confirmed spend's durable row still reserves its + // remaining inputs; purging the account would let a stale host + // snapshot offer them again. + if let Some((id, _)) = stale + .iter() + .find(|(id, _)| store.has_unreconciled_spend(*id)) + { + return Err(PlatformWalletError::ShieldedRecoveryKeysRequired { + account_index: id.account_index, + reason: "the account holds a partly confirmed spend; keep it bound until \ + shielded sync confirms the rest" + .to_owned(), + }); + } // A pending tip settles only through its paying account's scan // and prune passes, so that account must stay bound, with its keys. // While barriers are unreadable the owners are unknown (a damaged diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index 8a31406be19..8182144faba 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -135,9 +135,20 @@ pub struct FileBackedShieldedStore { /// with their inputs: a row goes once every input is known spent, so a /// restart in between still reserves the rest. lingering_redrive_rows: BTreeMap<(SubwalletId, [u8; 32]), Vec<[u8; 32]>>, + /// Wallets with a `shielded_known_spent` row that did not decode, and + /// whether one names no readable wallet. Their spent evidence is + /// incomplete, so their restores fail until a shielded Clear rebuilds it. + damaged_known_spent: std::collections::BTreeSet, + unattributed_known_spent_damage: bool, } impl FileBackedShieldedStore { + /// Make `wallet_id`'s recorded spends read as damaged. + #[cfg(test)] + pub(crate) fn damage_known_spent_for_test(&mut self, wallet_id: WalletId) { + self.damaged_known_spent.insert(wallet_id); + } + /// Make `wallet_id`'s barrier reads fail as if a stored row were /// unreadable. #[cfg(test)] @@ -314,6 +325,8 @@ impl FileBackedShieldedStore { unsettled_tip_settlements: BTreeMap::new(), known_spent: std::collections::BTreeSet::new(), lingering_redrive_rows: BTreeMap::new(), + damaged_known_spent: std::collections::BTreeSet::new(), + unattributed_known_spent_damage: false, }; store.rehydrate_pending_spends()?; store.load_tip_barriers()?; @@ -546,9 +559,10 @@ impl FileBackedShieldedStore { Ok(()) } - /// Load `shielded_known_spent`. Rows are only ever written with exact - /// sizes, so one that does not decode is external damage: it is skipped - /// (with an error) rather than discarding the others. + /// Load `shielded_known_spent`. A row that does not decode is damage: + /// the readable rows still load, and its wallet (or every wallet, when + /// the wallet id is unreadable) is marked so restores refuse instead of + /// treating the missing evidence as absent. fn load_known_spent(&mut self) -> Result<(), FileShieldedStoreError> { let conn = self.pending_conn.lock().expect("pending_conn mutex"); let mut statement = conn @@ -564,6 +578,8 @@ impl FileBackedShieldedStore { }) .map_err(|e| FileShieldedStoreError::Storage(format!("read known spends: {e}")))?; let mut known = std::collections::BTreeSet::new(); + let mut damaged = std::collections::BTreeSet::new(); + let mut unattributed = false; for row in rows { let (wallet_id, account_index, nullifier) = row.map_err(|e| FileShieldedStoreError::Storage(format!("read known spend: {e}")))?; @@ -575,12 +591,23 @@ impl FileBackedShieldedStore { (Ok(wallet_id), Ok(account_index), Ok(nullifier)) => { known.insert((SubwalletId::new(wallet_id, account_index), nullifier)); } - _ => tracing::error!("skipping an unreadable known-spend row"), + (Ok(wallet_id), _, _) => { + tracing::error!("unreadable known-spend row; its wallet cannot restore"); + damaged.insert(wallet_id); + } + (Err(_), _, _) => { + tracing::error!( + "known-spend row with an unreadable wallet; no wallet can restore" + ); + unattributed = true; + } } } drop(statement); drop(conn); self.known_spent = known; + self.damaged_known_spent = damaged; + self.unattributed_known_spent_damage = unattributed; Ok(()) } @@ -1295,6 +1322,7 @@ impl ShieldedStore for FileBackedShieldedStore { self.known_spent.retain(|(id, _)| id.wallet_id != wallet_id); self.lingering_redrive_rows .retain(|(id, _), _| id.wallet_id != wallet_id); + self.damaged_known_spent.remove(&wallet_id); Ok(()) } @@ -1467,7 +1495,29 @@ impl ShieldedStore for FileBackedShieldedStore { Ok(abandoned) } + fn has_unreconciled_spend(&self, id: SubwalletId) -> bool { + self.lingering_redrive_rows + .keys() + .any(|(owner, _)| *owner == id) + || self.subwallets.get(&id).is_some_and(|state| { + state.redrives.values().any(|redrive| { + redrive + .nullifiers + .iter() + .any(|nullifier| self.known_spent.contains(&(id, *nullifier))) + }) + }) + } + fn known_spent_nullifiers(&self, id: SubwalletId) -> Result, Self::Error> { + if self.unattributed_known_spent_damage || self.damaged_known_spent.contains(&id.wallet_id) + { + return Err(FileShieldedStoreError::Storage( + "recorded shielded spends are damaged; run a shielded Clear to rebuild them \ + from the chain" + .to_string(), + )); + } Ok(self .known_spent .range((id, [0; 32])..=(id, [0xFF; 32])) @@ -1564,7 +1614,15 @@ impl ShieldedStore for FileBackedShieldedStore { .map_err(|e| { FileShieldedStoreError::Storage(format!("purge all pending spends: {e}")) })?; - } + // Clear rebuilds every spend from the chain, and the host wipes + // its notes, so recorded spends (damaged or not) are not needed. + conn.execute("DELETE FROM shielded_known_spent", []) + .map_err(|e| FileShieldedStoreError::Storage(format!("purge known spends: {e}")))?; + } + self.known_spent.clear(); + self.lingering_redrive_rows.clear(); + self.damaged_known_spent.clear(); + self.unattributed_known_spent_damage = false; self.subwallets.clear(); Ok(()) } @@ -2103,6 +2161,66 @@ mod tests { let _ = std::fs::remove_file(&path); } + /// A known-spend row that does not decode marks its wallet's evidence + /// damaged (every wallet's when the wallet id is unreadable) instead of + /// reading as absent; a shielded Clear removes the evidence and damage. + #[test] + fn damaged_known_spends_refuse_reads_until_cleared() { + let path = temp_tree_path("known_spent_damage"); + let wallet = [0xCD; 32]; + let other = [0xCE; 32]; + let id = SubwalletId::new(wallet, 0); + { + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); + store + .save_note( + id, + &ShieldedNote { + position: 0, + cmx: [0x1; 32], + nullifier: [0x21; 32], + block_height: 10, + is_spent: false, + value: 500, + note_data: vec![0u8; 115], + }, + ) + .unwrap(); + store.mark_spent(id, &[0x21; 32]).unwrap(); + assert_eq!(store.known_spent_nullifiers(id).unwrap(), vec![[0x21; 32]]); + store + .pending_conn + .lock() + .unwrap() + .execute("UPDATE shielded_known_spent SET account_index = -1", []) + .unwrap(); + } + { + let store = FileBackedShieldedStore::open_path(&path, 100).expect("reopen"); + assert!(store.known_spent_nullifiers(id).is_err()); + assert!(store + .known_spent_nullifiers(SubwalletId::new(other, 0)) + .unwrap() + .is_empty()); + store + .pending_conn + .lock() + .unwrap() + .execute("UPDATE shielded_known_spent SET wallet_id = x'00'", []) + .unwrap(); + } + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("reopen"); + assert!(store + .known_spent_nullifiers(SubwalletId::new(other, 0)) + .is_err()); + store.purge_all_subwallets().unwrap(); + assert!(store.known_spent_nullifiers(id).unwrap().is_empty()); + drop(store); + let store = FileBackedShieldedStore::open_path(&path, 100).expect("reopen cleared"); + assert!(store.known_spent_nullifiers(id).unwrap().is_empty()); + let _ = std::fs::remove_file(&path); + } + /// A failed settlement whose write fails is retried at the next watermark /// advance too: a tip that never left the device has no nullifier the /// scan could see spent. diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index 572b198eff9..70068765b2f 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -589,6 +589,11 @@ pub trait ShieldedStore: Send + Sync { /// `purge_wallet`. fn known_spent_nullifiers(&self, id: SubwalletId) -> Result, Self::Error>; + /// Whether `id` holds a recovery operation some of whose inputs are + /// known spent and others not yet: its durable state still reserves the + /// rest, so the account must not be purged until it reconciles. + fn has_unreconciled_spend(&self, id: SubwalletId) -> bool; + // ── Confirmed tip recipients (per wallet) ────────────────────────── /// The recipient `(identity id, raw Orchard address)` the user last @@ -1262,6 +1267,12 @@ impl ShieldedStore for InMemoryShieldedStore { Ok(()) } + fn has_unreconciled_spend(&self, _id: SubwalletId) -> bool { + // Nothing here outlives the process, so a purge loses no durable + // reservation. + false + } + fn known_spent_nullifiers(&self, id: SubwalletId) -> Result, Self::Error> { Ok(self .known_spent From 4654d67843dcde7e72c8e3a30d57f028dda5de3a Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 1 Nov 2026 23:20:13 -0600 Subject: [PATCH 20/21] fix(platform-wallet): record a spend before marking it, and keep recorded spends through Clear A known-spend write that failed for an owned note with no recovery row left no durable exclusion while the scan went on. mark_spent now records before changing anything and fails the call if it cannot, so the pass is retried. Clear deleted recovery and spend evidence in separate statements and before the host wiped its notes. It now deletes recovery rows and only damaged spend rows in one transaction, keeps readable recorded spends, and is refused while a spend is partly confirmed. A released or cleared recovery row now also ends its account pin. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/shielded/balance_tests.rs | 5 + .../src/wallet/shielded/coordinator.rs | 13 +- .../src/wallet/shielded/file_store.rs | 209 ++++++++++++++---- .../src/wallet/shielded/store.rs | 8 + 4 files changed, 192 insertions(+), 43 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/balance_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/balance_tests.rs index 319019c5464..9e90a70ef3d 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/balance_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/balance_tests.rs @@ -306,6 +306,11 @@ async fn a_two_input_tip_keeps_both_inputs_excluded_across_interruptions() { "{dropped:?}" ); assert_eq!(unspent(&coordinator), vec![[3; 32]]); + // Clear is refused for the same reason. + assert!(matches!( + coordinator.clear().await, + Err(PlatformWalletError::ShieldedTipPending) + )); coordinator .store() .write() diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index 0f00f83cb30..83e4fa8c791 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -1314,13 +1314,17 @@ impl NetworkShieldedCoordinator { if self.tip_send_in_flight(None) { return Ok(true); } - tokio::time::timeout(std::time::Duration::from_millis(100), self.store.read()) + let store = tokio::time::timeout(std::time::Duration::from_millis(100), self.store.read()) .await .map_err(|_| { PlatformWalletError::ShieldedStoreError( "shielded store busy with a sync pass; retry Clear after it".to_string(), ) - })? + })?; + if store.has_unreconciled_spends() { + return Ok(true); + } + store .has_pending_tip_barriers() .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string())) } @@ -1405,6 +1409,11 @@ impl NetworkShieldedCoordinator { let mut store = self.store.write().await; // A pending tip settles through the note and recovery state a // Clear deletes; clearing first would leave it unresolvable. + // A partly confirmed spend's recovery row still reserves its + // other inputs, which a host snapshot may still list unspent. + if store.has_unreconciled_spends() { + return Err(crate::error::PlatformWalletError::ShieldedTipPending); + } match store.has_pending_tip_barriers() { Ok(false) => {} Ok(true) => return Err(crate::error::PlatformWalletError::ShieldedTipPending), diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index 8182144faba..db0367c8940 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -140,6 +140,8 @@ pub struct FileBackedShieldedStore { /// incomplete, so their restores fail until a shielded Clear rebuilds it. damaged_known_spent: std::collections::BTreeSet, unattributed_known_spent_damage: bool, + /// The rowids of those undecodable rows, which a shielded Clear deletes. + damaged_known_spent_rows: Vec, } impl FileBackedShieldedStore { @@ -327,6 +329,7 @@ impl FileBackedShieldedStore { lingering_redrive_rows: BTreeMap::new(), damaged_known_spent: std::collections::BTreeSet::new(), unattributed_known_spent_damage: false, + damaged_known_spent_rows: Vec::new(), }; store.rehydrate_pending_spends()?; store.load_tip_barriers()?; @@ -566,22 +569,24 @@ impl FileBackedShieldedStore { fn load_known_spent(&mut self) -> Result<(), FileShieldedStoreError> { let conn = self.pending_conn.lock().expect("pending_conn mutex"); let mut statement = conn - .prepare("SELECT wallet_id, account_index, nullifier FROM shielded_known_spent") + .prepare("SELECT rowid, wallet_id, account_index, nullifier FROM shielded_known_spent") .map_err(|e| FileShieldedStoreError::Storage(format!("read known spends: {e}")))?; let rows = statement .query_map([], |row| { Ok(( - row.get::<_, Vec>(0)?, - row.get::<_, i64>(1)?, - row.get::<_, Vec>(2)?, + row.get::<_, i64>(0)?, + row.get::<_, Vec>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, Vec>(3)?, )) }) .map_err(|e| FileShieldedStoreError::Storage(format!("read known spends: {e}")))?; let mut known = std::collections::BTreeSet::new(); let mut damaged = std::collections::BTreeSet::new(); let mut unattributed = false; + let mut damaged_rows = Vec::new(); for row in rows { - let (wallet_id, account_index, nullifier) = + let (rowid, wallet_id, account_index, nullifier) = row.map_err(|e| FileShieldedStoreError::Storage(format!("read known spend: {e}")))?; match ( <[u8; 32]>::try_from(wallet_id), @@ -594,12 +599,14 @@ impl FileBackedShieldedStore { (Ok(wallet_id), _, _) => { tracing::error!("unreadable known-spend row; its wallet cannot restore"); damaged.insert(wallet_id); + damaged_rows.push(rowid); } (Err(_), _, _) => { tracing::error!( "known-spend row with an unreadable wallet; no wallet can restore" ); unattributed = true; + damaged_rows.push(rowid); } } } @@ -608,6 +615,7 @@ impl FileBackedShieldedStore { self.known_spent = known; self.damaged_known_spent = damaged; self.unattributed_known_spent_damage = unattributed; + self.damaged_known_spent_rows = damaged_rows; Ok(()) } @@ -887,11 +895,9 @@ impl ShieldedStore for FileBackedShieldedStore { .map(|activity_id| ((id.wallet_id, *activity_id), TipBarrierStatus::Executed)) .collect(); self.settle_tips(executed); - let Some(sw) = self.subwallets.get_mut(&id) else { + let Some(sw) = self.subwallets.get(&id) else { if !tips.is_empty() { - if let Err(e) = self.record_known_spent(id, nullifier) { - tracing::warn!(error = %e, "tip input spend not recorded"); - } + self.record_known_spent(id, nullifier)?; } return Ok(false); }; @@ -903,27 +909,22 @@ impl ShieldedStore for FileBackedShieldedStore { .filter(|(_, redrive)| redrive.nullifiers.contains(nullifier)) .map(|(activity_id, redrive)| (*activity_id, redrive.nullifiers.clone())) .collect(); - let outcome = sw.mark_spent(nullifier); + let newly_spent = sw + .nullifier_index + .get(nullifier) + .is_some_and(|&index| sw.notes.get(index).is_some_and(|note| !note.is_spent)); // A host snapshot written before this spend still shows the note - // unspent. Record the exact nullifier of each owned note seen spent, - // and of tracked ones, before any recovery row goes; if that fails, - // keep the rows so a restart still reserves the note. - if (outcome.newly_spent || !resolved.is_empty() || !tips.is_empty()) - && self - .record_known_spent(id, nullifier) - .inspect_err(|e| { - tracing::warn!( - error = %e, - "spend not recorded; keeping its recovery rows until a restart reconciles it" - ) - }) - .is_err() - { - for (activity_id, inputs) in resolved { - self.lingering_redrive_rows.insert((id, activity_id), inputs); - } - return Ok(outcome.newly_spent); + // unspent. Record the exact nullifier of each owned note newly seen + // spent, and of tracked ones, before changing anything: if the write + // fails, nothing changed, the caller's pass fails before its + // watermark advances, and the next pass retries. + if newly_spent || !resolved.is_empty() || !tips.is_empty() { + self.record_known_spent(id, nullifier)?; } + let Some(sw) = self.subwallets.get_mut(&id) else { + return Ok(false); + }; + let outcome = sw.mark_spent(nullifier); for (activity_id, inputs) in resolved { self.lingering_redrive_rows .insert((id, activity_id), inputs); @@ -978,12 +979,17 @@ impl ShieldedStore for FileBackedShieldedStore { let removed = sw.clear_pending(nullifier); if removed { // Same log-don't-abort rationale as `mark_spent` above. - if let Err(e) = self.delete_redrive_rows_containing(id, nullifier) { - tracing::warn!( + match self.delete_redrive_rows_containing(id, nullifier) { + // The rows reserving this input are gone, so no lingering + // row still pins the account. + Ok(()) => self + .lingering_redrive_rows + .retain(|(owner, _), inputs| *owner != id || !inputs.contains(nullifier)), + Err(e) => tracing::warn!( error = %e, "redrive row deletion failed after clear_pending; a stale row may \ rehydrate on the next open (self-heals via reconcile/prune)" - ); + ), } } Ok(removed) @@ -1107,6 +1113,7 @@ impl ShieldedStore for FileBackedShieldedStore { // Persist first so an I/O failure leaves the active process guarded in // exactly the same way as a restart that rehydrates the surviving row. self.delete_redrive_row(id, activity_id)?; + self.lingering_redrive_rows.remove(&(id, *activity_id)); if let Some(sw) = self.subwallets.get_mut(&id) { sw.clear_redrive(activity_id); } @@ -1495,6 +1502,14 @@ impl ShieldedStore for FileBackedShieldedStore { Ok(abandoned) } + fn has_unreconciled_spends(&self) -> bool { + !self.lingering_redrive_rows.is_empty() + || self + .subwallets + .keys() + .any(|id| self.has_unreconciled_spend(*id)) + } + fn has_unreconciled_spend(&self, id: SubwalletId) -> bool { self.lingering_redrive_rows .keys() @@ -1607,22 +1622,35 @@ impl ShieldedStore for FileBackedShieldedStore { fn purge_all_subwallets(&mut self) -> Result<(), Self::Error> { // Durable redrive rows for every wallet go with the in-memory // purge; SQL first for the same fail-atomic reason as - // `purge_wallet`. + // `purge_wallet`, in one transaction so a failure leaves every + // recovery row. Recorded spends stay: a spent nullifier stays spent, + // and the host may still hold its old notes if the process stops + // before it wipes them. Only damaged ones go, since the rebuild from + // the chain replaces what they recorded. { let conn = self.pending_conn.lock().expect("pending_conn mutex"); - conn.execute("DELETE FROM shielded_pending_spends", []) - .map_err(|e| { - FileShieldedStoreError::Storage(format!("purge all pending spends: {e}")) - })?; - // Clear rebuilds every spend from the chain, and the host wipes - // its notes, so recorded spends (damaged or not) are not needed. - conn.execute("DELETE FROM shielded_known_spent", []) - .map_err(|e| FileShieldedStoreError::Storage(format!("purge known spends: {e}")))?; + let purge = || -> rusqlite::Result<()> { + conn.execute_batch("BEGIN IMMEDIATE")?; + conn.execute("DELETE FROM shielded_pending_spends", [])?; + for rowid in &self.damaged_known_spent_rows { + conn.execute( + "DELETE FROM shielded_known_spent WHERE rowid = ?1", + rusqlite::params![rowid], + )?; + } + conn.execute_batch("COMMIT") + }; + if let Err(e) = purge() { + let _ = conn.execute_batch("ROLLBACK"); + return Err(FileShieldedStoreError::Storage(format!( + "purge all pending spends: {e}" + ))); + } } - self.known_spent.clear(); self.lingering_redrive_rows.clear(); self.damaged_known_spent.clear(); self.unattributed_known_spent_damage = false; + self.damaged_known_spent_rows.clear(); self.subwallets.clear(); Ok(()) } @@ -2161,6 +2189,105 @@ mod tests { let _ = std::fs::remove_file(&path); } + fn owned_note(nullifier: u8) -> ShieldedNote { + ShieldedNote { + position: nullifier as u64, + cmx: [nullifier; 32], + nullifier: [nullifier; 32], + block_height: 10, + is_spent: false, + value: 500, + note_data: vec![0u8; 115], + } + } + + /// An owned note with no recovery row is recorded before it is marked: + /// a failed write changes nothing and fails the call, so the scan pass + /// is retried and a reopen with a stale snapshot still excludes it. + #[test] + fn a_failed_spend_record_marks_nothing_and_is_retried() { + let path = temp_tree_path("known_spent_write_failure"); + let id = SubwalletId::new([0xD1; 32], 0); + { + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); + store.save_note(id, &owned_note(0x31)).unwrap(); + store + .pending_conn + .lock() + .unwrap() + .execute_batch( + "CREATE TRIGGER reject_known BEFORE INSERT ON shielded_known_spent \ + BEGIN SELECT RAISE(FAIL, 'injected known-spend failure'); END;", + ) + .unwrap(); + assert!(store.mark_spent(id, &[0x31; 32]).is_err()); + assert_eq!(store.get_unspent_notes(id).unwrap().len(), 1); + store + .pending_conn + .lock() + .unwrap() + .execute_batch("DROP TRIGGER reject_known;") + .unwrap(); + assert!(store.mark_spent(id, &[0x31; 32]).unwrap()); + } + let store = FileBackedShieldedStore::open_path(&path, 100).expect("reopen"); + assert_eq!(store.known_spent_nullifiers(id).unwrap(), vec![[0x31; 32]]); + let _ = std::fs::remove_file(&path); + } + + /// A shielded Clear keeps readable recorded spends (the host may still + /// hold old notes if the process stops before it wipes them). + #[test] + fn a_clear_keeps_recorded_spends() { + let path = temp_tree_path("known_spent_clear"); + let id = SubwalletId::new([0xD2; 32], 0); + { + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); + store.save_note(id, &owned_note(0x32)).unwrap(); + store.mark_spent(id, &[0x32; 32]).unwrap(); + store.purge_all_subwallets().unwrap(); + assert_eq!(store.known_spent_nullifiers(id).unwrap(), vec![[0x32; 32]]); + } + let store = FileBackedShieldedStore::open_path(&path, 100).expect("reopen"); + assert_eq!(store.known_spent_nullifiers(id).unwrap(), vec![[0x32; 32]]); + let _ = std::fs::remove_file(&path); + } + + /// When a competing payment takes one input and the prune pass releases + /// the other, the recovery row goes and no longer pins the account. + #[test] + fn a_released_remainder_retires_its_account_pin() { + let path = temp_tree_path("lingering_release"); + let id = SubwalletId::new([0xD3; 32], 0); + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); + store.save_note(id, &owned_note(0x41)).unwrap(); + store.save_note(id, &owned_note(0x42)).unwrap(); + store + .arm_redrive( + id, + PendingRedrive { + activity_id: [0x51; 32], + anchor: [0x61; 32], + nullifiers: vec![[0x41; 32], [0x42; 32]], + st_bytes: vec![1; 8], + attempts: 0, + identity_nonce_finalized: false, + identity_user_abandoned: false, + }, + ) + .unwrap(); + store.mark_pending(id, &[0x42; 32]).unwrap(); + store.mark_spent(id, &[0x41; 32]).unwrap(); + assert!(store.has_unreconciled_spend(id)); + assert!(store.clear_pending(id, &[0x42; 32]).unwrap()); + assert!(!store.has_unreconciled_spend(id)); + assert!(!store.has_unreconciled_spends()); + drop(store); + let store = FileBackedShieldedStore::open_path(&path, 100).expect("reopen"); + assert!(store.pending_redrives(id).unwrap().is_empty()); + let _ = std::fs::remove_file(&path); + } + /// A known-spend row that does not decode marks its wallet's evidence /// damaged (every wallet's when the wallet id is unreadable) instead of /// reading as absent; a shielded Clear removes the evidence and damage. diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index 70068765b2f..4bafe303269 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -594,6 +594,10 @@ pub trait ShieldedStore: Send + Sync { /// rest, so the account must not be purged until it reconciles. fn has_unreconciled_spend(&self, id: SubwalletId) -> bool; + /// Whether any account holds such a partly confirmed spend; a shielded + /// Clear is refused while one does. + fn has_unreconciled_spends(&self) -> bool; + // ── Confirmed tip recipients (per wallet) ────────────────────────── /// The recipient `(identity id, raw Orchard address)` the user last @@ -1273,6 +1277,10 @@ impl ShieldedStore for InMemoryShieldedStore { false } + fn has_unreconciled_spends(&self) -> bool { + false + } + fn known_spent_nullifiers(&self, id: SubwalletId) -> Result, Self::Error> { Ok(self .known_spent From 158ae6af6a58e6c28fb9258d177903fc922a83a9 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 2 Nov 2026 00:09:07 -0600 Subject: [PATCH 21/21] fix(platform-wallet): record a spend before settling its tip, scope settlement to the paying account, and keep damaged spend evidence through Clear - mark_spent records the input's spend before settling a matching barrier, so a failed record leaves the barrier pending. - A barrier settles only from its paying account, not from another account replaying the same nullifier. - Recorded spends exclude their notes from balance and selection reads, covering a rescan that saved a receipt before replaying its nullifier. - Clear no longer repairs damaged spend evidence, since the host may still hold the snapshot it protects; removing the wallet repairs it. This also drops the rowid repair list that a wallet purge left stale. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/shielded/file_store.rs | 213 ++++++++++++------ .../src/wallet/shielded/store.rs | 45 +++- 2 files changed, 183 insertions(+), 75 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index db0367c8940..0f0c1f0d124 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -140,8 +140,6 @@ pub struct FileBackedShieldedStore { /// incomplete, so their restores fail until a shielded Clear rebuilds it. damaged_known_spent: std::collections::BTreeSet, unattributed_known_spent_damage: bool, - /// The rowids of those undecodable rows, which a shielded Clear deletes. - damaged_known_spent_rows: Vec, } impl FileBackedShieldedStore { @@ -329,7 +327,6 @@ impl FileBackedShieldedStore { lingering_redrive_rows: BTreeMap::new(), damaged_known_spent: std::collections::BTreeSet::new(), unattributed_known_spent_damage: false, - damaged_known_spent_rows: Vec::new(), }; store.rehydrate_pending_spends()?; store.load_tip_barriers()?; @@ -565,28 +562,28 @@ impl FileBackedShieldedStore { /// Load `shielded_known_spent`. A row that does not decode is damage: /// the readable rows still load, and its wallet (or every wallet, when /// the wallet id is unreadable) is marked so restores refuse instead of - /// treating the missing evidence as absent. + /// treating the missing evidence as absent. Removing the wallet, which + /// also deletes the host's snapshot of it, repairs its damage; a Clear + /// does not, since the host may still hold that snapshot. fn load_known_spent(&mut self) -> Result<(), FileShieldedStoreError> { let conn = self.pending_conn.lock().expect("pending_conn mutex"); let mut statement = conn - .prepare("SELECT rowid, wallet_id, account_index, nullifier FROM shielded_known_spent") + .prepare("SELECT wallet_id, account_index, nullifier FROM shielded_known_spent") .map_err(|e| FileShieldedStoreError::Storage(format!("read known spends: {e}")))?; let rows = statement .query_map([], |row| { Ok(( - row.get::<_, i64>(0)?, - row.get::<_, Vec>(1)?, - row.get::<_, i64>(2)?, - row.get::<_, Vec>(3)?, + row.get::<_, Vec>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, Vec>(2)?, )) }) .map_err(|e| FileShieldedStoreError::Storage(format!("read known spends: {e}")))?; let mut known = std::collections::BTreeSet::new(); let mut damaged = std::collections::BTreeSet::new(); let mut unattributed = false; - let mut damaged_rows = Vec::new(); for row in rows { - let (rowid, wallet_id, account_index, nullifier) = + let (wallet_id, account_index, nullifier) = row.map_err(|e| FileShieldedStoreError::Storage(format!("read known spend: {e}")))?; match ( <[u8; 32]>::try_from(wallet_id), @@ -599,14 +596,12 @@ impl FileBackedShieldedStore { (Ok(wallet_id), _, _) => { tracing::error!("unreadable known-spend row; its wallet cannot restore"); damaged.insert(wallet_id); - damaged_rows.push(rowid); } (Err(_), _, _) => { tracing::error!( "known-spend row with an unreadable wallet; no wallet can restore" ); unattributed = true; - damaged_rows.push(rowid); } } } @@ -615,7 +610,6 @@ impl FileBackedShieldedStore { self.known_spent = known; self.damaged_known_spent = damaged; self.unattributed_known_spent_damage = unattributed; - self.damaged_known_spent_rows = damaged_rows; Ok(()) } @@ -860,7 +854,7 @@ impl ShieldedStore for FileBackedShieldedStore { Ok(self .subwallets .get(&id) - .map(SubwalletState::unspent_notes) + .map(|state| state.unspent_notes_except(|n| self.known_spent.contains(&(id, *n)))) .unwrap_or_default()) } @@ -870,7 +864,9 @@ impl ShieldedStore for FileBackedShieldedStore { // still fail closed before sync or local snapshots can publish them. self.subwallets .get(&id) - .map_or(Some(0), SubwalletState::spendable_balance) + .map_or(Some(0), |state| { + state.spendable_balance_except(|n| self.known_spent.contains(&(id, *n))) + }) .ok_or_else(|| { FileShieldedStoreError::Storage("spendable shielded balance exceeds u64".into()) }) @@ -889,15 +885,18 @@ impl ShieldedStore for FileBackedShieldedStore { // crash between them never loses an executed tip's record. A failed // write leaves it pending (fail closed) and is retried at the next // watermark advance. - let tips = tip_barriers_spending(&self.tip_barriers, id.wallet_id, nullifier); - let executed = tips + let tips = tip_barriers_spending(&self.tip_barriers, id, nullifier); + let executed: Vec<_> = tips .iter() .map(|activity_id| ((id.wallet_id, *activity_id), TipBarrierStatus::Executed)) .collect(); - self.settle_tips(executed); + // Order: the exact input exclusion, then the barrier's settlement, + // then note and recovery state, so no step is published before the + // evidence the next ones rely on. let Some(sw) = self.subwallets.get(&id) else { if !tips.is_empty() { self.record_known_spent(id, nullifier)?; + self.settle_tips(executed); } return Ok(false); }; @@ -921,6 +920,7 @@ impl ShieldedStore for FileBackedShieldedStore { if newly_spent || !resolved.is_empty() || !tips.is_empty() { self.record_known_spent(id, nullifier)?; } + self.settle_tips(executed); let Some(sw) = self.subwallets.get_mut(&id) else { return Ok(false); }; @@ -1528,8 +1528,8 @@ impl ShieldedStore for FileBackedShieldedStore { if self.unattributed_known_spent_damage || self.damaged_known_spent.contains(&id.wallet_id) { return Err(FileShieldedStoreError::Storage( - "recorded shielded spends are damaged; run a shielded Clear to rebuild them \ - from the chain" + "recorded shielded spends are damaged; remove and re-add the wallet to rebuild \ + them from the chain" .to_string(), )); } @@ -1622,35 +1622,17 @@ impl ShieldedStore for FileBackedShieldedStore { fn purge_all_subwallets(&mut self) -> Result<(), Self::Error> { // Durable redrive rows for every wallet go with the in-memory // purge; SQL first for the same fail-atomic reason as - // `purge_wallet`, in one transaction so a failure leaves every - // recovery row. Recorded spends stay: a spent nullifier stays spent, - // and the host may still hold its old notes if the process stops - // before it wipes them. Only damaged ones go, since the rebuild from - // the chain replaces what they recorded. + // `purge_wallet`. Recorded spends stay, damaged ones included: a + // spent nullifier stays spent, and the host may still hold its old + // notes if the process stops before it wipes them. { let conn = self.pending_conn.lock().expect("pending_conn mutex"); - let purge = || -> rusqlite::Result<()> { - conn.execute_batch("BEGIN IMMEDIATE")?; - conn.execute("DELETE FROM shielded_pending_spends", [])?; - for rowid in &self.damaged_known_spent_rows { - conn.execute( - "DELETE FROM shielded_known_spent WHERE rowid = ?1", - rusqlite::params![rowid], - )?; - } - conn.execute_batch("COMMIT") - }; - if let Err(e) = purge() { - let _ = conn.execute_batch("ROLLBACK"); - return Err(FileShieldedStoreError::Storage(format!( - "purge all pending spends: {e}" - ))); - } + conn.execute("DELETE FROM shielded_pending_spends", []) + .map_err(|e| { + FileShieldedStoreError::Storage(format!("purge all pending spends: {e}")) + })?; } self.lingering_redrive_rows.clear(); - self.damaged_known_spent.clear(); - self.unattributed_known_spent_damage = false; - self.damaged_known_spent_rows.clear(); self.subwallets.clear(); Ok(()) } @@ -2290,29 +2272,17 @@ mod tests { /// A known-spend row that does not decode marks its wallet's evidence /// damaged (every wallet's when the wallet id is unreadable) instead of - /// reading as absent; a shielded Clear removes the evidence and damage. + /// reading as absent. A shielded Clear keeps it, since the host may still + /// hold the old snapshot; removing the wallet repairs it. #[test] - fn damaged_known_spends_refuse_reads_until_cleared() { + fn damaged_known_spends_refuse_reads_until_the_wallet_is_removed() { let path = temp_tree_path("known_spent_damage"); let wallet = [0xCD; 32]; let other = [0xCE; 32]; let id = SubwalletId::new(wallet, 0); { let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); - store - .save_note( - id, - &ShieldedNote { - position: 0, - cmx: [0x1; 32], - nullifier: [0x21; 32], - block_height: 10, - is_spent: false, - value: 500, - note_data: vec![0u8; 115], - }, - ) - .unwrap(); + store.save_note(id, &owned_note(0x21)).unwrap(); store.mark_spent(id, &[0x21; 32]).unwrap(); assert_eq!(store.known_spent_nullifiers(id).unwrap(), vec![[0x21; 32]]); store @@ -2323,12 +2293,25 @@ mod tests { .unwrap(); } { - let store = FileBackedShieldedStore::open_path(&path, 100).expect("reopen"); + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("reopen"); assert!(store.known_spent_nullifiers(id).is_err()); assert!(store .known_spent_nullifiers(SubwalletId::new(other, 0)) .unwrap() .is_empty()); + store.purge_all_subwallets().unwrap(); + } + { + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("after clear"); + assert!( + store.known_spent_nullifiers(id).is_err(), + "Clear keeps the damage" + ); + store.purge_wallet(wallet).unwrap(); + assert!(store.known_spent_nullifiers(id).unwrap().is_empty()); + // An unattributed row blocks every wallet. + store.save_note(id, &owned_note(0x22)).unwrap(); + store.mark_spent(id, &[0x22; 32]).unwrap(); store .pending_conn .lock() @@ -2336,15 +2319,109 @@ mod tests { .execute("UPDATE shielded_known_spent SET wallet_id = x'00'", []) .unwrap(); } - let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("reopen"); + let store = FileBackedShieldedStore::open_path(&path, 100).expect("reopen"); assert!(store .known_spent_nullifiers(SubwalletId::new(other, 0)) .is_err()); - store.purge_all_subwallets().unwrap(); - assert!(store.known_spent_nullifiers(id).unwrap().is_empty()); + let _ = std::fs::remove_file(&path); + } + + /// The spend is recorded before its barrier settles: a failed record + /// leaves the barrier pending (still pinning its account and Clear). + #[test] + fn a_failed_spend_record_leaves_the_tip_barrier_pending() { + let path = temp_tree_path("known_spent_before_settle"); + let wallet = [0xD4; 32]; + let id = SubwalletId::new(wallet, 0); + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); + store.save_note(id, &owned_note(0x33)).unwrap(); + store + .record_tip_barrier( + wallet, + &TipBarrier { + activity_id: [0x53; 32], + account_index: 0, + recipient: [7; 43], + nullifiers: vec![[0x33; 32]], + anchor: [9; 32], + status: TipBarrierStatus::Pending, + }, + ) + .unwrap(); + store + .pending_conn + .lock() + .unwrap() + .execute_batch( + "CREATE TRIGGER reject_known BEFORE INSERT ON shielded_known_spent \ + BEGIN SELECT RAISE(FAIL, 'injected known-spend failure'); END;", + ) + .unwrap(); + assert!(store.mark_spent(id, &[0x33; 32]).is_err()); + assert!(store.has_pending_tip_barriers().unwrap()); drop(store); - let store = FileBackedShieldedStore::open_path(&path, 100).expect("reopen cleared"); - assert!(store.known_spent_nullifiers(id).unwrap().is_empty()); + let store = FileBackedShieldedStore::open_path(&path, 100).expect("reopen"); + assert!(store.has_pending_tip_barriers().unwrap()); + let _ = std::fs::remove_file(&path); + } + + /// A barrier settles only from its paying account: the same nullifier + /// replayed against another account settles and records nothing. + #[test] + fn a_tip_barrier_settles_only_from_its_paying_account() { + let path = temp_tree_path("tip_barrier_account_scope"); + let wallet = [0xD5; 32]; + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); + store + .record_tip_barrier( + wallet, + &TipBarrier { + activity_id: [0x54; 32], + account_index: 1, + recipient: [7; 43], + nullifiers: vec![[0x34; 32]], + anchor: [9; 32], + status: TipBarrierStatus::Pending, + }, + ) + .unwrap(); + let other = SubwalletId::new(wallet, 0); + store.mark_spent(other, &[0x34; 32]).unwrap(); + assert!(store.has_pending_tip_barriers().unwrap()); + assert!(store.known_spent_nullifiers(other).unwrap().is_empty()); + let paying = SubwalletId::new(wallet, 1); + store.mark_spent(paying, &[0x34; 32]).unwrap(); + assert!(!store.has_pending_tip_barriers().unwrap()); + assert_eq!( + store.known_spent_nullifiers(paying).unwrap(), + vec![[0x34; 32]] + ); + let _ = std::fs::remove_file(&path); + } + + /// A recorded spend excludes its note from balance and selection even + /// when a rescan after Clear has saved the receipt unspent and the pass + /// fails before replaying its nullifier. + #[test] + fn a_recorded_spend_excludes_a_rescanned_receipt_before_replay() { + let path = temp_tree_path("known_spent_eligibility"); + let id = SubwalletId::new([0xD6; 32], 0); + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); + store.save_note(id, &owned_note(0x35)).unwrap(); + store.mark_spent(id, &[0x35; 32]).unwrap(); + store.purge_all_subwallets().unwrap(); + store.save_note(id, &owned_note(0x35)).unwrap(); + store.save_note(id, &owned_note(0x36)).unwrap(); + let unspent: Vec<_> = store + .get_unspent_notes(id) + .unwrap() + .into_iter() + .map(|note| note.nullifier) + .collect(); + assert_eq!(unspent, vec![[0x36; 32]]); + assert_eq!(store.spendable_balance(id).unwrap(), 500); + // The replay still reports the note newly spent, so the host learns it. + assert!(store.mark_spent(id, &[0x35; 32]).unwrap()); let _ = std::fs::remove_file(&path); } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index 4bafe303269..297628858ce 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -173,13 +173,14 @@ pub struct TipBarrier { /// spent note settles as executed: every one not executed already. pub(super) fn tip_barriers_spending( barriers: &BTreeMap<(WalletId, [u8; 32]), TipBarrier>, - wallet_id: WalletId, + id: SubwalletId, nullifier: &[u8; 32], ) -> Vec<[u8; 32]> { barriers .iter() .filter(|((wallet, _), barrier)| { - *wallet == wallet_id + *wallet == id.wallet_id + && barrier.account_index == id.account_index && barrier.status != TipBarrierStatus::Executed && barrier.nullifiers.contains(nullifier) }) @@ -749,12 +750,37 @@ impl SubwalletState { .filter(|note| !note.is_spent && !self.pending_nullifiers.contains_key(¬e.nullifier)) } + #[cfg(test)] pub(super) fn unspent_notes(&self) -> Vec { self.unspent_notes_iter().cloned().collect() } + /// [`Self::unspent_notes`] without the notes `spent` reports recorded as + /// spent: a recorded spend is authoritative even before the note's own + /// flag catches up (a rescan saves receipts before replaying nullifiers). + pub(super) fn unspent_notes_except( + &self, + spent: impl Fn(&[u8; 32]) -> bool, + ) -> Vec { + self.unspent_notes_iter() + .filter(|note| !spent(¬e.nullifier)) + .cloned() + .collect() + } + + /// [`Self::spendable_balance`] without the notes `spent` reports. + pub(super) fn spendable_balance_except( + &self, + spent: impl Fn(&[u8; 32]) -> bool, + ) -> Option { + self.unspent_notes_iter() + .filter(|note| !spent(¬e.nullifier)) + .try_fold(0u64, |total, note| total.checked_add(note.value)) + } + /// Both stores use the same borrowed, reservation-aware checked sum. /// Each caller maps overflow into its own storage error. + #[cfg(test)] pub(super) fn spendable_balance(&self) -> Option { self.unspent_notes_iter() .try_fold(0u64, |total, note| total.checked_add(note.value)) @@ -1009,17 +1035,21 @@ impl ShieldedStore for InMemoryShieldedStore { } fn get_unspent_notes(&self, id: SubwalletId) -> Result, Self::Error> { + let spent = self.known_spent.get(&id); Ok(self .subwallets .get(&id) - .map(SubwalletState::unspent_notes) + .map(|state| state.unspent_notes_except(|n| spent.is_some_and(|s| s.contains(n)))) .unwrap_or_default()) } fn spendable_balance(&self, id: SubwalletId) -> Result { + let spent = self.known_spent.get(&id); self.subwallets .get(&id) - .map_or(Some(0), SubwalletState::spendable_balance) + .map_or(Some(0), |state| { + state.spendable_balance_except(|n| spent.is_some_and(|s| s.contains(n))) + }) .ok_or_else(|| InMemoryStoreError("spendable shielded balance exceeds u64".to_string())) } @@ -1032,7 +1062,7 @@ impl ShieldedStore for InMemoryShieldedStore { } fn mark_spent(&mut self, id: SubwalletId, nullifier: &[u8; 32]) -> Result { - let tips = tip_barriers_spending(&self.tip_barriers, id.wallet_id, nullifier); + let tips = tip_barriers_spending(&self.tip_barriers, id, nullifier); for activity_id in &tips { if let Some(barrier) = self.tip_barriers.get_mut(&(id.wallet_id, *activity_id)) { barrier.status = TipBarrierStatus::Executed; @@ -1272,8 +1302,9 @@ impl ShieldedStore for InMemoryShieldedStore { } fn has_unreconciled_spend(&self, _id: SubwalletId) -> bool { - // Nothing here outlives the process, so a purge loses no durable - // reservation. + // A reduced test double: nothing here outlives the process, and it + // keeps no lingering-row ownership. Lifecycle tests that depend on + // partial-confirmation refusals use the file backend. false }