diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index b6c555a3add..01ab6ebeb16 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt @@ -90,6 +90,17 @@ sealed class DashSdkError( class CoreInsufficientFunds(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) + /** + * `ErrorShieldedInsufficientBalance` (native code 41; historical FFI + * spelling). A Platform Payment account's deterministic shield input + * set cannot cover the requested amount plus input 0's retained fee + * reserve. Nothing was built or broadcast. Refresh preflight and ask + * the user to confirm a smaller amount rather than retrying unchanged. + * This is distinct from insufficient private shielded-note balance. + */ + class PlatformShieldCapacityExceeded(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + class AssetLockNotTracked(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) @@ -509,6 +520,7 @@ sealed class DashSdkError( ) }.getOrNull() } ?: PlatformWallet.Generic(code, message, cause) + 41 -> PlatformWallet.PlatformShieldCapacityExceeded(message, cause) // ErrorSigningKeyUnavailable — the STRUCTURED signer // discriminator (dashpay/platform#4060 finding 7): the typed // completion code rides the whole Rust round-trip, no message diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt index 57f758e2868..37169cc094c 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt @@ -171,6 +171,25 @@ class DashSdkErrorTest { assertFalse("Generic platform-wallet errors are not retryable", mapped.isRetryable) } + @Test + fun platformShieldCapacityCode41MapsTyped() { + val message = + "Platform shield capacity exceeded: available 3623849220, required 3623849221" + val mapped = DashSdkError.fromNative( + DashSDKException( + DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 41, + message, + ), + ) + + assertTrue(mapped is DashSdkError.PlatformWallet.PlatformShieldCapacityExceeded) + assertEquals(message, mapped.message) + assertFalse( + "unchanged amount must not be retried without refreshing preflight", + mapped.isRetryable, + ) + } + @Test fun signingKeyUnavailableCode31MapsTyped() { // The STRUCTURED discriminator (dashpay/platform#4060 finding 7): diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index cb560d46f31..340bb1973eb 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -110,9 +110,9 @@ pub enum PlatformWalletFFIResultCode { ErrorInvalidIdentifier = 10, ErrorMemoryAllocation = 11, ErrorUtf8Conversion = 12, - /// Reserved slot for the arithmetic-overflow mapping arriving via #3549 — - /// no in-tree producer today. Holding the slot here keeps language-mirror - /// enums (Swift, Kotlin) numerically aligned with the eventual producer. + /// Maps `PlatformWalletError::InputSumOverflow`: summing candidate input + /// balances overflowed `u64`. Nothing was built or broadcast; retrying the + /// same inconsistent wallet state cannot succeed. ErrorArithmeticOverflow = 13, /// Auto-select had no candidate inputs. Covers all three "can't-select-inputs" /// wallet variants: `NoSpendableInputs` (account has nothing spendable), @@ -359,6 +359,15 @@ pub enum PlatformWalletFFIResultCode { /// `PlatformWalletError.contestedNameNotTradable`. ErrorContestedNameNotTradable = 40, + /// Maps `PlatformWalletError::PlatformShieldCapacityExceeded`. A shield's + /// selected Platform-address set cannot cover the requested claim plus the + /// fee reserve retained on input 0. The transition was not built or + /// broadcast; refresh preflight capacity and ask the user to confirm the + /// new amount. The public C spelling is retained for numeric ABI and + /// language-surface compatibility; it is a Platform Payment-account + /// shortfall, not a shielded-note shortfall. + ErrorShieldedInsufficientBalance = 41, + /// The named thing does not exist. /// /// Originally (and still mostly) the code for every `Option` returned as an @@ -545,6 +554,9 @@ impl From for PlatformWalletFFIResult { | PlatformWalletError::OnlyDustInputs { .. } => { PlatformWalletFFIResultCode::ErrorNoSelectableInputs } + PlatformWalletError::InputSumOverflow => { + PlatformWalletFFIResultCode::ErrorArithmeticOverflow + } PlatformWalletError::WalletAlreadyExists(..) => { PlatformWalletFFIResultCode::ErrorWalletAlreadyExists } @@ -570,6 +582,9 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::ShieldedNoRecordedAnchor(..) => { PlatformWalletFFIResultCode::ErrorShieldedNoRecordedAnchor } + PlatformWalletError::PlatformShieldCapacityExceeded { .. } => { + PlatformWalletFFIResultCode::ErrorShieldedInsufficientBalance + } // The core-transaction sibling of the shielded pair above: the // do-not-retry signal must survive the boundary as a typed code // so hosts can distinguish it from a definitive rejection. @@ -1156,6 +1171,37 @@ mod tests { assert_eq!(msg, rendered, "Display payload must survive verbatim"); } + #[test] + fn platform_shield_capacity_maps_to_dedicated_code_without_claiming_note_shortfalls() { + let error = PlatformWalletError::PlatformShieldCapacityExceeded { + available: 3_623_849_220, + required: 3_623_849_221, + }; + let rendered = error.to_string(); + let result: PlatformWalletFFIResult = error.into(); + + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorShieldedInsufficientBalance + ); + let message = unsafe { std::ffi::CStr::from_ptr(result.message) } + .to_string_lossy() + .into_owned(); + assert_eq!(message, rendered); + + let note_shortfall: PlatformWalletFFIResult = + PlatformWalletError::ShieldedInsufficientBalance { + available: 100, + required: 200, + } + .into(); + assert_eq!( + note_shortfall.code, + PlatformWalletFFIResultCode::ErrorUnknown, + "shielded-note selection must not claim the Platform funding code" + ); + } + /// The ambiguous core-broadcast outcome keeps its typed code across the /// boundary — flattening it to `ErrorUnknown` would erase the /// do-not-retry signal the variant exists to carry. @@ -1529,6 +1575,14 @@ mod tests { ); } + #[test] + fn shielded_insufficient_balance_code_is_pinned_at_41() { + assert_eq!( + PlatformWalletFFIResultCode::ErrorShieldedInsufficientBalance as i32, + 41 + ); + } + /// `MessageSigningFailed` is intentionally unmapped: its causes are /// internal invariant breaks, which should read as a bug rather than as a /// key-repair prompt, so it falls through to ErrorUnknown carrying the diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index c049c2d4339..6efb2c03ca0 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -60,6 +60,7 @@ use crate::error::*; use crate::handle::*; use crate::identity_registration_with_signer::{decode_identity_pubkeys, IdentityPubkeyFFI}; use crate::runtime::{block_on_worker, runtime}; +use crate::shielded_types::ShieldedShieldPreflightFFI; /// A serialized `PlatformAddress` is exactly 21 bytes (1-byte variant tag + 20-byte hash). const PLATFORM_ADDRESS_LEN: usize = 21; @@ -595,6 +596,16 @@ fn map_spend_result( PlatformWalletFFIResultCode::ErrorAddressNonceMismatch, format!("{operation} failed: {e}"), ), + // The cached Platform Payment-account set no longer covers the + // requested claim plus input-0's fee reserve. Keep this distinct from + // generic wallet-operation failures so hosts can refresh preflight and + // re-confirm a smaller amount instead of retrying unchanged. + Err(e @ PlatformWalletError::PlatformShieldCapacityExceeded { .. }) => { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorShieldedInsufficientBalance, + format!("{operation} failed: {e}"), + ) + } Err(e) => PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorWalletOperation, format!("{operation} failed: {e}"), @@ -809,6 +820,65 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_identity_create_from_p } } +/// Preflight the maximum credits the cached state can shield from one Platform +/// Payment account. +/// +/// Uses the exact same Rust planner as +/// [`platform_wallet_manager_shielded_shield`]: candidates are ordered by +/// lexicographic `PlatformAddress`, the leading prefix before the first address +/// whose balance is strictly greater than the shared fee reserve is excluded, +/// later addresses below the protocol version's minimum input amount are +/// omitted, and the lexicographically earliest usable set is truncated to the +/// versioned maximum address-input count. The reserve is retained only on input +/// 0. Capacity is therefore executable under the wallet's deterministic policy, +/// not globally optimized over later balances. No DAPI request, signing, proof +/// construction, or broadcast is performed. +/// +/// A normal no-capacity result writes all numeric fields (including the total +/// account balance and zero usable/max capacity), returns `Success`, and carries +/// an advisory reason in the result message. Bad handles, missing wallets or +/// accounts, and arithmetic overflow remain FFI errors with `out` untouched. +/// +/// # Safety +/// - `wallet_id_bytes` must point to 32 readable bytes. +/// - `out` must point to a writable `ShieldedShieldPreflightFFI`. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_shielded_shield_preflight( + handle: Handle, + wallet_id_bytes: *const u8, + payment_account: u32, + out: *mut ShieldedShieldPreflightFFI, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + check_ptr!(out); + + let mut wallet_id = [0u8; 32]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); + let wallet = match resolve_wallet(handle, &wallet_id) { + Ok(wallet) => wallet, + Err(result) => return result, + }; + + let result = + block_on_worker(async move { wallet.shielded_shield_preflight(payment_account).await }); + match result { + Ok(preflight) => { + *out = ShieldedShieldPreflightFFI { + can_shield: preflight.can_shield, + account_balance_credits: preflight.account_balance_credits, + usable_balance_credits: preflight.usable_balance_credits, + fee_reserve_credits: preflight.fee_reserve_credits, + max_shieldable_credits: preflight.max_shieldable_credits, + }; + match preflight.reason { + Some(reason) => PlatformWalletFFIResult::success_with_message(reason), + None => PlatformWalletFFIResult::ok(), + } + } + Err(error) => error.into(), + } +} + /// Shield: spend credits from a Platform Payment account into /// the bound shielded sub-wallet's pool. /// @@ -816,7 +886,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_identity_create_from_p /// the bound shielded sub-wallet receives the new note. /// `payment_account` selects which Platform Payment account on /// the transparent side funds the shield (auto-selects input -/// addresses in ascending derivation order until the cumulative +/// addresses in lexicographic Platform-address order until the cumulative /// balance covers `amount + fee buffer`). /// /// `signer_address_handle` is a `*mut SignerHandle` produced by @@ -1434,6 +1504,31 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_seed_pool_notes( } } +/// Resolve a wallet without requiring shielded coordinator configuration. +/// +/// Cached capacity preflight needs only the wallet's Platform Payment account; +/// requiring a bound/configured shielded coordinator here would turn an +/// otherwise valid balance query into a structural setup error. +fn resolve_wallet( + handle: Handle, + wallet_id: &[u8; 32], +) -> Result, PlatformWalletFFIResult> { + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + runtime().block_on(manager.get_wallet(wallet_id)) + }); + match option { + Some(Some(wallet)) => Ok(wallet), + Some(None) => Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("wallet not found: {}", hex::encode(wallet_id)), + )), + None => Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + format!("invalid manager handle: {handle}"), + )), + } +} + /// Resolve both the wallet `Arc` and the network-scoped shielded /// coordinator `Arc` for the given manager handle. Shielded /// spend operations need the coordinator's shared store, so this @@ -1575,6 +1670,31 @@ mod tests { } } + #[test] + fn shield_preflight_rejects_null_abi_pointers() { + unsafe { + let mut out = ShieldedShieldPreflightFFI::default(); + let missing_wallet_id = + platform_wallet_manager_shielded_shield_preflight(0, std::ptr::null(), 0, &mut out); + assert_eq!( + missing_wallet_id.code, + PlatformWalletFFIResultCode::ErrorNullPointer + ); + + let wallet_id = [0u8; 32]; + let missing_out = platform_wallet_manager_shielded_shield_preflight( + 0, + wallet_id.as_ptr(), + 0, + std::ptr::null_mut(), + ); + assert_eq!( + missing_out.code, + PlatformWalletFFIResultCode::ErrorNullPointer + ); + } + } + /// Read the Rust-owned message out of an FFI result for assertions. fn message_of(result: &PlatformWalletFFIResult) -> String { assert!( @@ -1686,4 +1806,37 @@ mod tests { "expected nonce must render exactly: {msg}" ); } + + #[test] + fn map_spend_result_maps_shield_capacity_race_to_dedicated_code() { + let shield_result = map_spend_result( + Err(PlatformWalletError::PlatformShieldCapacityExceeded { + available: 3_623_849_220, + required: 3_623_849_221, + }), + "shielded shield", + ); + + assert_eq!( + shield_result.code, + PlatformWalletFFIResultCode::ErrorShieldedInsufficientBalance + ); + let message = message_of(&shield_result); + assert!(message.contains("available 3623849220")); + assert!(message.contains("required 3623849221")); + + let transfer_result = map_spend_result( + Err(PlatformWalletError::ShieldedInsufficientBalance { + available: 3_623_849_220, + required: 3_623_849_221, + }), + "shielded transfer", + ); + + assert_eq!( + transfer_result.code, + PlatformWalletFFIResultCode::ErrorWalletOperation, + "the dedicated code is a Platform-to-shielded contract only" + ); + } } diff --git a/packages/rs-platform-wallet-ffi/src/shielded_types.rs b/packages/rs-platform-wallet-ffi/src/shielded_types.rs index 152b546b931..0fa572682c5 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_types.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_types.rs @@ -10,6 +10,23 @@ use std::os::raw::c_char; +/// Cached Platform-to-shielded capacity for one payment account. +/// +/// The Rust wallet planner computes every field from the same lexicographic +/// candidate set later used by the shield execution path, including the +/// versioned address-input cap. A normal no-capacity state is represented by +/// `can_shield == false`, not by an FFI error; the Success-coded result message +/// carries the optional explanation. +#[repr(C)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ShieldedShieldPreflightFFI { + pub can_shield: bool, + pub account_balance_credits: u64, + pub usable_balance_credits: u64, + pub fee_reserve_credits: u64, + pub max_shieldable_credits: u64, +} + /// Per-wallet outcome from a completed shielded sync pass. /// /// Mirrors diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 8dab7054699..ef165191583 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -485,6 +485,13 @@ pub enum PlatformWalletError { #[error("Insufficient shielded balance: available {available}, required {required}")] ShieldedInsufficientBalance { available: u64, required: u64 }, + /// A Platform Payment-account shield cannot be represented from the + /// wallet's deterministic address-input set at the requested amount. + /// Distinct from [`ShieldedInsufficientBalance`](Self::ShieldedInsufficientBalance), + /// which refers exclusively to private note selection. + #[error("Platform shield capacity exceeded: available {available}, required {required}")] + PlatformShieldCapacityExceeded { available: u64, required: u64 }, + #[error("Shielded build error: {0}")] ShieldedBuildError(String), diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index dc5f50eb8e5..49722444f89 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -79,7 +79,11 @@ pub use wallet::identity::{ RegistrationIndex, DEFAULT_CONTACT_GAP_LIMIT, }; pub use wallet::platform_wallet::PlatformWalletInfo; +#[cfg(feature = "shielded")] +pub use wallet::platform_wallet::ShieldedShieldPreflight; pub use wallet::provider_key_at_index::{ProviderDerivedKey, ProviderKeyKind}; +#[cfg(feature = "shielded")] +pub use wallet::shielded::operations::shield_fee_reserve_credits; pub use wallet::PlatformAddressTag; pub use wallet::PlatformWallet; diff --git a/packages/rs-platform-wallet/src/wallet/mod.rs b/packages/rs-platform-wallet/src/wallet/mod.rs index f300c6656b3..223ab8afb64 100644 --- a/packages/rs-platform-wallet/src/wallet/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/mod.rs @@ -24,10 +24,14 @@ pub use platform_addresses::{ PerAccountPlatformAddressState, PerWalletPlatformAddressState, PlatformAddressTag, PlatformAddressWallet, }; +#[cfg(feature = "shielded")] +pub use platform_wallet::ShieldedShieldPreflight; pub use platform_wallet::{ PlatformWallet, PlatformWalletInfo, WalletId, WalletStateReadGuard, WalletStateWriteGuard, }; pub use provider_key_at_index::{ProviderDerivedKey, ProviderKeyKind}; +#[cfg(feature = "shielded")] +pub use shielded::operations::shield_fee_reserve_credits; pub use signed_payment_registry::{ RegisterWrongGeneration, ReservationToken, SignedPaymentError, SignedPaymentRegistry, }; diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/mod.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/mod.rs index e8ca3d92704..dd16b9284df 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/mod.rs @@ -45,6 +45,7 @@ where pub use provider::{ PerAccountPlatformAddressState, PerWalletPlatformAddressState, PlatformAddressTag, }; +pub(crate) use wallet::merge_platform_payment_candidate_addresses; pub use wallet::PlatformAddressWallet; pub use withdrawal::WithdrawalPlan; diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs index 1d9c7b26e63..582f6fd2e87 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs @@ -20,6 +20,21 @@ use super::provider::PlatformPaymentAddressProvider; use dash_sdk::query_types::AddressInfos; +/// Merge transient derived addresses with persisted, hydrated balance keys. +/// +/// A `BTreeSet` deduplicates addresses present in both sources and gives every +/// payment-address operation the same deterministic post-relaunch candidate +/// set. +pub(crate) fn merge_platform_payment_candidate_addresses( + derived_addresses: impl IntoIterator, + hydrated_addresses: impl IntoIterator, +) -> BTreeSet { + derived_addresses + .into_iter() + .chain(hydrated_addresses) + .collect() +} + /// Platform address wallet providing DIP-17 platform payment address functionality. #[derive(Clone)] pub struct PlatformAddressWallet { @@ -105,14 +120,19 @@ impl PlatformAddressWallet { )) })?; - Ok(account - .addresses - .addresses - .values() - .filter_map(|addr_info| PlatformP2PKHAddress::from_address(&addr_info.address).ok()) - .chain(account.address_balances.keys().copied()) - .map(|p2pkh| PlatformAddress::P2pkh(p2pkh.to_bytes())) - .collect()) + Ok(merge_platform_payment_candidate_addresses( + account + .addresses + .addresses + .values() + .filter_map(|addr_info| { + PlatformP2PKHAddress::from_address(&addr_info.address).ok() + }), + account.address_balances.keys().copied(), + ) + .into_iter() + .map(|p2pkh| PlatformAddress::P2pkh(p2pkh.to_bytes())) + .collect()) } /// Build (or rebuild) the unified address provider covering every @@ -751,7 +771,29 @@ impl std::fmt::Debug for PlatformAddressWallet { #[cfg(test)] mod tests { - use super::PlatformAddressWallet; + use super::{merge_platform_payment_candidate_addresses, PlatformAddressWallet}; + use key_wallet::PlatformP2PKHAddress; + + #[test] + fn candidate_union_keeps_hydrated_balance_only_address_and_deduplicates_overlap() { + let derived_only = PlatformP2PKHAddress::new([1; 20]); + let present_in_both = PlatformP2PKHAddress::new([2; 20]); + let hydrated_balance_only = PlatformP2PKHAddress::new([3; 20]); + + let merged = merge_platform_payment_candidate_addresses( + [derived_only, present_in_both], + [present_in_both, hydrated_balance_only], + ); + + assert_eq!( + merged, + std::collections::BTreeSet::from([ + derived_only, + present_in_both, + hydrated_balance_only, + ]) + ); + } /// Build a `PlatformAddressWallet` on a mock SDK for getter tests that /// touch no I/O. Mirrors `transfer::tests::build_short_circuit_wallet`, diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index ccb27d2bc60..b6d29e67c40 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -7,6 +7,8 @@ use std::sync::Arc; use dashcore::OutPoint; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; +#[cfg(feature = "shielded")] +use key_wallet::PlatformP2PKHAddress; use key_wallet_manager::WalletManager; use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; @@ -15,7 +17,11 @@ use super::asset_lock::tracked::TrackedAssetLock; use super::core::{CoreWallet, WalletBalance, WalletGeneration}; use super::identity::{IdentityManager, IdentityWallet}; use super::persister::WalletPersister; +#[cfg(feature = "shielded")] +use super::platform_addresses::merge_platform_payment_candidate_addresses; use super::platform_addresses::PlatformAddressWallet; +#[cfg(feature = "shielded")] +use super::shielded::operations::shield_fee_reserve_credits; // Phase 4d.3 deleted the `ShieldedWallet` wrapper; per-account // keysets now live in `self.shielded_keys` directly. Spend // operations source the shared commitment-tree store from @@ -35,6 +41,193 @@ use dpp::prelude::Identifier; /// Unique identifier for a wallet (32-byte hash). pub type WalletId = [u8; 32]; +/// Cached capacity snapshot for shielding a Platform Payment account. +/// +/// The figures are computed from the same lexicographic address ordering and +/// fee-reserve rules used by [`PlatformWallet::shielded_shield_from_account`]. +/// No DAPI request, signing, proof construction, or broadcast is performed. +#[cfg(feature = "shielded")] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ShieldedShieldPreflight { + /// Whether the account can shield at least one credit. + pub can_shield: bool, + /// Sum of all funded candidate addresses in the payment account. + pub account_balance_credits: Credits, + /// Sum of the lexicographically earliest representable candidate set. It + /// begins at the first address whose balance is strictly greater than + /// the fee reserve ([`Self::fee_reserve_credits`]), omits later addresses + /// below the versioned minimum input amount, and contains at most the + /// versioned maximum address-input count. + pub usable_balance_credits: Credits, + /// Balance retained on input 0 for the transition fee — the versioned + /// [`shield_fee_reserve_credits`] value the plan was computed with. + pub fee_reserve_credits: Credits, + /// Maximum claim accepted by the wallet's deterministic selector: + /// `usable_balance_credits - fee_reserve_credits`, floored at zero. + /// This is not a balance-optimized subset of every funded address; it + /// preserves the established lexicographic selection policy within the + /// protocol's input-count limit. + pub max_shieldable_credits: Credits, + /// Human-readable explanation when [`can_shield`](Self::can_shield) is + /// false. Capacity exhaustion is a normal result, not a structural error. + pub reason: Option, +} + +#[cfg(feature = "shielded")] +#[derive(Debug, Clone)] +struct ShieldedShieldInputPlan { + preflight: ShieldedShieldPreflight, + usable_candidates: Vec<(PlatformAddress, Credits)>, + min_input_amount: Credits, +} + +#[cfg(feature = "shielded")] +impl ShieldedShieldInputPlan { + fn select_inputs( + &self, + amount: Credits, + ) -> Result, PlatformWalletError> { + if amount == 0 { + return Err(PlatformWalletError::ShieldedBuildError( + "amount must be > 0".to_string(), + )); + } + + if amount > self.preflight.max_shieldable_credits { + let available = if self.usable_candidates.is_empty() { + self.preflight.account_balance_credits + } else { + self.preflight.usable_balance_credits + }; + return Err(PlatformWalletError::PlatformShieldCapacityExceeded { + available, + required: amount.saturating_add(self.preflight.fee_reserve_credits), + }); + } + + let mut chosen = BTreeMap::new(); + let mut accumulated_claim = 0u64; + for (index, (address, balance)) in self.usable_candidates.iter().enumerate() { + if accumulated_claim >= amount { + break; + } + let max_claim = if index == 0 { + balance.saturating_sub(self.preflight.fee_reserve_credits) + } else { + *balance + }; + let remaining = amount - accumulated_claim; + let mut claim = max_claim.min(remaining); + // Input 0 receives the shield fee later, so even a tiny base claim + // clears the protocol minimum after `reserve_shield_fee_on_input_0`. + // Every later input has no such fee addition. If its final greedy + // residue is below the versioned minimum, request the minimum + // instead. Shield inputs are maximum contributions: their sum may + // exceed `amount`, and drive's reallocation leaves the excess on + // the source address rather than increasing the shielded output. + if index > 0 && claim > 0 && claim < self.min_input_amount { + claim = self.min_input_amount; + } + if claim > 0 { + chosen.insert(*address, claim); + accumulated_claim = accumulated_claim + .checked_add(claim) + .ok_or(PlatformWalletError::InputSumOverflow)?; + } + } + + // `max_shieldable_credits` is derived from these exact candidates, so + // this is an invariant guard rather than a second capacity rule. + if accumulated_claim < amount { + return Err(PlatformWalletError::PlatformShieldCapacityExceeded { + available: accumulated_claim, + required: amount, + }); + } + + Ok(chosen) + } +} + +#[cfg(feature = "shielded")] +fn checked_credit_sum<'a>( + mut balances: impl Iterator, +) -> Result { + balances.try_fold(0u64, |sum, balance| { + sum.checked_add(*balance) + .ok_or(PlatformWalletError::InputSumOverflow) + }) +} + +/// Analyze funded Platform addresses once for both preflight and execution. +/// +/// The representable set is the lexicographically earliest usable prefix, +/// capped at `max_address_inputs`. Deliberately retaining the wallet's existing +/// ordering policy avoids silently replacing earlier addresses with later, +/// larger balances; consequently preflight Max means the maximum accepted by +/// this deterministic policy, not a globally balance-optimized subset. +/// +/// `fee_reserve` is the versioned [`shield_fee_reserve_credits`] value; it is +/// the balance input 0 must retain unclaimed so execution can deduct the +/// actual metered fee from that input's residue (`DeductFromInput(0)`). +#[cfg(feature = "shielded")] +fn plan_shield_inputs( + mut candidates: Vec<(PlatformAddress, Credits)>, + fee_reserve: Credits, + min_input_amount: Credits, + max_address_inputs: usize, +) -> Result { + candidates.sort_by_key(|(address, _)| *address); + + let account_balance_credits = + checked_credit_sum(candidates.iter().map(|(_, balance)| balance))?; + let viable_input_0 = candidates + .iter() + .position(|(_, balance)| *balance > fee_reserve); + let usable_candidates: Vec<(PlatformAddress, Credits)> = viable_input_0 + .map(|index| { + // Keep the fee-bearing input 0 regardless of its post-reserve base + // capacity: the shield fee is added to its requested claim before + // structure validation. Later addresses get no fee addition, so a + // full balance below `min_input_amount` can never form a valid + // input and must not inflate preflight capacity. Finally, truncate + // the deterministic sequence before deriving capacity so Max can + // always be represented by a protocol-valid input count. + std::iter::once(candidates[index]) + .chain( + candidates[index + 1..] + .iter() + .copied() + .filter(|(_, balance)| *balance >= min_input_amount), + ) + .take(max_address_inputs) + .collect() + }) + .unwrap_or_default(); + let usable_balance_credits = + checked_credit_sum(usable_candidates.iter().map(|(_, balance)| balance))?; + let max_shieldable_credits = usable_balance_credits.saturating_sub(fee_reserve); + let can_shield = max_shieldable_credits > 0; + let reason = (!can_shield).then(|| { + format!( + "Platform payment account has {account_balance_credits} credits, but no address can retain the {fee_reserve}-credit shield fee reserve" + ) + }); + + Ok(ShieldedShieldInputPlan { + preflight: ShieldedShieldPreflight { + can_shield, + account_balance_credits, + usable_balance_credits, + fee_reserve_credits: fee_reserve, + max_shieldable_credits, + reason, + }, + usable_candidates, + min_input_amount, + }) +} + /// Consolidated mutable state for a platform wallet. /// /// Lives inside `WalletManager.wallet_infos`. The `Wallet` @@ -1348,6 +1541,84 @@ impl PlatformWallet { Ok(identity_id) } + #[cfg(feature = "shielded")] + async fn shielded_shield_plan_for_account( + &self, + payment_account: u32, + ) -> Result { + let wallet_manager = self.wallet_manager.read().await; + let wallet_info = wallet_manager + .get_wallet_info(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + let account = wallet_info + .core_wallet + .platform_payment_managed_account_at_index(payment_account) + .ok_or_else(|| { + PlatformWalletError::AddressOperation(format!( + "no platform payment account at index {payment_account}" + )) + })?; + + // Candidate discovery must include both the transient derived pool and + // persisted balances hydrated during wallet load. The latter can be + // populated before the derived pool after an app relaunch. Sorting + // happens in `plan_shield_inputs`, after conversion, because the + // resulting PlatformAddress order is what the BTreeMap and network use + // to identify input 0. + let candidate_addresses = merge_platform_payment_candidate_addresses( + account + .addresses + .addresses + .values() + .filter_map(|address_info| { + PlatformP2PKHAddress::from_address(&address_info.address).ok() + }), + account.address_balances.keys().copied(), + ); + let candidates = candidate_addresses + .into_iter() + .filter_map(|p2pkh| { + let balance = account.address_credit_balance(&p2pkh); + (balance > 0).then_some((PlatformAddress::P2pkh(p2pkh.to_bytes()), balance)) + }) + .collect(); + + let platform_version = self.sdk.version(); + let state_transition_version = &platform_version.dpp.state_transitions; + plan_shield_inputs( + candidates, + shield_fee_reserve_credits(platform_version)?, + state_transition_version.address_funds.min_input_amount, + usize::from(state_transition_version.max_address_inputs), + ) + } + + /// Return a cached capacity snapshot for shielding from one Platform + /// Payment account. + /// + /// This uses the exact planner later executed by + /// [`shielded_shield_from_account`](Self::shielded_shield_from_account): + /// Platform addresses are sorted lexicographically, the leading prefix + /// through the first address able to retain the shared fee reserve is + /// analyzed once, later addresses below the versioned minimum input amount + /// are omitted, and the lexicographically earliest usable addresses are + /// capped at the versioned maximum input count. The reported maximum is + /// therefore executable under the wallet's deterministic ordering policy; + /// it is not a balance-optimized subset. It performs no DAPI request, + /// signing, proof construction, or broadcast. A normal no-capacity state is returned with + /// `can_shield == false`; only missing wallet/account state or arithmetic + /// overflow is an error. + #[cfg(feature = "shielded")] + pub async fn shielded_shield_preflight( + &self, + payment_account: u32, + ) -> Result { + Ok(self + .shielded_shield_plan_for_account(payment_account) + .await? + .preflight) + } + /// Shield credits from a Platform Payment account into the /// wallet's shielded pool, with the resulting note assigned /// to `shielded_account`'s default Orchard address. @@ -1356,11 +1627,12 @@ impl PlatformWallet { /// account (different concept from `shielded_account` — this /// is the BIP-44-style funding account on the transparent /// side, not the ZIP-32 Orchard account). Auto-selects input - /// addresses from that account in ascending derivation-index - /// order until the cumulative balance covers `amount` plus a - /// conservative fee buffer (the on-chain fee comes off input - /// 0 via `DeductFromInput(0)`; the buffer absorbs the - /// discrepancy without a more sophisticated estimator). + /// addresses from that account in lexicographic Platform-address + /// order until the cumulative balance covers `amount` plus the + /// versioned fee reserve ([`shield_fee_reserve_credits`]; the + /// on-chain fee comes off input 0 via `DeductFromInput(0)`, so + /// that much balance stays unclaimed on input 0 for the + /// metered fee). /// /// The host supplies a `Signer` — typically /// `&VTableSigner` from `KeychainSigner.handle` — which signs @@ -1369,8 +1641,8 @@ impl PlatformWallet { /// Returns `ShieldedNotBound` if no shielded sub-wallet is /// bound, `AddressOperation` if the platform-payment account /// at `payment_account` doesn't exist, or - /// `ShieldedInsufficientBalance` if the account's total - /// credits can't cover `amount + fee_buffer`. + /// `PlatformShieldCapacityExceeded` if the selected Platform-address set + /// can't cover `amount` plus the fee reserve. #[cfg(feature = "shielded")] pub async fn shielded_shield_from_account( &self, @@ -1385,12 +1657,8 @@ impl PlatformWallet { S: dpp::identity::signer::Signer + Send + Sync, P: dpp::shielded::builder::OrchardProver, { - // Reject zero amount at the boundary. With `amount == 0` - // the selection loop exits immediately (claim 0 >= 0) and - // the post-loop insufficient-balance check (`0 < 0`) - // doesn't fire, so an empty inputs map would otherwise - // flow into the ~30 s Halo 2 proof build and fail deep and - // opaquely. Non-Swift FFI hosts don't have the UI guard. + // Preserve the boundary behavior for non-Swift hosts and avoid taking + // the single-flight/account locks for a request that can never build. if amount == 0 { return Err(PlatformWalletError::ShieldedBuildError( "amount must be > 0".to_string(), @@ -1403,77 +1671,14 @@ impl PlatformWallet { // a ~30 s proof). Held across selection → build → broadcast. let _shield_guard = self.shield_guard.lock().await; - // The shield transition uses `DeductFromInput(0)` as its fee - // strategy. drive-abci interprets that as "after each input - // address has had its `claim` deducted, take the fee out of - // input 0's *remaining* balance" (see - // `deduct_fee_from_outputs_or_remaining_balance_of_inputs_v0` - // in rs-dpp). "Input 0" is the smallest-key entry of the - // BTreeMap we hand to the builder. Therefore: - // - // * we must NOT claim each input's full balance — claiming - // `balance` leaves `remaining = 0`, and the fee - // deduction has nothing to bite into. - // * we must reserve at least `FEE_RESERVE_CREDITS` of - // unclaimed balance specifically on input 0 (the - // BTreeMap-smallest address). - // - // The flat shielded fee `F = compute_minimum_shielded_fee(2)` - // on a Type 15 transition lands at ~1.23e8 credits (~0.0012 - // DASH); `operations::shield` loads exactly `F` onto input 0's - // claim from this reserved headroom. Reserve 1e9 credits - // (0.01 DASH) — ~8× headroom over `F`, still trivial relative - // to typical balances. - const FEE_RESERVE_CREDITS: u64 = 1_000_000_000; - - // Build the inputs map under the wallet-manager read lock, - // then drop the lock before re-entering shielded so the - // guards don't nest unnecessarily. - let inputs: std::collections::BTreeMap< - dpp::address_funds::PlatformAddress, - dpp::fee::Credits, - > = { - let wm = self.wallet_manager.read().await; - let info = wm - .get_wallet_info(&self.wallet_id) - .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; - let account = info - .core_wallet - .platform_payment_managed_account_at_index(payment_account) - .ok_or_else(|| { - PlatformWalletError::AddressOperation(format!( - "no platform payment account at index {payment_account}" - )) - })?; - - // Collect (address, balance) for every funded address, - // sorted by address bytes — that determines BTreeMap - // key order downstream and therefore which input ends - // up at index 0. - let candidates: Vec<(dpp::address_funds::PlatformAddress, u64)> = account - .addresses - .addresses - .values() - .filter_map(|addr_info| { - let p2pkh = - key_wallet::PlatformP2PKHAddress::from_address(&addr_info.address).ok()?; - let balance = account.address_credit_balance(&p2pkh); - if balance == 0 { - None - } else { - Some(( - dpp::address_funds::PlatformAddress::P2pkh(p2pkh.to_bytes()), - balance, - )) - } - }) - .collect(); - // Selection rules live in `select_shield_inputs` (pure + - // unit-tested): sort by address, skip leading dust below the - // reserve, reserve fee headroom only on input 0, then claim - // in BTreeMap order up to `amount`. - select_shield_inputs(candidates, amount, FEE_RESERVE_CREDITS)? - }; + // Planning and amount selection are shared with the cached preflight. + // The helper drops the wallet-manager read lock before the expensive + // proof path, while the single-flight guard keeps two shields from + // planning and broadcasting against the same address nonce. + let inputs = self + .shielded_shield_plan_for_account(payment_account) + .await? + .select_inputs(amount)?; // Clone the account's viewing keys and release the slot before // the proof: `shield` runs a Halo 2 proof plus a broadcast, and @@ -1713,87 +1918,6 @@ impl DerefMut for WalletStateWriteGuard<'_> { } } -/// Select shield (Type 15) inputs from funded `(address, balance)` -/// candidates. -/// -/// Pure and deterministic so the selection rules are unit-testable -/// independent of the wallet manager — a future refactor can't silently -/// reintroduce the old `viable_input_0` dust/fee-reserve bug without -/// tripping a test. The rules: -/// * sort by address bytes — this fixes which input lands at index 0, -/// and the network deducts the transition fee from input 0 -/// (`DeductFromInput(0)`); -/// * skip any leading address with balance `<= fee_reserve` — input 0 -/// must keep at least `fee_reserve` unclaimed for the fee step; -/// * claim in BTreeMap order only up to `amount`, taking the reserve -/// headroom off input 0 alone. -/// -/// Errors with [`PlatformWalletError::ShieldedInsufficientBalance`] when -/// no viable input 0 exists, when usable balance can't cover -/// `amount + fee_reserve`, or when the walk can't accumulate `amount`. -#[cfg(feature = "shielded")] -fn select_shield_inputs( - mut candidates: Vec<(dpp::address_funds::PlatformAddress, u64)>, - amount: u64, - fee_reserve: u64, -) -> Result< - std::collections::BTreeMap, - PlatformWalletError, -> { - candidates.sort_by_key(|(addr, _)| *addr); - - let Some(viable_input_0) = candidates - .iter() - .position(|(_, balance)| *balance > fee_reserve) - else { - let total: u64 = candidates.iter().map(|(_, b)| b).sum(); - return Err(PlatformWalletError::ShieldedInsufficientBalance { - available: total, - required: amount.saturating_add(fee_reserve), - }); - }; - let usable = &candidates[viable_input_0..]; - - let total_usable: u64 = usable.iter().map(|(_, b)| b).sum(); - let needed = amount.saturating_add(fee_reserve); - if total_usable < needed { - return Err(PlatformWalletError::ShieldedInsufficientBalance { - available: total_usable, - required: needed, - }); - } - - let mut chosen: std::collections::BTreeMap< - dpp::address_funds::PlatformAddress, - dpp::fee::Credits, - > = std::collections::BTreeMap::new(); - let mut accumulated_claim: u64 = 0; - for (i, (addr, balance)) in usable.iter().enumerate() { - if accumulated_claim >= amount { - break; - } - let max_claim = if i == 0 { - balance.saturating_sub(fee_reserve) - } else { - *balance - }; - let still_need = amount - accumulated_claim; - let claim = max_claim.min(still_need); - if claim > 0 { - chosen.insert(*addr, claim); - accumulated_claim = accumulated_claim.saturating_add(claim); - } - } - - if accumulated_claim < amount { - return Err(PlatformWalletError::ShieldedInsufficientBalance { - available: accumulated_claim, - required: amount, - }); - } - Ok(chosen) -} - /// Verify a bech32m recipient's network class matches `network` before decoding. /// /// The address decoder is network-agnostic (`tdash` is shared by @@ -1929,36 +2053,89 @@ mod check_recipient_hrp_tests { mod shield_input_selection_tests { use super::*; use dpp::address_funds::PlatformAddress; + use dpp::version::LATEST_PLATFORM_VERSION; - const RESERVE: u64 = 1_000_000_000; + fn reserve() -> Credits { + shield_fee_reserve_credits(LATEST_PLATFORM_VERSION) + .expect("latest shield fee reserve must be computable") + } fn addr(b: u8) -> PlatformAddress { PlatformAddress::P2pkh([b; 20]) } + fn indexed_addr(index: usize) -> PlatformAddress { + let encoded = index.to_be_bytes(); + let mut hash = [0u8; 20]; + hash[20 - encoded.len()..].copy_from_slice(&encoded); + PlatformAddress::P2pkh(hash) + } + + fn min_input_amount() -> Credits { + LATEST_PLATFORM_VERSION + .dpp + .state_transitions + .address_funds + .min_input_amount + } + + fn max_address_inputs() -> usize { + usize::from( + LATEST_PLATFORM_VERSION + .dpp + .state_transitions + .max_address_inputs, + ) + } + + fn plan( + candidates: Vec<(PlatformAddress, Credits)>, + ) -> Result { + plan_shield_inputs( + candidates, + reserve(), + min_input_amount(), + max_address_inputs(), + ) + } + #[test] fn skips_leading_dust_address_below_reserve() { // addr(1) sorts first but is dust (== reserve, not > reserve); // addr(2) must become input 0. - let candidates = vec![(addr(1), RESERVE), (addr(2), 5 * RESERVE)]; - let chosen = select_shield_inputs(candidates, 2 * RESERVE, RESERVE).unwrap(); + let candidates = vec![(addr(1), reserve()), (addr(2), 5 * reserve())]; + let plan = plan(candidates).unwrap(); + let chosen = plan.select_inputs(2 * reserve()).unwrap(); assert!( !chosen.contains_key(&addr(1)), "dust leading address must be skipped" ); - assert_eq!(chosen.get(&addr(2)), Some(&(2 * RESERVE))); + assert_eq!(chosen.get(&addr(2)), Some(&(2 * reserve()))); } #[test] fn balance_exactly_at_reserve_is_not_viable_input_0() { // Strict `> reserve`: a sole address holding exactly the reserve // cannot be input 0. - let candidates = vec![(addr(1), RESERVE)]; - let err = select_shield_inputs(candidates, 1, RESERVE).unwrap_err(); + let candidates = vec![(addr(1), reserve())]; + let plan = plan(candidates).unwrap(); + assert_eq!( + plan.preflight, + ShieldedShieldPreflight { + can_shield: false, + account_balance_credits: reserve(), + usable_balance_credits: 0, + fee_reserve_credits: reserve(), + max_shieldable_credits: 0, + reason: plan.preflight.reason.clone(), + } + ); + assert!(plan.preflight.reason.is_some()); + let err = plan.select_inputs(1).unwrap_err(); assert!(matches!( err, - PlatformWalletError::ShieldedInsufficientBalance { available, required } - if available == RESERVE && required == 1 + RESERVE + PlatformWalletError::PlatformShieldCapacityExceeded { available, required } + if available == reserve() && required == 1 + reserve() )); } @@ -1966,33 +2143,200 @@ mod shield_input_selection_tests { fn amount_equal_to_total_minus_reserve_claims_exactly_amount() { // Single address holding exactly amount + reserve: claim == // amount, leaving the full reserve for DeductFromInput(0). - let amount = 3 * RESERVE; - let candidates = vec![(addr(1), amount + RESERVE)]; - let chosen = select_shield_inputs(candidates, amount, RESERVE).unwrap(); + let amount = 3 * reserve(); + let candidates = vec![(addr(1), amount + reserve())]; + let plan = plan(candidates).unwrap(); + assert_eq!(plan.preflight.max_shieldable_credits, amount); + let chosen = plan.select_inputs(amount).unwrap(); assert_eq!(chosen.len(), 1); assert_eq!(chosen.get(&addr(1)), Some(&amount)); } #[test] fn accumulates_across_inputs_reserving_only_on_input_0() { - let amount = 5 * RESERVE; + let amount = 5 * reserve(); // input 0 (addr 1) holds 2*reserve → contributes reserve after // its headroom; addr 2 covers the rest. - let candidates = vec![(addr(1), 2 * RESERVE), (addr(2), 5 * RESERVE)]; - let chosen = select_shield_inputs(candidates, amount, RESERVE).unwrap(); - assert_eq!(chosen.get(&addr(1)), Some(&RESERVE)); - assert_eq!(chosen.get(&addr(2)), Some(&(4 * RESERVE))); + let candidates = vec![(addr(1), 2 * reserve()), (addr(2), 5 * reserve())]; + let plan = plan(candidates).unwrap(); + let chosen = plan.select_inputs(amount).unwrap(); + assert_eq!(chosen.get(&addr(1)), Some(&reserve())); + assert_eq!(chosen.get(&addr(2)), Some(&(4 * reserve()))); assert_eq!(chosen.values().sum::(), amount); } #[test] fn insufficient_usable_balance_errors() { // Needs amount + reserve = 5*reserve, only 2*reserve available. - let candidates = vec![(addr(1), 2 * RESERVE)]; - let err = select_shield_inputs(candidates, 4 * RESERVE, RESERVE).unwrap_err(); + let candidates = vec![(addr(1), 2 * reserve())]; + let plan = plan(candidates).unwrap(); + let err = plan.select_inputs(4 * reserve()).unwrap_err(); assert!(matches!( err, - PlatformWalletError::ShieldedInsufficientBalance { .. } + PlatformWalletError::PlatformShieldCapacityExceeded { .. } )); } + + #[test] + fn regression_reports_max_from_usable_suffix_not_total_account_balance() { + // Real account snapshot: the leading address is below the reserve, so + // capacity must come from the usable suffix, not the account total. + assert!( + 297_264_780 <= reserve(), + "regression shape requires the leading address to stay below the reserve; \ + re-seed the balances if the versioned reserve drops under 297_264_780" + ); + let candidates = vec![ + (addr(1), 297_264_780), + (addr(2), 2_000_000_000), + (addr(3), 1_623_849_220), + ]; + let plan = plan(candidates).unwrap(); + let expected_max = 3_623_849_220 - reserve(); + + assert_eq!(plan.preflight.account_balance_credits, 3_921_114_000); + assert_eq!(plan.preflight.usable_balance_credits, 3_623_849_220); + assert_eq!(plan.preflight.fee_reserve_credits, reserve()); + assert_eq!(plan.preflight.max_shieldable_credits, expected_max); + assert!(plan.preflight.can_shield); + assert_eq!(plan.preflight.reason, None); + + let chosen = plan.select_inputs(expected_max).unwrap(); + assert!(!chosen.contains_key(&addr(1))); + assert_eq!(chosen.values().sum::(), expected_max); + + let err = plan.select_inputs(expected_max + 1).unwrap_err(); + assert!(matches!( + err, + PlatformWalletError::PlatformShieldCapacityExceeded { available, required } + if available == 3_623_849_220 && required == 3_623_849_221 + )); + } + + #[test] + fn no_viable_address_is_a_normal_zero_capacity_preflight() { + // Both addresses are funded but neither strictly exceeds the reserve, + // so no address can serve as the fee-paying input 0. + let below_reserve = reserve() / 2; + let plan = plan(vec![(addr(2), below_reserve), (addr(1), reserve())]).unwrap(); + + assert!(!plan.preflight.can_shield); + assert_eq!( + plan.preflight.account_balance_credits, + reserve() + below_reserve + ); + assert_eq!(plan.preflight.usable_balance_credits, 0); + assert_eq!(plan.preflight.max_shieldable_credits, 0); + assert!(plan + .preflight + .reason + .as_deref() + .is_some_and(|reason| reason.contains("Platform payment account"))); + } + + #[test] + fn planner_sorts_lexicographically_and_reserves_only_on_input_zero() { + let below_reserve = reserve() / 2; + let plan = plan(vec![ + (addr(3), 2 * reserve()), + (addr(1), below_reserve), + (addr(2), 2 * reserve()), + ]) + .unwrap(); + + assert_eq!( + plan.preflight.account_balance_credits, + 4 * reserve() + below_reserve + ); + assert_eq!(plan.preflight.usable_balance_credits, 4 * reserve()); + assert_eq!(plan.preflight.max_shieldable_credits, 3 * reserve()); + let chosen = plan.select_inputs(2 * reserve()).unwrap(); + assert_eq!(chosen.get(&addr(2)), Some(&reserve())); + assert_eq!(chosen.get(&addr(3)), Some(&reserve())); + assert!(!chosen.contains_key(&addr(1))); + } + + #[test] + fn planner_rejects_credit_sum_overflow() { + let err = plan(vec![(addr(1), u64::MAX), (addr(2), 1)]).unwrap_err(); + assert!(matches!(err, PlatformWalletError::InputSumOverflow)); + } + + #[test] + fn versioned_input_cap_excludes_max_plus_one_candidate_from_capacity_and_selection() { + let max_inputs = max_address_inputs(); + assert!(max_inputs > 0, "latest protocol must permit shield inputs"); + + let candidates = (1..=max_inputs + 1) + .map(|index| (indexed_addr(index), 2 * reserve())) + .collect(); + let plan = plan(candidates).unwrap(); + let expected_account_balance = (max_inputs as u64 + 1) * 2 * reserve(); + let expected_usable_balance = max_inputs as u64 * 2 * reserve(); + let expected_max = expected_usable_balance - reserve(); + + assert_eq!( + plan.preflight.account_balance_credits, + expected_account_balance + ); + assert_eq!( + plan.preflight.usable_balance_credits, + expected_usable_balance + ); + assert_eq!(plan.preflight.max_shieldable_credits, expected_max); + assert_eq!(plan.usable_candidates.len(), max_inputs); + + let selected = plan.select_inputs(expected_max).unwrap(); + assert_eq!(selected.len(), max_inputs); + assert!(!selected.contains_key(&indexed_addr(max_inputs + 1))); + + let err = plan.select_inputs(expected_max + 1).unwrap_err(); + assert!(matches!( + err, + PlatformWalletError::PlatformShieldCapacityExceeded { available, required } + if available == expected_usable_balance + && required == expected_usable_balance + 1 + )); + } + + #[test] + fn excludes_later_address_below_versioned_minimum_from_max() { + let dust = min_input_amount() - 1; + let plan = plan(vec![(addr(1), 2 * reserve()), (addr(2), dust)]).unwrap(); + + assert_eq!(plan.preflight.account_balance_credits, 2 * reserve() + dust); + assert_eq!(plan.preflight.usable_balance_credits, 2 * reserve()); + assert_eq!(plan.preflight.max_shieldable_credits, reserve()); + let chosen = plan.select_inputs(reserve()).unwrap(); + assert_eq!(chosen.len(), 1); + assert_eq!(chosen.get(&addr(1)), Some(&reserve())); + assert!(!chosen.contains_key(&addr(2))); + + let err = plan.select_inputs(reserve() + 1).unwrap_err(); + assert!(matches!( + err, + PlatformWalletError::PlatformShieldCapacityExceeded { available, required } + if available == 2 * reserve() && required == 2 * reserve() + 1 + )); + } + + #[test] + fn lifts_non_first_greedy_tail_to_versioned_minimum() { + let minimum = min_input_amount(); + let plan = plan(vec![ + (addr(1), 2 * reserve()), + (addr(2), minimum.saturating_mul(2)), + ]) + .unwrap(); + + let amount = reserve() + 1; + let chosen = plan.select_inputs(amount).unwrap(); + assert_eq!(chosen.get(&addr(1)), Some(&reserve())); + assert_eq!(chosen.get(&addr(2)), Some(&minimum)); + assert_eq!(chosen.values().sum::(), amount + minimum - 1); + assert!(chosen + .iter() + .skip(1) + .all(|(_, requested)| *requested >= minimum)); + } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 775dd309c79..4783ce75f9a 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -58,6 +58,7 @@ use dpp::shielded::compute_minimum_shielded_fee; use dpp::state_transition::proof_result::StateTransitionProofResult; use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; use dpp::state_transition::StateTransition; +use dpp::version::PlatformVersion; use dpp::withdrawal::Pooling; use grovedb_commitment_tree::{Anchor, PaymentAddress}; use tokio::sync::RwLock; @@ -75,6 +76,41 @@ use tracing::{debug, info, trace, warn}; /// count, so the wallet's fee reservation must use the same count. const SHIELD_NUM_ACTIONS: usize = 2; +/// Multiplier applied to the versioned minimum shield fee when sizing the +/// planner's input-0 reserve. +/// +/// Execution deducts the ACTUAL fee — the GroveDB-metered storage/processing +/// of the note/nullifier writes plus `compute_shielded_verification_fee` — +/// from input 0's post-reallocation residue, and rejects the shield when the +/// residue can't cover it. `compute_minimum_shielded_fee` estimates that +/// actual fee with a flat per-action storage term the client cannot meter +/// itself, so the reserve keeps one extra fee of headroom for metering +/// variance. The reserve is NOT what satisfies the structure gate +/// (`Σ claims ≥ amount + fee`) — `reserve_shield_fee_on_input_0` loads the +/// claimed fee for that — so it needs no allowance beyond metering variance. +const SHIELD_FEE_RESERVE_MULTIPLIER: u64 = 2; + +/// Versioned balance the shield planner keeps unclaimed on the +/// lexicographically first (fee-paying) input. +/// +/// The preflight and the execution path both derive capacity from this one +/// value, so it directly sets three host-visible numbers: the viability +/// threshold an address must exceed to serve as input 0, the account's +/// `max_shieldable_credits`, and the residue a Max shield leaves transparent +/// (`reserve − actual fee`). Deriving it from the versioned fee formula keeps +/// all three tracking fee-constant bumps instead of freezing a magic number +/// that overstates the fee and understates capacity. +pub fn shield_fee_reserve_credits( + platform_version: &PlatformVersion, +) -> Result { + let fee = compute_minimum_shielded_fee(SHIELD_NUM_ACTIONS, platform_version) + .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; + fee.checked_mul(SHIELD_FEE_RESERVE_MULTIPLIER) + .ok_or_else(|| { + PlatformWalletError::ShieldedBuildError("shield fee reserve overflows u64".to_string()) + }) +} + /// Try to extract a structured `AddressesNotEnoughFundsError` from /// a broadcast error so the shield path can format a diagnostic /// that includes Platform's actual per-input view (nonce + balance) @@ -121,6 +157,23 @@ fn address_not_enough_funds( } } +/// Promote the shield pre-broadcast hard balance check into the typed capacity +/// error the FFI and Swift layers recognize. +/// +/// The values are Platform's live per-input view, not the cached planner +/// snapshot. Their `Display` rendering therefore preserves the actionable +/// available/required diagnostic while the typed variant lets the host refresh +/// preflight instead of retrying the stale amount unchanged. +fn map_shield_input_fetch_error(e: &dash_sdk::Error) -> PlatformWalletError { + match address_not_enough_funds(e) { + Some(short) => PlatformWalletError::PlatformShieldCapacityExceeded { + available: short.balance(), + required: short.required_balance(), + }, + None => PlatformWalletError::ShieldedBuildError(format!("fetch input nonces: {e}")), + } +} + /// Format a one-line `addresses_with_info` summary for diagnostics — /// each entry rendered as `=(nonce , credits)`, /// matching what the wallet UI shows. @@ -422,9 +475,10 @@ pub async fn shield, P: OrchardPr // // The fee is loaded onto the smallest-key input — the `DeductFromInput(0)` // fee-strategy payer (input 0 == BTreeMap-smallest address). The caller - // (`shielded_shield_from_account`) reserves ~1e9 credits of unclaimed - // headroom on input 0 specifically for this, and `F` (~1.2e8 credits) - // fits well within it. Inflating the claim BEFORE the fetch lets the + // (`shielded_shield_from_account`) reserves `shield_fee_reserve_credits` + // (a small multiple of this same versioned fee) of unclaimed headroom on + // input 0 specifically for this, so `F` always fits within the reserve. + // Inflating the claim BEFORE the fetch lets the // single hard balance check below validate the fee-inclusive claim // against the on-chain balance in one shot — no second round-trip and // no claim that outruns its balance check. @@ -443,23 +497,9 @@ pub async fn shield, P: OrchardPr // nonce — bail loudly here instead). use dash_sdk::platform::transition::fetch_inputs_with_nonce; - let fetched = fetch_inputs_with_nonce(sdk, &inputs).await.map_err(|e| { - // The hard balance check is the common pre-broadcast failure; - // surface its structured (address, balance, required) info as a - // diagnostic string rather than the opaque `{e}` form, matching - // the richness of the broadcast-side handler below. The FFI - // shape is unchanged (the host still receives a string body). - if let Some(short) = address_not_enough_funds(&e) { - PlatformWalletError::ShieldedBuildError(format!( - "shield input {} has insufficient balance: requires {} credits, has {}", - short.address().to_bech32m_string(sdk.network), - short.required_balance(), - short.balance(), - )) - } else { - PlatformWalletError::ShieldedBuildError(format!("fetch input nonces: {e}")) - } - })?; + let fetched = fetch_inputs_with_nonce(sdk, &inputs) + .await + .map_err(|error| map_shield_input_fetch_error(&error))?; let mut inputs_with_nonce: BTreeMap = BTreeMap::new(); for (addr, (nonce, credits)) in fetched { @@ -2882,9 +2922,36 @@ mod classify_spend_wait_failure_tests { } } +#[cfg(test)] +mod shield_input_fetch_error_tests { + use super::*; + use dpp::consensus::state::address_funds::AddressNotEnoughFundsError; + + #[test] + fn live_address_shortfall_maps_to_typed_shield_capacity_error() { + let sdk_error = dash_sdk::Error::from(AddressNotEnoughFundsError::new( + PlatformAddress::P2pkh([7; 20]), + 3_623_849_220, + 3_623_849_221, + )); + + let mapped = map_shield_input_fetch_error(&sdk_error); + assert!(matches!( + &mapped, + PlatformWalletError::PlatformShieldCapacityExceeded { available, required } + if *available == 3_623_849_220 && *required == 3_623_849_221 + )); + assert_eq!( + mapped.to_string(), + "Platform shield capacity exceeded: available 3623849220, required 3623849221" + ); + } +} + #[cfg(test)] mod reserve_shield_fee_tests { use super::*; + use dpp::version::LATEST_PLATFORM_VERSION; fn addr(b: u8) -> PlatformAddress { PlatformAddress::P2pkh([b; 20]) @@ -2911,6 +2978,37 @@ mod reserve_shield_fee_tests { assert_eq!(out.values().sum::(), 6_000_000 + fee); } + #[test] + fn versioned_fee_keeps_input_zero_valid_and_reserve_tracks_the_fee() { + let min_input_amount = LATEST_PLATFORM_VERSION + .dpp + .state_transitions + .address_funds + .min_input_amount; + let shield_fee = compute_minimum_shielded_fee(SHIELD_NUM_ACTIONS, LATEST_PLATFORM_VERSION) + .expect("latest shield fee must be computable"); + let reserve = shield_fee_reserve_credits(LATEST_PLATFORM_VERSION) + .expect("latest shield fee reserve must be computable"); + let smallest_fee_inclusive_claim = shield_fee + .checked_add(1) + .expect("latest shield fee plus one credit must fit"); + + assert!( + smallest_fee_inclusive_claim >= min_input_amount, + "adding the fee must lift even input 0's smallest positive base claim above the protocol minimum" + ); + assert!( + reserve >= shield_fee, + "the retained input-0 headroom must cover the versioned shield fee" + ); + assert!( + reserve <= shield_fee.saturating_mul(4), + "the reserve must stay a small multiple of the versioned fee — an oversized \ + reserve silently understates preflight capacity and strands the excess \ + below the input-0 viability threshold after a Max shield" + ); + } + #[test] fn errors_on_empty_inputs() { let inputs: BTreeMap = BTreeMap::new(); diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index 30c3c92759f..1b94323e60e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -579,10 +579,90 @@ extension PlatformWalletManager { }.value } + /// Cached Platform-to-shielded capacity for one payment account. + /// + /// All values come from the same Rust planner used by `shieldedShield`. + /// `reason` is non-nil only for a normal zero-capacity result; bad handles, + /// wallet IDs, and missing payment accounts throw instead. + public struct ShieldedShieldPreflight: Sendable { + public let canShield: Bool + public let accountBalanceCredits: UInt64 + public let usableBalanceCredits: UInt64 + public let feeReserveCredits: UInt64 + public let maxShieldableCredits: UInt64 + public let reason: String? + } + + /// Return the cached amount a Platform Payment account can currently + /// shield without signing, proving, broadcasting, or querying DAPI. + /// + /// Rust sorts funded addresses lexicographically, excludes the leading + /// prefix before the first address that can retain the fee reserve, omits + /// later addresses below the protocol version's minimum input amount, and + /// truncates the lexicographically earliest usable set to the versioned + /// maximum input count. The result is executable under that deterministic + /// wallet policy rather than globally optimized over later balances. A + /// fragmented/no-capacity account returns `canShield == false` with + /// meaningful numeric fields and a reason; it is not thrown as an error. + public func shieldedShieldPreflight( + walletId: Data, + paymentAccount: UInt32 = 0 + ) async throws -> ShieldedShieldPreflight { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle( + "PlatformWalletManager not configured" + ) + } + guard walletId.count == 32 else { + throw PlatformWalletError.invalidParameter( + "walletId must be exactly 32 bytes" + ) + } + + let handle = self.handle + return try await Task.detached(priority: .userInitiated) { + () -> ShieldedShieldPreflight in + var out = ShieldedShieldPreflightFFI( + can_shield: false, + account_balance_credits: 0, + usable_balance_credits: 0, + fee_reserve_credits: 0, + max_shieldable_credits: 0 + ) + let result = try walletId.withUnsafeBytes { walletIdRaw in + guard let walletIdPointer = walletIdRaw.baseAddress? + .assumingMemoryBound(to: UInt8.self) + else { + throw PlatformWalletError.invalidParameter( + "walletId baseAddress is nil" + ) + } + return PlatformWalletResult( + platform_wallet_manager_shielded_shield_preflight( + handle, + walletIdPointer, + paymentAccount, + &out + ) + ) + } + try result.throwIfError() + let reason = out.can_shield ? nil : result.message + return ShieldedShieldPreflight( + canShield: out.can_shield, + accountBalanceCredits: out.account_balance_credits, + usableBalanceCredits: out.usable_balance_credits, + feeReserveCredits: out.fee_reserve_credits, + maxShieldableCredits: out.max_shieldable_credits, + reason: reason + ) + }.value + } + /// Platform → Shielded. Spends credits from a Platform Payment /// account on `walletId` into the bound shielded sub-wallet's /// pool. Inputs are auto-selected from the account's addresses - /// in ascending derivation order until they cover `amount` plus + /// in lexicographic Platform-address order until they cover `amount` plus /// a conservative on-chain fee buffer; the actual fee is /// deducted from input 0 by the network via the shield /// transition's fee strategy. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 684c4f8e4b8..c09e480a489 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -137,6 +137,10 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// reference it. Retry after the contest resolves. The message is a /// stable JSON detail object carrying the label and the vote end time. case errorContestedNameNotTradable = 40 + /// A Platform-to-shielded operation can no longer cover the requested + /// amount plus input 0's retained fee reserve. Refresh the shield + /// preflight and ask the user to confirm the new capacity. + case errorShieldedInsufficientBalance = 41 /// The named thing does not exist. Besides the handle/lookup failures this /// has always covered, BOTH deferred-send paths report the /// wallet-was-REMOVED case here. @@ -232,6 +236,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorInsufficientIdentityCredits case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_CONTESTED_NAME_NOT_TRADABLE: self = .errorContestedNameNotTradable + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHIELDED_INSUFFICIENT_BALANCE: + self = .errorShieldedInsufficientBalance case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -338,6 +344,11 @@ public enum PlatformWalletError: LocalizedError { /// sync reaches a confirmed state. Distinct from `shieldedSpendUnconfirmed`, /// which must NOT be retried. case shieldedNoRecordedAnchor(String) + /// The cached Platform Payment-account input set cannot cover a shield's + /// requested amount plus the fee reserve retained on input 0. Despite the + /// retained public Swift/FFI name, this is not a shielded-pool balance + /// failure. + case shieldedInsufficientBalance(String) /// A core transaction broadcast was submitted but its outcome is /// unknown — the transaction may already be on the network. The wallet /// keeps the spent inputs reserved so a retry cannot double-spend; the @@ -429,7 +440,7 @@ public enum PlatformWalletError: LocalizedError { .assetLockFundingMismatch(let m), .walletAlreadyExists(let m), .shieldedBroadcastFailed(let m), .shieldedBroadcastUnconfirmed(let m), .shieldedSpendUnconfirmed(let m), - .shieldedNoRecordedAnchor(let m), + .shieldedNoRecordedAnchor(let m), .shieldedInsufficientBalance(let m), .transactionBroadcastUnconfirmed(let m), .transactionBroadcastRejected(let m), .addressNonceMismatch(let m), @@ -496,6 +507,7 @@ public enum PlatformWalletError: LocalizedError { case .errorShieldedBroadcastUnconfirmed: self = .shieldedBroadcastUnconfirmed(detail) case .errorShieldedSpendUnconfirmed: self = .shieldedSpendUnconfirmed(detail) case .errorShieldedNoRecordedAnchor: self = .shieldedNoRecordedAnchor(detail) + case .errorShieldedInsufficientBalance: self = .shieldedInsufficientBalance(detail) case .errorTransactionBroadcastUnconfirmed: self = .transactionBroadcastUnconfirmed(detail) case .errorTransactionBroadcastRejected: diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift index 1001224cdd7..799617ae93c 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift @@ -56,6 +56,43 @@ final class ErrorHandlingTests: XCTestCase { ) } + func testShieldedInsufficientBalanceFFIResultMapping() { + XCTAssertEqual( + PlatformWalletResultCode( + ffi: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHIELDED_INSUFFICIENT_BALANCE + ), + .errorShieldedInsufficientBalance + ) + XCTAssertEqual( + PlatformWalletResultCode.errorShieldedInsufficientBalance.rawValue, + 41 + ) + + let error = PlatformWalletError( + code: .errorShieldedInsufficientBalance, + message: "available 3623849220, required 3623849221" + ) + guard case .shieldedInsufficientBalance(let message) = error else { + return XCTFail("expected typed shieldedInsufficientBalance error") + } + XCTAssertEqual(message, "available 3623849220, required 3623849221") + } + + @MainActor + func testShieldedShieldPreflightRejectsUnconfiguredManager() async { + let manager = PlatformWalletManager() + do { + _ = try await manager.shieldedShieldPreflight( + walletId: Data(repeating: 0, count: 32) + ) + XCTFail("Expected invalidHandle") + } catch PlatformWalletError.invalidHandle { + // Expected. + } catch { + XCTFail("Expected invalidHandle, got \(error)") + } + } + func testKeychainSignerMissingKeyErrorsClassifyAsSigningKeyUnavailable() { // The trampoline's structured completion code: "no stored key" // outcomes carry SigningKeyUnavailable (1); operational failures