diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index de1a6cb9441..f6a77890b77 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -164,6 +164,24 @@ impl PlatformWalletFFIResult { message: c_msg.into_raw(), } } + + /// A `Success`-coded result that still carries an advisory `message`. + /// + /// Used by non-error outcomes that want to convey a human-readable + /// explanation alongside an out-parameter — e.g. the withdrawal preflight's + /// "can't fund" case, where `can_withdraw = false` is the authoritative + /// signal and the message is the planner's typed reason. The `Success` code + /// keeps it off the error path (`.check()` on language bindings only + /// inspects the code); the message is freed like any other via + /// [`platform_wallet_ffi_result_free`] / `Drop`. + pub fn success_with_message(message: impl Into) -> Self { + let msg = message.into(); + let c_msg = CString::new(msg).unwrap_or_else(|_| CString::new("").unwrap()); + Self { + code: PlatformWalletFFIResultCode::Success, + message: c_msg.into_raw(), + } + } } /// Free the Rust-owned message held by an error result. diff --git a/packages/rs-platform-wallet-ffi/src/platform_address_types.rs b/packages/rs-platform-wallet-ffi/src/platform_address_types.rs index 867a3767471..f4f5a615133 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_address_types.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_address_types.rs @@ -10,10 +10,24 @@ use std::collections::BTreeMap; // --------------------------------------------------------------------------- /// Fixed-size C-compatible platform address. +/// +/// `address_type` mirrors the [`PlatformAddress`] variant discriminant +/// (`0 = P2pkh`, `1 = P2sh`) and is preserved faithfully by the +/// [`From`] direction. The **reverse** direction +/// ([`TryFrom`]) used by the platform-address +/// transfer/withdraw entry points (`parse_outputs`, +/// `parse_explicit_inputs`, `parse_explicit_inputs_with_nonces`) accepts +/// `0` (P2PKH) **only** — see that impl for why. Callers driving those +/// entry points must pass `address_type = 0`. #[repr(C)] #[derive(Debug, Clone, Copy)] pub struct PlatformAddressFFI { - /// 0 = P2pkh, 1 = P2sh + /// `0 = P2pkh`, `1 = P2sh`. + /// + /// NOTE: the platform-address transfer/withdraw surface only honors + /// `0` on the way **in** (see [`TryFrom`]); `1` + /// round-trips out of [`From`] but is rejected if + /// fed back into a transfer/withdraw input or output. pub address_type: u8, /// 20-byte hash pub hash: [u8; 20], @@ -36,10 +50,42 @@ impl From for PlatformAddressFFI { impl TryFrom for PlatformAddress { type Error = &'static str; + /// Accepts `address_type = 0` (P2PKH) **only**. + /// + /// This conversion backs the platform-address transfer/withdraw + /// inputs and outputs (`parse_explicit_inputs`, + /// `parse_explicit_inputs_with_nonces`, `parse_outputs`). P2SH + /// (`address_type = 1`) is intentionally rejected here even though + /// the [`PlatformAddress`] enum and the consensus transition can + /// represent it: + /// + /// - **Inputs** are spent via `Signer`, whose FFI + /// `VTableSigner::sign_create_witness` produces only P2PKH + /// witnesses and explicitly errors on P2SH (the iOS + /// `KeychainSigner` holds P2PKH key material only). A P2SH input + /// cannot be signed on this path. + /// - **Outputs/recipients** on this surface are always P2PKH in + /// practice: the wallet derives P2PKH platform-payment addresses, + /// and the Swift transfer UI tags own-wallet and pasted-hash + /// recipients as P2PKH. + /// + /// Accepting `1` here would only relocate the failure deeper (to the + /// signer for inputs) without enabling a working P2SH transfer, so + /// the contract is narrowed to P2PKH and the rejection is specific. + /// The identity-side siblings (`identity_transfer.rs`, + /// `identity_registration_with_signer.rs`) accept `1` because there + /// the address is a pure recipient signed by an *identity* key, never + /// spent as a `PlatformAddress` — a genuinely different capability. fn try_from(ffi: PlatformAddressFFI) -> Result { match ffi.address_type { 0 => Ok(PlatformAddress::P2pkh(ffi.hash)), - _ => Err("Unsupported address type"), + 1 => Err("platform-address transfers/withdrawals support P2PKH \ + (address_type 0) only; P2SH (address_type 1) cannot be \ + signed or spent on this surface"), + _ => Err( + "invalid address_type (platform-address transfers/withdrawals \ + accept P2PKH, address_type 0, only)", + ), } } } @@ -220,6 +266,44 @@ pub unsafe fn parse_outputs( Ok(map) } +// --------------------------------------------------------------------------- +// Withdrawal preflight result +// --------------------------------------------------------------------------- + +/// Result of `platform_address_wallet_preflight_withdrawal`: whether an AUTO +/// withdrawal of a platform-payment account can succeed, and — when it can — +/// the net credits that would be paid out plus the reserved transition fee. +/// +/// This is a pure, in-memory projection of the Rust planner +/// ([`platform_wallet::wallet::platform_addresses::WithdrawalPlan`]): the SAME +/// planning phase the real withdraw path executes, so a UI gating its submit +/// button on `can_withdraw` can never enable a withdrawal the spend path then +/// rejects (or vice versa). +/// +/// A genuine "can't fund" — every address is dust, or the largest input can't +/// retain the fee while clearing the per-input minimum, or the net falls below +/// `min_withdrawal_amount` — is reported as `can_withdraw = false` (a normal +/// result, **not** an FFI error), with `net_withdrawable` and `estimated_fee` +/// left at `0`. Only a structural failure (bad handle, missing account) is an +/// FFI error. The closing typed reason is surfaced via the +/// `PlatformWalletFFIResult` message on the `false` case so the caller can +/// explain *why* without mirroring protocol constants in Swift. +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct WithdrawalPreflightFFI { + /// `true` when the account can fund an AUTO withdrawal at the current + /// platform version; `false` for any "can't fund" case (the fields below + /// are then `0`). + pub can_withdraw: bool, + /// Net credits the chain would pay out (`Σ withdrawable inputs − + /// estimated_fee`). Valid only when `can_withdraw == true`; `0` otherwise. + pub net_withdrawable: u64, + /// The address-credit-withdrawal transition fee reserved on the fee-source + /// input, sized from the selected input count and the active fee schedule. + /// Valid only when `can_withdraw == true`; `0` otherwise. + pub estimated_fee: u64, +} + // --------------------------------------------------------------------------- // Funding address entry (for top_up) // --------------------------------------------------------------------------- @@ -516,6 +600,109 @@ mod tests { assert_eq!(err, "Duplicate input address"); } + /// The platform-address transfer/withdraw surface accepts P2PKH + /// (`address_type = 0`) only. P2SH (`address_type = 1`) must be + /// rejected by the shared `TryFrom` with a P2SH-specific message, and + /// any other discriminant with the generic invalid-type message — + /// across all three parse entry points (outputs + both input shapes). + /// The `From` direction still emits `1` for P2SH, so + /// the asymmetry is intentional and pinned here. + #[test] + fn try_from_accepts_p2pkh_and_rejects_p2sh_and_unknown() { + const P2SH_MSG: &str = "platform-address transfers/withdrawals support P2PKH \ + (address_type 0) only; P2SH (address_type 1) cannot be \ + signed or spent on this surface"; + const UNKNOWN_MSG: &str = "invalid address_type (platform-address transfers/withdrawals \ + accept P2PKH, address_type 0, only)"; + + // 0 → P2pkh round-trips. + let p2pkh = PlatformAddressFFI { + address_type: 0, + hash: [0x11; 20], + }; + assert_eq!( + PlatformAddress::try_from(p2pkh).expect("address_type 0 must be accepted"), + PlatformAddress::P2pkh([0x11; 20]), + ); + + // 1 → rejected with the P2SH-specific message. + let p2sh = PlatformAddressFFI { + address_type: 1, + hash: [0x22; 20], + }; + assert_eq!( + PlatformAddress::try_from(p2sh).expect_err("address_type 1 (P2SH) must be rejected"), + P2SH_MSG, + ); + + // Anything else → generic invalid-type message. + let unknown = PlatformAddressFFI { + address_type: 2, + hash: [0x33; 20], + }; + assert_eq!( + PlatformAddress::try_from(unknown).expect_err("unknown address_type must be rejected"), + UNKNOWN_MSG, + ); + + // The `From` direction still faithfully emits the P2SH + // discriminant; only the reverse (transfer/withdraw input) path is + // narrowed. + assert_eq!( + PlatformAddressFFI::from(PlatformAddress::P2sh([0x44; 20])).address_type, + 1, + ); + } + + /// All three input/output parse helpers funnel through the same + /// narrowed `TryFrom`, so a P2SH (`address_type = 1`) entry is rejected + /// with the P2SH-specific diagnostic on every entry point. + #[test] + fn parse_helpers_reject_p2sh_address_type() { + const P2SH_MSG: &str = "platform-address transfers/withdrawals support P2PKH \ + (address_type 0) only; P2SH (address_type 1) cannot be \ + signed or spent on this surface"; + + let p2sh = PlatformAddressFFI { + address_type: 1, + hash: [0xAB; 20], + }; + + let out = [AddressBalanceEntryFFI { + address: p2sh, + balance: 1_000_000, + nonce: 0, + account_index: 0, + address_index: 0, + }]; + assert_eq!( + unsafe { parse_outputs(out.as_ptr(), out.len()) } + .expect_err("parse_outputs must reject P2SH"), + P2SH_MSG, + ); + + let inp = [ExplicitInputFFI { + address: p2sh, + balance: 1_000_000, + }]; + assert_eq!( + unsafe { parse_explicit_inputs(inp.as_ptr(), inp.len()) } + .expect_err("parse_explicit_inputs must reject P2SH"), + P2SH_MSG, + ); + + let inp_nonce = [ExplicitInputWithNonceFFI { + address: p2sh, + nonce: 1, + balance: 1_000_000, + }]; + assert_eq!( + unsafe { parse_explicit_inputs_with_nonces(inp_nonce.as_ptr(), inp_nonce.len()) } + .expect_err("parse_explicit_inputs_with_nonces must reject P2SH"), + P2SH_MSG, + ); + } + /// Distinct addresses are accepted and the keys end up in DPP-canonical /// (lexicographic) order regardless of the caller's array order. #[test] diff --git a/packages/rs-platform-wallet-ffi/src/platform_addresses/wallet.rs b/packages/rs-platform-wallet-ffi/src/platform_addresses/wallet.rs index 89df3884151..c9351c282d3 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_addresses/wallet.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_addresses/wallet.rs @@ -76,6 +76,52 @@ pub unsafe extern "C" fn platform_address_wallet_total_credits( PlatformWalletFFIResult::ok() } +/// Get the per-input minimum credit amount (`min_input_amount`) the +/// chain enforces for address-funds transitions, read from the wallet's +/// current platform version. +/// +/// Pure getter: resolve the handle, read +/// `PlatformAddressWallet::min_input_amount()` (which reads the constant +/// off the wallet's SDK-resolved `PlatformVersion`), write it to +/// `out_min_input_amount`. This is the same floor the transfer/withdraw +/// auto-selectors use to drop sub-minimum dust inputs, so a UI gate that +/// sums only balances ≥ this stays in step with what Rust will spend. +#[no_mangle] +pub unsafe extern "C" fn platform_address_wallet_min_input_amount( + handle: Handle, + out_min_input_amount: *mut u64, +) -> PlatformWalletFFIResult { + check_ptr!(out_min_input_amount); + + let option = + PLATFORM_ADDRESS_WALLET_STORAGE.with_item(handle, |wallet| wallet.min_input_amount()); + *out_min_input_amount = unwrap_option_or_return!(option); + PlatformWalletFFIResult::ok() +} + +/// Get the per-output minimum credit amount (`min_output_amount`) the +/// chain enforces for address-funds transitions, read from the wallet's +/// current platform version. +/// +/// Pure getter: resolve the handle, read +/// `PlatformAddressWallet::min_output_amount()` (which reads the constant +/// off the wallet's SDK-resolved `PlatformVersion`), write it to +/// `out_min_output_amount`. DPP rejects any address-funds output below +/// this floor, so a transfer UI gate that requires the requested amount to +/// reach it stays in step with what DPP will accept. +#[no_mangle] +pub unsafe extern "C" fn platform_address_wallet_min_output_amount( + handle: Handle, + out_min_output_amount: *mut u64, +) -> PlatformWalletFFIResult { + check_ptr!(out_min_output_amount); + + let option = + PLATFORM_ADDRESS_WALLET_STORAGE.with_item(handle, |wallet| wallet.min_output_amount()); + *out_min_output_amount = unwrap_option_or_return!(option); + PlatformWalletFFIResult::ok() +} + /// Get all platform addresses with their cached balances. /// /// On success, `out_entries` and `out_count` are set to a heap-allocated array. diff --git a/packages/rs-platform-wallet-ffi/src/platform_addresses/withdrawal.rs b/packages/rs-platform-wallet-ffi/src/platform_addresses/withdrawal.rs index 1a5fa193b4e..4fd0caa5cdb 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_addresses/withdrawal.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_addresses/withdrawal.rs @@ -6,7 +6,10 @@ use crate::handle::*; use crate::platform_address_types::*; use crate::{unwrap_option_or_return, unwrap_result_or_return}; use dpp::identity::core_script::CoreScript; +use platform_wallet::PlatformWalletError; use rs_sdk_ffi::{SignerHandle, VTableSigner}; +use std::os::raw::c_char; +use std::str::FromStr; use super::{parse_input_selection, runtime}; @@ -64,3 +67,243 @@ pub unsafe extern "C" fn platform_address_wallet_withdraw( *out_changeset = PlatformAddressChangeSetFFI::from(&changeset); PlatformWalletFFIResult::ok() } + +/// Withdraw platform credits to a Core L1 address given as a base58 +/// string (e.g. `yXV…` on testnet / `X…` on mainnet). +/// +/// Sibling of [`platform_address_wallet_withdraw`] that accepts a +/// human-facing Core address instead of a pre-built `output_script` +/// byte buffer. The address is parsed and **network-checked against +/// the wallet's own network** entirely on the Rust side — a +/// testnet-shaped address can never be withdrawn to on a mainnet +/// wallet (and vice versa). The resulting P2PKH/P2SH `script_pubkey` +/// is then handed to the same `wallet.withdraw(...)` entry point, so +/// input selection, fee strategy, and signing are identical to the +/// raw-script path. +/// +/// `signer_address_handle` is a `*mut SignerHandle` produced by +/// `dash_sdk_signer_create_with_ctx` (e.g. via `KeychainSigner.handle`) +/// and is consumed as `Signer` for each input +/// address. The caller retains ownership of the handle; this function +/// does NOT destroy it. +/// +/// Free result with `platform_address_wallet_free_changeset`. +/// +/// # Safety +/// - `core_address` must be a valid, non-null, NUL-terminated C string. +/// - `signer_address_handle` must be a valid, non-destroyed +/// `*mut SignerHandle` that outlives this call. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_address_wallet_withdraw_to_address( + handle: Handle, + account_index: u32, + input_type: InputSelectionType, + explicit_inputs: *const ExplicitInputFFI, + explicit_inputs_count: usize, + nonce_inputs: *const ExplicitInputWithNonceFFI, + nonce_inputs_count: usize, + core_address: *const c_char, + core_fee_per_byte: u32, + fee_strategy: *const FeeStrategyStepFFI, + fee_strategy_count: usize, + signer_address_handle: *mut SignerHandle, + out_changeset: *mut PlatformAddressChangeSetFFI, +) -> PlatformWalletFFIResult { + check_ptr!(out_changeset); + check_ptr!(core_address); + check_ptr!(signer_address_handle); + + let address_str = unwrap_result_or_return!(std::ffi::CStr::from_ptr(core_address).to_str()); + // Parse the address as network-unchecked first; the network is + // pulled from the wallet (not threaded as a parameter, which would + // be ambiguous if the two disagreed) and enforced below. + let unchecked_address = unwrap_result_or_return!(dashcore::Address::from_str(address_str)); + + let input_selection = unwrap_result_or_return!(parse_input_selection( + input_type, + explicit_inputs, + explicit_inputs_count, + nonce_inputs, + nonce_inputs_count, + )); + + let fee = parse_fee_strategy(fee_strategy, fee_strategy_count); + + // SAFETY: caller guarantees `signer_address_handle` is a valid, + // non-destroyed handle that outlives this call. + let address_signer: &VTableSigner = &*(signer_address_handle as *const VTableSigner); + + // The closure returns a typed `PlatformWalletFFIResult` on the error + // side so the network-mismatch case can surface as the dedicated + // `ErrorInvalidNetwork` code instead of flattening to `ErrorUnknown` + // via the blanket `From` impl. The withdraw + // error still routes through that blanket conversion (`.into()`), + // preserving its per-variant code mapping. + let option = PLATFORM_ADDRESS_WALLET_STORAGE.with_item(handle, |wallet| { + // Network check: reject an address that doesn't belong to the + // wallet's network before any signing or submission happens. + // Mirrors the `require_network` precedent used elsewhere in the + // FFI for Core-address handling. `require_network` consumes the + // unchecked address, which isn't reused afterwards. + let checked_address = unchecked_address + .require_network(wallet.network()) + .map_err(|e| { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidNetwork, + format!( + "Core address is not valid for the wallet's network ({:?}): {e}", + wallet.network() + ), + ) + })?; + let core_script = CoreScript::new(checked_address.script_pubkey()); + runtime() + .block_on(wallet.withdraw( + account_index, + input_selection, + core_script, + core_fee_per_byte, + fee, + None, + address_signer, + )) + .map_err(PlatformWalletFFIResult::from) + }); + // `result` is `Result`: + // a network mismatch is already a typed `ErrorInvalidNetwork` result, + // any other withdraw failure is the blanket-mapped wallet error. + let result = unwrap_option_or_return!(option); + let changeset = unwrap_result_or_return!(result); + *out_changeset = PlatformAddressChangeSetFFI::from(&changeset); + PlatformWalletFFIResult::ok() +} + +/// Preflight an AUTO withdrawal of a platform-payment account WITHOUT signing, +/// broadcasting, or consuming a Core receive address. +/// +/// Runs the same Rust planning phase the real withdraw path executes +/// (`PlatformAddressWallet::preflight_withdrawal` → +/// `plan_withdrawal`/`reserve_withdrawal_fee_on_largest_input`): it drops +/// sub-`min_input_amount` dust, estimates the transition fee from the selected +/// input count (NOT from any destination script — no Core address is needed or +/// touched), reserves that fee on the largest-balance input, and verifies the +/// net clears `system_limits.min_withdrawal_amount`. Gating a UI submit button +/// on the result keeps it in lockstep with what the spend path will accept. +/// +/// On success `out` is written with `can_withdraw = true` and the net / +/// estimated-fee figures, and the call returns [`PlatformWalletFFIResult::ok`]. +/// +/// A genuine **"can't fund"** outcome — the account is all dust +/// (`OnlyDustInputs`), the largest input can't keep the per-input minimum after +/// the fee, the net falls below the minimum withdrawal amount, more funded +/// addresses than the protocol's `max_address_inputs` clear the minimum, the +/// net exceeds `max_withdrawal_amount`, or there are no funded addresses +/// (`AddressOperation`) — is NOT an FFI error: `out` is written with +/// `can_withdraw = false` (and zeroed figures) and the call still returns a +/// **Success-coded** [`PlatformWalletFFIResult`] whose `message` carries the +/// planner's typed `Display` reason (so a caller that wants a human-readable +/// explanation can read it without mirroring protocol constants in Swift). The +/// authoritative signal is `can_withdraw`; the message is advisory. +/// +/// Only a **structural** failure — a bad/destroyed handle, or a missing +/// account at `account_index` (`WalletNotFound` / `AddressSync`) — is reported +/// as an FFI error code with `out` left untouched. +/// +/// # Safety +/// - `out` must be a valid, non-null, writable `*mut WithdrawalPreflightFFI`. +#[no_mangle] +pub unsafe extern "C" fn platform_address_wallet_preflight_withdrawal( + handle: Handle, + account_index: u32, + _core_fee_per_byte: u32, + out: *mut WithdrawalPreflightFFI, +) -> PlatformWalletFFIResult { + check_ptr!(out); + + let option = PLATFORM_ADDRESS_WALLET_STORAGE.with_item(handle, |wallet| { + runtime().block_on(wallet.preflight_withdrawal(account_index)) + }); + // `None` → invalid handle (mapped to NotFound by the blanket Option impl). + let result = unwrap_option_or_return!(option); + + match result { + Ok(plan) => { + *out = WithdrawalPreflightFFI { + can_withdraw: true, + net_withdrawable: plan.net_withdrawable, + estimated_fee: plan.estimated_fee, + }; + PlatformWalletFFIResult::ok() + } + // "Can't fund" is a NORMAL result, not an FFI error: the account simply + // has nothing withdrawable at this version. Report it as + // `can_withdraw = false` with zeroed figures so the UI can disable + // submit and explain why, without treating it as a failure. + // + // `OnlyDustInputs` (every funded address below `min_input_amount`) and + // `AddressOperation` (the fee / per-input / min-withdrawal headroom + // failures, the too-many-inputs and above-max-withdrawal gates inside + // `reserve_withdrawal_fee_on_largest_input`, plus the "no funded + // addresses" case in `select_withdrawable_inputs`) are all genuine + // can't-fund states. + Err( + e @ (PlatformWalletError::OnlyDustInputs { .. } + | PlatformWalletError::AddressOperation(_)), + ) => { + *out = WithdrawalPreflightFFI { + can_withdraw: false, + net_withdrawable: 0, + estimated_fee: 0, + }; + // Carry the typed reason as a Success-coded message so callers that + // want a human-readable explanation can read it; the `can_withdraw` + // flag is the authoritative signal and the Success code keeps this + // off the error path (`.check()` on the Swift side only inspects + // the code). + PlatformWalletFFIResult::success_with_message(e.to_string()) + } + // Structural failures (missing wallet/account) stay FFI errors with + // `out` untouched, mapped via the blanket `From`. + Err(other) => other.into(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dashcore::Network; + + /// Pins the exact network-validation mechanism + /// `platform_address_wallet_withdraw_to_address` relies on: a + /// testnet-prefixed Core address must pass `require_network` on a + /// testnet wallet and fail on a mainnet wallet, and the resulting + /// script must be a P2PKH that builds a `CoreScript`. + /// + /// We exercise the helper logic directly (parse → require_network → + /// script_pubkey → CoreScript) rather than the FFI entry point, + /// which would need a live wallet handle. + #[test] + fn withdraw_address_network_check_rejects_wrong_network() { + // A valid testnet-prefixed (0x8C, "y…") P2PKH address. + let addr = "yMqShkrgjTRuReBGFpQr7FozEF1QcNBBYA"; + let unchecked = dashcore::Address::from_str(addr).expect("valid base58 address"); + + // Mainnet wallet must reject a testnet address. + assert!( + unchecked.clone().require_network(Network::Mainnet).is_err(), + "testnet address must fail require_network(Mainnet)" + ); + + // Testnet wallet must accept it, and the script must be P2PKH. + let checked = unchecked + .require_network(Network::Testnet) + .expect("testnet address must pass require_network(Testnet)"); + let script = checked.script_pubkey(); + let core_script = CoreScript::new(script); + assert!( + core_script.is_p2pkh(), + "a P2PKH address must produce a P2PKH CoreScript" + ); + } +} diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index c94cb7093d1..a90444a0319 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -103,11 +103,17 @@ pub enum PlatformWalletError { min_input_amount: Credits, }, + // The `Display` text is surfaced verbatim to the user by the withdrawal + // preflight (the FFI carries `e.to_string()` as the can't-fund reason), so + // it is kept user-presentable: it explains the situation and the action + // ("consolidate funds onto fewer addresses") without naming an internal + // selection API. The numeric fields stay in the message as an actionable + // breadcrumb. #[error( - "no selectable inputs: every funded address is below the per-input \ - minimum (sub_min_count={sub_min_count}, sub_min_aggregate={sub_min_aggregate} \ - credits, min_input_amount={min_input_amount}); consolidate funds or use \ - InputSelection::Explicit" + "Every funded address holds less than the per-input minimum of \ + {min_input_amount} credits ({sub_min_count} addresses totaling \ + {sub_min_aggregate} credits), so none can fund a withdrawal on its \ + own. Consolidate funds onto fewer addresses, then try again." )] OnlyDustInputs { /// Number of addresses with a positive balance below `min_input_amount`. 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 2dd2d1e98d4..e8ca3d92704 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/mod.rs @@ -46,6 +46,7 @@ pub use provider::{ PerAccountPlatformAddressState, PerWalletPlatformAddressState, PlatformAddressTag, }; pub use wallet::PlatformAddressWallet; +pub use withdrawal::WithdrawalPlan; /// Specifies how input addresses are selected for a transaction. pub enum InputSelection { diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs index c5eb0a51100..afb03f0fc7c 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs @@ -5,7 +5,6 @@ use dpp::fee::Credits; use dpp::identity::signer::Signer; use dpp::state_transition::address_funds_transfer_transition::AddressFundsTransferTransition; use dpp::version::PlatformVersion; -use dpp::version::LATEST_PLATFORM_VERSION; use key_wallet::PlatformP2PKHAddress; use crate::changeset::Merge; @@ -138,7 +137,11 @@ impl PlatformAddressWallet { /// /// Input addresses can be specified explicitly or selected automatically /// from the account via [`InputSelection::Auto`]. When `platform_version` - /// is `None`, [`LATEST_PLATFORM_VERSION`] drives fee estimation. + /// is `None`, the wallet's SDK version (`self.sdk.version()`) drives fee + /// estimation and every version-keyed limit (`min_input_amount`, + /// `max_address_inputs`) during auto-selection — the same source the UI + /// preflight reads, so the submit gate and the spend path never diverge on + /// a non-latest-pinned SDK. An explicit `Some(v)` is honored as given. /// /// `address_signer` produces ECDSA signatures for the input /// [`PlatformAddress`]es; the wallet itself holds no key material — @@ -198,7 +201,17 @@ impl PlatformAddressWallet { )); } - let version = platform_version.unwrap_or(LATEST_PLATFORM_VERSION); + // Single source of truth for the planning version: when the caller + // pins an explicit `Some(v)` we honor it, but the default is the + // wallet's SDK version (`self.sdk.version()`) — NOT + // `LATEST_PLATFORM_VERSION`. This is the same network-floored, + // protocol-version-tracking accessor that the UI preflight and the + // `min_input_amount` / `min_output_amount` getters read, so the submit + // gate and this spend path size every version-keyed value + // (`min_input_amount`, `max_address_inputs`, and `estimate_min_fee`) + // against the SAME version. Defaulting to LATEST here would let the + // gate and the spend path diverge on a non-latest-pinned SDK. + let version = platform_version.unwrap_or_else(|| self.sdk.version()); let address_infos = match input_selection { InputSelection::Explicit(inputs) => { @@ -405,7 +418,18 @@ impl PlatformAddressWallet { } }; - let version = platform_version.unwrap_or(LATEST_PLATFORM_VERSION); + // Default to the wallet's SDK version (`self.sdk.version()`) — the + // same network-floored, protocol-version-tracking accessor that + // `transfer()` uses — rather than `LATEST_PLATFORM_VERSION`. This + // wrapper rejects `InputSelection::Auto`, so the production Auto UI + // never reaches it, but defaulting to LATEST here would still let the + // change-augmentation / fee-headroom math size version-keyed values + // (`min_output_amount`, `estimate_min_fee`) against a different + // version than the submit gate on a non-latest-pinned SDK. Defending + // in depth keeps both `transfer` entry points sizing every + // version-keyed value against the SAME version. An explicit `Some(v)` + // is honored as given. + let version = platform_version.unwrap_or_else(|| self.sdk.version()); let final_outputs = match output_change_address { Some(change_addr) => { @@ -547,27 +571,38 @@ impl PlatformAddressWallet { } } - match fee_strategy { + let selected = match fee_strategy { [AddressFundsFeeStrategyStep::DeductFromInput(0)] => select_inputs_deduct_from_input( candidates, outputs, total_output, fee_strategy, platform_version, - ), + )?, [AddressFundsFeeStrategyStep::ReduceOutput(0)] => select_inputs_reduce_output( candidates, outputs, total_output, fee_strategy, platform_version, - ), - _ => Err(PlatformWalletError::AddressOperation( - "auto_select_inputs supports fee_strategy = [DeductFromInput(0)] \ - or [ReduceOutput(0)]; other shapes must use InputSelection::Explicit" - .to_string(), - )), - } + )?, + _ => { + return Err(PlatformWalletError::AddressOperation( + "auto_select_inputs supports fee_strategy = [DeductFromInput(0)] \ + or [ReduceOutput(0)]; other shapes must use InputSelection::Explicit" + .to_string(), + )) + } + }; + + // Gate the FINAL selected set against the DPP per-transition input cap. + // This is the single chokepoint reached after BOTH the + // `[DeductFromInput(0)]` and `[ReduceOutput(0)]` selectors, where + // `selected.len()` equals the input count `transfer` will sign — so the + // cap is enforced for every Auto fee-strategy shape. + enforce_max_address_inputs(&selected, platform_version)?; + + Ok(selected) } /// Simulate the fee strategy to determine how much additional balance @@ -681,6 +716,39 @@ where candidates } +/// Enforce DPP's per-transition input cap on the FINAL Auto-selected input set. +/// +/// DPP's `AddressFundsTransferTransition` v0 validator rejects the whole +/// transition when `inputs.len() > max_address_inputs` (16 on v2/v3) with +/// `TransitionOverMaxInputsError` — see `validate_structure` in +/// `address_funds/address_funds_transfer_transition/v0/state_transition_validation.rs`. +/// The Auto path produces exactly one input per selected address (the same map +/// `transfer` then signs), so an account whose covering prefix exceeds +/// `max_address_inputs` funded (≥ min_input) addresses would otherwise pass the +/// submit gate, sign, then deterministically fail structure validation. +/// +/// We ERROR rather than auto-cap: capping would change the "cover the requested +/// output" contract (it would silently fund less than the caller asked for) and +/// is a product decision out of scope. The typed `AddressOperation` carries +/// consolidate/smaller-amount guidance, mirroring the withdrawal planner's +/// `reserve_withdrawal_fee_on_largest_input` input-count cap. +fn enforce_max_address_inputs( + selected: &BTreeMap, + platform_version: &PlatformVersion, +) -> Result<(), PlatformWalletError> { + let max_address_inputs = platform_version.dpp.state_transitions.max_address_inputs as usize; + if selected.len() > max_address_inputs { + return Err(PlatformWalletError::AddressOperation(format!( + "Too many funded addresses to cover this transfer at once: {} addresses are \ + needed but the protocol allows at most {} inputs per transfer. Consolidate \ + funds onto fewer addresses, or send a smaller amount.", + selected.len(), + max_address_inputs + ))); + } + Ok(()) +} + /// Classify why no candidate survived the filter. Returns `None` when no /// funded address exists at all (caller falls through to generic /// insufficient-balance); otherwise returns the dominant failure shape. @@ -1208,6 +1276,7 @@ mod auto_select_tests { use dpp::address_funds::AddressWitness; use dpp::state_transition::address_funds_transfer_transition::v0::AddressFundsTransferTransitionV0; use dpp::state_transition::StateTransitionStructureValidation; + use dpp::version::LATEST_PLATFORM_VERSION; fn p2pkh(byte: u8) -> PlatformAddress { PlatformAddress::P2pkh([byte; 20]) } @@ -1532,6 +1601,53 @@ mod auto_select_tests { assert!(matches!(err, PlatformWalletError::AddressOperation(_))); } + /// A covering input set larger than `max_address_inputs` must NOT be + /// shippable: DPP's v0 validator rejects the whole transition with + /// `TransitionOverMaxInputsError` once `inputs.len()` exceeds the cap. The + /// Auto path uses one input per selected address, so the + /// `enforce_max_address_inputs` gate (called in `auto_select_inputs` after + /// the selector returns) must surface this as a typed "too many inputs" + /// error rather than letting `transfer` sign a guaranteed-rejected + /// transition. Mirrors withdrawal's `plan_more_than_max_inputs_cant_fund`. + /// We build the final selected map directly (one input per address, one + /// more than the cap) since the gate operates on the post-selection set. + #[test] + fn auto_select_more_than_max_inputs_cant_fund() { + let pv = LATEST_PLATFORM_VERSION; + let max_inputs = pv.dpp.state_transitions.max_address_inputs as usize; + + // One input per address, one more than the cap. The amounts are + // irrelevant to the count cap — only `selected.len()` matters. + let mut selected: BTreeMap = BTreeMap::new(); + for i in 0..=max_inputs { + selected.insert(p2pkh(i as u8), 1_000_000u64); + } + assert_eq!( + selected.len(), + max_inputs + 1, + "test setup: must hold one more input than the cap" + ); + + let err = enforce_max_address_inputs(&selected, pv) + .expect_err("more than max_address_inputs selected inputs must not be fundable"); + match err { + PlatformWalletError::AddressOperation(msg) => assert!( + msg.contains("at most") && msg.contains("inputs"), + "expected a too-many-inputs message, got: {msg}" + ), + other => panic!("expected AddressOperation too-many-inputs, got {other:?}"), + } + + // Exactly at the cap must pass the gate (boundary check). + let mut at_cap: BTreeMap = BTreeMap::new(); + for i in 0..max_inputs { + at_cap.insert(p2pkh(i as u8), 1_000_000u64); + } + assert_eq!(at_cap.len(), max_inputs); + enforce_max_address_inputs(&at_cap, pv) + .expect("an input set exactly at the cap must be allowed"); + } + /// `total_output < min_input_amount` is unsatisfiable. The selector must /// reject upfront with a descriptive error. #[test] 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 a4bb1bd1e53..976d1b725a8 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs @@ -162,6 +162,61 @@ impl PlatformAddressWallet { self.sdk.network } + /// The per-input minimum credit amount enforced by the chain for + /// address-funds transitions, read from the wallet's **current** + /// platform version + /// (`platform_version.dpp.state_transitions.address_funds.min_input_amount`). + /// + /// This is the same constant the transfer/withdraw auto-selectors use + /// to drop sub-minimum "dust" inputs (see + /// [`select_withdrawable_inputs`](super::withdrawal) and + /// [`build_auto_select_candidates`](super::transfer)): DPP rejects any + /// address-funds input below this floor, so an address whose balance is + /// under it cannot be spent on its own. Exposed so UI gating can sum + /// only spendable (≥ this) balances instead of every funded row, + /// keeping the enabled/disabled decision in step with what the Rust + /// selectors will actually consume. + /// + /// The version is resolved from the wallet's SDK + /// ([`dash_sdk::Sdk::version`]), the same network-floored, + /// protocol-version-tracking source the spend paths run under — so the + /// figure is version-locked rather than a hardcoded mirror of the + /// constant. + pub fn min_input_amount(&self) -> Credits { + self.sdk + .version() + .dpp + .state_transitions + .address_funds + .min_input_amount + } + + /// The per-output minimum credit amount enforced by the chain for + /// address-funds transitions, read from the wallet's **current** + /// platform version + /// (`platform_version.dpp.state_transitions.address_funds.min_output_amount`). + /// + /// DPP rejects any address-funds *output* below this floor, so a transfer + /// that sends a single output under it deterministically fails structure + /// validation after submit. Exposed so UI gating can disable submit (and + /// explain why) when the requested amount is below the minimum, keeping + /// the enabled/disabled decision in step with what DPP will accept — + /// rather than mirroring the protocol constant in Swift, which would + /// drift if the version changed it. + /// + /// The version is resolved from the wallet's SDK + /// ([`dash_sdk::Sdk::version`]), the same network-floored, + /// protocol-version-tracking source the spend paths run under, so the + /// figure is version-locked. Companion to [`min_input_amount`](Self::min_input_amount). + pub fn min_output_amount(&self) -> Credits { + self.sdk + .version() + .dpp + .state_transitions + .address_funds + .min_output_amount + } + /// Wallet id this `PlatformAddressWallet` operates on. Exposed so /// FFI callers that build a `MnemonicResolverCoreSigner` on demand /// can thread the wallet id through to the resolver callback. @@ -299,6 +354,13 @@ impl PlatformAddressWallet { /// /// Returns the balances from the last call to [`sync_balances`](Self::sync_balances), /// [`transfer`](Self::transfer), or [`withdraw`](Self::withdraw). + /// + /// Resolves against the **first** platform-payment account (account index 0, + /// key class 0). This is a read-only display query; account-scoped input + /// selection for transfers/withdrawals happens inside + /// [`transfer`](Self::transfer) / [`withdraw`](Self::withdraw) via + /// [`InputSelection::Auto`](super::InputSelection::Auto), which resolves the + /// requested account on the Rust side. pub async fn addresses_with_balances(&self) -> Vec<(PlatformAddress, Credits)> { let wm = self.wallet_manager.read().await; wm.get_wallet_info(&self.wallet_id) @@ -347,3 +409,78 @@ impl std::fmt::Debug for PlatformAddressWallet { .finish() } } + +#[cfg(test)] +mod tests { + use super::PlatformAddressWallet; + + /// Build a `PlatformAddressWallet` on a mock SDK for getter tests that + /// touch no I/O. Mirrors `transfer::tests::build_short_circuit_wallet`, + /// duplicated here because that helper is private to the transfer + /// module's `tests`. + fn build_test_wallet() -> PlatformAddressWallet { + use crate::broadcaster::SpvBroadcaster; + use crate::events::PlatformEventManager; + use crate::spv::SpvRuntime; + use crate::wallet::asset_lock::manager::AssetLockManager; + use crate::wallet::persister::{NoPlatformPersistence, WalletPersister}; + use std::sync::Arc; + use tokio::sync::{Notify, RwLock}; + + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let wallet_manager = Arc::new(RwLock::new(key_wallet_manager::WalletManager::new( + sdk.network, + ))); + let persister = WalletPersister::new([0u8; 32], Arc::new(NoPlatformPersistence)); + let event_manager = Arc::new(PlatformEventManager::new(Vec::new())); + let spv = Arc::new(SpvRuntime::new(Arc::clone(&wallet_manager), event_manager)); + let broadcaster = Arc::new(SpvBroadcaster::new(spv)); + let asset_locks = Arc::new(AssetLockManager::new( + Arc::clone(&sdk), + Arc::clone(&wallet_manager), + [0u8; 32], + Arc::new(Notify::new()), + broadcaster, + persister.clone(), + )); + PlatformAddressWallet::new(sdk, wallet_manager, [0u8; 32], asset_locks, persister) + } + + /// `min_input_amount()` must return the constant from the wallet's own + /// SDK-resolved `PlatformVersion`, i.e. exactly + /// `version.dpp.state_transitions.address_funds.min_input_amount` — the + /// same floor the auto-selectors use to drop dust. Pins the getter to + /// the version's value rather than a hardcoded literal, so the UI gate + /// stays version-locked. + #[test] + fn min_input_amount_matches_sdk_version_constant() { + let wallet = build_test_wallet(); + let expected = wallet + .sdk + .version() + .dpp + .state_transitions + .address_funds + .min_input_amount; + assert_eq!(wallet.min_input_amount(), expected); + } + + /// `min_output_amount()` must likewise return the constant from the + /// wallet's own SDK-resolved `PlatformVersion`, i.e. exactly + /// `version.dpp.state_transitions.address_funds.min_output_amount` — the + /// per-output floor DPP enforces on address-funds transitions. Pins the + /// getter to the version's value rather than a hardcoded literal so the + /// transfer UI gate stays version-locked. + #[test] + fn min_output_amount_matches_sdk_version_constant() { + let wallet = build_test_wallet(); + let expected = wallet + .sdk + .version() + .dpp + .state_transitions + .address_funds + .min_output_amount; + assert_eq!(wallet.min_output_amount(), expected); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs index 61695829700..289b2c3eccc 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs @@ -6,23 +6,66 @@ use dpp::identity::core_script::CoreScript; use dpp::identity::signer::Signer; use dpp::state_transition::address_credit_withdrawal_transition::AddressCreditWithdrawalTransition; use dpp::version::PlatformVersion; -use dpp::version::LATEST_PLATFORM_VERSION; use dpp::withdrawal::Pooling; use key_wallet::PlatformP2PKHAddress; use super::InputSelection; +use crate::changeset::Merge; use crate::wallet::PlatformAddressWallet; use crate::{PlatformAddressChangeSet, PlatformWalletError}; use dash_sdk::platform::transition::address_credit_withdrawal::WithdrawAddressFunds; +/// The fully-planned shape of an AUTO withdrawal, computed by +/// [`PlatformAddressWallet::plan_withdrawal`] without any signing, broadcast, +/// or Core-address consumption. +/// +/// A `WithdrawalPlan` is the single source of truth for *can this account +/// withdraw, and for how much*: it carries the dust-filtered, fee-reserved +/// input map and matching fee strategy that the real `withdraw(...)` path +/// signs and submits, alongside the two figures a UI preflight needs +/// (`net_withdrawable`, `estimated_fee`). Building the plan and executing it +/// from the **same** function guarantees the preflight gate and the spend +/// path can never drift — there is no second, parallel fee/min computation to +/// fall out of sync with the protocol version. +/// +/// Constructing a plan is a pure, in-memory computation over the account's +/// cached balances and the active platform version; it does **not** touch the +/// Core receive pool (the fee estimate depends only on the input/output +/// *counts*, not on any destination script), so a preflight can be run on +/// every input change without burning a receive address. +#[derive(Debug, Clone)] +pub struct WithdrawalPlan { + /// The adjusted **withdraw-amount** map: each chosen input address mapped + /// to the amount to withdraw from it (the fee-source input's amount is + /// already reduced by `estimated_fee` so the chain has fee headroom). This + /// is what `withdraw(...)` hands to the SDK as the explicit input set. + pub inputs: BTreeMap, + /// The fee strategy targeting the fee-source (largest-balance) input by + /// its BTreeMap index. The AUTO path owns this because only the planner + /// knows the final input ordering. + pub fee_strategy: AddressFundsFeeStrategy, + /// The net credits that will actually be withdrawn: + /// `Σ inputs − estimated_fee`. This is the figure a UI should show as + /// "amount to withdraw" and the figure that must clear + /// `system_limits.min_withdrawal_amount`. + pub net_withdrawable: Credits, + /// The estimated address-credit-withdrawal transition fee reserved on the + /// fee-source input, sized from the selected input count (no change + /// output) and the active platform version's fee schedule. + pub estimated_fee: Credits, +} + impl PlatformAddressWallet { /// Withdraw platform credits to a Core L1 address. /// /// Input addresses can be specified explicitly or selected automatically /// from the account via [`InputSelection::Auto`]. /// - /// If `platform_version` is `None`, the latest platform version's fee - /// schedule is used for fee estimation during auto-selection. + /// If `platform_version` is `None`, the wallet's SDK version + /// (`self.sdk.version()`) is used for fee estimation and every + /// version-keyed limit during auto-selection — the same source the UI + /// preflight reads, so the gate and the spend path never diverge on a + /// non-latest-pinned SDK. An explicit `Some(v)` is honored as given. /// /// `address_signer` produces ECDSA signatures for the input /// [`PlatformAddress`]es; the wallet struct carries no key material @@ -46,7 +89,18 @@ impl PlatformAddressWallet { )); } - let version = platform_version.unwrap_or(LATEST_PLATFORM_VERSION); + // Single source of truth for the planning version: when the caller + // pins an explicit `Some(v)` we honor it, but the default is the + // wallet's SDK version (`self.sdk.version()`) — NOT + // `LATEST_PLATFORM_VERSION`. This is the same network-floored, + // protocol-version-tracking accessor that `preflight_withdrawal`, + // `min_input_amount`, and `min_output_amount` read, so the preflight + // gate and this spend path size every version-keyed value + // (min_input_amount, min_withdrawal_amount, max_address_inputs, + // max_withdrawal_amount, and `estimate_min_fee`) against the SAME + // version. Defaulting to LATEST here would let the gate and the spend + // path diverge on a non-latest-pinned SDK. + let version = platform_version.unwrap_or_else(|| self.sdk.version()); let address_infos = match input_selection { InputSelection::Explicit(inputs) => { @@ -88,14 +142,28 @@ impl PlatformAddressWallet { .await? } InputSelection::Auto => { - let inputs = self - .auto_select_inputs_for_withdrawal(account_index, &fee_strategy, version) - .await?; + // The AUTO path owns its own fee strategy: it picks the + // fee-source input by balance (largest selected input) and + // emits the matching `DeductFromInput(index)`, ignoring the + // caller's `fee_strategy`. The caller cannot know the final + // BTreeMap ordering of auto-selected inputs, so trusting a + // hardcoded index (e.g. the wrapper's `DeductFromInput(0)`, + // which resolves to the lex-smallest address regardless of + // balance) would reserve the fee on an arbitrarily small + // input and reject otherwise-fundable withdrawals. + // + // Selection, fee estimation, fee reservation, and the + // minimum-withdrawal check all live in `plan_withdrawal`, the + // SAME function the UI preflight calls. Executing the plan it + // returns (rather than re-deriving inputs/fee here) guarantees + // the preflight gate and this spend path can never disagree + // about whether — or for how much — the account can withdraw. + let plan = self.plan_withdrawal(account_index, version).await?; self.sdk .withdraw_address_funds( - inputs, + plan.inputs, None, - fee_strategy, + plan.fee_strategy, core_fee_per_byte, Pooling::Never, output_script, @@ -160,19 +228,99 @@ impl PlatformAddressWallet { } } } + drop(wm); + + // Mirror `transfer.rs` / `sync.rs`: persist post-broadcast balances so a + // restart doesn't reseed `plan_withdrawal` from stale rows (which would + // let a non-Swift caller, or any host where the SwiftData write + // side-channel is absent, build invalid follow-up spends against + // pre-withdrawal balances). Log-on-error because the on-chain + // transition already succeeded. + if !cs.is_empty() { + if let Err(e) = self.persister.store(cs.clone().into()) { + tracing::error!("Failed to persist withdrawal changeset: {}", e); + } + } Ok(cs) } - /// Auto-select all funded addresses for withdrawal. Withdrawals consume - /// all input balances (minus the fee), so we select every funded address - /// and verify there's enough to cover the fee. - async fn auto_select_inputs_for_withdrawal( + /// Plan an AUTO withdrawal for `account_index` against the SDK's + /// **current** platform version, without signing, broadcasting, or + /// touching the Core receive pool. + /// + /// This is the public preflight entry point: it resolves the version from + /// the wallet's SDK (the same network-floored, protocol-version-tracking + /// source the real spend runs under) and delegates to + /// [`plan_withdrawal`](Self::plan_withdrawal). On success the returned + /// [`WithdrawalPlan`] reports `net_withdrawable`/`estimated_fee` for a UI + /// summary; the *typed* error variants distinguish a genuine "can't fund" + /// (`OnlyDustInputs`, or the `AddressOperation` fee/minimum-withdrawal + /// failures) from a hard failure (missing wallet/account), letting the FFI + /// surface "can't fund" as a normal disabled-button result rather than an + /// error. + /// + /// Because the plan it returns is the exact same object `withdraw(...)`'s + /// AUTO path executes, gating the UI on this can never enable a withdrawal + /// the spend path would then reject (or vice versa). + pub async fn preflight_withdrawal( + &self, + account_index: u32, + ) -> Result { + let version = self.sdk.version(); + self.plan_withdrawal(account_index, version).await + } + + /// Build the full [`WithdrawalPlan`] for an AUTO withdrawal: select the + /// withdrawable funded addresses, estimate the transition fee, reserve it + /// on the largest-balance input, and verify the result clears the minimum + /// withdrawal amount — the complete planning phase shared by the UI + /// preflight and the real `withdraw(...)` spend path. NO signing, + /// broadcast, or receive-address consumption happens here. + /// + /// Only addresses whose balance reaches `min_input_amount` are selected: + /// DPP's `AddressCreditWithdrawalTransition` v0 validator rejects the + /// *entire* transition if any input amount is below + /// `platform_version.dpp.state_transitions.address_funds.min_input_amount` + /// (see `InputBelowMinimumError` in + /// `address_credit_withdrawal_transition/v0/state_transition_validation.rs`), + /// so a single sub-minimum "dust" address would otherwise fail an + /// otherwise-fundable withdrawal. The auto path therefore withdraws the + /// full *withdrawable* (≥ `min_input_amount`) balance, NOT literally every + /// credit — sub-minimum dust is left in place. This mirrors the transfer + /// path's `build_auto_select_candidates`, which applies the same filter. + /// When every funded address is dust we return a typed + /// [`PlatformWalletError::OnlyDustInputs`], matching the transfer path's + /// `detect_no_selectable_inputs`. + /// + /// The per-input `Credits` value in the plan's `inputs` map is the amount + /// to *withdraw* from that address, not its on-chain balance. The chain + /// deducts the transition fee from each input's **remaining** balance + /// (`on_chain_balance − withdraw_amount`), so a withdraw amount equal to + /// the full balance leaves zero remaining and is rejected with + /// `fee_fully_covered = false` — see + /// `test_exact_balance_withdrawal_fails_insufficient_remaining_for_fees` + /// in the drive-abci address-credit-withdrawal tests, and the transfer + /// path's `select_inputs_deduct_from_input` for the same invariant. + /// + /// We therefore select every withdrawable address at its full balance, then + /// reduce the withdraw amount on the **largest-balance** selected input + /// by the estimated fee so that input keeps `≥ estimated_fee` of + /// remaining balance for the chain to deduct. The largest input is the + /// most likely to absorb the fee while staying above `min_input_amount`, + /// so picking it (rather than the lexicographically-smallest index-0 + /// entry) avoids rejecting an otherwise-fundable withdrawal when the + /// lex-smallest input happens to be tiny. + /// + /// The plan's `fee_strategy` targets the fee-source input. The AUTO path + /// owns this strategy because only it knows the final BTreeMap ordering of + /// the auto-selected inputs (and therefore which `DeductFromInput(index)` + /// resolves to the largest input). + pub(crate) async fn plan_withdrawal( &self, account_index: u32, - fee_strategy: &[AddressFundsFeeStrategyStep], platform_version: &PlatformVersion, - ) -> Result, PlatformWalletError> { + ) -> Result { let wm = self.wallet_manager.read().await; let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { PlatformWalletError::WalletNotFound(format!( @@ -191,46 +339,713 @@ impl PlatformAddressWallet { )) })?; - // Select all funded addresses. - let mut selected = BTreeMap::new(); - let mut accumulated: Credits = 0; - - for addr_info in account.addresses.addresses.values() { - if let Ok(p2pkh) = PlatformP2PKHAddress::from_address(&addr_info.address) { - let balance = account.address_credit_balance(&p2pkh); - if balance > 0 { - let address = PlatformAddress::P2pkh(p2pkh.to_bytes()); - selected.insert(address, balance); - accumulated = accumulated.saturating_add(balance); - } - } + // Collect every funded address's (PlatformAddress, on-chain balance) + // pair, then let the helper apply the per-input-minimum filter and + // classify the dust-only case. Keeping the filter in a free function + // mirrors the transfer path and makes the dust policy unit-testable + // without a live wallet. + let funded = account + .addresses + .addresses + .values() + .filter_map(|addr_info| { + PlatformP2PKHAddress::from_address(&addr_info.address) + .ok() + .map(|p2pkh| { + let balance = account.address_credit_balance(&p2pkh); + (PlatformAddress::P2pkh(p2pkh.to_bytes()), balance) + }) + }); + + let selected = select_withdrawable_inputs(funded, platform_version)?; + + reserve_withdrawal_fee_on_largest_input(selected, platform_version) + } +} + +/// Filter the funded addresses to those withdrawable on their own — i.e. with a +/// balance of at least `min_input_amount`. +/// +/// DPP's `AddressCreditWithdrawalTransition` v0 validator rejects the **entire** +/// transition if *any* input amount is below +/// `platform_version.dpp.state_transitions.address_funds.min_input_amount`, so a +/// single sub-minimum "dust" address would otherwise sink an otherwise-fundable +/// withdrawal. We therefore drop dust here, mirroring the transfer path's +/// `build_auto_select_candidates`. +/// +/// Returns the selected full-balance input map. When no address clears the +/// minimum we return a typed error: [`PlatformWalletError::OnlyDustInputs`] when +/// every funded address is dust (an actionable consolidate-funds case, mirroring +/// the transfer path's `detect_no_selectable_inputs`), or +/// [`PlatformWalletError::AddressOperation`] when there are no funds at all. +fn select_withdrawable_inputs( + funded: I, + platform_version: &PlatformVersion, +) -> Result, PlatformWalletError> +where + I: IntoIterator, +{ + let min_input_amount = platform_version + .dpp + .state_transitions + .address_funds + .min_input_amount; + + let mut selected = BTreeMap::new(); + let mut sub_min_count: usize = 0; + let mut sub_min_aggregate: Credits = 0; + + for (address, balance) in funded { + if balance >= min_input_amount { + selected.insert(address, balance); + } else if balance > 0 { + sub_min_count = sub_min_count.saturating_add(1); + sub_min_aggregate = sub_min_aggregate.saturating_add(balance); } + } - if selected.is_empty() { - return Err(PlatformWalletError::AddressOperation( - "No funded addresses available for withdrawal".to_string(), - )); + if selected.is_empty() { + if sub_min_count > 0 { + return Err(PlatformWalletError::OnlyDustInputs { + sub_min_count, + sub_min_aggregate, + min_input_amount, + }); } + return Err(PlatformWalletError::AddressOperation( + "No funded addresses available for withdrawal".to_string(), + )); + } - // Verify the total covers the fee. - let estimated_fee = AddressCreditWithdrawalTransition::estimate_min_fee( + Ok(selected) +} + +/// Convert a full-balance input map into a withdraw-amount map that leaves the +/// chain enough fee headroom on the fee-source input, and compute the fee +/// strategy that targets that input. +/// +/// `selected` maps each chosen input address to its **full on-chain balance**. +/// The chain deducts the transition fee from each input's *remaining* balance +/// (`on_chain_balance − withdraw_amount`); since the auto path has no change +/// output, withdrawing the full balance everywhere leaves zero remaining and +/// the chain rejects the transition with `fee_fully_covered = false`. We reduce +/// the withdraw amount on the **largest-balance** selected input by the +/// estimated fee, so that input retains exactly `estimated_fee` of remaining +/// balance for the chain to deduct. This mirrors the transfer path's +/// `select_inputs_deduct_from_input` invariant: the `DeductFromInput` target +/// must keep `balance − consumed ≥ estimated_fee`. +/// +/// Picking the largest input as the fee source (rather than the +/// lexicographically-smallest index-0 entry) is what makes an otherwise- +/// fundable withdrawal succeed: the on-chain `DeductFromInput(index)` resolves +/// against BTreeMap iteration order, which is address-hash ordering — unrelated +/// to balance. A tiny lex-smallest input could fail to absorb the fee even +/// when a much larger peer trivially could. We therefore locate the largest +/// input, then emit `DeductFromInput()`. +/// +/// Also enforces the two DPP structure limits the auto path could otherwise +/// trip after signing, so the preflight gate, this spend path, and the DPP +/// validator stay in lockstep: the selected input count must not exceed +/// `platform_version.dpp.state_transitions.max_address_inputs` +/// (`TransitionOverMaxInputsError`), and the net withdrawal must not exceed +/// `platform_version.system_limits.max_withdrawal_amount` +/// (`WithdrawalBelowMinAmountError`, the range error). Both surface as typed +/// "can't fund" errors with consolidate/split guidance rather than auto-capping +/// (which would change the "withdraw the full withdrawable balance" semantics). +/// +/// Returns a [`WithdrawalPlan`] carrying the adjusted withdraw-amount map, the +/// fee strategy targeting the fee-source input, and the `net_withdrawable` / +/// `estimated_fee` figures; or a typed [`PlatformWalletError::AddressOperation`] +/// when no input can absorb the fee while respecting the per-input minimum, the +/// net falls below the minimum withdrawal amount, there are too many inputs, or +/// the net exceeds the maximum withdrawal amount. +fn reserve_withdrawal_fee_on_largest_input( + mut selected: BTreeMap, + platform_version: &PlatformVersion, +) -> Result { + // DPP's `AddressCreditWithdrawalTransition` v0 validator rejects the whole + // transition when `inputs.len() > max_address_inputs` (16 on v2/v3) with + // `TransitionOverMaxInputsError` — see `validate_structure` in + // `address_credit_withdrawal_transition/v0/state_transition_validation.rs`. + // The auto path uses exactly one input per selected withdrawable address, + // so an account with more than `max_address_inputs` funded (≥ min_input) + // addresses would otherwise preflight as withdrawable, sign, then + // deterministically fail structure validation. Gate it here so the + // preflight reports `can_withdraw = false` with an actionable + // "too many inputs — consolidate" reason BEFORE signing. We ERROR rather + // than silently dropping inputs down to the cap: capping would change the + // "withdraw the full withdrawable balance" semantics and is a product + // decision out of scope. + let max_address_inputs = platform_version.dpp.state_transitions.max_address_inputs as usize; + if selected.len() > max_address_inputs { + return Err(PlatformWalletError::AddressOperation(format!( + "Too many funded addresses to withdraw at once: {} addresses clear the \ + per-input minimum but the protocol allows at most {} inputs per \ + withdrawal. Consolidate funds onto fewer addresses, then withdraw.", selected.len(), - false, // no change output - platform_version, + max_address_inputs + ))); + } + + let accumulated: Credits = selected + .values() + .copied() + .fold(0, |acc, b| acc.saturating_add(b)); + + // Estimate the transition fee for the selected input count (no change + // output on the auto path). + let estimated_fee = AddressCreditWithdrawalTransition::estimate_min_fee( + selected.len(), + false, // no change output + platform_version, + ); + + // Locate the fee-source input: the largest balance, ties broken by the + // first in BTreeMap (address-hash) order so the choice is deterministic. + // `max_by_key` returns the *last* maximal element on ties, so iterate and + // keep the first occurrence of the maximum explicitly. + let (fee_source_index, fee_source_addr, fee_source_balance) = selected + .iter() + .enumerate() + .fold(None, |best, (idx, (&addr, &balance))| match best { + Some((_, _, best_balance)) if best_balance >= balance => best, + _ => Some((idx, addr, balance)), + }) + .expect("selected is non-empty: callers reject empty input maps"); + + // The reduced fee-source amount must still be ≥ `min_input_amount`, and the + // overall withdrawal (accumulated − estimated_fee) must clear the minimum + // withdrawal amount, otherwise the transition is rejected on-chain. + let min_input_amount = platform_version + .dpp + .state_transitions + .address_funds + .min_input_amount; + let min_withdrawal_amount = platform_version.system_limits.min_withdrawal_amount; + // DPP rejects `withdrawal_amount > max_withdrawal_amount` (50_000_000_000_000 + // = 500 DASH on v1/v2 system_limits) with `WithdrawalBelowMinAmountError` + // (the range error carries both bounds) — see `validate_structure` in + // `address_credit_withdrawal_transition/v0/state_transition_validation.rs`. + // The auto path withdraws the full withdrawable balance, so an account whose + // aggregate-minus-fee exceeds the maximum would otherwise preflight as + // withdrawable, sign, then fail structure validation. Fold the max into the + // same range check as the min below. + let max_withdrawal_amount = platform_version.system_limits.max_withdrawal_amount; + + let withdraw_total = accumulated.saturating_sub(estimated_fee); + if accumulated <= estimated_fee || withdraw_total < min_withdrawal_amount { + return Err(PlatformWalletError::AddressOperation(format!( + "Insufficient balance for withdrawal fee: available {} credits, \ + estimated fee {}, leaving {} below the minimum withdrawal amount {}", + accumulated, estimated_fee, withdraw_total, min_withdrawal_amount + ))); + } + if withdraw_total > max_withdrawal_amount { + // ERROR rather than auto-cap: capping would change the "withdraw the + // full withdrawable balance" semantics and is a product decision out + // of scope. A clear "exceeds the maximum — split it up" message + // matches the validator and tells the user what to do. + return Err(PlatformWalletError::AddressOperation(format!( + "Withdrawal amount {} exceeds the maximum single withdrawal of {} \ + credits. Withdraw to fewer addresses at a time, or split the \ + withdrawal into multiple transactions.", + withdraw_total, max_withdrawal_amount + ))); + } + + let fee_source_amount = fee_source_balance.saturating_sub(estimated_fee); + if fee_source_amount < min_input_amount { + // The largest input cannot absorb the fee while staying above the + // per-input minimum, so no input can: a genuine insufficiency. + return Err(PlatformWalletError::AddressOperation(format!( + "Cannot reserve withdrawal fee on the fee-source input: largest input \ + balance {} minus estimated fee {} leaves {}, below the minimum input \ + amount {}. Consolidate funds onto fewer addresses or fund the largest \ + address more before withdrawing.", + fee_source_balance, estimated_fee, fee_source_amount, min_input_amount + ))); + } + + // Same key → BTreeMap ordering (and thus the index resolution below) is + // preserved; only the withdraw amount on the fee-source input shrinks. + selected.insert(fee_source_addr, fee_source_amount); + + // The fee-strategy index is a u16; guard the narrowing so a pathological + // input count (> u16::MAX) errors instead of silently wrapping to the + // wrong fee-source input. + let fee_source_index_u16: u16 = fee_source_index.try_into().map_err(|_| { + PlatformWalletError::AddressOperation(format!( + "Too many withdrawal inputs: fee-source index {} exceeds u16::MAX", + fee_source_index + )) + })?; + + let fee_strategy = vec![AddressFundsFeeStrategyStep::DeductFromInput( + fee_source_index_u16, + )]; + + Ok(WithdrawalPlan { + inputs: selected, + fee_strategy, + // `withdraw_total = accumulated − estimated_fee` is the net amount the + // chain pays out (the fee is booked from the fee-source input's + // remaining balance). We computed and validated it above against + // `min_withdrawal_amount`, so it is the figure a UI should display. + net_withdrawable: withdraw_total, + estimated_fee, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `PlatformAddress::P2pkh` is `Ord`-derived, so a smaller leading byte sorts + /// first in the BTreeMap and becomes the `DeductFromInput(0)` target. + fn addr(first_byte: u8) -> PlatformAddress { + let mut bytes = [0u8; 20]; + bytes[0] = first_byte; + PlatformAddress::P2pkh(bytes) + } + + fn estimated_fee(input_count: usize, pv: &PlatformVersion) -> Credits { + AddressCreditWithdrawalTransition::estimate_min_fee(input_count, false, pv) + } + + /// A single funded input must keep `estimated_fee` of headroom: the withdraw + /// amount on the fee-source input is its balance minus the estimated fee, NOT + /// the full balance (which would leave zero remaining → `fee_fully_covered = + /// false` on-chain). With one input it is trivially the largest, so the + /// emitted strategy targets index 0. + #[test] + fn reserves_fee_headroom_on_single_input() { + let pv = PlatformVersion::latest(); + let fee = estimated_fee(1, pv); + let balance = fee + dpp::dash_to_credits!(1.0); + + let mut input = BTreeMap::new(); + input.insert(addr(1), balance); + + let plan = reserve_withdrawal_fee_on_largest_input(input, pv) + .expect("single funded input above the fee should select"); + + assert_eq!(plan.inputs.get(&addr(1)).copied(), Some(balance - fee)); + assert_eq!( + plan.fee_strategy, + vec![AddressFundsFeeStrategyStep::DeductFromInput(0)], + "the single input is the fee source at index 0" + ); + // The plan's reported figures: the net is the full balance minus the + // reserved fee, and `estimated_fee` matches the schedule for 1 input. + assert_eq!(plan.estimated_fee, fee); + assert_eq!(plan.net_withdrawable, balance - fee); + } + + /// The reviewer's scenario, corrected: input[0] (lex-smallest, BTreeMap + /// index 0) is much smaller than the fee while a larger peer exists. The fee + /// must now be reserved on the LARGER peer (the fee source picked by + /// balance), so the small lex-smallest input is withdrawn in full and the + /// larger input's withdraw amount drops by the fee. The emitted strategy + /// must target the larger input's BTreeMap index, NOT index 0. + #[test] + fn reserves_fee_on_largest_input_even_when_lex_smallest_is_tiny() { + let pv = PlatformVersion::latest(); + let fee = estimated_fee(2, pv); + // Small lex-smallest input: too small to absorb the fee on its own + // (would have failed the old index-0 path), but withdrawn in full here. + let small = dpp::dash_to_credits!(0.001); + let large = dpp::dash_to_credits!(10.0); + + let mut inputs = BTreeMap::new(); + inputs.insert(addr(1), small); // lex-smallest → BTreeMap index 0 + inputs.insert(addr(9), large); // larger → BTreeMap index 1 + + let plan = reserve_withdrawal_fee_on_largest_input(inputs, pv) + .expect("the larger peer can absorb the fee"); + + assert_eq!( + plan.inputs.get(&addr(1)).copied(), + Some(small), + "the small lex-smallest input is withdrawn in full" + ); + assert_eq!( + plan.inputs.get(&addr(9)).copied(), + Some(large - fee), + "the fee is reserved on the largest input" + ); + assert_eq!( + plan.fee_strategy, + vec![AddressFundsFeeStrategyStep::DeductFromInput(1)], + "the emitted DeductFromInput index points at the largest input (BTreeMap index 1)" + ); + // The net withdrawable is the aggregate minus the reserved fee, the + // figure a UI preflight would display. + assert_eq!(plan.estimated_fee, fee); + assert_eq!(plan.net_withdrawable, small + large - fee); + } + + /// The emitted `DeductFromInput` index points at the largest input even when + /// that input is NOT the last in BTreeMap (address-hash) order — i.e. the + /// balance ranking and the address-hash ranking disagree. + #[test] + fn emitted_index_points_at_largest_input_not_last() { + let pv = PlatformVersion::latest(); + let fee = estimated_fee(3, pv); + let large = dpp::dash_to_credits!(10.0); + let small_a = dpp::dash_to_credits!(0.01); + let small_b = dpp::dash_to_credits!(0.02); + + let mut inputs = BTreeMap::new(); + inputs.insert(addr(1), large); // lex-smallest → BTreeMap index 0, largest balance + inputs.insert(addr(5), small_a); // index 1 + inputs.insert(addr(9), small_b); // index 2 + + let plan = reserve_withdrawal_fee_on_largest_input(inputs, pv) + .expect("the largest input can absorb the fee"); + + assert_eq!( + plan.fee_strategy, + vec![AddressFundsFeeStrategyStep::DeductFromInput(0)], + "the largest input is at BTreeMap index 0, so the fee deducts from index 0" + ); + assert_eq!(plan.inputs.get(&addr(1)).copied(), Some(large - fee)); + assert_eq!(plan.inputs.get(&addr(5)).copied(), Some(small_a)); + assert_eq!(plan.inputs.get(&addr(9)).copied(), Some(small_b)); + assert_eq!(plan.estimated_fee, fee); + assert_eq!(plan.net_withdrawable, large + small_a + small_b - fee); + } + + /// Genuine insufficiency: even the LARGEST input cannot retain + /// `estimated_fee` while keeping its withdraw amount ≥ `min_input_amount`, + /// so no input can. We error rather than ship a guaranteed-rejected + /// transition (mirrors the transfer path's headroom error). The aggregate + /// here clears `min_withdrawal_amount`, so the error is specifically the + /// per-input headroom failure, not the aggregate-too-small gate. + #[test] + fn errors_when_largest_input_too_small_to_absorb_fee() { + let pv = PlatformVersion::latest(); + let fee = estimated_fee(3, pv); + let min_input = pv.dpp.state_transitions.address_funds.min_input_amount; + let min_withdrawal = pv.system_limits.min_withdrawal_amount; + + // Largest input leaves < min_input after the fee is reserved. + let large = fee + min_input - 1; + // Two equal peers, each smaller than `large` so it stays the maximum, + // sized so the aggregate clears `min_withdrawal_amount + fee`. + let peer = large / 2; + + let mut inputs = BTreeMap::new(); + inputs.insert(addr(1), peer); + inputs.insert(addr(5), peer); + inputs.insert(addr(9), large); + + // Sanity: the aggregate clears the withdrawal minimum, so the only + // remaining failure path is the largest-input headroom check. + let accumulated = peer + peer + large; + assert!( + accumulated.saturating_sub(fee) >= min_withdrawal, + "test setup: aggregate must clear the withdrawal minimum" + ); + + let err = reserve_withdrawal_fee_on_largest_input(inputs, pv) + .expect_err("largest input below fee + min_input must error"); + assert!(matches!(err, PlatformWalletError::AddressOperation(_))); + } + + /// Aggregate balance below the fee (or leaving less than the minimum + /// withdrawal amount) is rejected up front. + #[test] + fn errors_when_total_below_fee() { + let pv = PlatformVersion::latest(); + let fee = estimated_fee(1, pv); + + let mut inputs = BTreeMap::new(); + inputs.insert(addr(1), fee - 1); + + let err = reserve_withdrawal_fee_on_largest_input(inputs, pv) + .expect_err("balance below the fee must error"); + assert!(matches!(err, PlatformWalletError::AddressOperation(_))); + } + + // ---- Planner-shaped tests for the new `WithdrawalPlan` contract ---- + // + // These exercise the planning phase the UI preflight and the real + // `withdraw(...)` path share, focusing on the three figures the preflight + // surfaces: a fee-covering success returns the expected net, and the two + // genuine "can't-fund" cases the FFI must report as `can_withdraw = false`. + + /// Covers-fee success: a single input comfortably above `min_input_amount` + /// + the fee yields a plan whose `net_withdrawable` is exactly + /// `Σ inputs − estimated_fee` and whose `inputs` reserve that fee on the + /// fee-source input. This is the figure a UI preflight displays as "amount + /// to withdraw". + #[test] + fn plan_covers_fee_returns_expected_net() { + let pv = PlatformVersion::latest(); + let fee = estimated_fee(1, pv); + let balance = fee + dpp::dash_to_credits!(2.0); + + let mut inputs = BTreeMap::new(); + inputs.insert(addr(3), balance); + + let plan = reserve_withdrawal_fee_on_largest_input(inputs, pv) + .expect("a balance above min_input + fee must plan successfully"); + + assert_eq!(plan.estimated_fee, fee); + assert_eq!( + plan.net_withdrawable, + balance - fee, + "net withdrawable is the input sum minus the reserved fee" + ); + assert_eq!( + plan.inputs.get(&addr(3)).copied(), + Some(balance - fee), + "the fee-source input keeps fee headroom" ); + } + + /// Single-input-below-(min_input + fee): the only funded input cannot keep + /// `≥ min_input_amount` after reserving the fee, so no input can absorb it. + /// The planner must return a typed "can't-fund" error (NOT a panic, NOT a + /// success) so the FFI can report `can_withdraw = false`. + /// + /// The two guards (`accumulated ≤ fee || net < min_withdrawal`) are checked + /// before the per-input headroom check, so this test sizes the input to net + /// **above** `min_withdrawal_amount` — isolating the per-input headroom + /// failure. With more than one input (so the largest is not trivially the + /// whole aggregate) the per-input check is the only one left to fire. This + /// is the multi-input variant of `errors_when_largest_input_too_small_to_ + /// absorb_fee`, reframed around the planner's "can't-fund" contract. + #[test] + fn plan_largest_input_below_min_plus_fee_cant_fund() { + let pv = PlatformVersion::latest(); + let fee = estimated_fee(2, pv); + let min_input = pv.dpp.state_transitions.address_funds.min_input_amount; + let min_withdrawal = pv.system_limits.min_withdrawal_amount; - // Only check if fee comes from inputs. - let fee_from_inputs = fee_strategy - .iter() - .any(|s| matches!(s, AddressFundsFeeStrategyStep::DeductFromInput(_))); + // Largest input leaves < min_input after the fee is reserved on it. + let large = fee + min_input - 1; + // A peer (smaller than `large`, so `large` stays the maximum) sized so + // the aggregate-after-fee clears the withdrawal minimum, isolating the + // per-input headroom failure from the aggregate gate. + let peer = min_withdrawal + min_input; // ≥ min_input, < large + assert!( + peer < large, + "test setup: peer must stay below the largest input" + ); + + let mut inputs = BTreeMap::new(); + inputs.insert(addr(2), peer); + inputs.insert(addr(8), large); + + // Sanity: the aggregate clears the withdrawal minimum after the fee, so + // the only remaining failure path is the largest-input headroom check. + let accumulated = peer + large; + assert!( + accumulated.saturating_sub(fee) >= min_withdrawal, + "test setup: aggregate-after-fee must clear the withdrawal minimum" + ); + + let err = reserve_withdrawal_fee_on_largest_input(inputs, pv) + .expect_err("the largest input below min_input + fee cannot fund a withdrawal"); + assert!( + matches!(err, PlatformWalletError::AddressOperation(_)), + "can't-fund must be a typed error the FFI maps to can_withdraw = false" + ); + } + + /// Aggregate-below-min_withdrawal-after-fee: a single input clears + /// `min_input_amount` and is larger than the fee (so it is not rejected by + /// the `accumulated ≤ fee` guard), yet its net (`balance − fee`) is still + /// below `system_limits.min_withdrawal_amount`. The planner must reject + /// this as a typed "can't-fund" error so the FFI reports + /// `can_withdraw = false` rather than shipping a transition the chain + /// rejects on the minimum. + /// + /// Constructed only when `min_withdrawal_amount > 0` (always true on the + /// real versions). The input is `fee + min_withdrawal − 1`, so it exceeds + /// the fee but nets exactly one credit short of the withdrawal floor. + #[test] + fn plan_aggregate_below_min_withdrawal_cant_fund() { + let pv = PlatformVersion::latest(); + let fee = estimated_fee(1, pv); + let min_input = pv.dpp.state_transitions.address_funds.min_input_amount; + let min_withdrawal = pv.system_limits.min_withdrawal_amount; - if fee_from_inputs && accumulated < estimated_fee { - return Err(PlatformWalletError::AddressOperation(format!( - "Insufficient balance for withdrawal fee: available {} credits, fee {}", - accumulated, estimated_fee - ))); + // Net = balance − fee = min_withdrawal − 1, i.e. one credit short of + // the withdrawal floor while still clearing the fee. + let balance = fee + min_withdrawal - 1; + // The input itself clears the per-input minimum, so this is genuinely + // the aggregate-below-min_withdrawal gate, not the dust filter. + assert!( + balance >= min_input, + "test setup: the input must clear the per-input minimum" + ); + assert!( + balance > fee, + "test setup: the input must exceed the fee so the accumulated ≤ fee guard doesn't fire" + ); + + let mut inputs = BTreeMap::new(); + inputs.insert(addr(4), balance); + + let err = reserve_withdrawal_fee_on_largest_input(inputs, pv) + .expect_err("an input that nets below the withdrawal floor cannot fund a withdrawal"); + assert!( + matches!(err, PlatformWalletError::AddressOperation(_)), + "can't-fund must be a typed error the FFI maps to can_withdraw = false" + ); + } + + /// More than `max_address_inputs` funded (≥ min_input) addresses must NOT + /// preflight as withdrawable: DPP's v0 validator rejects the whole + /// transition with `TransitionOverMaxInputsError` once `inputs.len()` + /// exceeds the cap. The auto path uses one input per selected address, so + /// the planner must surface this as a typed "can't fund — too many inputs" + /// error (mapped to `can_withdraw = false` by the FFI) rather than shipping + /// a guaranteed-rejected transition. We size each input well above + /// `min_input_amount + fee` so neither the per-input headroom nor the + /// aggregate gates fire — isolating the input-count cap as the only failure. + #[test] + fn plan_more_than_max_inputs_cant_fund() { + let pv = PlatformVersion::latest(); + let max_inputs = pv.dpp.state_transitions.max_address_inputs as usize; + // One input per address, one more than the cap. Each input is large + // enough that the only thing wrong is the count. + let per_input = dpp::dash_to_credits!(1.0); + + let mut inputs = BTreeMap::new(); + for i in 0..=max_inputs { + // Distinct addresses via the leading two bytes; max_inputs is 16 on + // the real versions, so a u8 first byte is plenty. + let mut bytes = [0u8; 20]; + bytes[0] = i as u8; + inputs.insert(PlatformAddress::P2pkh(bytes), per_input); } + assert_eq!( + inputs.len(), + max_inputs + 1, + "test setup: must hold one more input than the cap" + ); - Ok(selected) + let err = reserve_withdrawal_fee_on_largest_input(inputs, pv) + .expect_err("more than max_address_inputs funded inputs cannot withdraw at once"); + match err { + PlatformWalletError::AddressOperation(msg) => assert!( + msg.contains("at most") && msg.contains("inputs"), + "expected a too-many-inputs message, got: {msg}" + ), + other => panic!("expected AddressOperation too-many-inputs, got {other:?}"), + } + } + + /// A withdrawal whose net (aggregate − fee) exceeds + /// `system_limits.max_withdrawal_amount` (500 DASH) must NOT preflight as + /// withdrawable: DPP's v0 validator rejects the transition with the + /// `WithdrawalBelowMinAmountError` range error once + /// `withdrawal_amount > max_withdrawal_amount`. A single input keeps the + /// input-count gate trivially satisfied, isolating the max-amount check. + #[test] + fn plan_aggregate_above_max_withdrawal_cant_fund() { + let pv = PlatformVersion::latest(); + let fee = estimated_fee(1, pv); + let max_withdrawal = pv.system_limits.max_withdrawal_amount; + + // Net = balance − fee = max_withdrawal + 1, i.e. one credit over the + // maximum while a single input keeps the count gate satisfied. + let balance = fee + max_withdrawal + 1; + + let mut inputs = BTreeMap::new(); + inputs.insert(addr(7), balance); + + let err = reserve_withdrawal_fee_on_largest_input(inputs, pv) + .expect_err("a net above the maximum withdrawal cannot be withdrawn in one go"); + match err { + PlatformWalletError::AddressOperation(msg) => assert!( + msg.contains("exceeds the maximum"), + "expected an exceeds-maximum message, got: {msg}" + ), + other => panic!("expected AddressOperation exceeds-maximum, got {other:?}"), + } + } + + /// AUTO selection must drop sub-`min_input_amount` dust: the chain rejects + /// the whole transition if any input is below the per-input minimum, so a + /// single dust address must NOT sink an otherwise-fundable withdrawal. The + /// fundable peers are selected at full balance; the dust address is + /// excluded. (Withdrawal therefore takes the full *withdrawable* balance, + /// not literally every credit.) + #[test] + fn select_withdrawable_inputs_excludes_dust_keeps_fundable() { + let pv = PlatformVersion::latest(); + let min_input = pv.dpp.state_transitions.address_funds.min_input_amount; + + let dust = min_input - 1; // below the per-input minimum + let fundable_a = min_input; // exactly at the minimum is withdrawable + let fundable_b = dpp::dash_to_credits!(1.0); + + let funded = vec![ + (addr(1), dust), + (addr(5), fundable_a), + (addr(9), fundable_b), + ]; + + let selected = + select_withdrawable_inputs(funded, pv).expect("fundable peers exist beside the dust"); + + assert_eq!( + selected.get(&addr(1)).copied(), + None, + "the sub-minimum dust address is excluded" + ); + assert_eq!(selected.get(&addr(5)).copied(), Some(fundable_a)); + assert_eq!(selected.get(&addr(9)).copied(), Some(fundable_b)); + assert_eq!(selected.len(), 2, "only the two fundable inputs survive"); + } + + /// An account whose every funded address is dust returns the typed + /// `OnlyDustInputs` error (mirroring the transfer path), carrying the + /// dust count/aggregate and the active `min_input_amount` so the UI can + /// tell the user to consolidate funds — never a guaranteed-rejected + /// transition. + #[test] + fn select_withdrawable_inputs_only_dust_errors_typed() { + let pv = PlatformVersion::latest(); + let min_input = pv.dpp.state_transitions.address_funds.min_input_amount; + + let dust_a = min_input - 1; + let dust_b = min_input / 2; + let funded = vec![(addr(1), dust_a), (addr(9), dust_b)]; + + let err = select_withdrawable_inputs(funded, pv) + .expect_err("an all-dust account cannot withdraw"); + match err { + PlatformWalletError::OnlyDustInputs { + sub_min_count, + sub_min_aggregate, + min_input_amount, + } => { + assert_eq!(sub_min_count, 2); + assert_eq!(sub_min_aggregate, dust_a + dust_b); + assert_eq!(min_input_amount, min_input); + } + other => panic!("expected OnlyDustInputs, got {other:?}"), + } + } + + /// No funds at all (every balance is zero) is distinct from the dust case: + /// it falls through to the generic `AddressOperation` error rather than + /// `OnlyDustInputs`. + #[test] + fn select_withdrawable_inputs_no_funds_errors_generic() { + let pv = PlatformVersion::latest(); + let funded = vec![(addr(1), 0u64), (addr(9), 0u64)]; + + let err = select_withdrawable_inputs(funded, pv) + .expect_err("a zero-balance account cannot withdraw"); + assert!( + matches!(err, PlatformWalletError::AddressOperation(_)), + "no-funds case is the generic error, not OnlyDustInputs" + ); } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift index 5e6cc62c596..b2adbb0d17a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift @@ -40,6 +40,41 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { return credits } + /// The per-input minimum credit amount (`min_input_amount`) the chain + /// enforces for address-funds transitions, read from the wallet's + /// current platform version on the Rust side. + /// + /// This is the same floor the Rust transfer/withdraw auto-selectors use + /// to drop sub-minimum "dust" inputs (DPP rejects any address-funds + /// input below it, so an address whose balance is under this can't be + /// spent on its own). UI that gates a transfer/withdraw should sum only + /// balances `>= this` so the enabled/disabled decision matches what + /// Rust will actually consume — rather than mirroring the `100_000` + /// protocol constant in Swift, which would drift if the version + /// changed it. + public func minInputAmount() throws -> UInt64 { + var amount: UInt64 = 0 + try platform_address_wallet_min_input_amount(handle, &amount).check() + return amount + } + + /// The per-output minimum credit amount (`min_output_amount`) the chain + /// enforces for address-funds transitions, read from the wallet's + /// current platform version on the Rust side. + /// + /// DPP rejects any address-funds output below this floor, so a transfer + /// that sends a single output under it fails structure validation after + /// submit. UI that gates a transfer should require the requested amount + /// to reach this (and explain why when it doesn't) so the + /// enabled/disabled decision matches what DPP will accept — rather than + /// mirroring the protocol constant in Swift, which would drift if the + /// version changed it. Companion to `minInputAmount()`. + public func minOutputAmount() throws -> UInt64 { + var amount: UInt64 = 0 + try platform_address_wallet_min_output_amount(handle, &amount).check() + return amount + } + /// Get all platform addresses with their cached balances. public func addressesWithBalances() throws -> [AddressBalance] { var entriesPtr: UnsafeMutablePointer? @@ -69,7 +104,13 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { /// One recipient row for `transfer(...)`. public struct TransferOutput: Sendable { - /// `0 = P2PKH`, `1 = P2SH`. Mirrors the Rust-side `PlatformAddress` discriminant. + /// Must be `0` (P2PKH). The platform-address transfer surface + /// supports P2PKH only: the Rust `TryFrom` + /// backing `parse_outputs`/`parse_explicit_inputs*` rejects + /// `address_type = 1` (P2SH) because inputs are signed by a + /// P2PKH-only `Signer` and recipients on this + /// surface are always P2PKH. The field stays `UInt8` to mirror the + /// Rust discriminant layout, but `1` will be rejected by the FFI. public let addressType: UInt8 /// 20-byte address hash. public let hash: Data @@ -83,21 +124,6 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { } } - /// Destination address for the change output the wrapper appends to - /// every transfer. Carries no `credits` field — the wrapper computes - /// the change amount itself as `sum(inputs) - sum(outputs)`. - public struct ChangeAddress: Sendable { - /// `0 = P2PKH`, `1 = P2SH`. Mirrors the Rust-side `PlatformAddress` discriminant. - public let addressType: UInt8 - /// 20-byte address hash. - public let hash: Data - - public init(addressType: UInt8, hash: Data) { - self.addressType = addressType - self.hash = hash - } - } - /// Updated balance for an address after a transfer. public struct UpdatedBalance: Sendable { public let addressType: UInt8 @@ -110,53 +136,48 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { /// Transfer credits between platform addresses. /// - /// The `AddressFundsTransferTransition` protocol requires - /// `sum(inputs) == sum(outputs)` and forbids any output address from - /// also appearing as an input. This wrapper handles both invariants: - /// - Picks the smallest set of inputs (largest-balance first) that - /// covers `sum(outputs) + feeBuffer`. - /// - Routes the change to `changeAddress` if supplied — typically a - /// fresh, unused HD address pulled from the SwiftData address - /// pool (lowest `addressIndex` with `isUsed == false`, the same - /// selection rule the Receive screen uses). When `changeAddress` - /// is `nil` the wrapper falls back to reserving the smallest - /// non-recipient balance-bearing address — workable but it - /// accumulates change onto an existing address. + /// Input selection, the `Σ inputs == Σ outputs` balancing, fee strategy, + /// and nonce resolution all happen inside the Rust `platform-wallet` crate. + /// This wrapper marshals the recipient outputs and drives + /// `platform_address_wallet_transfer` with `INPUT_SELECTION_TYPE_AUTO`: /// - /// Fee strategy is `ReduceOutput(change_index)` so each recipient - /// gets exactly its requested amount and the change output absorbs - /// the on-chain fee. + /// - The Rust auto-selector resolves the requested `accountIndex` (key + /// class 0), picks the smallest balance-descending covering prefix of its + /// funded addresses (skipping sub-`min_input_amount` dust and any address + /// that is also a recipient — DPP forbids the same address as both input + /// and output), and **partially consumes** the last selected input so the + /// surplus stays on the source address. There is no separate change + /// output and no change address: the credit-balance model leaves residual + /// credits in place on the source addresses. + /// - The fee strategy is left empty (`nil, 0`); the FFI maps that to + /// `[DeductFromInput(0)]`, which the Auto path supports — each recipient + /// gets exactly its requested amount and the on-chain fee is deducted from + /// the lex-smallest selected input's remaining balance (the selector + /// reserves the fee headroom on that input). /// - /// The signer must be able to sign for the selected inputs — i.e. - /// the `KeychainSigner` resolves their derivation paths via - /// SwiftData + the wallet mnemonic (the `0xFF` branch in - /// `KeychainSigner.swift`). - /// Cushion held back so the change output stays positive after the on-chain - /// fee is deducted (the transfer uses `ReduceOutput(change_index)`). - /// Observed fee for a 1-input/2-output transition is ~6.5M credits; - /// this is intentionally an order of magnitude larger so estimation - /// drift doesn't force an "insufficient" failure in normal use. - private static let feeBuffer: UInt64 = 100_000_000 - + /// The wrapper still rejects empty/duplicate recipients and a recipient sum + /// that overflows `UInt64` before the Task detach, so callers get a + /// synchronous error; the Rust side enforces the same invariants plus typed + /// diagnostics for insufficient / dust-only / output-collision balances. + /// + /// The signer must be able to sign for the auto-selected inputs — i.e. + /// the `KeychainSigner` resolves their derivation paths via SwiftData + the + /// wallet mnemonic (the `0xFF` branch in `KeychainSigner.swift`). @discardableResult public func transfer( accountIndex: UInt32, outputs: [TransferOutput], - changeAddress: ChangeAddress? = nil, signer: KeychainSigner ) async throws -> [UpdatedBalance] { guard !outputs.isEmpty else { throw PlatformWalletError.invalidParameter("outputs is empty") } - // Reject duplicate recipient addresses. Rust's parse_outputs - // (platform_address_types.rs:189-204) inserts into a - // `BTreeMap` keyed by address, so a - // duplicate would silently overwrite earlier entries — Swift - // would still sum every output toward the change calculation, - // leaving the transition misbalanced. We key on `hash` only: - // P2PKH/P2SH share the same 20-byte hash space, so the same - // hash with different `addressType` is still ambiguous. + // Reject duplicate recipient addresses. Rust's parse_outputs inserts + // into a `BTreeMap` keyed by address, so a + // duplicate would silently overwrite earlier entries. We key on `hash` + // only: P2PKH/P2SH share the same 20-byte hash space, so the same hash + // with different `addressType` is still ambiguous. let outputHashes = Set(outputs.map { $0.hash }) guard outputHashes.count == outputs.count else { throw PlatformWalletError.invalidParameter( @@ -164,9 +185,9 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { ) } - // Sum recipient amounts. Reject overflow rather than silently - // wrapping (would let a caller smuggle bogus amounts past the - // protocol's sum check). + // Validate hashes + reject a recipient sum that overflows UInt64 + // before the Task detach (the protocol's sum check would otherwise + // reject it Rust-side after the detach + signer pin). var totalRecipientCredits: UInt64 = 0 for out in outputs { guard out.hash.count == 20 else { @@ -183,191 +204,66 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { totalRecipientCredits = sum.partialValue } - // Read available balances. We pick inputs from balance-bearing - // addresses; the change destination must differ from both the - // recipient set and the chosen inputs. - let recipientHashes = Set(outputs.map { $0.hash }) - - // Validate explicit change-address up front if the caller supplied one. - if let cc = changeAddress { - guard cc.hash.count == 20 else { - throw PlatformWalletError.walletOperation( - "changeAddress.hash must be exactly 20 bytes (got \(cc.hash.count))" - ) - } - guard !recipientHashes.contains(cc.hash) else { - throw PlatformWalletError.walletOperation( - "changeAddress collides with a recipient address." - ) - } - } - - let balanced = try addressesWithBalances() - .filter { $0.balance > 0 } - .filter { !recipientHashes.contains($0.hash) } - .sorted { $0.balance > $1.balance } - - // Pick the smallest set of inputs (largest-first) that covers - // recipients + fee buffer. When the caller hasn't supplied a - // dedicated change address, leave the last balance-bearing - // candidate aside so we can use it as change. - let reserveOneForChange = (changeAddress == nil) - var selectedInputs: [AddressBalance] = [] - var totalInputs: UInt64 = 0 - for b in balanced { - if reserveOneForChange && selectedInputs.count == balanced.count - 1 { break } - // Skip if this address is the explicit change destination. - if let cc = changeAddress, b.hash == cc.hash { continue } - selectedInputs.append(b) - totalInputs += b.balance - if totalInputs >= totalRecipientCredits + Self.feeBuffer { break } - } - guard totalInputs >= totalRecipientCredits + Self.feeBuffer else { - throw PlatformWalletError.walletOperation( - "Insufficient platform balance: have \(totalInputs) credits across \(selectedInputs.count) input(s), need at least \(totalRecipientCredits + Self.feeBuffer)" - ) - } - let selectedHashes = Set(selectedInputs.map { $0.hash }) - - // Resolve the change destination: caller-supplied address wins - // (fresh HD address from the unused pool); otherwise reserve a - // balance-bearing address that's neither input nor recipient. - let resolvedChange: (addressType: UInt8, hash: Data) - if let cc = changeAddress { - guard !selectedHashes.contains(cc.hash) else { - throw PlatformWalletError.walletOperation( - "changeAddress collides with a selected input address." - ) - } - resolvedChange = (cc.addressType, cc.hash) - } else { - guard let fallback = balanced.first(where: { !selectedHashes.contains($0.hash) }) else { - throw PlatformWalletError.walletOperation( - "Could not find a wallet address distinct from inputs to use as the change destination — pass a fresh HD address via `changeAddress`." - ) - } - resolvedChange = (fallback.addressType, fallback.hash) - } - - // Marshal explicit inputs. - var ffiInputs: [ExplicitInputFFI] = [] - ffiInputs.reserveCapacity(selectedInputs.count) - for inp in selectedInputs { - let inputTuple = Self.hashTuple(from: inp.hash) - ffiInputs.append( - ExplicitInputFFI( - address: PlatformAddressFFI(address_type: inp.addressType, hash: inputTuple), - balance: inp.balance - ) + // Marshal the recipient outputs. Rust canonicalises to a + // `BTreeMap`, so insertion order here is + // irrelevant — the Auto path resolves its own fee index against the + // lex-sorted map. + let ffiOutputs: [AddressBalanceEntryFFI] = outputs.map { out in + AddressBalanceEntryFFI( + address: PlatformAddressFFI( + address_type: out.addressType, + hash: Self.hashTuple(from: out.hash) + ), + balance: out.credits, + nonce: 0, + account_index: 0, + address_index: 0 ) } - // Build the FFI output list in the same lexicographic order Rust's - // BTreeMap canonicalizes to, so the fee-reduction - // index we hand it lines up with the row Rust will actually decrement. - let changeAmount = totalInputs - totalRecipientCredits - let (ffiOutputs, changeIndex) = Self.buildSortedFFIOutputs( - recipients: outputs, - change: (resolvedChange.addressType, resolvedChange.hash, changeAmount) - ) - - let feeStrategy: [FeeStrategyStepFFI] = [ - FeeStrategyStepFFI(step_type: 1, index: changeIndex) // 1 = ReduceOutput - ] - let handle = self.handle let signerHandle = signer.handle - let inRows = ffiInputs let outRows = ffiOutputs - let feeRows = feeStrategy return try await Task.detached(priority: .userInitiated) { () -> [UpdatedBalance] in - _ = signer var changeset = PlatformAddressChangeSetFFI(updated: nil, updated_count: 0) - let result = inRows.withUnsafeBufferPointer { - inBp -> PlatformWalletFFIResult in - outRows.withUnsafeBufferPointer { outBp in - feeRows.withUnsafeBufferPointer { feeBp in - platform_address_wallet_transfer( - handle, - accountIndex, - INPUT_SELECTION_TYPE_EXPLICIT, - inBp.baseAddress, - UInt(inBp.count), - nil, - 0, - outBp.baseAddress, - UInt(outBp.count), - feeBp.baseAddress, - UInt(feeBp.count), - signerHandle, - &changeset - ) - } + // `withExtendedLifetime(signer)` pins the resolver-backed signer for + // the entire FFI call. `KeychainSigner` registers its vtable ctx via + // `Unmanaged.passUnretained(self)`, so a bare `_ = signer` can be + // elided by the -O optimizer and drop the signer mid-call, causing a + // use-after-free in the synchronous Rust vtable callback. Mirrors the + // `withdraw` / `fundFromAssetLock` wrappers. + // + // The AUTO path owns input selection and its own fee strategy + // (`[DeductFromInput(0)]`), so inputs are `nil, 0` (auto-select) and + // the fee strategy is `nil, 0` (FFI defaults it to + // `[DeductFromInput(0)]`); mirrors the `withdraw` wrapper. + let result = withExtendedLifetime(signer) { + outRows.withUnsafeBufferPointer { outBp -> PlatformWalletFFIResult in + platform_address_wallet_transfer( + handle, + accountIndex, + INPUT_SELECTION_TYPE_AUTO, + nil, + 0, + nil, + 0, + outBp.baseAddress, + UInt(outBp.count), + nil, + 0, + signerHandle, + &changeset + ) } } try result.check() defer { platform_address_wallet_free_changeset(&changeset) } - - guard let updatedPtr = changeset.updated, changeset.updated_count > 0 else { - return [] - } - return (0..` uses, and - /// return the change row's index in that sorted list. - /// - /// Mirrors `derive(Ord)` on - /// `enum PlatformAddress { P2pkh([u8;20]), P2sh([u8;20]) }`: variant - /// discriminant first (`P2pkh = 0 < P2sh = 1`), then 20-byte hash - /// compared lexicographically. Load-bearing because - /// `FeeStrategyStep::ReduceOutput(N)` on the Rust side indexes the - /// post-canonicalization output list — not Swift's insertion order. - /// See https://github.com/dashpay/platform/issues/3738. - internal static func buildSortedFFIOutputs( - recipients: [TransferOutput], - change: (addressType: UInt8, hash: Data, balance: UInt64) - ) -> (rows: [AddressBalanceEntryFFI], changeIndex: UInt16) { - var rows: [(addressType: UInt8, hash: Data, balance: UInt64)] = - recipients.map { (addressType: $0.addressType, hash: $0.hash, balance: $0.credits) } - rows.append(change) - rows.sort { a, b in - if a.addressType != b.addressType { return a.addressType < b.addressType } - return a.hash.lexicographicallyPrecedes(b.hash) - } - let changeIdx = UInt16(rows.firstIndex { - $0.addressType == change.addressType && $0.hash == change.hash - }!) - let ffiRows = rows.map { row in - AddressBalanceEntryFFI( - address: PlatformAddressFFI( - address_type: row.addressType, - hash: hashTuple(from: row.hash) - ), - balance: row.balance, - nonce: 0, - account_index: 0, - address_index: 0 - ) - } - return (ffiRows, changeIdx) - } - /// Copy a 20-byte `Data` into the fixed-size tuple shape the FFI expects. private static func hashTuple( from data: Data @@ -385,6 +281,198 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { ) } + // MARK: - Withdraw + + /// Result of `preflightWithdrawal(accountIndex:coreFeePerByte:)`: whether an + /// AUTO withdrawal of the account can succeed, and — when it can — the net + /// credits paid out and the reserved transition fee. + public struct WithdrawalPreflight: Sendable { + /// `true` when the account can fund an AUTO withdrawal at the current + /// platform version; `false` for any "can't fund" case (dust-only, + /// largest input can't cover the fee, net below the minimum + /// withdrawal, too many inputs, or net above the maximum withdrawal). + /// The numeric fields are `0` when `false`. + public let canWithdraw: Bool + /// Net credits the chain would pay out (`Σ withdrawable inputs − + /// estimatedFee`). `0` when `canWithdraw == false`. + public let netWithdrawable: UInt64 + /// The address-credit-withdrawal transition fee reserved on the + /// fee-source input. `0` when `canWithdraw == false`. + public let estimatedFee: UInt64 + /// The Rust planner's user-presentable explanation of *why* the account + /// can't fund a withdrawal, surfaced verbatim from the FFI result + /// message (`PlatformWalletError`'s `Display`). `nil` when + /// `canWithdraw == true`. This is the single source of the can't-fund + /// reason — the UI must show it rather than re-deriving a + /// classification in Swift (which would mean mirroring protocol + /// decisions on the wrong side of the FFI boundary). + public let reason: String? + } + + /// Preflight an AUTO withdrawal of a platform-payment account WITHOUT + /// signing, broadcasting, or consuming a Core receive address. + /// + /// This runs the **same** Rust planning phase the real `withdraw(...)` path + /// executes (`PlatformAddressWallet::preflight_withdrawal`): it drops + /// sub-`min_input_amount` dust, estimates the transition fee from the + /// selected input count, reserves it on the largest-balance input, and + /// verifies the net clears the minimum withdrawal amount. Gating a UI + /// submit button on `canWithdraw` keeps it in lockstep with what the spend + /// path will accept — a small-but-non-dust account that can't cover the fee + /// reports `canWithdraw == false` here instead of failing after sign. + /// + /// The fee estimate depends only on the input/output **counts**, not on any + /// destination script, so this needs no Core address and touches no receive + /// pool. It's a pure in-memory computation over cached balances, so it's + /// fast and safe to call on the main actor whenever the selected account or + /// fee rate changes. + /// + /// A genuine "can't fund" is a normal result (`canWithdraw == false`), NOT + /// a thrown error. Only a structural failure — a bad handle or a missing + /// account at `accountIndex` — throws. + /// + /// `coreFeePerByte` is accepted for symmetry with `withdraw(...)`; the + /// platform-side transition fee the preflight reserves does not depend on + /// it (it sizes the eventual L1 payout, not the credit-side fee), but + /// threading it keeps the call sites parallel. + public func preflightWithdrawal( + accountIndex: UInt32, + coreFeePerByte: UInt32 = 1 + ) throws -> WithdrawalPreflight { + var out = WithdrawalPreflightFFI( + can_withdraw: false, + net_withdrawable: 0, + estimated_fee: 0 + ) + // Both the can-withdraw and the can't-fund outcomes return a + // Success-coded result; only a structural failure (bad handle / missing + // account) is an error code. The can't-fund result carries the Rust + // planner's typed reason in its `message`, which we surface verbatim as + // `reason`. We construct the result wrapper explicitly (rather than + // `.check()`) so we can read `.message` BEFORE the wrapper deinits and + // frees the underlying C string; `throwIfError()` still throws on the + // structural-failure codes. + let result = PlatformWalletResult( + platform_address_wallet_preflight_withdrawal( + handle, + accountIndex, + coreFeePerByte, + &out + ) + ) + try result.throwIfError() + // Read the message while the wrapper is still alive; only attach it as + // the can't-fund reason (a `canWithdraw == true` result has no reason). + let reason = out.can_withdraw ? nil : result.message + return WithdrawalPreflight( + canWithdraw: out.can_withdraw, + netWithdrawable: out.net_withdrawable, + estimatedFee: out.estimated_fee, + reason: reason + ) + } + + /// Withdraw this platform-payment account's withdrawable credit + /// balance to a Core L1 address (less the transition fee). + /// + /// `AddressCreditWithdrawalTransition` has no change output, so the + /// on-chain fee is deducted from the inputs. We drive the Rust side + /// with `INPUT_SELECTION_TYPE_AUTO`, which on `accountIndex` selects + /// every funded address whose balance clears the per-input minimum + /// (sub-minimum "dust" addresses are skipped so they can't sink the + /// whole transition) and **owns its own fee strategy**: it picks the + /// largest-balance selected input as the fee source and emits the + /// matching `DeductFromInput()`, ignoring whatever + /// fee strategy the caller passes. We therefore pass an empty strategy + /// here — anything we passed would be discarded. The auto-selector + /// withdraws `balance − estimated_fee` from the fee-source input and + /// the full balance from every other input; without that reservation a + /// full-balance withdraw would leave zero remaining on the fee-source + /// input and the chain would reject the transition with + /// `fee_fully_covered = false`. + /// + /// `coreAddress` is a base58 Core address (e.g. `yXV…` on testnet, + /// `X…` on mainnet). It is parsed **and network-checked on the + /// Rust side** against the wallet's own network by + /// `platform_address_wallet_withdraw_to_address` — a wrong-network + /// address fails fast with a typed error before any signing. + /// + /// `coreFeePerByte` is the Core L1 fee rate (duffs/byte) used to + /// size the eventual L1 payout transaction; `1` is the usual + /// default. + /// + /// The signer must be able to sign for the selected inputs — i.e. + /// the `KeychainSigner` resolves their derivation paths via + /// SwiftData + the wallet mnemonic (the `0xFF` branch in + /// `KeychainSigner.swift`). Pass `KeychainSigner(modelContainer:)`. + /// + /// Returns the per-address `UpdatedBalance`s the Rust changeset + /// reports (drained inputs read `0`; the fee-source input retains the + /// reserved-fee remainder until the chain books the fee). + @discardableResult + public func withdraw( + accountIndex: UInt32, + coreAddress: String, + coreFeePerByte: UInt32 = 1, + signer: KeychainSigner + ) async throws -> [UpdatedBalance] { + let trimmed = coreAddress.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw PlatformWalletError.invalidParameter("coreAddress is empty") + } + // A Swift String can carry an embedded NUL that survives into the + // `withCString` buffer; Rust's `CStr::from_ptr(...).to_str()` stops + // at the first NUL, so `valid\0suffix` would withdraw to `valid` + // while the Swift value differs. Reject it before it can diverge. + guard !trimmed.utf8.contains(0) else { + throw PlatformWalletError.invalidParameter( + "coreAddress contains an embedded NUL byte" + ) + } + + let handle = self.handle + let signerHandle = signer.handle + let address = trimmed + let feePerByte = coreFeePerByte + + return try await Task.detached(priority: .userInitiated) { + () -> [UpdatedBalance] in + // The AUTO path owns its own fee strategy (largest-balance + // input as the fee source) and ignores the caller's, so we + // pass an empty strategy (`nil, 0`); see this wrapper's doc + // comment. + var changeset = PlatformAddressChangeSetFFI(updated: nil, updated_count: 0) + // `withExtendedLifetime(signer)` pins the resolver-backed + // signer for the entire FFI call. Mirrors the + // `fundFromAssetLock` wrapper: a bare `_ = signer` can be + // elided by the -O optimizer and drop the signer mid-call, + // causing a use-after-free in the vtable callback. + let result = withExtendedLifetime(signer) { + address.withCString { addrCStr in + platform_address_wallet_withdraw_to_address( + handle, + accountIndex, + INPUT_SELECTION_TYPE_AUTO, + nil, + 0, + nil, + 0, + addrCStr, + feePerByte, + nil, + 0, + signerHandle, + &changeset + ) + } + } + try result.check() + + defer { platform_address_wallet_free_changeset(&changeset) } + return Self.decodeChangeset(&changeset) + }.value + } + // MARK: - Fund from Core asset lock /// Recipient entry for `fundFromAssetLock(...)`. @@ -394,7 +482,11 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { /// (the asset lock is consumed in full, so a remainder bucket is /// mandatory). public struct FundFromAssetLockRecipient: Sendable { - /// `0 = P2PKH`, `1 = P2SH`. Mirrors the Rust-side `PlatformAddress` discriminant. + /// Must be `0` (P2PKH). Funding recipients flow through the same + /// P2PKH-only Rust `TryFrom` as transfer + /// inputs/outputs, so `1` (P2SH) is rejected (also enforced + /// up-front by `fundFromAssetLockPreflight`). The field stays + /// `UInt8` to mirror the Rust discriminant layout. public let addressType: UInt8 /// 20-byte address hash. public let hash: Data @@ -621,11 +713,11 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { for r in recipients { // The Rust FFI accepts only addressType == 0 (P2PKH) — see // `impl TryFrom for PlatformAddress` in - // packages/rs-platform-wallet-ffi/src/platform_address_types.rs. - // Catch the P2SH discriminant the type signature still - // documents so the caller gets a synchronous, type-specific + // packages/rs-platform-wallet-ffi/src/platform_address_types.rs, + // which rejects P2SH with a type-specific message. Catch the + // P2SH discriminant here too so the caller gets a synchronous // error instead of paying for the Task detach + signer pin - // and then receiving a generic FFI failure. + // and then receiving the same rejection from Rust. guard r.addressType == 0 else { throw PlatformWalletError.invalidParameter( "FundFromAssetLockRecipient.addressType must be 0 (P2PKH) for platform-address funding (got \(r.addressType))" diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index af2a8bd63d2..ae5d4ecee9f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -118,6 +118,24 @@ class SendViewModel: ObservableObject { @Published var error: String? @Published var successMessage: String? + /// Per-output minimum credit amount (`min_output_amount`) the chain + /// enforces for address-funds transitions, resolved on the Rust side from + /// the wallet's current platform version and pushed in by the VIEW + /// (`SendTransactionView.resolvePlatformLimits()`) on appear — the view + /// model has no wallet handle of its own. A `platformToPlatform` transfer + /// sends a single output, and DPP rejects any output below this floor, so + /// `canSend` requires the requested credits to reach it. + /// + /// `nil` until the view resolves it (or if resolution fails). An + /// unresolved floor keeps the `.platformToPlatform` Send gate CLOSED + /// (never *under*-gates) — the same conservative treatment the dedicated + /// `TransferPlatformAddressView` gives a nil `minOutputAmount`. Resolved + /// via `ManagedPlatformAddressWallet.minOutputAmount()` rather than + /// mirroring the protocol constant in Swift, which would drift if the + /// version changed it. Only the platform path consults this; the + /// core/shielded flows are unaffected. + @Published var platformMinOutputAmount: UInt64? + private let network: Network init(network: Network) { @@ -147,6 +165,27 @@ class SendViewModel: ObservableObject { /// (Core uses duffs; Platform / shielded use credits). var amountDuffs: UInt64? { amount } + /// The recipient's 20-byte platform address hash, when the typed/scanned + /// recipient resolves to a platform address (`detectedAddressType == + /// .platform`). `nil` for every other address type or a malformed + /// payload. + /// + /// This is the SAME already-decoded payload `executeSend`'s + /// `.platformToPlatform` branch reads — `detectedAddressType` is + /// populated by `DashAddress.parse` (via the `recipientAddress` `didSet`), + /// and the 21-byte platform payload is `[type byte] + [20-byte hash]` + /// (see rs-dpp/src/address_funds/platform_address.rs). We slice the hash + /// out here rather than re-running any address decoding, so the view can + /// exclude an own-wallet recipient that collides with a candidate source + /// input — mirroring `TransferPlatformAddressView.sourceInputHashes` and + /// the Rust Auto selector, which forbid an address being both an input + /// and an output of the same transfer. + var platformRecipientHash: Data? { + guard case .platform(let payload) = detectedAddressType, + payload.count == 21 else { return nil } + return payload.subdata(in: 1..<21) + } + // MARK: - Multi-recipient (coreToCore only) /// Append an empty extra Core output. The Rust coin-selector handles @@ -314,7 +353,18 @@ class SendViewModel: ObservableObject { // the lock floor so a doomed (sub-fee) amount can't kick off // the lock-build + proof pipeline. return (amountDuffs ?? 0) >= Self.minShieldFromCoreDuffs - case .platformToPlatform, .platformToShielded, + case .platformToPlatform: + // An address-funds transfer sends exactly one output, and DPP + // rejects any output below `min_output_amount`. Gate on the + // version-locked floor (resolved Rust-side and pushed in by the + // view) so the button reflects what DPP will accept, rather than + // only `> 0`, which would enable a sub-minimum amount that fails + // structure validation after submit — matching the dedicated + // `TransferPlatformAddressView`. An unresolved floor (`nil`) keeps + // the gate CLOSED (never *under*-gates); it loads on appear. + guard let minOutput = platformMinOutputAmount else { return false } + return (amountCredits ?? 0) >= minOutput + case .platformToShielded, .shieldedToShielded, .shieldedToPlatform, .shieldedToCore: return (amountCredits ?? 0) > 0 } @@ -430,7 +480,6 @@ class SendViewModel: ObservableObject { platformAddressWallet: ManagedPlatformAddressWallet?, signer: KeychainSigner?, senderAccountIndex: UInt32, - changeAddressRow: PersistentPlatformAddress?, modelContext: ModelContext ) async { guard let flow = detectedFlow else { return } @@ -497,10 +546,11 @@ class SendViewModel: ObservableObject { return } // The Rust FFI's `PlatformAddressFFI → PlatformAddress` - // conversion (rs-platform-wallet-ffi/src/platform_address_types.rs:42) - // only accepts P2PKH; sending to a P2SH platform address - // would surface a raw "Unsupported address type" string - // from Rust. Fail fast with a user-readable message. + // conversion (rs-platform-wallet-ffi/src/platform_address_types.rs, + // `impl TryFrom`) accepts P2PKH only; + // sending to a P2SH platform address would surface a + // P2SH-specific rejection from Rust. Fail fast here with a + // user-readable message instead. guard ffiAddressType == 0 else { error = "P2SH platform addresses aren't supported yet. Use a P2PKH recipient." return @@ -511,19 +561,12 @@ class SendViewModel: ObservableObject { hash: hash, credits: credits ) - // If the view passed a fresh unused HD address from the - // pool, use it as the dedicated change destination — - // matches the Receive screen's lowest-unused selection. - let change: ManagedPlatformAddressWallet.ChangeAddress? = changeAddressRow.map { - ManagedPlatformAddressWallet.ChangeAddress( - addressType: $0.addressType, - hash: $0.addressHash - ) - } + // Input selection, fee strategy, and the surplus (left on + // the source addresses in the credit-balance model) are all + // owned by the Rust Auto path — no change address to pass. let updated = try await addressWallet.transfer( accountIndex: senderAccountIndex, outputs: [output], - changeAddress: change, signer: signer ) @@ -537,16 +580,21 @@ class SendViewModel: ObservableObject { // persister callback ordering ever changes. // // Mirrors PlatformWalletPersistenceHandler.persistAddressBalances: - // fetch each row by `addressHash`, update the - // volatile fields, stamp `lastUpdated`. Every entry - // returned was touched by the transition, so - // `isUsed = true` unconditionally. Rows that aren't - // found are silently skipped — same defensive shape - // the BLAST handler uses. + // fetch each row by `walletId + addressHash`, update the + // volatile fields, stamp `lastUpdated`. Scope by `walletId` + // too (mirroring the dedicated transfer sheet): a hash-only + // predicate can match another wallet's row in a multi-wallet + // store. Every entry returned was touched by the transition, + // so `isUsed = true` unconditionally. Rows that aren't found + // are silently skipped — same defensive shape the BLAST + // handler uses. + let walletId = wallet.walletId for entry in updated { let entryHash = entry.hash let descriptor = FetchDescriptor( - predicate: #Predicate { $0.addressHash == entryHash } + predicate: #Predicate { + $0.walletId == walletId && $0.addressHash == entryHash + } ) guard let row = try? modelContext.fetch(descriptor).first else { continue @@ -556,15 +604,23 @@ class SendViewModel: ObservableObject { row.isUsed = true row.lastUpdated = Date() } + // The transfer has ALREADY succeeded on-chain by this point, + // and a DIP-17 resync corrects balances regardless. So a + // local SwiftData `save()` failure must NOT be reported as + // the transfer having failed (that would make the user + // think credits didn't move when they did) — but it also + // must not be silently swallowed. Keep the SUCCESS message + // and append a non-fatal caveat noting balances will refresh + // on the next sync. do { try modelContext.save() + successMessage = "Platform transfer sent" } catch { - self.error = "Couldn't persist post-transfer balances: \(error.localizedDescription)" - return + successMessage = "Platform transfer sent. Local balances " + + "couldn't be updated — they'll refresh on the next " + + "sync: \(error.localizedDescription)" } - successMessage = "Platform transfer sent" - case .shieldedToShielded: // Shielded → Shielded: spend notes from this // wallet's shielded balance, create a new note diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift index a25d8abc630..df158c6c0e1 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift @@ -14,6 +14,27 @@ struct SendTransactionView: View { /// Drives the camera QR scanner sheet launched from the recipient row. @State private var showQRScanner = false + /// Per-input minimum credit amount (`min_input_amount`) the chain + /// enforces for address-funds transitions, resolved from the wallet's + /// current platform version via + /// `ManagedPlatformAddressWallet.minInputAmount()` once on appear — + /// mirroring `TransferPlatformAddressView`. The Rust Auto selector's + /// `build_auto_select_candidates` drops any funded address below this + /// floor, so the per-account aggregation in + /// `resolvePlatformSenderAccountIndex()` must sum only balances `>=` it to + /// match the input set Rust will actually consume; counting dust could + /// rank a dust-heavy account above a sibling whose spendable (≥ floor) + /// balance actually covers amount + fee. + /// + /// `nil` until resolved (or if resolution fails). Unlike the dedicated + /// sheet — which also gates its submit button on this being non-nil — the + /// generic Send picker only uses it to score per-account coverage, and the + /// account picker falls back to a conservative `balance > 0` floor when + /// it's unresolved (see `resolvePlatformSenderAccountIndex()`). The + /// separate `platformMinOutputAmount` gate on the view model keeps the + /// Send button closed for sub-minimum platform amounts regardless. + @State private var minInputAmount: UInt64? = nil + @Environment(\.modelContext) private var modelContext /// BLAST-synced platform-address balances for this wallet — @@ -228,28 +249,43 @@ struct SendTransactionView: View { let managed = walletManager.wallet(for: wallet.walletId) let coreWallet = try? managed?.coreWallet() let platformAddressWallet = try? managed?.platformAddressWallet() - // Pick the account holding the platform - // balance. Most wallets have a single - // PlatformPayment account (index 0); - // fallback handles that case too. - let senderAccountIndex = addressBalances - .first(where: { $0.balance > 0 })? - .accountIndex ?? 0 - // Mirror ReceiveAddressView's selection: - // the lowest-indexed HD address that has - // never been used. Used as the change - // destination so the transition doesn't - // collide with any input address. Scoped - // to `senderAccountIndex` so multi-account - // wallets don't land change on a different - // platform-payment account than the inputs. - let changeAddressRow = addressBalances - .filter { - $0.accountIndex == senderAccountIndex - && !$0.isUsed - && $0.balance == 0 + // Pick the account that will FUND a platform → + // platform transfer. The Rust Auto selector + // resolves the source via + // `platform_payment_managed_account_at_index` + // (key class 0) and selects its inputs WITHIN + // that single account — it does not span + // accounts. `canSend` only gates on the + // aggregate platform balance, so with multiple + // key-class-0 Platform Payment accounts we must + // choose an account whose OWN balance covers the + // requested amount + fee; otherwise we'd enable a + // send Rust rejects. The selection is factored + // into the pure, unit-tested + // `PlatformPaymentAccountSelection` helper. + // + // Only the platform → platform path needs this + // coverage-aware pick; every other flow ignores + // `senderAccountIndex`, so the prior + // "first key-class-0 positive balance, else 0" + // behaviour is preserved for them. + let senderAccountIndex: UInt32 + if viewModel.detectedFlow == .platformToPlatform { + guard let resolved = resolvePlatformSenderAccountIndex() else { + viewModel.error = "No single Platform Payment account has enough credits for this transfer." + return } - .min(by: { $0.addressIndex < $1.addressIndex }) + senderAccountIndex = resolved + } else { + senderAccountIndex = addressBalances + .filter { $0.account?.keyClass == 0 } + .first(where: { $0.balance > 0 })? + .accountIndex ?? 0 + } + // Input selection and surplus handling are owned + // by the Rust Auto path (surplus stays on the + // source addresses in the credit-balance model), + // so there's no change address to pick here. let signer = KeychainSigner( modelContainer: modelContext.container ) @@ -263,7 +299,6 @@ struct SendTransactionView: View { platformAddressWallet: platformAddressWallet, signer: signer, senderAccountIndex: senderAccountIndex, - changeAddressRow: changeAddressRow, modelContext: modelContext ) } @@ -294,6 +329,17 @@ struct SendTransactionView: View { Text(msg) } } + .onAppear { + // Resolve the version-locked address-funds limits once from + // the wallet's current platform version (read Rust-side), the + // same accessors the dedicated TransferPlatformAddressView + // reads on appear. `minInputAmount` floors per-account + // coverage scoring in `resolvePlatformSenderAccountIndex()`; + // `platformMinOutputAmount` is pushed to the view model so + // `canSend` can reject a sub-`min_output_amount` platform + // transfer up front instead of after submit. + resolvePlatformLimits() + } .onChange(of: viewModel.detectedAddressType) { _, _ in autoSelectSource() } @@ -430,6 +476,151 @@ struct SendTransactionView: View { return wallet.identities.reduce(UInt64(0)) { $0 + UInt64(bitPattern: $1.balance) } } + /// Resolve the chain's per-input (`min_input_amount`) and per-output + /// (`min_output_amount`) credit floors once from the wallet's current + /// platform version (version-locked, read on the Rust side), mirroring + /// `TransferPlatformAddressView.resolveMinInputAmount` / + /// `resolveMinOutputAmount`. Both are obtained from the SAME + /// `ManagedPlatformAddressWallet` the dedicated sheet uses (looked up by + /// this view's `wallet`, not the manager's "active" slot). On any failure + /// the corresponding field is left `nil`: + /// + /// - `minInputAmount == nil` → the account picker falls back to a + /// conservative `balance > 0` floor (see + /// `resolvePlatformSenderAccountIndex()`); it never UNDER-counts. + /// - `platformMinOutputAmount == nil` → `canSend`'s `.platformToPlatform` + /// branch stays CLOSED (never *under*-gates), matching how the dedicated + /// sheet treats an unresolved output floor. + private func resolvePlatformLimits() { + guard let managed = walletManager.wallet(for: wallet.walletId) else { return } + guard let addressWallet = try? managed.platformAddressWallet() else { return } + if minInputAmount == nil { + minInputAmount = try? addressWallet.minInputAmount() + } + if viewModel.platformMinOutputAmount == nil { + viewModel.platformMinOutputAmount = try? addressWallet.minOutputAmount() + } + } + + /// Choose which key-class-0 Platform Payment account funds a + /// platform → platform transfer, returning `nil` when no single + /// account can cover the requested amount + fee. + /// + /// Aggregates each key-class-0 PlatformPayment account's balance from + /// the BLAST-synced `addressBalances` rows (scoping by + /// `accountType == 14 && keyClass == 0`, matching the dedicated + /// transfer/withdraw sheets and the Rust source resolution) — but + /// counts only rows whose balance clears the per-input minimum + /// (`minInputAmount`) AND EXCLUDES the recipient's own row (an own-wallet + /// send to a key-class-0 address), since the Rust Auto selector can't use + /// the output address as an input — then delegates the pick to the pure + /// `PlatformPaymentAccountSelection` helper. The Rust Auto selector + /// spends inputs WITHIN one account only, so a covering account must hold + /// the whole amount + fee on its own (minus any recipient-collision row) + /// — not merely contribute to the aggregate the Send button gates on. + /// + /// Dust floor: Rust's `build_auto_select_candidates` drops any funded + /// address below `min_input_amount`, so a sub-floor "dust" balance is NOT + /// spendable as an input. Summing it here would inflate an account's + /// coverage and could rank a dust-heavy account above a sibling whose + /// spendable (≥ floor) balance actually covers amount + fee — the picker + /// would then choose the dust account and Rust would reject the send + /// post-submit. We use the same resolved `minInputAmount` + /// (`ManagedPlatformAddressWallet.minInputAmount()`) the dedicated + /// `TransferPlatformAddressView` reads. When the floor is unresolved + /// (`nil`) we fall back to the conservative `balance > 0` floor — the same + /// fallback the dedicated sheet's `sourceInputHashes` uses — so we never + /// UNDER-count a real input; the separate `platformMinOutputAmount` gate + /// on the view model independently keeps the Send button closed for a + /// sub-minimum platform amount. + /// + /// `viewModel.amountCredits` and `viewModel.estimatedFee` are both + /// available on this path (`canSend` requires `amountCredits > 0` for + /// the credits flows, and `updateFlow()` populates `estimatedFee`). + /// If either is somehow absent we fall back to the largest-balance + /// account — strictly better than the prior "first positive" pick — + /// rather than blocking the send. + private func resolvePlatformSenderAccountIndex() -> UInt32? { + // The Rust Auto selector excludes the recipient address from its + // input set — DPP forbids an address being both an input and an + // output of the same transfer (the invariant + // `TransferPlatformAddressView.sourceInputHashes` also enforces). + // So when the recipient is an own-wallet address in a key-class-0 + // Platform Payment account, its balance must NOT count toward that + // account's spendable coverage; otherwise the picker could choose an + // account whose recipient-excluded balance is below amount + fee and + // Rust would reject the send the UI enabled. `platformRecipientHash` + // is the already-decoded recipient hash (no address decoding is + // re-run here); a non-platform recipient yields `nil`, which excludes + // nothing. + let recipientHash = viewModel.platformRecipientHash + + // Per-input spendable floor: an address can only be an Auto-selected + // input when its balance reaches the chain's `min_input_amount`. With + // the floor resolved, require `balance >= minInputAmount`; with it + // unresolved (`nil`), fall back to `balance > 0` so we never UNDER- + // count a real input (same conservative fallback the dedicated sheet's + // `sourceInputHashes` uses). See this function's doc comment. + let isSpendableInput: (UInt64) -> Bool = { balance in + if let floor = minInputAmount { + return balance >= floor + } + return balance > 0 + } + + // Aggregate balance per key-class-0 PlatformPayment account, + // counting only spendable (≥ floor) rows and excluding any row that IS + // the recipient. + var totals: [UInt32: UInt64] = [:] + for row in addressBalances { + guard let account = row.account, + account.accountType == 14, + account.keyClass == 0 else { continue } + // Drop sub-`min_input_amount` dust: Rust's Auto selector won't + // spend it, so summing it would inflate this account's coverage + // and could outrank a sibling whose spendable balance actually + // covers amount + fee. + guard isSpendableInput(row.balance) else { continue } + // Skip the recipient row: it's an output, so the Auto selector + // won't spend it. Scoped to this same key-class-0 / account-type + // set (and this wallet via `addressBalances`' query predicate), + // mirroring `sourceInputHashes`. + if let recipientHash, row.addressHash == recipientHash { continue } + let (sum, overflow) = (totals[row.accountIndex] ?? 0) + .addingReportingOverflow(row.balance) + // An overflowing per-account sum is treated as "saturated" so + // it still ranks as a (more than) covering account rather than + // wrapping to a small value. + totals[row.accountIndex] = overflow ? UInt64.max : sum + } + + let candidates = totals.map { + PlatformPaymentAccountSelection.Candidate( + accountIndex: $0.key, + balance: $0.value + ) + } + + // Amount + fee for this transfer (credits). `?? 0` only triggers + // off-path; with a 0 requirement the largest account trivially + // "covers" it, yielding the largest-balance fallback. + let amount = viewModel.amountCredits ?? 0 + let fee = viewModel.estimatedFee ?? SendFlow.platformToPlatform.estimatedFee + + switch PlatformPaymentAccountSelection.choose( + from: candidates, + amount: amount, + fee: fee + ) { + case .covering(let accountIndex): + return accountIndex + case .insufficient: + // No single account covers amount + fee — don't silently pick + // an underfunded account; let the caller surface a clear error. + return nil + } + } + private func availableSources(coreBalance: UInt64) -> [FundSource] { viewModel.availableSources( coreBalance: coreBalance, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index 2a0d84540e3..9576cef5224 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -29,6 +29,8 @@ struct WalletDetailView: View { @State private var showSendTransaction = false @State private var showWalletInfo = false @State private var showFundPlatformAddress = false + @State private var showTransferPlatformAddress = false + @State private var showWithdrawPlatformAddress = false @State private var showShieldFromAssetLock = false /// Devnet/testnet-only shielded pool seeding sheet (Seed Pool Notes). @State private var showSeedShieldedPool = false @@ -89,6 +91,8 @@ struct WalletDetailView: View { BalanceCardView( wallet: wallet, onFundPlatform: { showFundPlatformAddress = true }, + onTransferPlatform: { showTransferPlatformAddress = true }, + onWithdrawPlatform: { showWithdrawPlatformAddress = true }, onFundShielded: { showShieldFromAssetLock = true } ) .padding() @@ -244,6 +248,12 @@ struct WalletDetailView: View { .sheet(isPresented: $showFundPlatformAddress) { FundFromAssetLockPlatformAddressView(wallet: wallet) } + .sheet(isPresented: $showTransferPlatformAddress) { + TransferPlatformAddressView(wallet: wallet) + } + .sheet(isPresented: $showWithdrawPlatformAddress) { + WithdrawPlatformAddressView(wallet: wallet) + } .sheet(item: $resumingAssetLock) { lock in FundFromAssetLockPlatformAddressView(wallet: wallet, resumeFromLock: lock) } @@ -951,6 +961,12 @@ struct BalanceCardView: View { /// `nil` hides the affordance entirely (e.g. for read-only /// surfaces). var onFundPlatform: (() -> Void)? + /// Opens the wallet-signed Platform→Platform credit transfer sheet + /// (`TransferPlatformAddressView`, ADDR-02). + var onTransferPlatform: (() -> Void)? + /// Opens the wallet-signed Platform→Core L1 withdrawal sheet + /// (`WithdrawPlatformAddressView`, ADDR-04). + var onWithdrawPlatform: (() -> Void)? /// Same shape as `onFundPlatform`, for the Shielded Balance row. /// Opens the Core L1 → shielded-pool funding sheet /// (`ShieldedFundFromAssetLockView`, Type 18). @@ -966,10 +982,14 @@ struct BalanceCardView: View { init( wallet: PersistentWallet, onFundPlatform: (() -> Void)? = nil, + onTransferPlatform: (() -> Void)? = nil, + onWithdrawPlatform: (() -> Void)? = nil, onFundShielded: (() -> Void)? = nil ) { self.wallet = wallet self.onFundPlatform = onFundPlatform + self.onTransferPlatform = onTransferPlatform + self.onWithdrawPlatform = onWithdrawPlatform self.onFundShielded = onFundShielded let walletId = wallet.walletId let walletNetworkRaw = (wallet.network ?? .testnet).rawValue @@ -1021,6 +1041,47 @@ struct BalanceCardView: View { } } + /// Trailing-menu items for the Platform Balance row. Built only + /// when at least one of Transfer/Withdraw is wired (the editable + /// Wallet Detail surface); empty otherwise so read-only surfaces and + /// the legacy single-action `+` path stay intact. Top Up is included + /// in the menu whenever it's present so all three live in one place. + private var platformMenuItems: [WalletBalanceRow.TrailingMenuItem] { + guard onTransferPlatform != nil || onWithdrawPlatform != nil else { return [] } + var items: [WalletBalanceRow.TrailingMenuItem] = [] + if let fund = onFundPlatform { + items.append( + WalletBalanceRow.TrailingMenuItem( + title: "Top Up from Core", + systemImage: "plus.circle", + accessibilityIdentifier: "balanceCard.platform.topUp", + action: fund + ) + ) + } + if let transfer = onTransferPlatform { + items.append( + WalletBalanceRow.TrailingMenuItem( + title: "Transfer Credits", + systemImage: "arrow.left.arrow.right", + accessibilityIdentifier: "balanceCard.platform.transfer", + action: transfer + ) + ) + } + if let withdraw = onWithdrawPlatform { + items.append( + WalletBalanceRow.TrailingMenuItem( + title: "Withdraw to Core", + systemImage: "arrow.up.circle", + accessibilityIdentifier: "balanceCard.platform.withdraw", + action: withdraw + ) + ) + } + return items + } + var body: some View { let totalCore = confirmedBalance + unconfirmedBalance let allZero = totalCore == 0 && platformBalance == 0 && shieldedService.shieldedBalance == 0 @@ -1040,24 +1101,34 @@ struct BalanceCardView: View { unit: .duffs ) - // Platform Balance row — when `onFundPlatform` is - // wired (i.e. on the editable Wallet Detail surface), - // a trailing `+` button opens the Core→Platform - // funding sheet. Read-only call sites pass `nil` and - // the affordance disappears. + // Platform Balance row — on the editable Wallet Detail + // surface this exposes a trailing menu with Top Up + // (Core→Platform), Transfer (Platform→Platform, + // ADDR-02), and Withdraw (Platform→Core L1, ADDR-04). + // Read-only call sites pass `nil` for all three and the + // affordance disappears. A single Top Up closure with no + // transfer/withdraw still renders the legacy `+` button. WalletBalanceRow( label: "Platform Balance", amount: platformBalance, color: .blue, unit: .credits, showSyncIndicator: platformBalanceSyncService.isSyncing, - trailingAction: onFundPlatform.map { fund in - WalletBalanceRow.TrailingAction( - systemImage: "plus.circle.fill", - accessibilityLabel: "Top Up Platform Balance from Core", - action: fund + trailingAction: platformMenuItems.isEmpty + ? onFundPlatform.map { fund in + WalletBalanceRow.TrailingAction( + systemImage: "plus.circle.fill", + accessibilityLabel: "Top Up Platform Balance from Core", + action: fund + ) + } + : nil, + trailingMenu: platformMenuItems.isEmpty + ? nil + : ( + accessibilityLabel: "Platform Balance Actions", + items: platformMenuItems ) - } ) // Shielded Balance row — mirrors the Platform @@ -1106,6 +1177,17 @@ private struct WalletBalanceRow: View { let action: () -> Void } + /// One entry in a trailing `Menu`. Used by the Platform Balance + /// row to offer Top Up / Transfer / Withdraw without crowding the + /// row with three separate glyph buttons. + struct TrailingMenuItem: Identifiable { + let id = UUID() + let title: String + let systemImage: String + let accessibilityIdentifier: String + let action: () -> Void + } + let label: String var amount: UInt64 var incoming: UInt64 = 0 @@ -1113,6 +1195,11 @@ private struct WalletBalanceRow: View { var unit: WalletBalanceUnit = .duffs var showSyncIndicator: Bool = false var trailingAction: TrailingAction? = nil + /// When set, the trailing affordance is a `Menu` (ellipsis glyph) + /// listing these items instead of a single `trailingAction` button. + /// `trailingMenu` takes precedence over `trailingAction` if both + /// are supplied. + var trailingMenu: (accessibilityLabel: String, items: [TrailingMenuItem])? = nil var body: some View { HStack { @@ -1145,7 +1232,21 @@ private struct WalletBalanceRow: View { .foregroundColor(.orange) } } - if let trailing = trailingAction { + if let menu = trailingMenu { + Menu { + ForEach(menu.items) { item in + Button(action: item.action) { + Label(item.title, systemImage: item.systemImage) + } + .accessibilityIdentifier(item.accessibilityIdentifier) + } + } label: { + Image(systemName: "ellipsis.circle.fill") + .font(.title3) + .foregroundColor(color) + } + .accessibilityLabel(menu.accessibilityLabel) + } else if let trailing = trailingAction { Button(action: trailing.action) { Image(systemName: trailing.systemImage) .font(.title3) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PlatformPaymentAccountSelection.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PlatformPaymentAccountSelection.swift new file mode 100644 index 00000000000..4976c5b8380 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PlatformPaymentAccountSelection.swift @@ -0,0 +1,85 @@ +// PlatformPaymentAccountSelection.swift +// SwiftExampleApp +// +// Pure, testable source of truth for choosing WHICH DIP-17 Platform +// Payment account funds a platform → platform transfer. Kept out of the +// SwiftUI view (mirroring `WithdrawalCoreFeeRates`) so the selection +// logic can be unit-tested without a SwiftData model container. +// +// Why this matters: the Rust `platform-wallet` Auto selector picks the +// transfer's inputs WITHIN a single chosen `account_index` (resolved via +// `platform_payment_managed_account_at_index`, key class 0); it does NOT +// span accounts. The send screen, however, gates its Send button on the +// AGGREGATE platform balance. With multiple key-class-0 Platform Payment +// accounts, naively handing Rust "the first account with any balance" +// can pick an account that can't cover the amount + fee while a sibling +// account could — Rust then rejects a send the UI enabled. This helper +// picks an account whose own balance covers amount + fee, so the chosen +// index matches what Rust will actually be able to spend. + +import Foundation + +enum PlatformPaymentAccountSelection { + /// One candidate funding account: its DIP-17 `accountIndex` and the + /// total credit balance held across that account's key-class-0 + /// addresses (the only addresses Rust will spend for this index). + struct Candidate { + let accountIndex: UInt32 + let balance: UInt64 + } + + /// Outcome of choosing a funding account. + enum Outcome: Equatable { + /// An account whose own balance covers amount + fee was chosen. + case covering(accountIndex: UInt32) + /// No single account covers amount + fee; the largest-balance + /// account is offered as a best-effort fallback (Rust will return + /// a typed insufficient-balance error if it truly can't cover it). + /// `nil` when there are no candidate accounts at all. + case insufficient(largestAccountIndex: UInt32?) + } + + /// Choose the funding account for a transfer of `amount` credits with + /// an estimated `fee` (both in platform credits). + /// + /// Selection rule: + /// - Among accounts whose OWN balance is `>= amount + fee`, prefer the + /// one with the largest balance (deterministic tie-break on the + /// smaller `accountIndex`), and return `.covering`. + /// - If none covers it, return `.insufficient` carrying the + /// largest-balance account index (or `nil` if there are no + /// candidates), so the caller can decide whether to surface an + /// error or proceed best-effort. + /// + /// `amount + fee` is summed with `addingReportingOverflow`; an + /// overflowing requirement is treated as "no account can cover it" + /// (`.insufficient`) rather than trapping/wrapping. + static func choose( + from candidates: [Candidate], + amount: UInt64, + fee: UInt64 + ) -> Outcome { + // Largest-balance account overall (tie-break on smaller index) — + // used both as the covering pick's preference order and as the + // insufficient-case fallback. + let largest = candidates.max { + ($0.balance, $1.accountIndex) < ($1.balance, $0.accountIndex) + } + + let (required, overflow) = amount.addingReportingOverflow(fee) + if overflow { + return .insufficient(largestAccountIndex: largest?.accountIndex) + } + + // Largest covering account (same tie-break: larger balance, then + // smaller index). + let covering = candidates + .filter { $0.balance >= required } + .max { ($0.balance, $1.accountIndex) < ($1.balance, $0.accountIndex) } + + if let covering { + return .covering(accountIndex: covering.accountIndex) + } + return .insufficient(largestAccountIndex: largest?.accountIndex) + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift new file mode 100644 index 00000000000..aec97bf55f0 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift @@ -0,0 +1,797 @@ +// TransferPlatformAddressView.swift +// SwiftExampleApp +// +// Production (wallet-signed) UI for ADDR-02: transfer credits between +// Platform (DIP-17) addresses. Mirrors the shape of +// `FundFromAssetLockPlatformAddressView` (Source → Destination → Amount +// → Submit) and drives `ManagedPlatformAddressWallet.transfer(...)` +// end-to-end with a `KeychainSigner`. +// +// No private keys are ever entered here. Input selection (Auto), +// the `Σ inputs == Σ outputs` balancing, fee strategy, nonce +// selection, and signing all happen inside the Rust `platform-wallet` +// crate via the FFI wrapper — the only thing this view decides is the +// source account, the amount, and the destination address. The +// credit-balance model leaves surplus on the source addresses, so +// there is no change address to pick. Contrast with the raw +// `TransferAddressFundsView` debug form, which pastes a 64-char +// private key. + +import SwiftUI +import SwiftDashSDK +import SwiftData + +struct TransferPlatformAddressView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + @EnvironmentObject var walletManager: PlatformWalletManager + @EnvironmentObject var platformBalanceSyncService: PlatformBalanceSyncService + + /// Wallet whose DIP-17 platform-payment accounts/addresses this + /// transfer operates on. + let wallet: PersistentWallet + + @Query private var allAccounts: [PersistentAccount] + @Query private var allPlatformAddresses: [PersistentPlatformAddress] + + // MARK: - Selection state + + private enum DestinationMode: String, CaseIterable, Identifiable { + case ownWallet = "My Wallet" + case external = "External" + var id: String { rawValue } + } + + @State private var sourceAccountIndex: UInt32? = nil + @State private var destinationMode: DestinationMode = .ownWallet + /// Selected own-wallet recipient (20-byte hash) when mode == .ownWallet. + @State private var selectedRecipientHash: Data? = nil + /// Pasted/scanned external recipient hash, 40 hex chars (20 bytes). + @State private var externalHashHex: String = "" + @State private var amountDash: String = "0.0001" + + /// Per-input minimum credit amount (`min_input_amount`) the chain + /// enforces for address-funds transitions, resolved from the wallet's + /// current platform version via + /// `ManagedPlatformAddressWallet.minInputAmount()` once on appear. The + /// Rust Auto selector drops any funded address below this floor, so the + /// per-account total and the submit gate must sum only balances `>=` it + /// to match the input set Rust will actually consume. + /// + /// `nil` until resolved (or if resolution fails). We treat an + /// unresolved floor as a closed gate (`canSubmit` requires it to be + /// known) rather than substituting a numeric default: a fallback like + /// `0` would re-introduce the over-permissive behavior this fixes + /// (every dust row counted) and let the button enable an op Rust would + /// reject as dust-only, while hardcoding the `100_000` protocol + /// constant would violate the no-Swift-mirror rule. The view still + /// renders fully when it's `nil`; only the spendable total reads `0` + /// and submit stays disabled until the version-locked floor loads. + @State private var minInputAmount: UInt64? = nil + + /// Per-output minimum credit amount (`min_output_amount`) the chain + /// enforces for address-funds transitions, resolved from the wallet's + /// current platform version via + /// `ManagedPlatformAddressWallet.minOutputAmount()` once on appear. An + /// address-funds transfer sends exactly one output, and DPP rejects any + /// output below this floor (currently 500,000 credits), so a small amount + /// that clears `parsedCredits > 0` would still fail structure validation + /// after submit. The submit gate and the amount footer must enforce + /// `credits >= this` so the button reflects what DPP will accept. + /// + /// `nil` until resolved (or if resolution fails). Same safe pattern as + /// `minInputAmount`: an unresolved floor keeps the gate CLOSED + /// (`canSubmit` requires it to be known) rather than substituting a + /// numeric default — a fallback like `0` would re-open the gate for a + /// sub-minimum amount DPP rejects, and hardcoding the `500_000` protocol + /// constant would violate the no-Swift-mirror rule. This never + /// *under*-gates: when unknown, submit simply stays disabled until the + /// version-locked floor loads. + @State private var minOutputAmount: UInt64? = nil + + // MARK: - Submit state + + @State private var submitError: SubmitError? = nil + @State private var isSubmitting = false + @State private var didSucceed = false + /// Non-fatal caveat shown on the success screen when the transfer + /// succeeded on-chain but the local SwiftData balance write failed. + /// The transfer itself is NOT a failure (the `performSync()` that runs + /// right after corrects balances regardless), so this must not be + /// surfaced as `submitError` — but it must not be silently swallowed + /// either. + @State private var saveWarning: String? = nil + + /// 1e11 credits per DASH. Matches `CreateIdentityView`. Integer so + /// the amount→credits conversion is exact — binary floating point + /// can't represent every credit value at the 1e11 boundary, and a + /// value-transfer path must not round the user's intended amount. + private static let creditsPerDash: UInt64 = 100_000_000_000 + /// Number of fractional decimal digits in one DASH worth of credits + /// (1e11 = 11 zeros). Anything finer than 1e-11 DASH is sub-credit + /// and rejected rather than truncated. + private static let creditFractionDigits = 11 + + /// UI-only cushion: the Rust Auto path deducts the on-chain fee from + /// the lex-smallest selected input's remaining balance + /// (`[DeductFromInput(0)]`), so the source account must hold the + /// transfer amount PLUS the fee. We hold back this cushion when + /// gating the submit button so the button isn't enabled for an amount + /// the account can't actually cover once the fee is taken. The Rust + /// side computes the exact fee and returns a typed insufficient- + /// balance error if this estimate is wrong; this is purely to avoid a + /// dead-on-tap button. Observed fee for a small transfer is ~6.5M + /// credits; this is intentionally an order of magnitude larger so + /// estimation drift doesn't surprise the user. + private static let feeBuffer: UInt64 = 100_000_000 + + var body: some View { + NavigationStack { + Form { + if didSucceed { + successSection + } else { + walletSection + sourceAccountSection + destinationSection + amountSection + if canSubmit { + submitSection + } + } + } + .navigationTitle("Transfer Platform Credits") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { dismiss() } + .disabled(isSubmitting) + } + } + .alert(item: $submitError) { err in + Alert( + title: Text("Could not transfer credits"), + message: Text(err.message), + dismissButton: .default(Text("OK")) + ) + } + .onAppear { + resolveMinInputAmount() + resolveMinOutputAmount() + autoSelectDefaults() + } + // Block swipe-to-dismiss while a transfer is in flight — only + // the (disabled) Cancel button otherwise gates it, so a swipe + // could tear the sheet down mid-submit. + .interactiveDismissDisabled(isSubmitting) + } + } + + // MARK: - Sections + + private var walletSection: some View { + Section { + HStack { + Label("Wallet", systemImage: "wallet.pass") + Spacer() + Text(wallet.name ?? hexShort(wallet.walletId)) + .lineLimit(1) + .truncationMode(.middle) + .foregroundColor(.secondary) + } + } header: { + Text("Source") + } + } + + @ViewBuilder + private var sourceAccountSection: some View { + let options = platformAccountOptions + Section { + if options.isEmpty { + Text("No DIP-17 Platform Payment accounts on this wallet yet.") + .font(.caption) + .foregroundColor(.secondary) + } else { + Picker("Source Account", selection: $sourceAccountIndex) { + Text("Select…").tag(Optional.none) + ForEach(options, id: \.accountIndex) { opt in + Text("Account #\(opt.accountIndex) — \(formatCredits(opt.totalCredits))") + .tag(Optional(opt.accountIndex)) + } + } + .accessibilityIdentifier("transferPlatform.sourceAccountPicker") + .onChange(of: sourceAccountIndex) { _, _ in + selectedRecipientHash = nil + autoSelectRecipient() + } + } + } header: { + Text("Source Account") + } footer: { + Text("Platform Payment account funding the transfer. Picker shows its current credit balance.") + } + } + + @ViewBuilder + private var destinationSection: some View { + Section { + Picker("Destination", selection: $destinationMode) { + ForEach(DestinationMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + .accessibilityIdentifier("transferPlatform.destinationModePicker") + + switch destinationMode { + case .ownWallet: + let options = ownWalletRecipientCandidates + if options.isEmpty { + Text("No other addresses available on this wallet to receive credits. Sync first or add funds.") + .font(.caption) + .foregroundColor(.secondary) + } else { + Picker("Recipient", selection: $selectedRecipientHash) { + Text("Select…").tag(Optional.none) + ForEach(options, id: \.addressHash) { row in + Text("Addr #\(row.addressIndex) — \(row.address.prefix(12))…") + .tag(Optional(row.addressHash)) + } + } + .accessibilityIdentifier("transferPlatform.recipientPicker") + } + case .external: + VStack(alignment: .leading, spacing: 4) { + TextField("Recipient hash (40 hex chars = 20 bytes)", text: $externalHashHex) + .textFieldStyle(.roundedBorder) + .autocapitalization(.none) + .disableAutocorrection(true) + .monospaced() + .accessibilityIdentifier("transferPlatform.externalHashField") + if !externalHashHex.isEmpty && parsedExternalHash == nil { + Text("Enter exactly 40 hexadecimal characters.") + .font(.caption) + .foregroundColor(.red) + } + } + } + } header: { + Text("Destination Address") + } footer: { + Text("Send to another address on this wallet, or paste a 20-byte P2PKH address hash. Surplus stays on the source addresses — there's no change address to pick.") + } + } + + @ViewBuilder + private var amountSection: some View { + Section { + HStack { + TextField("Amount", text: $amountDash) + .keyboardType(.decimalPad) + .textFieldStyle(.roundedBorder) + .disabled(isSubmitting) + .accessibilityIdentifier("transferPlatform.amountField") + Text("DASH") + .foregroundColor(.secondary) + } + } header: { + Text("Amount") + } footer: { + if let credits = parsedCredits { + // Below-minimum takes precedence over the balance check: a tiny + // amount can clear the balance check yet still be rejected by + // DPP for falling under `min_output_amount`, so explain that + // first. Only shown once the floor has resolved (`minOutputAmount` + // non-nil) so we never claim a minimum we haven't read. + let available = selectedSourceAccountCredits + let needed = credits.addingReportingOverflow(Self.feeBuffer) + if let minOutput = minOutputAmount, credits < minOutput { + Text("Minimum transfer is \(formatCredits(minOutput)). Increase the amount to at least that.") + .foregroundColor(.red) + } else if needed.overflow || needed.partialValue > available { + Text("Insufficient balance: \(formatCredits(credits)) + fee exceeds the account's \(formatCredits(available)).") + .foregroundColor(.red) + } else { + Text("\(formatCredits(credits)) will be transferred (plus a small on-chain fee taken from the source balance).") + } + } else { + Text("Enter an amount in DASH.") + } + } + } + + private var submitSection: some View { + Section { + Button { + submit() + } label: { + HStack { + if isSubmitting { + ProgressView() + Text("Transferring…") + } else { + Text("Transfer") + } + Spacer() + } + .foregroundColor(.white) + } + .frame(maxWidth: .infinity) + .listRowBackground(Color.accentColor) + .disabled(isSubmitting) + .accessibilityIdentifier("transferPlatform.submitButton") + } + } + + private var successSection: some View { + Section { + VStack(alignment: .leading, spacing: 8) { + Label("Credits transferred", systemImage: "checkmark.seal.fill") + .foregroundColor(.green) + .font(.headline) + Text("The transfer was submitted and your balances are resyncing.") + .font(.callout) + .foregroundColor(.secondary) + if let saveWarning { + Label(saveWarning, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundColor(.orange) + .accessibilityIdentifier("transferPlatform.saveWarning") + } + Button { + dismiss() + } label: { + Text("Done").frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding(.top, 4) + } + } + } + + // MARK: - Derived + + private struct PlatformAccountOption { + let accountIndex: UInt32 + let totalCredits: UInt64 + } + + /// Source accounts the transfer can actually spend from. + /// + /// Offers every DIP-17 platform-payment account (`accountType == 14`, + /// key class 0) on this wallet. The Rust `platform-wallet` Auto selector + /// resolves the chosen `accountIndex` via + /// `platform_payment_managed_account_at_index(account_index)` (key class 0) + /// and spends from that account, so the source matches the `accountIndex` + /// the transfer persists/nonces against — the picker is multi-account, + /// matching the withdraw flow. + private var platformAccountOptions: [PlatformAccountOption] { + // Spendable threshold: a funded address can only be an input if its + // balance reaches the chain's `min_input_amount`. When the floor + // hasn't resolved yet (`nil`), `UInt64.max` makes every row dust so + // the spendable total is 0 and the submit gate stays closed — we + // never count an unknown-floor balance as spendable. See the + // `minInputAmount` doc comment for why we don't fall back to a + // numeric default. + let threshold = minInputAmount ?? UInt64.max + let accounts = allAccounts + .filter { $0.wallet.walletId == wallet.walletId } + .filter { $0.accountType == 14 && $0.keyClass == 0 } + .sorted { $0.accountIndex < $1.accountIndex } + return accounts.map { acct in + // Sum only addresses whose parent account is key class 0 + // (`account?.keyClass == 0`) AND whose balance clears the + // per-input minimum (`balance >= threshold`). Rust's Auto + // selector drops sub-`min_input_amount` dust before selecting + // inputs (and returns `OnlyDustInputs` if nothing clears it), so + // counting dust here would inflate the total and let `canSubmit` + // promise more than Rust will spend — enabling a transfer Rust + // then refuses. Summing every key-class-0 row regardless of key + // class would likewise over-count. + let total = allPlatformAddresses + .filter { + $0.walletId == wallet.walletId + && $0.accountIndex == acct.accountIndex + && $0.account?.keyClass == 0 + && $0.balance >= threshold + } + .reduce(into: UInt64(0)) { acc, addr in acc &+= addr.balance } + return PlatformAccountOption(accountIndex: acct.accountIndex, totalCredits: total) + } + } + + private var selectedSourceAccountCredits: UInt64 { + guard let idx = sourceAccountIndex else { return 0 } + return platformAccountOptions.first(where: { $0.accountIndex == idx })?.totalCredits ?? 0 + } + + /// Funded addresses on the selected source account for this wallet that + /// the Rust Auto selector could actually consume as inputs. + /// The `AddressFundsTransferTransition` protocol forbids any output + /// address from also being an input, and the selector excludes recipient + /// addresses from its input set, so a recipient that collides with a real + /// source input would enable the button here, then come up short Rust-side + /// once that input is excluded. Gate on this set so the collision is caught + /// up front. + /// + /// Floors on `min_input_amount`, NOT `balance > 0`: Rust's Auto selector + /// only treats an address as a candidate input when its balance reaches + /// `min_input_amount` (`build_auto_select_candidates` drops everything + /// below it). A dust source-account address is therefore NOT an input, so + /// sending TO it is structurally fine — excluding it on the old `> 0` + /// floor wrongly removed legitimate dust recipients from the picker and + /// rejected them as pasted externals. We use the same resolved + /// `minInputAmount` the spendable-total/submit gate reads so this set + /// matches the input set Rust will actually consume. + /// + /// When `minInputAmount` is unresolved (`nil`) we fall back to the prior + /// `balance > 0` floor: with an unknown per-input minimum we cannot tell + /// dust from a real input, so we conservatively treat every funded row as + /// a possible input rather than risk UNDER-excluding (and offering a real + /// input as a recipient). The submit gate is independently closed while + /// `minInputAmount == nil`, so this only affects which recipients the + /// picker offers. + /// + /// Scoped to DIP-17 platform-payment accounts at key class 0 + /// (`account?.accountType == 14 && account?.keyClass == 0`), matching + /// `platformAccountOptions` and `selectedSourceAccountCredits`: Rust + /// resolves the source via + /// `platform_payment_managed_account_at_index(accountIndex)` (key + /// class 0, account type 14) and only spends those rows, so a sibling + /// row at the same `accountIndex` with a different account type or key + /// class is not an input. Including it here would wrongly drop it as a + /// destination candidate, blocking legitimate own-wallet/pasted + /// recipients on multi-account-type / multi-key-class wallets. + private var sourceInputHashes: Set { + guard let acctIdx = sourceAccountIndex else { return [] } + // Match Rust's candidate floor: an address is a possible input only + // when its balance reaches `min_input_amount`. With the floor + // unresolved, fall back to `> 0` so we never UNDER-exclude a real + // input (offering it as a recipient would let Rust come up short). + let isPossibleInput: (PersistentPlatformAddress) -> Bool = { addr in + if let floor = minInputAmount { + return addr.balance >= floor + } + return addr.balance > 0 + } + return Set( + allPlatformAddresses + .filter { + $0.walletId == wallet.walletId + && $0.accountIndex == acctIdx + && isPossibleInput($0) + && $0.account?.accountType == 14 + && $0.account?.keyClass == 0 + } + .map { $0.addressHash } + ) + } + + /// Own-wallet recipients: any address on the wallet that is NOT a + /// funded source-account input. We surface unused (zero-balance) + /// addresses on any platform-payment account so the user can send to a + /// fresh address; the Rust Auto selector excludes recipients from its + /// input set (DPP forbids the same address as both input and output), + /// so a recipient that collides with a funded source input would be + /// dropped from selection — we exclude those here so the button isn't + /// enabled for a recipient Rust would refuse to fund against. + /// + /// Restricted to P2PKH rows (`addressType == 0`): the transfer FFI's + /// `PlatformAddressFFI → PlatformAddress` conversion accepts P2PKH + /// only (the P2PKH-only contract established earlier in this PR), so a + /// persisted P2SH (`addressType == 1`) own-wallet row would parse here + /// but only fail after submit. Filtering it out keeps the picker in + /// step with what Rust will actually accept. + private var ownWalletRecipientCandidates: [PersistentPlatformAddress] { + let inputs = sourceInputHashes + return allPlatformAddresses + .filter { $0.walletId == wallet.walletId } + .filter { $0.addressType == 0 } + .filter { !inputs.contains($0.addressHash) } + .sorted { ($0.accountIndex, $0.addressIndex) < ($1.accountIndex, $1.addressIndex) } + } + + /// Parse the pasted external hash (40 hex chars → 20 bytes). + private var parsedExternalHash: Data? { + let raw = externalHashHex.trimmingCharacters(in: .whitespacesAndNewlines) + guard raw.count == 40 else { return nil } + var bytes = [UInt8]() + bytes.reserveCapacity(20) + var idx = raw.startIndex + while idx < raw.endIndex { + let next = raw.index(idx, offsetBy: 2) + guard let b = UInt8(raw[idx.. 0 else { return nil } + return total.partialValue + } + + private var canSubmit: Bool { + guard + !isSubmitting, + // The per-input minimum must be known before we can promise the + // account covers the transfer: `selectedSourceAccountCredits` + // sums only balances ≥ this floor, and an unresolved floor makes + // that figure 0. Keep the gate closed until it loads rather than + // gating on an unknown/over-permissive spendable total. + minInputAmount != nil, + // The per-OUTPUT minimum must also be known before we enable + // submit: an address-funds transfer sends one output, and DPP + // rejects any output below `min_output_amount`. An unresolved + // floor keeps the gate closed (never *under*-gates) rather than + // letting a sub-minimum amount through to a post-submit failure. + let minOutput = minOutputAmount, + sourceAccountIndex != nil, + let credits = parsedCredits, credits > 0, + // The single output must reach `min_output_amount` or DPP rejects + // the transition after submit — gate on it up front so the button + // isn't enabled for an amount the chain will refuse. + credits >= minOutput, + let dest = resolvedDestination + else { return false } + // Reject a recipient that collides with a funded source input. + // The Rust Auto selector excludes recipients from its input set, + // so a recipient on a funded source input would be dropped from + // selection and the transfer could come up short Rust-side. + // Covers both own-wallet picks and pasted externals. + if sourceInputHashes.contains(dest.hash) { return false } + // Gate on amount + fee cushion <= account balance. The Auto path + // deducts the on-chain fee from the source balance, so the account + // must cover amount + fee; this is a conservative UI gate (Rust + // computes the exact fee and rejects an over-spend with a typed + // error). + let needed = credits.addingReportingOverflow(Self.feeBuffer) + if needed.overflow { return false } + return selectedSourceAccountCredits >= needed.partialValue + } + + // MARK: - Actions + + /// Resolve the chain's per-input minimum (`min_input_amount`) once from + /// the wallet's current platform version (version-locked, read on the + /// Rust side). Called on appear. On any failure we leave + /// `minInputAmount == nil`, which keeps the spendable total at 0 and the + /// submit gate closed — a deliberately conservative fallback that never + /// *under*-gates (see the `minInputAmount` doc comment). + private func resolveMinInputAmount() { + guard minInputAmount == nil else { return } + guard let managedHolder = walletManager.wallet(for: wallet.walletId) else { return } + do { + let addressWallet = try managedHolder.platformAddressWallet() + minInputAmount = try addressWallet.minInputAmount() + } catch { + // Leave nil: gate stays closed until a later appearance resolves it. + minInputAmount = nil + } + } + + /// Resolve the chain's per-output minimum (`min_output_amount`) once from + /// the wallet's current platform version (version-locked, read on the + /// Rust side). Called on appear. On any failure we leave + /// `minOutputAmount == nil`, which keeps the submit gate closed until a + /// later appearance resolves it — the same conservative fallback as + /// `resolveMinInputAmount`, which never *under*-gates. + private func resolveMinOutputAmount() { + guard minOutputAmount == nil else { return } + guard let managedHolder = walletManager.wallet(for: wallet.walletId) else { return } + do { + let addressWallet = try managedHolder.platformAddressWallet() + minOutputAmount = try addressWallet.minOutputAmount() + } catch { + // Leave nil: gate stays closed until a later appearance resolves it. + minOutputAmount = nil + } + } + + private func autoSelectDefaults() { + if sourceAccountIndex == nil { + sourceAccountIndex = platformAccountOptions + .first(where: { $0.totalCredits > 0 })?.accountIndex + ?? platformAccountOptions.first?.accountIndex + } + autoSelectRecipient() + } + + private func autoSelectRecipient() { + if destinationMode == .ownWallet && selectedRecipientHash == nil { + selectedRecipientHash = ownWalletRecipientCandidates.first?.addressHash + } + } + + private func submit() { + guard !isSubmitting else { return } + guard + let sourceAccount = sourceAccountIndex, + let credits = parsedCredits, + let dest = resolvedDestination + else { return } + + guard !sourceInputHashes.contains(dest.hash) else { + submitError = SubmitError( + message: "The destination is a funded address on the source account, which the transfer uses as an input. Pick a different recipient." + ) + return + } + + let managedHolder = walletManager.wallet(for: wallet.walletId) + guard let managedHolder else { + submitError = SubmitError(message: "Wallet handle not found in the wallet manager.") + return + } + let addressWallet: ManagedPlatformAddressWallet + do { + addressWallet = try managedHolder.platformAddressWallet() + } catch { + submitError = SubmitError(message: "Couldn't acquire platform-address wallet: \(error.localizedDescription)") + return + } + + let signer = KeychainSigner(modelContainer: modelContext.container) + let outputs = [ + ManagedPlatformAddressWallet.TransferOutput( + addressType: dest.addressType, + hash: dest.hash, + credits: credits + ) + ] + + isSubmitting = true + Task { + defer { isSubmitting = false } + do { + let updated = try await addressWallet.transfer( + accountIndex: sourceAccount, + outputs: outputs, + signer: signer + ) + // The transfer has ALREADY succeeded on-chain here. + // Persist the post-transfer balances Rust reported BEFORE + // the resync so SwiftData doesn't show spent inputs as + // spendable in the gap before `performSync()` catches up. + // Mirrors the BLAST persister callback's upsert shape. + // + // A local save failure must NOT mark the transfer as failed + // (it succeeded; `performSync()` below corrects balances + // regardless) — but it must not be swallowed either. Surface + // it as a non-fatal caveat on the success screen rather than + // the hard error alert. + do { + try persistUpdatedBalances(updated) + } catch { + saveWarning = "Submitted successfully, but local balances " + + "couldn't be updated — they'll refresh on the next " + + "sync: \(error.localizedDescription)" + } + // Trigger a DIP-17 resync so balances + the unused- + // address pool catch up after the transfer. + await platformBalanceSyncService.performSync() + didSucceed = true + } catch { + submitError = SubmitError(message: error.localizedDescription) + } + } + } + + /// Apply the per-address `UpdatedBalance`s from a transfer's Rust + /// changeset to the matching `PersistentPlatformAddress` rows. Scoped + /// to this wallet and matched by 20-byte `addressHash`, mirroring the + /// BLAST `persistAddressBalances` callback so the row state is + /// consistent whether it lands from here or from the next sync round. + /// + /// Throws the SwiftData `save()` error to the caller rather than + /// swallowing it with `try?`. The caller has already confirmed the + /// on-chain transfer succeeded, so it routes this to a non-fatal + /// caveat (NOT the failure path) — the transfer stands and the next + /// sync reconciles balances regardless. + private func persistUpdatedBalances( + _ updated: [ManagedPlatformAddressWallet.UpdatedBalance] + ) throws { + guard !updated.isEmpty else { return } + let walletId = wallet.walletId + for entry in updated { + let hash = entry.hash + let descriptor = FetchDescriptor( + predicate: #Predicate { + $0.walletId == walletId && $0.addressHash == hash + } + ) + guard let row = try? modelContext.fetch(descriptor).first else { continue } + row.balance = entry.balance + row.nonce = entry.nonce + if entry.balance > 0 || entry.nonce > 0 { + row.isUsed = true + } + row.lastUpdated = Date() + } + try modelContext.save() + } + + // MARK: - Helpers + + private func formatCredits(_ credits: UInt64) -> String { + // Display only — the Double divide here never feeds a transfer + // amount, so the FP imprecision the parse path avoids is fine. + let dash = Double(credits) / Double(Self.creditsPerDash) + return String(format: "%.6f DASH", dash) + } + + private func hexShort(_ data: Data) -> String { + let hex = data.map { String(format: "%02x", $0) }.joined() + return hex.count > 12 ? "\(hex.prefix(6))…\(hex.suffix(6))" : hex + } +} + +// MARK: - Submit error wrapper + +private struct SubmitError: Identifiable { + let id = UUID() + let message: String +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionCategoryView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionCategoryView.swift index f649683fcc7..7ab03ad21c3 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionCategoryView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionCategoryView.swift @@ -53,30 +53,6 @@ struct TransitionCategoryView: View { var body: some View { if category == .address { List { - NavigationLink(destination: TransferAddressFundsView()) { - VStack(alignment: .leading, spacing: 8) { - Text("Transfer Address Funds") - .font(.headline) - Text("Transfer credits between Platform addresses") - .font(.caption) - .foregroundColor(.secondary) - .lineLimit(2) - } - .padding(.vertical, 4) - } - - NavigationLink(destination: WithdrawAddressFundsView()) { - VStack(alignment: .leading, spacing: 8) { - Text("Withdraw Address Funds") - .font(.headline) - Text("Withdraw credits from Platform to Core (L1)") - .font(.caption) - .foregroundColor(.secondary) - .lineLimit(2) - } - .padding(.vertical, 4) - } - NavigationLink(destination: TopUpAddressFromAssetLockView()) { VStack(alignment: .leading, spacing: 8) { Text("Top Up Address (Asset Lock)") @@ -124,6 +100,53 @@ struct TransitionCategoryView: View { } .padding(.vertical, 4) } + + // Debug-only raw (private-key) forms. The production, + // wallet-signed equivalents now live off the + // `WalletDetailView` Platform Balance row's ⋯ menu: + // Transfer Credits (ADDR-02, `TransferPlatformAddressView`) + // and Withdraw to Core (ADDR-04, + // `WithdrawPlatformAddressView`). These raw forms paste a + // 64-char private key and exist only for low-level + // debugging / arbitrary-address operations. + // + // Gated behind `#if DEBUG` so a Release/TestFlight build + // can't direct users to paste a raw private key, bypassing + // the `KeychainSigner` boundary the production sheets + // enforce. The view definitions stay compiled (they live in + // AddressQueriesView.swift); only these entry-point + // NavigationLinks are debug-only. + #if DEBUG + Section { + NavigationLink(destination: TransferAddressFundsView()) { + VStack(alignment: .leading, spacing: 8) { + Text("🧪 Transfer Address Funds (raw)") + .font(.headline) + Text("Debug-only: transfer credits between Platform addresses using a pasted private key. Production path: Wallet → Platform Balance → ⋯ → Transfer Credits.") + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(3) + } + .padding(.vertical, 4) + } + + NavigationLink(destination: WithdrawAddressFundsView()) { + VStack(alignment: .leading, spacing: 8) { + Text("🧪 Withdraw Address Funds (raw)") + .font(.headline) + Text("Debug-only: withdraw credits from Platform to Core (L1) using a pasted private key. Production path: Wallet → Platform Balance → ⋯ → Withdraw to Core.") + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(3) + } + .padding(.vertical, 4) + } + } header: { + Text("Debug / Raw (private-key) forms") + } footer: { + Text("These paste a raw 64-char private key and bypass the wallet signer. Use the production sheets off the wallet's Platform Balance row instead.") + } + #endif } .navigationTitle(category.rawValue) .navigationBarTitleDisplayMode(.inline) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift new file mode 100644 index 00000000000..3787a26d11d --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift @@ -0,0 +1,760 @@ +// WithdrawPlatformAddressView.swift +// SwiftExampleApp +// +// Production (wallet-signed) UI for ADDR-04: withdraw a Platform +// payment account's credits back to a Core L1 address. Mirrors +// `FundFromAssetLockPlatformAddressView`'s shape and drives +// `ManagedPlatformAddressWallet.withdraw(accountIndex:coreAddress: +// coreFeePerByte:signer:)` with a `KeychainSigner`. +// +// Withdrawals consume the FULL funded balance of the account (no +// per-address amount, no change output), so this view shows the +// computed total rather than an amount field. The Core destination +// can be one of the wallet's own receive addresses ("My Wallet") or a +// pasted/scanned external address; the address is network-checked on +// the Rust side. No private keys are entered here — contrast with the +// raw `WithdrawAddressFundsView` debug form. + +import SwiftUI +import SwiftDashSDK +import SwiftData + +struct WithdrawPlatformAddressView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + @EnvironmentObject var walletManager: PlatformWalletManager + @EnvironmentObject var platformBalanceSyncService: PlatformBalanceSyncService + + let wallet: PersistentWallet + + @Query private var allAccounts: [PersistentAccount] + @Query private var allPlatformAddresses: [PersistentPlatformAddress] + + // MARK: - Selection state + + private enum DestinationMode: String, CaseIterable, Identifiable { + case myWallet = "My Wallet" + case external = "External" + var id: String { rawValue } + } + + @State private var sourceAccountIndex: UInt32? = nil + @State private var destinationMode: DestinationMode = .myWallet + /// Core L1 address derived from this wallet's Core receive pool + /// (mode == .myWallet). Resolved lazily in `resolveMyWalletAddress`. + @State private var myWalletAddress: String? = nil + /// Pasted/scanned external Core address (mode == .external). + @State private var externalAddress: String = "" + /// Selected Core L1 fee rate (duffs/byte). Constrained by the picker + /// to a protocol-valid value (see `validFeeRates`); defaults to 1. + @State private var coreFeePerByte: UInt32 = 1 + + /// Per-input minimum credit amount (`min_input_amount`) the chain + /// enforces for address-funds transitions, resolved from the wallet's + /// current platform version via + /// `ManagedPlatformAddressWallet.minInputAmount()` once on appear. The + /// Rust withdraw selector (`select_withdrawable_inputs`) keeps only + /// addresses whose balance reaches this floor and returns + /// `OnlyDustInputs` when none do, so the "Total to Withdraw" figure and + /// the submit gate must sum only balances `>=` it to reflect the + /// *withdrawable* balance Rust will actually take. + /// + /// `nil` until resolved (or if resolution fails). We treat an + /// unresolved floor as a closed gate (`canSubmit` requires it to be + /// known) rather than substituting a numeric default: a fallback like + /// `0` would re-introduce the over-permissive behavior this fixes (every + /// dust row counted) and let the button enable a dust-only withdrawal + /// Rust would reject, while hardcoding the `100_000` protocol constant + /// would violate the no-Swift-mirror rule. The view still renders fully + /// when it's `nil`; only the withdrawable total reads `0` and submit + /// stays disabled until the version-locked floor loads. + @State private var minInputAmount: UInt64? = nil + + /// Rust-owned withdrawal preflight for the selected source account at the + /// current fee rate. Computed by `ManagedPlatformAddressWallet + /// .preflightWithdrawal(...)`, which runs the **same** planning phase the + /// real withdraw path executes (dust filter → fee estimate → fee + /// reservation → minimum-withdrawal check). This is the authoritative + /// submit gate: it inherently accounts for dust + the transition fee + the + /// minimum withdrawal amount, so a small-but-non-dust account that can't + /// cover the fee reports `canWithdraw == false` here instead of failing + /// after sign (the bug this fixes). + /// + /// `nil` until first computed (or if the computation throws). We treat an + /// unresolved/failed preflight as a CLOSED gate — `canSubmit` requires + /// `canWithdraw == true` — so the button never enables on a guess, never + /// *under*-gating. Recomputed only when the selected account or fee rate + /// changes (and on appear), never on a hot per-render path. + @State private var preflight: ManagedPlatformAddressWallet.WithdrawalPreflight? = nil + + // MARK: - Core readiness + + /// nil = not yet checked, true/false = Core wallet usable. + @State private var coreReady: Bool? = nil + @State private var coreNotReadyReason: String? = nil + + // MARK: - Submit state + + @State private var submitError: SubmitError? = nil + @State private var isSubmitting = false + @State private var didSucceed = false + /// Non-fatal caveat shown on the success screen when the withdrawal + /// succeeded on-chain but the local SwiftData balance write failed. + /// The withdrawal itself is NOT a failure (the `performSync()` that + /// runs right after corrects balances regardless), so this must not be + /// surfaced as `submitError` — but it must not be silently swallowed + /// either. + @State private var saveWarning: String? = nil + + private static let creditsPerDash: Double = 100_000_000_000.0 + + /// Upper bound on the Core L1 fee rate (duffs/byte). The normal rate + /// is 1; even heavy congestion rarely exceeds a few hundred. Because a + /// withdrawal is full-balance with the fee deducted from inputs, a + /// fat-fingered rate could eat the entire payout, so we cap well above + /// any legitimate manual override (10_000 = 10,000× the default) while + /// still rejecting obviously destructive values. This is an app-side + /// ceiling only — the protocol imposes no upper bound. + private static let maxFeePerByte: UInt32 = 10_000 + + /// The Core fee rates the protocol accepts, offered by the picker. + /// + /// DPP's `AddressCreditWithdrawalTransitionV0::validate_structure` + /// rejects any `core_fee_per_byte` that is not a NON-ZERO Fibonacci + /// number, so non-Fibonacci rates (4, 6, 7, 9, 10, 100, …) + /// deterministically fail structure validation on submit. The set is + /// generated by `WithdrawalCoreFeeRates` (a Fibonacci walk that mirrors + /// the validator) and capped at the app-side `maxFeePerByte` ceiling. + private static let validFeeRates: [UInt32] = + WithdrawalCoreFeeRates.rates(upTo: maxFeePerByte) + + var body: some View { + NavigationStack { + Form { + if didSucceed { + successSection + } else if coreReady == false { + coreNotReadySection + } else { + walletSection + sourceAccountSection + destinationSection + feeSection + summarySection + if canSubmit { + submitSection + } + } + } + .navigationTitle("Withdraw to Core") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { dismiss() } + .disabled(isSubmitting) + } + } + .alert(item: $submitError) { err in + Alert( + title: Text("Could not withdraw"), + message: Text(err.message), + dismissButton: .default(Text("OK")) + ) + } + .onAppear { + checkCoreReady() + resolveMinInputAmount() + autoSelectDefaults() + // `autoSelectDefaults()` may have just picked the source + // account, so run the preflight after it. + recomputePreflight() + } + .onChange(of: destinationMode) { _, mode in + if mode == .myWallet { resolveMyWalletAddress() } + } + // Recompute the Rust-owned preflight only when an input it depends + // on actually changes — the selected source account or the fee + // rate — never on a hot per-render path. The preflight is a local + // in-memory computation (no network), so this stays cheap. + .onChange(of: sourceAccountIndex) { _, _ in + recomputePreflight() + } + .onChange(of: coreFeePerByte) { _, _ in + recomputePreflight() + } + // Block swipe-to-dismiss while a withdrawal is in flight — + // only the (disabled) Cancel button otherwise gates it, so a + // swipe could tear the sheet down mid-submit. + .interactiveDismissDisabled(isSubmitting) + } + } + + // MARK: - Sections + + private var coreNotReadySection: some View { + Section { + VStack(alignment: .leading, spacing: 8) { + Label("Core wallet not ready", systemImage: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + .font(.headline) + Text(coreNotReadyReason + ?? "The Core (SPV) wallet must be initialized before you can withdraw to an L1 address. Sync the Core wallet and try again.") + .font(.callout) + .foregroundColor(.secondary) + Button("Close") { dismiss() } + .padding(.top, 4) + } + } + .accessibilityIdentifier("withdrawPlatform.coreNotReadySection") + } + + private var walletSection: some View { + Section { + HStack { + Label("Wallet", systemImage: "wallet.pass") + Spacer() + Text(wallet.name ?? hexShort(wallet.walletId)) + .lineLimit(1) + .truncationMode(.middle) + .foregroundColor(.secondary) + } + } header: { + Text("Source") + } + } + + @ViewBuilder + private var sourceAccountSection: some View { + let options = platformAccountOptions + Section { + if options.isEmpty { + Text("No DIP-17 Platform Payment accounts on this wallet yet.") + .font(.caption) + .foregroundColor(.secondary) + } else { + Picker("Source Account", selection: $sourceAccountIndex) { + Text("Select…").tag(Optional.none) + ForEach(options, id: \.accountIndex) { opt in + Text("Account #\(opt.accountIndex) — \(formatCredits(opt.totalCredits))") + .tag(Optional(opt.accountIndex)) + } + } + .accessibilityIdentifier("withdrawPlatform.sourceAccountPicker") + } + } header: { + Text("Source Account") + } footer: { + Text("The full credit balance of this account is withdrawn — there is no partial amount.") + } + } + + @ViewBuilder + private var destinationSection: some View { + Section { + Picker("Destination", selection: $destinationMode) { + ForEach(DestinationMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + .accessibilityIdentifier("withdrawPlatform.destinationModePicker") + + switch destinationMode { + case .myWallet: + if let addr = myWalletAddress { + HStack { + Label("Receive Address", systemImage: "arrow.down.circle") + Spacer() + Text("\(addr.prefix(10))…\(addr.suffix(6))") + .font(.system(.body, design: .monospaced)) + .foregroundColor(.secondary) + } + } else { + Text("Resolving a Core receive address…") + .font(.caption) + .foregroundColor(.secondary) + } + case .external: + VStack(alignment: .leading, spacing: 4) { + TextField("Core L1 address", text: $externalAddress) + .textFieldStyle(.roundedBorder) + .autocapitalization(.none) + .disableAutocorrection(true) + .monospaced() + .accessibilityIdentifier("withdrawPlatform.externalAddressField") + Text("The address is validated for this network on submit.") + .font(.caption) + .foregroundColor(.secondary) + } + } + } header: { + Text("Core L1 Destination") + } footer: { + Text("Withdraw to one of your own Core receive addresses, or paste an external Core address.") + } + } + + @ViewBuilder + private var feeSection: some View { + Section { + Picker("Fee per byte", selection: $coreFeePerByte) { + ForEach(Self.validFeeRates, id: \.self) { rate in + Text("\(rate) duffs/byte") + .tag(rate) + .accessibilityIdentifier("withdrawPlatform.feeRate.\(rate)") + } + } + .disabled(isSubmitting) + .accessibleFormPicker("withdrawPlatform.feePerBytePicker") + } header: { + Text("Core Fee Rate") + } footer: { + Text("Fee rate for the eventual L1 payout transaction. The protocol only accepts non-zero Fibonacci rates (1, 2, 3, 5, 8, …), so the picker offers exactly those. Default is 1.") + } + } + + @ViewBuilder + private var summarySection: some View { + Section { + // Account balance (dust-filtered, the spendable rows). Kept as + // context, but the authoritative payout figure is the preflight's + // net below. + HStack { + Label("Account Balance", systemImage: "creditcard") + Spacer() + Text(formatCredits(selectedSourceAccountCredits)) + .foregroundColor(.secondary) + } + + if let preflight, preflight.canWithdraw { + // Rust-owned figures: the net the chain actually pays out and + // the transition fee reserved on the fee-source input. + HStack { + Label("Estimated Fee", systemImage: "minus.circle") + Spacer() + Text(formatCredits(preflight.estimatedFee)) + .foregroundColor(.secondary) + } + HStack { + Label("You Will Withdraw", systemImage: "dollarsign.circle") + Spacer() + Text(formatCredits(preflight.netWithdrawable)) + .fontWeight(.semibold) + .accessibilityIdentifier("withdrawPlatform.netWithdrawable") + } + } else if sourceAccountIndex != nil, let reason = cantWithdrawReason { + // A resolved preflight that can't fund: explain why and keep + // submit disabled. (`cantWithdrawReason` is nil while the + // preflight is still unresolved, so we don't flash a false + // negative before the first computation lands.) + Label(reason, systemImage: "exclamationmark.triangle.fill") + .font(.callout) + .foregroundColor(.orange) + .accessibilityIdentifier("withdrawPlatform.cantWithdrawReason") + } + } header: { + Text("Summary") + } footer: { + Text("The platform-side fee is deducted from these inputs. The remaining balance is converted to Core duffs and paid out on L1 (minus the L1 fee).") + } + } + + private var submitSection: some View { + Section { + Button { + submit() + } label: { + HStack { + if isSubmitting { + ProgressView() + Text("Withdrawing…") + } else { + Text("Withdraw") + } + Spacer() + } + .foregroundColor(.white) + } + .frame(maxWidth: .infinity) + .listRowBackground(Color.accentColor) + .disabled(isSubmitting) + .accessibilityIdentifier("withdrawPlatform.submitButton") + } + } + + private var successSection: some View { + Section { + VStack(alignment: .leading, spacing: 8) { + Label("Withdrawal submitted", systemImage: "checkmark.seal.fill") + .foregroundColor(.green) + .font(.headline) + Text("The withdrawal was submitted. Credits will arrive on L1 once the payout is processed; balances are resyncing.") + .font(.callout) + .foregroundColor(.secondary) + if let saveWarning { + Label(saveWarning, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundColor(.orange) + .accessibilityIdentifier("withdrawPlatform.saveWarning") + } + Button { + dismiss() + } label: { + Text("Done").frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding(.top, 4) + } + } + } + + // MARK: - Derived + + private struct PlatformAccountOption { + let accountIndex: UInt32 + let totalCredits: UInt64 + } + + /// Source accounts: only DIP-17 PlatformPayment (`accountType == 14`) + /// accounts on the **default key class** (`keyClass == 0`). The Rust + /// `platform-wallet` crate resolves the withdraw source via + /// `platform_payment_managed_account_at_index(account_index)` = key + /// class 0, so a key-class-other account at the same index would never + /// be the spent source. Mirrors `TransferPlatformAddressView`. + /// + /// The displayed per-account balance sums only addresses whose parent + /// account is key class 0 (`account?.keyClass == 0`) AND whose balance + /// clears the chain's per-input minimum (`balance >= threshold`). The + /// Rust withdraw selector keeps only inputs that reach + /// `min_input_amount` and withdraws that *withdrawable* balance (dropping + /// sub-minimum dust, or failing with `OnlyDustInputs` if none clear it), + /// so this is the figure actually paid out. Summing every key-class-0 + /// row regardless of key class or balance would inflate the total and + /// let `canSubmit` enable a withdrawal Rust then refuses as dust-only. + private var platformAccountOptions: [PlatformAccountOption] { + // Withdrawable threshold: an address can only be a withdrawal input + // if its balance reaches the chain's `min_input_amount`. When the + // floor hasn't resolved yet (`nil`), `UInt64.max` makes every row + // dust so the withdrawable total is 0 and the submit gate stays + // closed — we never count an unknown-floor balance as withdrawable. + // See the `minInputAmount` doc comment for why we don't fall back to + // a numeric default. + let threshold = minInputAmount ?? UInt64.max + let accounts = allAccounts + .filter { $0.wallet.walletId == wallet.walletId } + .filter { $0.accountType == 14 && $0.keyClass == 0 } + .sorted { $0.accountIndex < $1.accountIndex } + return accounts.map { acct in + let total = allPlatformAddresses + .filter { + $0.walletId == wallet.walletId + && $0.accountIndex == acct.accountIndex + && $0.account?.keyClass == 0 + && $0.balance >= threshold + } + .reduce(into: UInt64(0)) { acc, addr in acc &+= addr.balance } + return PlatformAccountOption(accountIndex: acct.accountIndex, totalCredits: total) + } + } + + private var selectedSourceAccountCredits: UInt64 { + guard let idx = sourceAccountIndex else { return 0 } + return platformAccountOptions.first(where: { $0.accountIndex == idx })?.totalCredits ?? 0 + } + + /// The fee rate to submit. The picker constrains `coreFeePerByte` to a + /// protocol-valid (non-zero Fibonacci) value within the app ceiling, so + /// this is always non-nil; kept Optional so `canSubmit`/`submit()` read + /// unchanged and stay robust if the binding is ever widened. + private var parsedFeePerByte: UInt32? { + Self.validFeeRates.contains(coreFeePerByte) ? coreFeePerByte : nil + } + + /// Resolved Core destination address for the current mode. + private var resolvedCoreAddress: String? { + switch destinationMode { + case .myWallet: + return myWalletAddress + case .external: + let trimmed = externalAddress.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + } + + private var canSubmit: Bool { + guard + !isSubmitting, + coreReady == true, + sourceAccountIndex != nil, + // The authoritative gate: the Rust-owned preflight must have + // resolved AND reported the account can fund the withdrawal. This + // supersedes the old raw `selectedSourceAccountCredits > 0` check — + // it inherently accounts for dust + the transition fee + the + // minimum withdrawal amount, so a small-but-non-dust account that + // can't cover the fee (the bug this fixes) never enables submit. + // An unresolved or thrown preflight (`nil`) keeps the gate closed, + // so we never *under*-gate. + preflight?.canWithdraw == true, + parsedFeePerByte != nil, + let addr = resolvedCoreAddress, !addr.isEmpty + else { return false } + return true + } + + /// Human-readable reason the selected account can't fund a withdrawal, or + /// `nil` when the preflight is unresolved or the account CAN withdraw. Used + /// only for display; the authoritative gate is `preflight?.canWithdraw`. + /// + /// The reason is the Rust planner's own message, surfaced verbatim through + /// `WithdrawalPreflight.reason`. The planner is the single source of the + /// can't-fund classification (dust-only, fee/minimum headroom, too many + /// inputs, or above the maximum withdrawal); Swift must NOT re-derive it + /// from balances, which would mean mirroring protocol decisions on the + /// wrong side of the FFI boundary. The generic fallback covers only the + /// unexpected case where a `canWithdraw == false` result arrives without a + /// message. + private var cantWithdrawReason: String? { + guard let preflight, !preflight.canWithdraw else { return nil } + return preflight.reason ?? "This account can't fund a withdrawal right now." + } + + // MARK: - Actions + + private func autoSelectDefaults() { + if sourceAccountIndex == nil { + sourceAccountIndex = platformAccountOptions + .first(where: { $0.totalCredits > 0 })?.accountIndex + ?? platformAccountOptions.first?.accountIndex + } + if destinationMode == .myWallet { + resolveMyWalletAddress() + } + } + + /// Gate the whole flow on the Core (SPV) wallet being usable. + /// + /// This must be a NON-mutating probe: it runs on every sheet open, so + /// anything with a side effect would churn wallet state just from the + /// user glancing at the sheet. `coreWallet()` (`platform_wallet_get_core`) + /// already throws if the Core side isn't initialized, and `network()` is + /// a lock-free read that succeeds whenever the handle is live — together + /// they confirm the Core wallet is acquirable and answering without + /// touching the BIP-44 receive pool. + /// + /// The earlier implementation probed `nextReceiveAddress`, but that FFI + /// passes `advance = true` (`CoreWallet::next_receive_address_for_account`, + /// rs-platform-wallet/src/wallet/core/wallet.rs:103), so every readiness + /// check ADVANCED the external pool — opening the sheet repeatedly burned + /// receive addresses. The Core wallet FFI surface has no non-advancing + /// "peek" or "is account present" call, so we gate on `network()` here + /// and only consume an address when the user actually needs a My-Wallet + /// destination (see `resolveMyWalletAddress`, which caches its one fetch). + private func checkCoreReady() { + guard let managedHolder = walletManager.wallet(for: wallet.walletId) else { + coreReady = false + coreNotReadyReason = "Wallet handle not found in the wallet manager." + return + } + do { + let core = try managedHolder.coreWallet() + _ = try core.network() + coreReady = true + } catch { + coreReady = false + coreNotReadyReason = "Core wallet is not ready: \(error.localizedDescription)" + } + } + + /// Resolve the chain's per-input minimum (`min_input_amount`) once from + /// the wallet's current platform version (version-locked, read on the + /// Rust side). Called on appear. On any failure we leave + /// `minInputAmount == nil`, which keeps the withdrawable total at 0 and + /// the submit gate closed — a deliberately conservative fallback that + /// never *under*-gates (see the `minInputAmount` doc comment). + private func resolveMinInputAmount() { + guard minInputAmount == nil else { return } + guard let managedHolder = walletManager.wallet(for: wallet.walletId) else { return } + do { + let addressWallet = try managedHolder.platformAddressWallet() + minInputAmount = try addressWallet.minInputAmount() + } catch { + // Leave nil: gate stays closed until a later appearance resolves it. + minInputAmount = nil + } + } + + /// Recompute the Rust-owned withdrawal preflight for the currently selected + /// source account at the current fee rate, storing it in `preflight`. + /// + /// Called on appear and whenever `sourceAccountIndex` / `coreFeePerByte` + /// changes — never on a hot per-render path. The preflight is a local + /// in-memory computation (`platform_address_wallet_preflight_withdrawal` + /// runs the planner over cached balances, no network), so it's cheap enough + /// to run synchronously on these input changes. + /// + /// When no source account is selected we clear the result (`nil`), which + /// keeps `canSubmit` closed. A thrown preflight (bad handle / missing + /// account — a structural failure, NOT a "can't fund") also clears it, + /// resolving gracefully by leaving submit disabled rather than enabling on + /// a guess; "can't fund" is a normal non-throwing result with + /// `canWithdraw == false`. + private func recomputePreflight() { + guard let idx = sourceAccountIndex else { + preflight = nil + return + } + guard let managedHolder = walletManager.wallet(for: wallet.walletId) else { + preflight = nil + return + } + do { + let addressWallet = try managedHolder.platformAddressWallet() + preflight = try addressWallet.preflightWithdrawal( + accountIndex: idx, + coreFeePerByte: coreFeePerByte + ) + } catch { + // Structural failure: leave submit disabled (don't under-gate). + preflight = nil + } + } + + /// Resolve a Core receive address for the "My Wallet" destination and + /// cache it in `myWalletAddress` for the sheet's lifetime. + /// + /// `core.nextReceiveAddress(accountIndex:)` ADVANCES the BIP-44 external + /// pool (`advance = true` on the Rust side), so it must be called at most + /// once per sheet session and only when the user actually needs a + /// My-Wallet destination — never as a readiness probe (see + /// `checkCoreReady`). The `myWalletAddress == nil` guard makes repeated + /// calls (e.g. toggling the destination segment back to My Wallet) + /// no-ops, so open/cancel/toggle consumes exactly one receive address, + /// not one per interaction. Only invoked from `autoSelectDefaults` + /// (when the default My-Wallet mode is active) and the destination-mode + /// `onChange` when switching back to My Wallet. + private func resolveMyWalletAddress() { + guard myWalletAddress == nil else { return } + guard let managedHolder = walletManager.wallet(for: wallet.walletId) else { return } + do { + let core = try managedHolder.coreWallet() + myWalletAddress = try core.nextReceiveAddress(accountIndex: 0) + } catch { + // Leave nil; the destination section shows the resolving + // placeholder and Core-readiness gating handles the rest. + myWalletAddress = nil + } + } + + private func submit() { + guard !isSubmitting else { return } + guard + let sourceAccount = sourceAccountIndex, + let feePerByte = parsedFeePerByte, + let coreAddress = resolvedCoreAddress + else { return } + + let managedHolder = walletManager.wallet(for: wallet.walletId) + guard let managedHolder else { + submitError = SubmitError(message: "Wallet handle not found in the wallet manager.") + return + } + let addressWallet: ManagedPlatformAddressWallet + do { + addressWallet = try managedHolder.platformAddressWallet() + } catch { + submitError = SubmitError(message: "Couldn't acquire platform-address wallet: \(error.localizedDescription)") + return + } + + let signer = KeychainSigner(modelContainer: modelContext.container) + + isSubmitting = true + Task { + defer { isSubmitting = false } + do { + let updated = try await addressWallet.withdraw( + accountIndex: sourceAccount, + coreAddress: coreAddress, + coreFeePerByte: feePerByte, + signer: signer + ) + // The withdrawal has ALREADY succeeded on-chain here. + // Persist the drained balances Rust just reported BEFORE + // the resync so SwiftData stops showing the consumed + // inputs as spendable in the gap before `performSync()` + // catches up. Mirrors the BLAST persister callback's + // upsert shape (`persistAddressBalances`). + // + // A local save failure must NOT mark the withdrawal as + // failed (it succeeded; `performSync()` below corrects + // balances regardless) — but it must not be swallowed + // either. Surface it as a non-fatal caveat on the success + // screen rather than the hard error alert. + do { + try persistUpdatedBalances(updated) + } catch { + saveWarning = "Submitted successfully, but local balances " + + "couldn't be updated — they'll refresh on the next " + + "sync: \(error.localizedDescription)" + } + await platformBalanceSyncService.performSync() + didSucceed = true + } catch { + submitError = SubmitError(message: error.localizedDescription) + } + } + } + + /// Apply the per-address `UpdatedBalance`s from a withdrawal's Rust + /// changeset to the matching `PersistentPlatformAddress` rows. Scoped + /// to this wallet and matched by 20-byte `addressHash`, mirroring the + /// BLAST `persistAddressBalances` callback so the row state is + /// consistent whether it lands from here or from the next sync round. + /// + /// Throws the SwiftData `save()` error to the caller rather than + /// swallowing it with `try?`. The caller has already confirmed the + /// on-chain withdrawal succeeded, so it routes this to a non-fatal + /// caveat (NOT the failure path) — the withdrawal stands and the next + /// sync reconciles balances regardless. + private func persistUpdatedBalances( + _ updated: [ManagedPlatformAddressWallet.UpdatedBalance] + ) throws { + guard !updated.isEmpty else { return } + let walletId = wallet.walletId + for entry in updated { + let hash = entry.hash + let descriptor = FetchDescriptor( + predicate: #Predicate { + $0.walletId == walletId && $0.addressHash == hash + } + ) + guard let row = try? modelContext.fetch(descriptor).first else { continue } + row.balance = entry.balance + row.nonce = entry.nonce + if entry.balance > 0 || entry.nonce > 0 { + row.isUsed = true + } + row.lastUpdated = Date() + } + try modelContext.save() + } + + // MARK: - Helpers + + private func formatCredits(_ credits: UInt64) -> String { + let dash = Double(credits) / Self.creditsPerDash + return String(format: "%.6f DASH", dash) + } + + private func hexShort(_ data: Data) -> String { + let hex = data.map { String(format: "%02x", $0) }.joined() + return hex.count > 12 ? "\(hex.prefix(6))…\(hex.suffix(6))" : hex + } +} + +// MARK: - Submit error wrapper + +private struct SubmitError: Identifiable { + let id = UUID() + let message: String +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawalCoreFeeRates.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawalCoreFeeRates.swift new file mode 100644 index 00000000000..01abb538714 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawalCoreFeeRates.swift @@ -0,0 +1,41 @@ +// WithdrawalCoreFeeRates.swift +// SwiftExampleApp +// +// Pure, testable source of truth for the Core L1 fee rates the protocol +// accepts on an address credit withdrawal. Kept out of the SwiftUI view +// (mirroring `KeyDisableGate`) so the offered set can be unit-tested. +// +// DPP's `AddressCreditWithdrawalTransitionV0::validate_structure` rejects +// any `core_fee_per_byte` that is not a NON-ZERO Fibonacci number +// (`is_non_zero_fibonacci_number`). Non-Fibonacci rates (4, 6, 7, 9, 10, +// 100, …) deterministically fail structure validation on submit, so the +// withdraw sheet must only offer Fibonacci rates. We generate the same +// sequence the validator recognizes — 1, 2, 3, 5, 8, … — by a Fibonacci +// walk (rather than hardcoding) so the offered values stay in lockstep +// with the protocol's definition, capped at an app-side ceiling. + +import Foundation + +enum WithdrawalCoreFeeRates { + /// Non-zero Fibonacci fee rates up to and including `ceiling` + /// (deduplicated; the protocol accepts 1, and 0 is rejected). + /// + /// `addingReportingOverflow` guards the walk so an unusually large + /// ceiling can never wrap `UInt32`. + static func rates(upTo ceiling: UInt32) -> [UInt32] { + guard ceiling >= 1 else { return [] } + var rates: [UInt32] = [] + var previous: UInt32 = 1 + var current: UInt32 = 1 + while previous <= ceiling { + if rates.last != previous { + rates.append(previous) + } + let (next, overflow) = previous.addingReportingOverflow(current) + previous = current + current = next + if overflow { break } + } + return rates + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/PlatformPaymentAccountSelectionTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/PlatformPaymentAccountSelectionTests.swift new file mode 100644 index 00000000000..4c1e4913145 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/PlatformPaymentAccountSelectionTests.swift @@ -0,0 +1,143 @@ +// +// PlatformPaymentAccountSelectionTests.swift +// SwiftExampleAppTests +// +// Unit coverage for `PlatformPaymentAccountSelection.choose(...)` — the +// pure logic that picks WHICH key-class-0 Platform Payment account funds +// a platform → platform transfer. The Rust Auto selector spends inputs +// within a single account, so the chosen account must cover the full +// amount + fee on its own; the helper must never hand back an account +// that can't, and must pick the largest covering account when several do. +// + +import XCTest +@testable import SwiftExampleApp + +final class PlatformPaymentAccountSelectionTests: XCTestCase { + + private typealias Selection = PlatformPaymentAccountSelection + private typealias Candidate = PlatformPaymentAccountSelection.Candidate + + // MARK: - Covering picks + + func testSingleCoveringAccountIsChosen() { + let candidates = [Candidate(accountIndex: 0, balance: 1_000)] + XCTAssertEqual( + Selection.choose(from: candidates, amount: 500, fee: 100), + .covering(accountIndex: 0) + ) + } + + /// With several covering accounts, prefer the largest balance. + func testPrefersLargestCoveringAccount() { + let candidates = [ + Candidate(accountIndex: 0, balance: 1_000), + Candidate(accountIndex: 1, balance: 5_000), + Candidate(accountIndex: 2, balance: 2_000), + ] + XCTAssertEqual( + Selection.choose(from: candidates, amount: 800, fee: 100), + .covering(accountIndex: 1) + ) + } + + /// Equal-balance covering accounts tie-break on the smaller index, so + /// the pick is deterministic regardless of input order. + func testCoveringTieBreaksOnSmallerIndex() { + let candidates = [ + Candidate(accountIndex: 3, balance: 1_000), + Candidate(accountIndex: 1, balance: 1_000), + Candidate(accountIndex: 2, balance: 1_000), + ] + XCTAssertEqual( + Selection.choose(from: candidates, amount: 500, fee: 100), + .covering(accountIndex: 1) + ) + } + + /// The account must cover amount + FEE, not just the amount: an + /// account that holds the amount but not the fee is NOT covering. + func testFeeIsIncludedInCoverageRequirement() { + let candidates = [ + Candidate(accountIndex: 0, balance: 500), // exactly the amount + Candidate(accountIndex: 1, balance: 650), // amount + fee + ] + XCTAssertEqual( + Selection.choose(from: candidates, amount: 500, fee: 100), + .covering(accountIndex: 1) + ) + } + + /// Exact coverage (balance == amount + fee) qualifies. + func testExactCoverageQualifies() { + let candidates = [Candidate(accountIndex: 7, balance: 600)] + XCTAssertEqual( + Selection.choose(from: candidates, amount: 500, fee: 100), + .covering(accountIndex: 7) + ) + } + + // MARK: - Insufficient (the core CodeRabbit bug) + + /// The aggregate covers the transfer but NO single account does — this + /// is exactly the case the UI's aggregate-balance gate let through + /// before. The helper must report `.insufficient`, not pick one. + func testAggregateCoversButNoSingleAccountDoes() { + let candidates = [ + Candidate(accountIndex: 0, balance: 400), + Candidate(accountIndex: 1, balance: 400), + ] // aggregate 800 >= 600, but neither account alone does + XCTAssertEqual( + Selection.choose(from: candidates, amount: 500, fee: 100), + .insufficient(largestAccountIndex: 0) + ) + } + + /// Insufficient still surfaces the largest-balance account (tie-broken + /// on smaller index) as a best-effort fallback for callers that opt to + /// proceed; the send screen chooses to abort instead. + func testInsufficientReportsLargestAccountFallback() { + let candidates = [ + Candidate(accountIndex: 5, balance: 100), + Candidate(accountIndex: 2, balance: 300), + Candidate(accountIndex: 9, balance: 300), + ] + XCTAssertEqual( + Selection.choose(from: candidates, amount: 10_000, fee: 1), + .insufficient(largestAccountIndex: 2) + ) + } + + /// No candidate accounts at all → insufficient with no fallback index. + func testNoCandidatesYieldsInsufficientNil() { + XCTAssertEqual( + Selection.choose(from: [], amount: 500, fee: 100), + .insufficient(largestAccountIndex: nil) + ) + } + + // MARK: - Overflow safety + + /// amount + fee overflowing UInt64 must be treated as "no account can + /// cover it" (insufficient), never trap or wrap to a tiny requirement. + func testAmountPlusFeeOverflowIsInsufficient() { + let candidates = [Candidate(accountIndex: 0, balance: UInt64.max)] + XCTAssertEqual( + Selection.choose(from: candidates, amount: UInt64.max, fee: 1), + .insufficient(largestAccountIndex: 0) + ) + } + + /// A zero requirement (off-path fallback when amount/fee are absent) + /// makes the largest account trivially covering. + func testZeroRequirementPicksLargestAccount() { + let candidates = [ + Candidate(accountIndex: 0, balance: 10), + Candidate(accountIndex: 1, balance: 20), + ] + XCTAssertEqual( + Selection.choose(from: candidates, amount: 0, fee: 0), + .covering(accountIndex: 1) + ) + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/WithdrawalCoreFeeRatesTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/WithdrawalCoreFeeRatesTests.swift new file mode 100644 index 00000000000..35ff577f394 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/WithdrawalCoreFeeRatesTests.swift @@ -0,0 +1,62 @@ +// +// WithdrawalCoreFeeRatesTests.swift +// SwiftExampleAppTests +// +// Unit coverage for `WithdrawalCoreFeeRates.rates(upTo:)` — the set of +// Core L1 fee rates the ADDR-04 withdraw sheet offers. The protocol +// (DPP `AddressCreditWithdrawalTransitionV0::validate_structure`) only +// accepts NON-ZERO Fibonacci rates, so this set must contain exactly the +// Fibonacci numbers within the app-side ceiling and nothing else. +// + +import XCTest +@testable import SwiftExampleApp + +final class WithdrawalCoreFeeRatesTests: XCTestCase { + + /// The Fibonacci numbers <= 10_000 — the ceiling the withdraw sheet + /// uses. Matches the validator's accepted set (sibling DPP test + /// `should_accept_valid_fibonacci_core_fees` accepts [1,2,3,5,8,13,21]). + func testRatesUpTo10000AreExactlyTheFibonacciNumbers() { + let expected: [UInt32] = [ + 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, + 1597, 2584, 4181, 6765, + ] + XCTAssertEqual(WithdrawalCoreFeeRates.rates(upTo: 10_000), expected) + } + + /// 1 is the default and must be present and first. + func testDefaultRateOneIsOfferedFirst() { + let rates = WithdrawalCoreFeeRates.rates(upTo: 10_000) + XCTAssertEqual(rates.first, 1) + } + + /// The leading repeated 1 in the Fibonacci sequence is de-duplicated. + func testNoDuplicateLeadingOne() { + XCTAssertEqual(WithdrawalCoreFeeRates.rates(upTo: 10_000).filter { $0 == 1 }.count, 1) + } + + /// Known non-Fibonacci values the reviewer called out are never offered. + func testNonFibonacciRatesAreNotOffered() { + let rates = Set(WithdrawalCoreFeeRates.rates(upTo: 10_000)) + for invalid: UInt32 in [4, 6, 7, 9, 10, 11, 12, 14, 100, 1000] { + XCTAssertFalse(rates.contains(invalid), "\(invalid) is not Fibonacci and must not be offered") + } + } + + /// The ceiling itself is inclusive when it is a Fibonacci number, and + /// nothing above the ceiling leaks in. + func testCeilingIsInclusiveAndBounded() { + // 13 is Fibonacci -> included; 14 is the boundary for a 13 ceiling. + XCTAssertEqual(WithdrawalCoreFeeRates.rates(upTo: 13).last, 13) + XCTAssertEqual(WithdrawalCoreFeeRates.rates(upTo: 14).last, 13) + XCTAssertTrue(WithdrawalCoreFeeRates.rates(upTo: 10_000).allSatisfy { $0 <= 10_000 }) + } + + /// Degenerate ceilings: 0 yields an empty set; 1 yields exactly [1]. + func testLowCeilings() { + XCTAssertEqual(WithdrawalCoreFeeRates.rates(upTo: 0), []) + XCTAssertEqual(WithdrawalCoreFeeRates.rates(upTo: 1), [1]) + XCTAssertEqual(WithdrawalCoreFeeRates.rates(upTo: 2), [1, 2]) + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md index 32582aea6e4..723908778fa 100644 --- a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md +++ b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md @@ -99,7 +99,7 @@ Most Platform actions have hard preconditions. Establish these fixtures before s | 🚫 | Not implemented anywhere (no FFI, no UI). | No | | ➖ | Retired — the thing this row tracked was removed or folded into another row. | n/a | -> **Entry-point reality check.** A few Platform write transitions (data-contract create/update, `DC-03`/`DC-04`) are reachable in the app **only through `Settings → Platform State Transitions` → `TransitionDetailView`** (marked 🧪). They broadcast for real, but there is no per-identity "happy path" button for them. The QA agent must navigate to the builder for those rows. The full **document** write family now has production UI: create (`DOC-02`) via Contracts → contract → document type → **New Document**, and replace/delete/transfer/set-price/purchase (`DOC-03`..`DOC-07`) via Contracts → **Browse Documents** (`contracts.browseDocuments`) → document → **⋯** action menu (ownership-gated) → `platform_wallet_document_*`. Identity credit *transfer* (`ID-04`), *withdrawal* (`ID-10`), and *key-disable* (`ID-12`) also have production buttons (`IdentityDetailView` / `KeyDetailView`). The builder and the read-only **Platform Queries** catalog both live under the **Settings** tab's **Platform** section (scroll past *Network* and *Data*). +> **Entry-point reality check.** A few Platform write transitions (data-contract create/update, `DC-03`/`DC-04`) are reachable in the app **only through `Settings → Platform State Transitions` → `TransitionDetailView`** (marked 🧪). They broadcast for real, but there is no per-identity "happy path" button for them. The QA agent must navigate to the builder for those rows. The full **document** write family now has production UI: create (`DOC-02`) via Contracts → contract → document type → **New Document**, and replace/delete/transfer/set-price/purchase (`DOC-03`..`DOC-07`) via Contracts → **Browse Documents** (`contracts.browseDocuments`) → document → **⋯** action menu (ownership-gated) → `platform_wallet_document_*`. Identity credit *transfer* (`ID-04`), *withdrawal* (`ID-10`), and *key-disable* (`ID-12`) also have production buttons (`IdentityDetailView` / `KeyDetailView`). The DIP-17 platform-address *transfer* (`ADDR-02`) and *withdrawal* (`ADDR-04`) now have production sheets off the `WalletDetailView` Platform Balance row's ⋯ menu — see those rows. The builder and the read-only **Platform Queries** catalog both live under the **Settings** tab's **Platform** section (scroll past *Network* and *Data*). --- @@ -160,9 +160,9 @@ The app is a full multi-wallet client: `PlatformWalletManager` holds N wallets c | ID | Action | Layer | Tier | Status | Entry point & test notes | |---|---|---|---|---|---| | ADDR-01 | Query address info / multiple infos | Platform | Common | ✅ | `GetAddressInfoViewModel` / `GetAddressesInfosViewModel` → `dash_sdk_address_fetch_info(s)`. | -| ADDR-02 | Transfer credits address → address | Platform | Thorough | ✅ | `AddressQueriesView` → TransferAddressFunds → `dash_sdk_address_transfer_funds`. | +| ADDR-02 | Transfer credits address → address | Platform | Thorough | ✅ | `WalletDetailView` → Platform Balance row **⋯ menu → Transfer Credits** (sheet, `TransferPlatformAddressView`) → `ManagedPlatformAddressWallet.transfer` → `platform_address_wallet_transfer` (keychain-signed). Source = DIP-17 platform-payment account picker; destination = own-wallet address picker or pasted 20-byte P2PKH hash. Input selection (Auto), the `Σ inputs == Σ outputs` balancing, fee strategy, and nonce all happen Rust-side — surplus stays on the source addresses (credit-balance model), so there's no change address to pick, and no private-key entry. Submit gated on amount + fee ≤ account balance and recipient ∉ funded source inputs. On success a DIP-17 resync runs. (Also reachable via the 🧪 debug builder *Settings → Platform State Transitions → Address → Transfer Address Funds (raw)* → `dash_sdk_address_transfer_funds`, which pastes a raw 64-char private key.) | | ADDR-03 | Top up address from asset lock | Cross | Thorough | ✅ | `FundFromAssetLockPlatformAddressView` → `dash_sdk_address_top_up_from_asset_lock`. | -| ADDR-04 | Withdraw address credits → Core L1 | Cross | Thorough | ✅ | `AddressQueriesView` → WithdrawAddressFunds → `dash_sdk_address_withdraw_funds`. | +| ADDR-04 | Withdraw address credits → Core L1 | Cross | Thorough | ✅ | `WalletDetailView` → Platform Balance row **⋯ menu → Withdraw to Core** (sheet, `WithdrawPlatformAddressView`) → `ManagedPlatformAddressWallet.withdraw` → `platform_address_wallet_withdraw_to_address` (keychain-signed). Source = DIP-17 platform-payment account picker; the **full** account balance is withdrawn (no per-address amount, no change). Core L1 destination = own wallet (`core_wallet_next_receive_address`) or pasted external address, network-checked Rust-side. `coreFeePerByte` defaults to 1. Gated on the Core (SPV) wallet being initialized — shows a "Core not ready" state otherwise. Identity/address credit balance drops; L1 payout is pooled and processed asynchronously (no immediate txid). On success a DIP-17 resync runs. (Also reachable via the 🧪 debug builder *Settings → Platform State Transitions → Address → Withdraw Address Funds (raw)* → `dash_sdk_address_withdraw_funds`, which pastes a raw 64-char private key.) | | ADDR-05 | Address balance-change history (recent / compacted / branch / trunk) | Platform | Uncommon | 🔌 | FFI `dash_sdk_address_fetch_recent_balance_changes` / `_compacted_balance_changes` / `_branch_state` / `_trunk_state`; no UI. | | ADDR-06 | Display / share your Platform receive address | Platform | Common | ✅ | "Receive Dash" sheet → **Platform** tab (`ReceiveAddressView`, `ReceiveAddressTab.platform`, "Your Platform Address"): QR + bech32m DIP-17 address + Copy. The receive counterpart to the credit-transfer / top-up funding paths. | diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ManagedPlatformAddressWalletTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ManagedPlatformAddressWalletTests.swift deleted file mode 100644 index a34c0701ba2..00000000000 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ManagedPlatformAddressWalletTests.swift +++ /dev/null @@ -1,82 +0,0 @@ -import XCTest -@testable import SwiftDashSDK - -final class ManagedPlatformAddressWalletTests: XCTestCase { - - /// Convert the FFI's 20-byte tuple back to Data for assertion. - private func hashData(_ entry: AddressBalanceEntryFFI) -> Data { - withUnsafeBytes(of: entry.address.hash) { Data($0) } - } - - // Pre-fix this returned changeIndex == 1 (insertion order, change was - // last). Rust then indexed sorted row 1 (the recipient) and carved the - // fee out of them. Regression scenario from issue #3738. - func test_buildSortedFFIOutputs_changeSortsBeforeRecipient_indexIsZero() { - let recipientHash = Data(repeating: 0xFF, count: 20) - let changeHash = Data(repeating: 0x00, count: 20) - let recipient = ManagedPlatformAddressWallet.TransferOutput( - addressType: 0, - hash: recipientHash, - credits: 100 - ) - let change = ( - addressType: UInt8(0), - hash: changeHash, - balance: UInt64(50) - ) - - let (rows, changeIndex) = ManagedPlatformAddressWallet.buildSortedFFIOutputs( - recipients: [recipient], - change: change - ) - - XCTAssertEqual(changeIndex, 0) - XCTAssertEqual(rows.count, 2) - XCTAssertEqual(hashData(rows[0]), changeHash, "row 0 = change address (0x00…)") - XCTAssertEqual(rows[0].balance, 50) - XCTAssertEqual(hashData(rows[1]), recipientHash, "row 1 = recipient address (0xFF…)") - XCTAssertEqual(rows[1].balance, 100) - } - - // Multi-recipient: change address sorts into the MIDDLE of the - // output list. Defends against an off-by-one or - // last-position-assumption regression in the helper, and crosses - // the 0x7F/0x80 byte boundary so that any accidental signed-byte - // comparison would flip the order and fail the test. - func test_buildSortedFFIOutputs_multipleRecipients_changeInMiddle() { - let lowRecipientHash = Data(repeating: 0x10, count: 20) - let changeHash = Data(repeating: 0x80, count: 20) - let highRecipientHash = Data(repeating: 0xF0, count: 20) - let recipients = [ - ManagedPlatformAddressWallet.TransferOutput( - addressType: 0, - hash: lowRecipientHash, - credits: 100 - ), - ManagedPlatformAddressWallet.TransferOutput( - addressType: 0, - hash: highRecipientHash, - credits: 200 - ), - ] - let change = ( - addressType: UInt8(0), - hash: changeHash, - balance: UInt64(75) - ) - - let (rows, changeIndex) = ManagedPlatformAddressWallet.buildSortedFFIOutputs( - recipients: recipients, - change: change - ) - - XCTAssertEqual(rows.count, 3) - XCTAssertEqual(changeIndex, 1, "change at 0x80… sorts between 0x10… and 0xF0…") - XCTAssertEqual(hashData(rows[0]), lowRecipientHash) - XCTAssertEqual(rows[0].balance, 100) - XCTAssertEqual(hashData(rows[1]), changeHash) - XCTAssertEqual(rows[1].balance, 75) - XCTAssertEqual(hashData(rows[2]), highRecipientHash) - XCTAssertEqual(rows[2].balance, 200) - } -}