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..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 @@ -17,16 +17,29 @@ 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 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 + 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 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..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 @@ -39,3 +39,82 @@ 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) + +/** 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 ab3ed08fe2c..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 @@ -2015,6 +2016,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) } @@ -2022,22 +2027,90 @@ class PlatformWalletManager( mapNativeErrors { FundingNative.prepareShieldedTipAddress(managerHandle, walletId, mnemonicResolver.nativeHandle, identityId) } } - /** Send only if fresh resolution still matches the recipient shown in confirmation. */ + /** + * 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. 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 { + 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. + */ + 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. + */ + 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. Every + * failure is thrown as [org.dashfoundation.dashsdk.tokens.ShieldedTipFailure] carrying Rust's + * 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 - } - 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 { + // 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) { + throw error + } catch (error: Exception) { + throw org.dashfoundation.dashsdk.tokens.ShieldedTipFailure( + org.dashfoundation.dashsdk.tokens.ShieldedTipDisposition.fromNative(disposition[0]), error, + ) } } @@ -2844,12 +2917,21 @@ 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 { + NativeLoader.ensureLoaded() + return FundingNative.isShieldedTipAccount(account) + } } } 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..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}; @@ -53,7 +54,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::{ + IdentityTipBalance, PlatformWalletError, ShieldedTipDisposition, ShieldedTipRecipientChange, + ShieldedTipReview, ShieldedTipSource, +}; use rs_sdk_ffi::{ MnemonicResolverCoreSigner, MnemonicResolverHandle, SignerHandle, VTableSigner, DASH_SDK_SIGNER_ERR_KEY_UNAVAILABLE_PREFIX, @@ -2395,9 +2399,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 +2424,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 +2482,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 +2507,305 @@ 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() + .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) { + 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 +} + +/// 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 +} + +/// `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. 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, + 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 { + tokio::time::timeout( + Duration::from_millis(100), + wallet.identity_tip_balance(&coordinator, &identity_id), + ) + .await + }) { + Ok(Ok(value)) => { + balance = Some(value); + PlatformWalletFFIResult::ok() + } + Ok(Err(e)) => e.into(), + Err(_) => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "identity tip balance busy; retry after sync releases its locks", + ), + } + }); + 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. +#[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 +} + +/// 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. @@ -2548,6 +2865,44 @@ 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; + 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 +3512,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 +3530,7 @@ mod tests { address.as_ptr(), 1_000, std::ptr::null(), + &mut disposition, ) }; let prepare = unsafe { @@ -3189,6 +3546,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 +3598,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 +3614,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 +3680,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-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 4ffa17dbd95..5eaacb3b253 100644 --- a/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md +++ b/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md @@ -50,6 +50,15 @@ 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. +`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. @@ -70,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. @@ -85,8 +96,19 @@ 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. +Parse the amount the user typed with `parse_shielded_tip_amount`: exact credits from +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 +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 +117,35 @@ 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 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 when the tip never left the device (no node address was available): a rejection +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 +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 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..7f06e1e12df 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, - SHIELDED_TIP_ACCOUNT_BASE, + is_shielded_tip_account, parse_shielded_tip_amount, shielded_tip_account_index, + IdentityTipAccount, IdentityTipBalance, ShieldedTipDisposition, ShieldedTipRecipientChange, + ShieldedTipReview, ShieldedTipSource, SHIELDED_TIP_ACCOUNT_BASE, }; 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/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..68ab010b28e 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)), } } @@ -973,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) { @@ -1472,6 +1548,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 +1587,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 +1598,7 @@ impl PlatformWallet { amount, memo, &prover, + guard_before_broadcast, ) .await } @@ -2356,6 +2462,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/balance_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/balance_tests.rs index 30a459ce40f..9e90a70ef3d 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,321 @@ async fn ready( } } +/// 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_observed_spend_stays_applied_across_a_stale_host_snapshot() { + use super::store::{PendingRedrive, TipBarrier, TipBarrierStatus}; + let path = tree_path(); + let wallet = [0x5A; 32]; + let id = SubwalletId::new(wallet, 0); + { + 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 + .damage_tip_barriers_for_test(wallet); + register(&coordinator, wallet, &[0]).await; + let snapshot = ShieldedSyncStartState { + per_subwallet: BTreeMap::from([( + id, + ShieldedSubwalletStartState { + notes: vec![ + note(5, 500, false), + note(6, 600, false), + note(7, 700, false), + ], + has_sync_state: true, + last_synced_index: 9, + ..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, + 600 + ); + 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); +} + +/// 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 + ); + // 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]]); + // Clear is refused for the same reason. + assert!(matches!( + coordinator.clear().await, + Err(PlatformWalletError::ShieldedTipPending) + )); + 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); +} + +/// 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] +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 731b35e51ad..83e4fa8c791 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,63 @@ 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. + /// 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; + 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; + } + } + + /// 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,8 +785,51 @@ 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 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 + // 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) + { + 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()))?; @@ -869,7 +1004,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 @@ -882,7 +1018,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; + // + // 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 lifecycle; + } + }; self.accounts .write() .await @@ -1034,6 +1182,8 @@ impl NetworkShieldedCoordinator { } let mut store = self.store.write().await; + // Spent marks for the host, queued once the store is released. + 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 @@ -1057,6 +1207,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 @@ -1087,6 +1241,17 @@ impl NetworkShieldedCoordinator { })?; } } + // 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()) + })? { + known_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 @@ -1135,9 +1300,35 @@ 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, known_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); + } + 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())) + } + /// Drop every wallet registration, purge all per-subwallet /// store state (notes, spent marks, sync watermarks, /// nullifier checkpoints), empty the shared commitment tree, @@ -1198,6 +1389,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` @@ -1211,6 +1407,22 @@ 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. + // 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), + 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 0970e75d48a..0f0c1f0d124 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,13 @@ 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, + tip_barriers_spending, ConfirmedTipRecipient, PendingRedrive, ShieldedNote, + ShieldedOutgoingNote, ShieldedStore, StalePendingSpend, SubwalletId, SubwalletState, + TipBarrier, TipBarrierStatus, }; use crate::error::PlatformWalletError; use crate::wallet::platform_wallet::WalletId; @@ -109,9 +111,54 @@ 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>, + /// 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, + /// 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>, + /// 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]>>, + /// 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)] + 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( @@ -175,6 +222,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 ( @@ -194,6 +248,50 @@ 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 ( + 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}")) + })?; + 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 @@ -221,8 +319,18 @@ impl FileBackedShieldedStore { max_checkpoints, subwallets: BTreeMap::new(), pending_conn: Mutex::new(pending_conn), + tip_barriers: BTreeMap::new(), + 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(), + damaged_known_spent: std::collections::BTreeSet::new(), + unattributed_known_spent_damage: false, }; store.rehydrate_pending_spends()?; + store.load_tip_barriers()?; + store.load_known_spent()?; Ok(store) } @@ -381,6 +489,213 @@ 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, 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>(( + 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 (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( + |(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_rows + .entry(wallet_id) + .or_default() + .push(rowid); + } + (None, _) => { + tracing::warn!("shielded tip barrier with an unreadable wallet; blocking tips"); + self.unattributed_tip_barrier_damage = true; + } + } + } + Ok(()) + } + + /// 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. 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 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(); + 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}")))?; + 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)); + } + (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(()) + } + + /// Durably note that `id`'s `nullifier` was seen spent. + fn record_known_spent( + &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") + .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_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. + 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.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, + 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. @@ -506,6 +821,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; @@ -518,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()) } @@ -528,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()) }) @@ -543,27 +881,80 @@ 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) and is retried at the next + // watermark advance. + 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(); + // 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); + }; + // 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 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 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)?; + } + self.settle_tips(executed); let Some(sw) = self.subwallets.get_mut(&id) else { return Ok(false); }; let outcome = sw.mark_spent(nullifier); - // 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) @@ -588,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) @@ -717,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); } @@ -870,6 +1267,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_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); @@ -894,11 +1295,295 @@ 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}")) + })?; + 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}")) + })?; + 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 // 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_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); + self.damaged_known_spent.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 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( + "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 + || !matches!( + existing.status, + TipBarrierStatus::Executed | TipBarrierStatus::Failed + ) + }); + 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_rows.contains_key(&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_rows.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) + .inspect_err(|_| self.retry_tip_settlement(wallet_id, *activity_id, status)) + } + + 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()], + ) + .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. + for rowid in &damaged { + conn.execute( + "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 barrier: {e}")) + })?; + } + 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 { + barrier.status = TipBarrierStatus::Abandoned; + abandoned += 1; + } + } + 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() + .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; remove and re-add the wallet to rebuild \ + them from the chain" + .to_string(), + )); + } + Ok(self + .known_spent + .range((id, [0; 32])..=(id, [0xFF; 32])) + .map(|(_, nullifier)| *nullifier) + .collect()) + } + + 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(()) } @@ -937,7 +1622,9 @@ 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`. 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"); conn.execute("DELETE FROM shielded_pending_spends", []) @@ -945,6 +1632,7 @@ impl ShieldedStore for FileBackedShieldedStore { FileShieldedStoreError::Storage(format!("purge all pending spends: {e}")) })?; } + self.lingering_redrive_rows.clear(); self.subwallets.clear(); Ok(()) } @@ -1483,6 +2171,668 @@ 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 keeps it, since the host may still + /// hold the old snapshot; removing the wallet repairs it. + #[test] + 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, &owned_note(0x21)).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 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() + .unwrap() + .execute("UPDATE shielded_known_spent SET wallet_id = x'00'", []) + .unwrap(); + } + let store = FileBackedShieldedStore::open_path(&path, 100).expect("reopen"); + assert!(store + .known_spent_nullifiers(SubwalletId::new(other, 0)) + .is_err()); + 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"); + 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); + } + + /// 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. + #[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] + 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. + #[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 + /// 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(); + // 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"); + 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()); + 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 + ); + drop(store); + 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] + 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..e67dcd1f08c 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs @@ -50,8 +50,9 @@ pub mod store; pub mod sync; pub mod tips; pub use tips::{ - is_shielded_tip_account, shielded_tip_account_index, ShieldedTipSource, - SHIELDED_TIP_ACCOUNT_BASE, + is_shielded_tip_account, parse_shielded_tip_amount, shielded_tip_account_index, + 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/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index ea864889a3f..8fe8791d728 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; @@ -1767,6 +1771,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 +1874,33 @@ 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, + &to_address.to_raw_address_bytes(), + 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; @@ -1900,6 +1948,19 @@ pub async fn transfer( None, ) .await; + // 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, + &entry.id, + TipBarrierStatus::Failed, + ) { + warn!(error = %e, "tip barrier failure not persisted; it stays pending"); + } + } cancel_pending(store, id, &selected_notes).await; Err(e) } @@ -3036,6 +3097,157 @@ async fn arm_redrive_record( } } +/// 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, +) -> 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(), + ) + })?; + // 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 + .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| { + 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 + .arm_redrive( + id, + PendingRedrive { + activity_id: entry.id, + anchor, + nullifiers, + st_bytes, + attempts: MAX_REDRIVE_ATTEMPTS, + identity_nonce_finalized: false, + identity_user_abandoned: false, + }, + ) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string())) +} + +/// 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; + matches!( + e, + dash_sdk::Error::DapiClientError(DapiClientError::NoAvailableAddresses) + ) +} + +/// 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>, + id: SubwalletId, + pending_entry: &Option, + state_transition: &StateTransition, +) -> Result { + let single_attempt = PutSettings { + request_settings: RequestSettings { + retries: Some(0), + ..Default::default() + }, + ..Default::default() + }; + 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 +} + +/// 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 @@ -3070,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 @@ -3261,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!( @@ -3822,13 +4070,61 @@ 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)); + // 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(); + 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 ); @@ -3842,35 +4138,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 ); } @@ -4110,6 +4412,38 @@ mod classify_spend_wait_failure_tests { ))) } + /// 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_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(), + )), + ))); + 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), + ] { + assert!(!tip_broadcast_never_sent(&unresolved), "{unresolved}"); + } + } + /// 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 @@ -4769,3 +5103,200 @@ 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 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 { + 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, + &[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), + &[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]]); + } + + /// 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] + 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, + &[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); + 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..297628858ce 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -133,6 +133,61 @@ 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]); + +/// 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>, + id: SubwalletId, + nullifier: &[u8; 32], +) -> Vec<[u8; 32]> { + barriers + .iter() + .filter(|((wallet, _), barrier)| { + *wallet == id.wallet_id + && barrier.account_index == id.account_index + && 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 @@ -488,6 +543,84 @@ 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 + /// 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, + 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. + /// 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; + + /// 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>; + + /// 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; + + /// 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 + /// 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 @@ -617,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)) @@ -853,6 +1011,12 @@ 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>, + /// Tip payment barriers by `(wallet, activity id)`. + tip_barriers: BTreeMap<(WalletId, [u8; 32]), TipBarrier>, + /// See [`ShieldedStore::known_spent_nullifiers`]. + known_spent: BTreeMap>, } impl InMemoryShieldedStore { @@ -871,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())) } @@ -894,14 +1062,26 @@ impl ShieldedStore for InMemoryShieldedStore { } fn mark_spent(&mut self, id: SubwalletId, nullifier: &[u8; 32]) -> Result { + 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; + } + } // 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.newly_spent || !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 { @@ -1113,6 +1293,116 @@ 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); + self.tip_barriers + .retain(|(wallet, _), _| *wallet != wallet_id); + self.known_spent.retain(|id, _| id.wallet_id != wallet_id); + Ok(()) + } + + fn has_unreconciled_spend(&self, _id: SubwalletId) -> bool { + // 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 + } + + fn has_unreconciled_spends(&self) -> bool { + false + } + + 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, + 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 + || !matches!( + existing.status, + TipBarrierStatus::Executed | TipBarrierStatus::Failed + ) + }); + 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, + 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..8adcba71030 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/tips.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/tips.rs @@ -8,9 +8,12 @@ //! 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; +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 grovedb_commitment_tree::PaymentAddress; @@ -43,6 +46,184 @@ 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, +} + +/// 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)] +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 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 barriers = store.tip_barriers(wallet_id).map_err(|e| { + PlatformWalletError::ShieldedRecoveryCorrupted { + account_index: None, + reason: e.to_string(), + } + })?; + 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. +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 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 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() && fraction.is_empty()) + || (separated && fraction.is_empty()) + || !digits(whole) + || !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 whole = if whole.is_empty() { "0" } else { whole }; + 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, + 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 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 { + dash_sdk::platform::dpns_usernames::normalize_dpns_label(username.trim()) +} + /// 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 +350,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 +369,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 +496,113 @@ impl PlatformWallet { } } + /// The identity's tip balance: unverified until this session proves its + /// 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( + &self, + 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); + } + let ledger = coordinator.local_balance_snapshot(self.wallet_id()).await?; + let balance = match ledger { + 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, + }), + _ => 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 + /// is still unresolved (a send to it would be refused). + pub async fn review_shielded_tip( + &self, + coordinator: &Arc, + username: &str, + ) -> Result { + let username = username.trim(); + 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, + }) + } + + /// 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)?; + // 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() + .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 @@ -303,7 +623,22 @@ impl PlatformWallet { memo: [u8; 36], prover: P, ) -> Result<(), PlatformWalletError> { + let _sending = self + .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(), &self.shielded_detached) + .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. + 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() @@ -315,7 +650,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,14 +683,15 @@ impl PlatformWallet { amount, memo, prover, + true, ) .await } } -/// 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; @@ -338,47 +699,184 @@ 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)] mod tests { + use super::super::store::{InMemoryShieldedStore, SubwalletId, TipBarrier}; use super::super::OrchardKeySet; use super::*; use key_wallet::Network; + #[test] + fn should_parse_exact_tip_amounts_and_refuse_ambiguous_input() { + 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", + ".", + "1.", + "-1", + "+1", + "1e3", + "1 000", + "1,000.5", + "1.000,5", + "0.000000000001", + "184467441", + "1.2.3", + "\u{663}", + ] { + assert!(parse_shielded_tip_amount(refused).is_err(), "{refused:?}"); + } + } + + #[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 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_pending_tip_as_unresolved_for_its_recipient() { + let wallet_id = [1; 32]; + let id = SubwalletId::new(wallet_id, 0); + let (recipient, other) = ([4; 43], [5; 43]); + let mut store = InMemoryShieldedStore::new(); + store + .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(); + 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()); + + // 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()); + + store.mark_spent(id, &[3; 32]).unwrap(); + assert!(!has_unresolved_transfer_to(&store, wallet_id, &recipient).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..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 @@ -1887,3 +1887,551 @@ 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); +} + +/// 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:?}" + ); +} + +/// 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(); +} + +/// 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(); +} + +/// 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] +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] +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, &wallet.shielded_detached) + .await + .unwrap(); + 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(); +} + +/// 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 +/// 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, +/// 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; + 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(); + 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_eq!( + wallet + .identity_tip_balance(&coordinator, &id) + .await + .unwrap(), + IdentityTipBalance::Available { + account: tip, + credits: 0 + } + ); + coordinator.clear().await.unwrap(); + assert_eq!( + wallet + .identity_tip_balance(&coordinator, &id) + .await + .unwrap(), + IdentityTipBalance::Unavailable { account: tip } + ); +} 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/rs-unified-sdk-jni/src/funding.rs b/packages/rs-unified-sdk-jni/src/funding.rs index e1ddef36184..7d07c76bdac 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,267 @@ 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()) + }) +} + +/// 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) + }) +} + +/// 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] +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() + }) +} + +/// 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( + 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; + } + match jlong::try_from(credits) { + Ok(credits) => credits, + Err(_) => { + throw_sdk_exception(env, 2, "Amount exceeds the largest supported value"); + 0 + } + } + }) +} + #[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..97eb174496d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -1595,9 +1595,101 @@ 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 == UInt32(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 UInt32(SHIELDED_TIP_RECIPIENT_NEW): + change = .new + case UInt32(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 + } +} + +/// 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. +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 { + 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) { @@ -1638,38 +1730,188 @@ extension PlatformWalletManager { }.value } - /// Recheck the confirmed recipient before building and broadcasting the payment. + /// 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. + /// + /// 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") + } + // 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 + } + } + + /// 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") + } + 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. + 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. + 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 requireNoEmbeddedNul(username) + 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. 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, + !username.utf8.contains(0), 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, username 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) { 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 = UInt32(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 } } +/// 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). + 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 + } + 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 2186860d536..92af86f017e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md @@ -715,13 +715,22 @@ 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, 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`: +`.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`. 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