From 926eebb0da6feb5201e523d863fab85c142a3b7b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 16 Jun 2026 19:48:10 +0100 Subject: [PATCH 01/21] feat(swift-example-app): wallet-signed Transfer & Withdraw for platform addresses (ADDR-02/04) Promote two SwiftExampleApp platform-address (DIP-17) actions from raw private-key demo forms to first-class, wallet-signed production UI, so users never paste a 64-char private key. Key derivation and signing stay in the platform-wallet crate behind rs-platform-wallet-ffi, driven by a KeychainSigner; Swift only picks source account / amount / destination and calls a single entry point. ADDR-02 (transfer) - UI only; the wallet-signed stack already existed: - New TransferPlatformAddressView: DIP-17 account picker (accountType 14) with credit balances, destination via own-wallet picker or pasted 20-byte P2PKH hash, amount field. Auto-selects an unused change address from the SwiftData pool internally, passes Auto nonce selection (not hardcoded 0), gates submit on amount + fee buffer <= balance and recipient != change, single-flight guard, resyncs balances on success. ADDR-04 (withdraw) - small Rust/FFI add + Swift wrapper + UI: - New FFI sibling platform_address_wallet_withdraw_to_address that accepts a base58 Core address, network-checks it Rust-side via require_network(wallet.network()), builds the script_pubkey, and delegates to the existing wallet.withdraw(...). Includes a unit test for the network check. - Thin ManagedPlatformAddressWallet.withdraw(accountIndex:coreAddress: coreFeePerByte:signer:) wrapper using INPUT_SELECTION_TYPE_AUTO + DeductFromInput(0) (full balance, no change output). - New WithdrawPlatformAddressView: source picker, Core L1 destination toggle (my-wallet via core_wallet_next_receive_address vs external paste), coreFeePerByte (default 1), full-balance total display, gated on Core wallet initialized with a clear "Core not ready" state, single-flight guard, resync on success. Wiring & docs: - WalletDetailView: Platform Balance row gains a Top Up / Transfer / Withdraw menu presenting both sheets. - TransitionCategoryView: legacy raw private-key Transfer/Withdraw links moved into a debug-only section. - TEST_PLAN.md: ADDR-02/ADDR-04 flipped to production status. Not a consensus change. Verified: cargo fmt clean, cargo check and the new FFI unit test pass, build_ios.sh regenerates the cbindgen header with the new symbol, and a clean SwiftExampleApp simulator build succeeds. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/platform_addresses/withdrawal.rs | 143 ++++++ .../ManagedPlatformAddressWallet.swift | 87 ++++ .../Core/Views/WalletDetailView.swift | 125 ++++- .../Views/TransferPlatformAddressView.swift | 476 ++++++++++++++++++ .../Views/TransitionCategoryView.swift | 62 ++- .../Views/WithdrawPlatformAddressView.swift | 450 +++++++++++++++++ .../swift-sdk/SwiftExampleApp/TEST_PLAN.md | 6 +- 7 files changed, 1310 insertions(+), 39 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift 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..acebb68e4b1 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_addresses/withdrawal.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_addresses/withdrawal.rs @@ -7,6 +7,8 @@ use crate::platform_address_types::*; use crate::{unwrap_option_or_return, unwrap_result_or_return}; use dpp::identity::core_script::CoreScript; use rs_sdk_ffi::{SignerHandle, VTableSigner}; +use std::os::raw::c_char; +use std::str::FromStr; use super::{parse_input_selection, runtime}; @@ -64,3 +66,144 @@ 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); + + 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. `dashcore::address::Error` has + // no `From` impl on `PlatformWalletError`, so map it into the + // typed `AddressOperation` variant the rest of this path uses. + let checked_address = unchecked_address + .clone() + .require_network(wallet.network()) + .map_err(|e| { + platform_wallet::PlatformWalletError::AddressOperation(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, + )) + }); + let result = unwrap_option_or_return!(option); + // `result` is `Result` + // where the address-network failure surfaces as a + // `dashcore::address::Error` widened into `PlatformWalletError`. + let changeset = unwrap_result_or_return!(result); + *out_changeset = PlatformAddressChangeSetFFI::from(&changeset); + PlatformWalletFFIResult::ok() +} + +#[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/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift index 5e6cc62c596..1b599b1ecf3 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift @@ -385,6 +385,93 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { ) } + // MARK: - Withdraw + + /// Withdraw this platform-payment account's full credit balance to + /// a Core L1 address. + /// + /// `AddressCreditWithdrawalTransition` consumes the **entire** + /// funded balance of every input address it selects — there is no + /// change output. We therefore drive the Rust side with + /// `INPUT_SELECTION_TYPE_AUTO`, which selects every funded address + /// on `accountIndex`, and `DeductFromInput(0)` so the on-chain fee + /// comes out of the inputs (not a non-existent change/output row). + /// + /// `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 (each drained input now reads `0`). + @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") + } + + let handle = self.handle + let signerHandle = signer.handle + let address = trimmed + let feePerByte = coreFeePerByte + + return try await Task.detached(priority: .userInitiated) { + () -> [UpdatedBalance] in + // Withdrawals consume the full funded balance with no + // change output, so the fee is deducted from the inputs. + let feeRows: [FeeStrategyStepFFI] = [ + FeeStrategyStepFFI(step_type: 0, index: 0) // 0 = DeductFromInput + ] + 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 + feeRows.withUnsafeBufferPointer { feeBp in + platform_address_wallet_withdraw_to_address( + handle, + accountIndex, + INPUT_SELECTION_TYPE_AUTO, + nil, + 0, + nil, + 0, + addrCStr, + feePerByte, + feeBp.baseAddress, + UInt(feeBp.count), + 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(...)`. 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/TransferPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift new file mode 100644 index 00000000000..28805d718f0 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift @@ -0,0 +1,476 @@ +// 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, change +// routing, fee strategy, nonce selection (Auto), 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, +// the destination address, and which (unused) wallet address to route +// change to. 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" + + // MARK: - Submit state + + @State private var submitError: SubmitError? = nil + @State private var isSubmitting = false + @State private var didSucceed = false + + /// 1e11 credits per DASH. Matches `CreateIdentityView`. + private static let creditsPerDash: Double = 100_000_000_000.0 + + /// Mirror of `ManagedPlatformAddressWallet.feeBuffer` (held back so + /// the change output survives the on-chain fee). Used here only to + /// gate the submit button with the same accounting the wrapper + /// enforces — the wrapper still throws if this is violated. + 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(perform: autoSelectDefaults) + } + } + + // 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. Change routes automatically to a fresh unused address.") + } + } + + @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 { + let available = selectedSourceAccountCredits + if credits + Self.feeBuffer > 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 held back from change).") + } + } 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) + Button { + dismiss() + } label: { + Text("Done").frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding(.top, 4) + } + } + } + + // MARK: - Derived + + private struct PlatformAccountOption { + let accountIndex: UInt32 + let totalCredits: UInt64 + } + + private var platformAccountOptions: [PlatformAccountOption] { + let accounts = allAccounts + .filter { $0.wallet.walletId == wallet.walletId } + .filter { $0.accountType == 14 } + .sorted { $0.accountIndex < $1.accountIndex } + return accounts.map { acct in + let total = allPlatformAddresses + .filter { + $0.walletId == wallet.walletId && $0.accountIndex == acct.accountIndex + } + .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 + } + + /// Own-wallet recipients: any address on the wallet that is NOT the + /// auto-selected change address and NOT a source-account input that + /// holds balance. We surface unused (zero-balance) addresses on any + /// platform-payment account so the user can send to a fresh address; + /// the FFI wrapper rejects a recipient that collides with an input. + private var ownWalletRecipientCandidates: [PersistentPlatformAddress] { + let changeHash = autoChangeAddress?.addressHash + return allPlatformAddresses + .filter { $0.walletId == wallet.walletId } + .filter { $0.addressHash != changeHash } + .sorted { ($0.accountIndex, $0.addressIndex) < ($1.accountIndex, $1.addressIndex) } + } + + /// Lowest-index unused, zero-balance address on the source account — + /// the change destination. Picked internally; never exposed in the UI. + private var autoChangeAddress: PersistentPlatformAddress? { + guard let acctIdx = sourceAccountIndex else { return nil } + return allPlatformAddresses + .filter { + $0.walletId == wallet.walletId + && $0.accountIndex == acctIdx + && !$0.isUsed + && $0.balance == 0 + } + .sorted { $0.addressIndex < $1.addressIndex } + .first + } + + /// 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 } + let creditsDouble = dash * Self.creditsPerDash + guard creditsDouble.isFinite, creditsDouble <= Double(UInt64.max) else { return nil } + return UInt64(creditsDouble.rounded(.toNearestOrAwayFromZero)) + } + + private var canSubmit: Bool { + guard + !isSubmitting, + sourceAccountIndex != nil, + let credits = parsedCredits, credits > 0, + let dest = resolvedDestination, + autoChangeAddress != nil + else { return false } + // Reject change-address / recipient collision up front (the + // wrapper rejects it too, but a dead button is worse UX). + if dest.hash == autoChangeAddress?.addressHash { return false } + // Gate on amount + fee buffer <= account balance. + let needed = credits.addingReportingOverflow(Self.feeBuffer) + if needed.overflow { return false } + return selectedSourceAccountCredits >= needed.partialValue + } + + // MARK: - Actions + + 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, + let change = autoChangeAddress + else { return } + + guard dest.hash != change.addressHash else { + submitError = SubmitError( + message: "The destination collides with the auto-selected change address. 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 + ) + ] + let changeAddress = ManagedPlatformAddressWallet.ChangeAddress( + addressType: change.addressType, + hash: change.addressHash + ) + + isSubmitting = true + Task { + defer { isSubmitting = false } + do { + _ = try await addressWallet.transfer( + accountIndex: sourceAccount, + outputs: outputs, + changeAddress: changeAddress, + signer: signer + ) + // 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) + } + } + } + + // 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/TransitionCategoryView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionCategoryView.swift index 77070100032..e510fd676fc 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,44 @@ 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. + 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.") + } } .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..10bde074214 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift @@ -0,0 +1,450 @@ +// 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 = "" + @State private var coreFeePerByte: String = "1" + + // 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 + + private static let creditsPerDash: Double = 100_000_000_000.0 + + 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() + autoSelectDefaults() + } + .onChange(of: destinationMode) { _, mode in + if mode == .myWallet { resolveMyWalletAddress() } + } + } + } + + // 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 { + HStack { + TextField("Fee per byte", text: $coreFeePerByte) + .keyboardType(.numberPad) + .textFieldStyle(.roundedBorder) + .disabled(isSubmitting) + .accessibilityIdentifier("withdrawPlatform.feePerByteField") + Text("duffs/byte") + .foregroundColor(.secondary) + } + } header: { + Text("Core Fee Rate") + } footer: { + Text("Fee rate for the eventual L1 payout transaction. Default is 1.") + } + } + + private var summarySection: some View { + Section { + HStack { + Label("Total to Withdraw", systemImage: "dollarsign.circle") + Spacer() + Text(formatCredits(selectedSourceAccountCredits)) + .foregroundColor(.secondary) + } + } header: { + Text("Summary") + } footer: { + Text("The platform-side fee is deducted from these inputs. The full 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) + Button { + dismiss() + } label: { + Text("Done").frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding(.top, 4) + } + } + } + + // MARK: - Derived + + private struct PlatformAccountOption { + let accountIndex: UInt32 + let totalCredits: UInt64 + } + + private var platformAccountOptions: [PlatformAccountOption] { + let accounts = allAccounts + .filter { $0.wallet.walletId == wallet.walletId } + .filter { $0.accountType == 14 } + .sorted { $0.accountIndex < $1.accountIndex } + return accounts.map { acct in + let total = allPlatformAddresses + .filter { + $0.walletId == wallet.walletId && $0.accountIndex == acct.accountIndex + } + .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 + } + + private var parsedFeePerByte: UInt32? { + let raw = coreFeePerByte.trimmingCharacters(in: .whitespacesAndNewlines) + guard let v = UInt32(raw), v > 0 else { return nil } + return v + } + + /// 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, + selectedSourceAccountCredits > 0, + parsedFeePerByte != nil, + let addr = resolvedCoreAddress, !addr.isEmpty + else { return false } + return true + } + + // 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. + /// `coreWallet()` throws if the Core side isn't initialized; we + /// also probe `nextReceiveAddress` so a half-initialized wallet + /// surfaces here rather than at submit time. + 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.nextReceiveAddress(accountIndex: 0) + coreReady = true + } catch { + coreReady = false + coreNotReadyReason = "Core wallet is not ready: \(error.localizedDescription)" + } + } + + 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 { + _ = try await addressWallet.withdraw( + accountIndex: sourceAccount, + coreAddress: coreAddress, + coreFeePerByte: feePerByte, + signer: signer + ) + await platformBalanceSyncService.performSync() + didSucceed = true + } catch { + submitError = SubmitError(message: error.localizedDescription) + } + } + } + + // 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/TEST_PLAN.md b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md index 9f4f3521a98..5898b9a2b3d 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 set of Platform write transitions (document create/replace/delete/transfer/price/purchase, data-contract create/update) 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. (Identity credit *transfer*, `ID-04`, *withdrawal*, `ID-10`, now have production buttons in `IdentityDetailView`, and identity *key-disable*, `ID-12`, now has a production action in `KeyDetailView` — 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*). +> **Entry-point reality check.** A set of Platform write transitions (document create/replace/delete/transfer/price/purchase, data-contract create/update) 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. (Identity credit *transfer*, `ID-04`, *withdrawal*, `ID-10`, now have production buttons in `IdentityDetailView`, and identity *key-disable*, `ID-12`, now has a production action in `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, change routing (auto fresh unused address), fee strategy, and Auto nonce all happen Rust-side — no private-key entry. Submit gated on amount + fee ≤ account balance and recipient ≠ change. 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. | From 20761a2442b5f61cc8e3d0998a47975357ccfaf9 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 16 Jun 2026 20:17:47 +0100 Subject: [PATCH 02/21] fix(swift-example-app): address review feedback on platform-address transfer/withdraw UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Parse DASH→credits exactly via integer arithmetic (multipliedReportingOverflow + addingReportingOverflow), dropping Double from the value-transfer path; Double is now display-only in formatCredits. - Add .interactiveDismissDisabled(isSubmitting) to both sheets so they can't be swiped away mid-submit. - Use overflow-safe fee arithmetic in the transfer amount footer (match canSubmit). - Exclude funded source-account inputs from transfer recipients (own-wallet picker, canSubmit, and submit guard) to avoid an enabled button that fails in the wrapper. - Scope resolvedDestination own-wallet lookup to the current wallet. - Bound withdraw coreFeePerByte to a sane max (10_000 duffs/byte) with validation. - Persist the returned [UpdatedBalance] changeset to matching rows immediately (both transfer and withdraw) before performSync(), mirroring the persistAddressBalances upsert, so SwiftData doesn't show drained inputs as spendable until sync. - TEST_PLAN: drop "document create" from the builder-only list (DOC-02 has a production Contracts flow). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Views/TransferPlatformAddressView.swift | 144 ++++++++++++++++-- .../Views/WithdrawPlatformAddressView.swift | 58 ++++++- .../swift-sdk/SwiftExampleApp/TEST_PLAN.md | 2 +- 3 files changed, 188 insertions(+), 16 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift index 28805d718f0..1efcb8dc75c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift @@ -54,8 +54,15 @@ struct TransferPlatformAddressView: View { @State private var isSubmitting = false @State private var didSucceed = false - /// 1e11 credits per DASH. Matches `CreateIdentityView`. - private static let creditsPerDash: Double = 100_000_000_000.0 + /// 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 /// Mirror of `ManagedPlatformAddressWallet.feeBuffer` (held back so /// the change output survives the on-chain fee). Used here only to @@ -94,6 +101,10 @@ struct TransferPlatformAddressView: View { ) } .onAppear(perform: 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) } } @@ -210,7 +221,8 @@ struct TransferPlatformAddressView: View { } footer: { if let credits = parsedCredits { let available = selectedSourceAccountCredits - if credits + Self.feeBuffer > available { + let needed = credits.addingReportingOverflow(Self.feeBuffer) + if needed.overflow || needed.partialValue > available { Text("Insufficient balance: \(formatCredits(credits)) + fee exceeds the account's \(formatCredits(available)).") .foregroundColor(.red) } else { @@ -292,16 +304,39 @@ struct TransferPlatformAddressView: View { return platformAccountOptions.first(where: { $0.accountIndex == idx })?.totalCredits ?? 0 } + /// Funded addresses on the selected source account for this wallet. + /// The `transfer` wrapper picks its inputs from these (balance > 0), + /// and the `AddressFundsTransferTransition` protocol forbids any + /// output address from also being an input. The wrapper excludes + /// recipient hashes from input selection *before* its sufficiency + /// check, so a recipient that collides with a funded source input + /// would enable the button here, then fail Rust-side once that input + /// is removed. Gate on this set so the collision is caught up front. + private var sourceInputHashes: Set { + guard let acctIdx = sourceAccountIndex else { return [] } + return Set( + allPlatformAddresses + .filter { + $0.walletId == wallet.walletId + && $0.accountIndex == acctIdx + && $0.balance > 0 + } + .map { $0.addressHash } + ) + } + /// Own-wallet recipients: any address on the wallet that is NOT the - /// auto-selected change address and NOT a source-account input that - /// holds balance. We surface unused (zero-balance) addresses on any + /// auto-selected change address and 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 FFI wrapper rejects a recipient that collides with an input. private var ownWalletRecipientCandidates: [PersistentPlatformAddress] { let changeHash = autoChangeAddress?.addressHash + let inputs = sourceInputHashes return allPlatformAddresses .filter { $0.walletId == wallet.walletId } .filter { $0.addressHash != changeHash } + .filter { !inputs.contains($0.addressHash) } .sorted { ($0.accountIndex, $0.addressIndex) < ($1.accountIndex, $1.addressIndex) } } @@ -340,8 +375,13 @@ struct TransferPlatformAddressView: View { private var resolvedDestination: (addressType: UInt8, hash: Data)? { switch destinationMode { case .ownWallet: + // Scope by walletId AND hash: a hash-only lookup can match + // another wallet's row in a multi-wallet store and route the + // transfer to the wrong wallet's address. guard let hash = selectedRecipientHash, - let row = allPlatformAddresses.first(where: { $0.addressHash == hash }) + let row = allPlatformAddresses.first(where: { + $0.walletId == wallet.walletId && $0.addressHash == hash + }) else { return nil } return (row.addressType, row.addressHash) case .external: @@ -351,12 +391,45 @@ struct TransferPlatformAddressView: View { } } + /// Exact decimal→credits conversion. The amount is a value-transfer + /// quantity, so it must NOT pass through `Double` (binary FP can't + /// represent every credit value at the 1e11 boundary and would round + /// the user's intended amount). Parse the decimal string directly: + /// split on ".", require digits only, ≤11 fractional digits, then + /// `whole * 1e11 + fractionalPaddedTo11` with overflow rejection. + /// Returns nil for any malformed / zero / overflowing input. private var parsedCredits: UInt64? { let raw = amountDash.trimmingCharacters(in: .whitespacesAndNewlines) - guard let dash = Double(raw), dash > 0 else { return nil } - let creditsDouble = dash * Self.creditsPerDash - guard creditsDouble.isFinite, creditsDouble <= Double(UInt64.max) else { return nil } - return UInt64(creditsDouble.rounded(.toNearestOrAwayFromZero)) + guard !raw.isEmpty else { return nil } + + let parts = raw.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count <= 2 else { return nil } + let wholeStr = String(parts.first ?? "") + let fracStr = parts.count == 2 ? String(parts[1]) : "" + + // Reject empty/sign/non-digit components. An empty whole part is + // allowed only when there's a fractional part (".5" → "0.5"). + guard wholeStr.allSatisfy(\.isNumber), fracStr.allSatisfy(\.isNumber) else { return nil } + guard !(wholeStr.isEmpty && fracStr.isEmpty) else { return nil } + guard fracStr.count <= Self.creditFractionDigits else { return nil } + + let whole = wholeStr.isEmpty ? 0 : UInt64(wholeStr) + guard wholeStr.isEmpty || whole != nil else { return nil } + + // whole * 1e11 + let scaled = (whole ?? 0).multipliedReportingOverflow(by: Self.creditsPerDash) + guard !scaled.overflow else { return nil } + + // Pad the fractional part to 11 digits so it expresses credits + // directly, then add. (".5" → "50000000000" credits.) + let paddedFrac = fracStr.padding( + toLength: Self.creditFractionDigits, withPad: "0", startingAt: 0 + ) + guard let fracCredits = paddedFrac.isEmpty ? 0 : UInt64(paddedFrac) else { return nil } + + let total = scaled.partialValue.addingReportingOverflow(fracCredits) + guard !total.overflow, total.partialValue > 0 else { return nil } + return total.partialValue } private var canSubmit: Bool { @@ -370,6 +443,11 @@ struct TransferPlatformAddressView: View { // Reject change-address / recipient collision up front (the // wrapper rejects it too, but a dead button is worse UX). if dest.hash == autoChangeAddress?.addressHash { return false } + // Reject a recipient that collides with a funded source input. + // The wrapper drops it from input selection before its + // sufficiency check, so the button would enable then fail + // Rust-side. Covers both own-wallet picks and pasted externals. + if sourceInputHashes.contains(dest.hash) { return false } // Gate on amount + fee buffer <= account balance. let needed = credits.addingReportingOverflow(Self.feeBuffer) if needed.overflow { return false } @@ -409,6 +487,13 @@ struct TransferPlatformAddressView: View { 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.") @@ -439,12 +524,17 @@ struct TransferPlatformAddressView: View { Task { defer { isSubmitting = false } do { - _ = try await addressWallet.transfer( + let updated = try await addressWallet.transfer( accountIndex: sourceAccount, outputs: outputs, changeAddress: changeAddress, signer: signer ) + // 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. + persistUpdatedBalances(updated) // Trigger a DIP-17 resync so balances + the unused- // address pool catch up after the transfer. await platformBalanceSyncService.performSync() @@ -455,10 +545,40 @@ struct TransferPlatformAddressView: View { } } + /// 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. + private func persistUpdatedBalances( + _ updated: [ManagedPlatformAddressWallet.UpdatedBalance] + ) { + 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 + // 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) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift index 10bde074214..adce583ad4b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift @@ -61,6 +61,14 @@ struct WithdrawPlatformAddressView: View { 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. + private static let maxFeePerByte: UInt32 = 10_000 + var body: some View { NavigationStack { Form { @@ -101,6 +109,10 @@ struct WithdrawPlatformAddressView: View { .onChange(of: destinationMode) { _, mode in if mode == .myWallet { resolveMyWalletAddress() } } + // 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) } } @@ -224,7 +236,13 @@ struct WithdrawPlatformAddressView: View { } header: { Text("Core Fee Rate") } footer: { - Text("Fee rate for the eventual L1 payout transaction. Default is 1.") + if !coreFeePerByte.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && parsedFeePerByte == nil { + Text("Enter a whole number between 1 and \(Self.maxFeePerByte) duffs/byte. A higher rate would eat the withdrawal, since the fee is deducted from the payout.") + .foregroundColor(.red) + } else { + Text("Fee rate for the eventual L1 payout transaction. Default is 1.") + } } } @@ -315,7 +333,7 @@ struct WithdrawPlatformAddressView: View { private var parsedFeePerByte: UInt32? { let raw = coreFeePerByte.trimmingCharacters(in: .whitespacesAndNewlines) - guard let v = UInt32(raw), v > 0 else { return nil } + guard let v = UInt32(raw), v > 0, v <= Self.maxFeePerByte else { return nil } return v } @@ -415,12 +433,18 @@ struct WithdrawPlatformAddressView: View { Task { defer { isSubmitting = false } do { - _ = try await addressWallet.withdraw( + let updated = try await addressWallet.withdraw( accountIndex: sourceAccount, coreAddress: coreAddress, coreFeePerByte: feePerByte, signer: signer ) + // 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`). + persistUpdatedBalances(updated) await platformBalanceSyncService.performSync() didSucceed = true } catch { @@ -429,6 +453,34 @@ struct WithdrawPlatformAddressView: View { } } + /// 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. + private func persistUpdatedBalances( + _ updated: [ManagedPlatformAddressWallet.UpdatedBalance] + ) { + 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 { diff --git a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md index 5898b9a2b3d..fcbb4bad29c 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 set of Platform write transitions (document create/replace/delete/transfer/price/purchase, data-contract create/update) 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. (Identity credit *transfer*, `ID-04`, *withdrawal*, `ID-10`, now have production buttons in `IdentityDetailView`, and identity *key-disable*, `ID-12`, now has a production action in `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*). +> **Entry-point reality check.** A set of Platform write transitions (document replace/delete/transfer/price/purchase, data-contract create/update) 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. (Document *create*, `DOC-02`, now has a production flow via **Contracts → contract → document type → New Document** — see that row; the builder remains as a test-signer alternative. Identity credit *transfer*, `ID-04`, *withdrawal*, `ID-10`, now have production buttons in `IdentityDetailView`, and identity *key-disable*, `ID-12`, now has a production action in `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*). --- From 6b65c448d438bc2da7374976d6021e01aae935df Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 16 Jun 2026 20:35:27 +0100 Subject: [PATCH 03/21] fix(swift-example-app): honor source account + classify wrong-network withdrawal error Address second-reviewer findings on the platform-address transfer/withdraw UI: - Transfer source-account divergence (blocking): the transfer wrapper always spends first_platform_payment_managed_account() (account 0/key_class 0) via addresses_with_balances(), which is not account-scoped, while it forwards the picked accountIndex to Rust only for persistence. With >1 platform-payment account this spends the wrong account. Constrain the transfer source picker to accountType==14 && accountIndex==0 && keyClass==0 so the picker's choice provably matches the wrapper's real source. Withdraw's INPUT_SELECTION_TYPE_AUTO path is account-scoped (platform_payment_managed_account_at_index), so its multi-account picker is correct and left unchanged; both behaviors documented. - Wrong-network Core address now returns ErrorInvalidNetwork (maps to Swift .invalidNetwork) from platform_address_wallet_withdraw_to_address instead of falling through the blanket PlatformWalletError conversion to ErrorUnknown. - Drop the unnecessary unchecked_address.clone() in the FFI body (kept in the test). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/platform_addresses/withdrawal.rs | 49 +++++++++++-------- .../Views/TransferPlatformAddressView.swift | 21 +++++++- 2 files changed, 49 insertions(+), 21 deletions(-) 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 acebb68e4b1..166a2296344 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_addresses/withdrawal.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_addresses/withdrawal.rs @@ -133,37 +133,46 @@ pub unsafe extern "C" fn platform_address_wallet_withdraw_to_address( // 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. `dashcore::address::Error` has - // no `From` impl on `PlatformWalletError`, so map it into the - // typed `AddressOperation` variant the rest of this path uses. + // FFI for Core-address handling. `require_network` consumes the + // unchecked address, which isn't reused afterwards. let checked_address = unchecked_address - .clone() .require_network(wallet.network()) .map_err(|e| { - platform_wallet::PlatformWalletError::AddressOperation(format!( - "Core address is not valid for the wallet's network ({:?}): {e}", - wallet.network() - )) + 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, - )) + 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); - // `result` is `Result` - // where the address-network failure surfaces as a - // `dashcore::address::Error` widened into `PlatformWalletError`. let changeset = unwrap_result_or_return!(result); *out_changeset = PlatformAddressChangeSetFFI::from(&changeset); PlatformWalletFFIResult::ok() diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift index 1efcb8dc75c..ca12a68df41 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift @@ -284,10 +284,29 @@ struct TransferPlatformAddressView: View { let totalCredits: UInt64 } + /// Source accounts the transfer can actually spend from. + /// + /// CONSTRAINED to the single *first* platform-payment account + /// (account index 0, key class 0). The `transfer` wrapper builds its + /// explicit inputs from `addressesWithBalances()`, which the Rust + /// `platform-wallet` crate resolves via + /// `first_platform_payment_managed_account()` — i.e. always account + /// (0, 0) — regardless of the `accountIndex` it is told to persist + /// against. Offering any other `accountType == 14` account here would + /// let the picker's choice diverge from the wrapper's real source: + /// picking account #1 would build inputs from account #0's addresses + /// while telling Rust `account_index = 1`, drifting persistence and + /// the sufficiency gate. Until an account-scoped input-selection FFI + /// exists in the Rust library (input selection belongs there, not in + /// Swift — see swift-sdk/CLAUDE.md), the picker must not offer an + /// account the stack won't honor. The withdraw flow has no such + /// divergence: its `INPUT_SELECTION_TYPE_AUTO` path is account-scoped + /// on the Rust side (`auto_select_inputs_for_withdrawal(account_index)`), + /// so its picker stays multi-account. private var platformAccountOptions: [PlatformAccountOption] { let accounts = allAccounts .filter { $0.wallet.walletId == wallet.walletId } - .filter { $0.accountType == 14 } + .filter { $0.accountType == 14 && $0.accountIndex == 0 && $0.keyClass == 0 } .sorted { $0.accountIndex < $1.accountIndex } return accounts.map { acct in let total = allPlatformAddresses From 057b64e9a85dee6d9c00b6e13c0cd7ad42a66aba Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 16 Jun 2026 20:54:50 +0100 Subject: [PATCH 04/21] fix(platform-wallet): reserve withdrawal fee on the fee-source input The platform-address withdrawal auto-selector inserted each selected input's full balance as its withdraw amount, leaving zero remaining balance on every input. Because the chain deducts the fee from each input's *remaining* balance (on_chain_balance - withdraw_amount), cascading through the fee_strategy steps, a single DeductFromInput(0) step could never be covered and the transition was rejected with fee_fully_covered = false for any input configuration (asserted by test_exact_balance_withdrawal_fails_insufficient_remaining_for_fees). The fee target also resolves to the BTreeMap index-0 entry, i.e. the lexicographically smallest address hash (PlatformAddress: Ord), independent of balance. Add reserve_withdrawal_fee_on_fee_source, which reduces the withdraw amount on the fee-source input (the index the first DeductFromInput step resolves to) by the estimated fee, leaving >= estimated_fee of headroom there while withdrawing the other inputs in full. Typed errors when no input can absorb the fee above min_input_amount or the net withdrawal falls below min_withdrawal_amount. This mirrors the transfer path's select_inputs_deduct_from_input invariant. Adds 4 unit tests (single-input headroom, small-fee-source-with-larger-peer, fee-source-too-small, total-below-fee). The buggy auto-selector predates this work (#3602); PR #3923 is the first to ship it to users via the ADDR-04 withdraw path, so it is fixed here. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../wallet/platform_addresses/withdrawal.rs | 255 ++++++++++++++++-- .../ManagedPlatformAddressWallet.swift | 30 ++- 2 files changed, 255 insertions(+), 30 deletions(-) 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..ef527ed1d07 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs @@ -164,9 +164,24 @@ impl PlatformAddressWallet { 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. + /// Auto-select all funded addresses for withdrawal. + /// + /// The per-input `Credits` value in the returned 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 funded address at its full balance, then, + /// for a `DeductFromInput`-based fee strategy, reduce the withdraw + /// amount on the fee-source input (the BTreeMap index-0 / lex-smallest + /// entry that `DeductFromInput(0)` resolves to) by the estimated fee so + /// that input keeps `≥ estimated_fee` of remaining balance for the chain + /// to deduct. The withdrawn total is the account balance minus the fee. async fn auto_select_inputs_for_withdrawal( &self, account_index: u32, @@ -212,25 +227,227 @@ impl PlatformAddressWallet { )); } - // Verify the total covers the fee. - let estimated_fee = AddressCreditWithdrawalTransition::estimate_min_fee( - selected.len(), - false, // no change output - platform_version, + reserve_withdrawal_fee_on_fee_source(selected, fee_strategy, platform_version) + } +} + +/// Convert a full-balance input map into a withdraw-amount map that leaves the +/// chain enough fee headroom on the fee-source 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 fee-source input — the BTreeMap entry the first +/// `DeductFromInput(index)` step resolves to — 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`. +/// +/// Returns the adjusted withdraw-amount map, or a typed +/// [`PlatformWalletError::AddressOperation`] when no input can absorb the fee +/// while respecting the per-input minimum / minimum withdrawal amount. +fn reserve_withdrawal_fee_on_fee_source( + mut selected: BTreeMap, + fee_strategy: &[AddressFundsFeeStrategyStep], + platform_version: &PlatformVersion, +) -> Result, PlatformWalletError> { + 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, + ); + + // The fee-source input is the first `DeductFromInput` step's target index + // (production always sends `[DeductFromInput(0)]`). If the fee strategy + // never deducts from an input (e.g. `ReduceOutput`-only, which the auto + // path doesn't build today since there is no output), no input headroom is + // required and we withdraw every balance in full. + let fee_source_index = fee_strategy.iter().find_map(|s| match s { + AddressFundsFeeStrategyStep::DeductFromInput(index) => Some(*index as usize), + AddressFundsFeeStrategyStep::ReduceOutput(_) => None, + }); + + let Some(fee_source_index) = fee_source_index else { + return Ok(selected); + }; + + // Resolve the fee-source address by BTreeMap iteration order, matching how + // the chain's `deduct_fee_from_outputs_or_remaining_balance_of_inputs` + // resolves `DeductFromInput(index)` against the input map. + let Some((&fee_source_addr, &fee_source_balance)) = selected.iter().nth(fee_source_index) + else { + // Out-of-range index would be rejected by structure validation; surface + // a typed wallet-side error instead of shipping a doomed transition. + return Err(PlatformWalletError::AddressOperation(format!( + "Fee strategy DeductFromInput({}) is out of range for {} selected input(s)", + fee_source_index, + selected.len() + ))); + }; + + // 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; + + 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 + ))); + } + + let fee_source_amount = fee_source_balance.saturating_sub(estimated_fee); + if fee_source_amount < min_input_amount { + return Err(PlatformWalletError::AddressOperation(format!( + "Cannot reserve withdrawal fee on the fee-source input: balance {} \ + minus estimated fee {} leaves {}, below the minimum input amount {}. \ + Consolidate funds onto fewer addresses or fund the smallest address \ + more before withdrawing.", + fee_source_balance, estimated_fee, fee_source_amount, min_input_amount + ))); + } + + // Same key → BTreeMap ordering (and thus the index-0 resolution above) is + // preserved; only the withdraw amount on the fee-source input shrinks. + selected.insert(fee_source_addr, fee_source_amount); + + Ok(selected) +} + +#[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). + #[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 result = reserve_withdrawal_fee_on_fee_source( + input, + &[AddressFundsFeeStrategyStep::DeductFromInput(0)], + pv, + ) + .expect("single funded input above the fee should select"); + + assert_eq!(result.get(&addr(1)).copied(), Some(balance - fee)); + } + + /// The reviewer's scenario: input[0] (lex-smallest, the `DeductFromInput(0)` + /// target) is much smaller than the fee while a larger input exists. The fee + /// must be reserved on input[0] itself (the index the chain deducts from), so + /// input[0]'s withdraw amount drops by the fee and the larger input is + /// withdrawn in full. Both must stay ≥ `min_input_amount`. + #[test] + fn reserves_fee_on_lex_smallest_input_even_when_a_larger_input_exists() { + let pv = PlatformVersion::latest(); + let fee = estimated_fee(2, pv); + // Small input[0] still large enough to absorb the fee + keep min_input. + let small = fee + dpp::dash_to_credits!(0.5); + let large = dpp::dash_to_credits!(10.0); + + let mut inputs = BTreeMap::new(); + inputs.insert(addr(1), small); // lex-smallest → index 0 + inputs.insert(addr(9), large); + + let result = reserve_withdrawal_fee_on_fee_source( + inputs, + &[AddressFundsFeeStrategyStep::DeductFromInput(0)], + pv, + ) + .expect("fee-source input can absorb the fee"); + + assert_eq!( + result.get(&addr(1)).copied(), + Some(small - fee), + "fee is reserved on the lex-smallest (index-0) input" + ); + assert_eq!( + result.get(&addr(9)).copied(), + Some(large), + "the larger non-fee-source input is withdrawn in full" ); + } - // Only check if fee comes from inputs. - let fee_from_inputs = fee_strategy - .iter() - .any(|s| matches!(s, AddressFundsFeeStrategyStep::DeductFromInput(_))); + /// When the fee-source input cannot retain `estimated_fee` while keeping its + /// withdraw amount ≥ `min_input_amount`, we error rather than ship a + /// guaranteed-rejected transition (mirrors the transfer path's headroom error). + #[test] + fn errors_when_fee_source_input_too_small_to_absorb_fee() { + let pv = PlatformVersion::latest(); + let fee = estimated_fee(2, pv); + let min_input = pv.dpp.state_transitions.address_funds.min_input_amount; + // Fee-source balance leaves < min_input after the fee is reserved. + let small = fee + min_input - 1; + let large = dpp::dash_to_credits!(10.0); - if fee_from_inputs && accumulated < estimated_fee { - return Err(PlatformWalletError::AddressOperation(format!( - "Insufficient balance for withdrawal fee: available {} credits, fee {}", - accumulated, estimated_fee - ))); - } + let mut inputs = BTreeMap::new(); + inputs.insert(addr(1), small); + inputs.insert(addr(9), large); + + let err = reserve_withdrawal_fee_on_fee_source( + inputs, + &[AddressFundsFeeStrategyStep::DeductFromInput(0)], + pv, + ) + .expect_err("fee-source 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); - Ok(selected) + let err = reserve_withdrawal_fee_on_fee_source( + inputs, + &[AddressFundsFeeStrategyStep::DeductFromInput(0)], + pv, + ) + .expect_err("balance below the fee must error"); + assert!(matches!(err, PlatformWalletError::AddressOperation(_))); } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift index 1b599b1ecf3..28959ee079b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift @@ -387,15 +387,20 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { // MARK: - Withdraw - /// Withdraw this platform-payment account's full credit balance to - /// a Core L1 address. + /// Withdraw this platform-payment account's credit balance to a Core + /// L1 address (less the transition fee). /// - /// `AddressCreditWithdrawalTransition` consumes the **entire** - /// funded balance of every input address it selects — there is no - /// change output. We therefore drive the Rust side with - /// `INPUT_SELECTION_TYPE_AUTO`, which selects every funded address - /// on `accountIndex`, and `DeductFromInput(0)` so the on-chain fee - /// comes out of the inputs (not a non-existent change/output row). + /// `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 selects every funded + /// address on `accountIndex`, and `DeductFromInput(0)` so the fee + /// comes out of the fee-source input. The Rust auto-selector reserves + /// the estimated fee on that input (the lexicographically-smallest + /// address, which `DeductFromInput(0)` resolves to) — it withdraws + /// `balance − estimated_fee` there 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 @@ -413,7 +418,8 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { /// `KeychainSigner.swift`). Pass `KeychainSigner(modelContainer:)`. /// /// Returns the per-address `UpdatedBalance`s the Rust changeset - /// reports (each drained input now reads `0`). + /// 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, @@ -433,8 +439,10 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { return try await Task.detached(priority: .userInitiated) { () -> [UpdatedBalance] in - // Withdrawals consume the full funded balance with no - // change output, so the fee is deducted from the inputs. + // Withdrawals have no change output, so the fee is deducted + // from the inputs. The Rust auto-selector reserves the + // estimated fee on the index-0 (fee-source) input so the chain + // can cover it; see this wrapper's doc comment. let feeRows: [FeeStrategyStepFFI] = [ FeeStrategyStepFFI(step_type: 0, index: 0) // 0 = DeductFromInput ] From 1bcedc7693135621322cbee4d5fcb691b2da17d8 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 16 Jun 2026 21:16:50 +0100 Subject: [PATCH 05/21] fix(swift-sdk): honor transfer account, pin signer, overflow-safe fee, largest-input withdrawal fee Address second-pass review findings on the platform-address transfer/withdraw SDK boundary (all newly shipped to users by this PR): - Pin the KeychainSigner across the transfer FFI call: transfer(...) now wraps the whole platform_address_wallet_transfer cascade in withExtendedLifetime(signer) and drops the elision-prone `_ = signer`, matching withdraw/fundFromAssetLock. Prevents a use-after-free of the Unmanaged.passUnretained signer ctx under -O. - Honor the caller's accountIndex in transfer input selection. Added Rust PlatformAddressWallet::addresses_with_balances_for_account(account_index) (using platform_payment_managed_account_at_index) + FFI platform_address_wallet_addresses_with_balances_for_account; transfer(...) now builds explicit inputs from the chosen account instead of always first_platform_payment_managed_account(). The existing account-agnostic method is untouched. The TransferPlatformAddressView picker offers all platform-payment accounts again (workaround pin removed). - Overflow-safe public transfer API: compute totalRecipientCredits + feeBuffer via addingReportingOverflow once and throw invalidParameter on overflow, instead of a trapping checked-add during the sufficiency gate. - Withdrawal fee source = largest-balance selected input. The auto-withdrawal path now reserves the fee on the largest selected input and emits DeductFromInput() rather than trusting the wrapper's hardcoded index 0 (which resolves to the lexicographically-smallest address hash). A small lex-first input no longer blocks an otherwise-fundable withdrawal; genuine insufficiency still returns a typed error. platform-wallet lib tests 207 passed, platform-wallet-ffi 97 passed; build_ios.sh BUILD SUCCEEDED; SwiftExampleApp simulator build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/platform_addresses/wallet.rs | 46 +++ .../src/wallet/platform_addresses/wallet.rs | 39 +++ .../wallet/platform_addresses/withdrawal.rs | 270 +++++++++++------- .../ManagedPlatformAddressWallet.swift | 106 +++++-- .../Views/TransferPlatformAddressView.swift | 29 +- 5 files changed, 342 insertions(+), 148 deletions(-) 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..da694725cbf 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_addresses/wallet.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_addresses/wallet.rs @@ -112,6 +112,52 @@ pub unsafe extern "C" fn platform_address_wallet_addresses_with_balances( PlatformWalletFFIResult::ok() } +/// Get all platform addresses with their cached balances for a specific +/// platform-payment account (`account_index`, key class 0). +/// +/// Account-scoped sibling of +/// [`platform_address_wallet_addresses_with_balances`]: resolves the requested +/// account rather than always the first one, and stamps each returned entry's +/// `account_index` with the requested value. Callers that build explicit +/// transfer inputs must use this so the spent source account matches the +/// `account_index` the transfer persists/nonces against. +/// +/// On success, `out_entries` and `out_count` are set to a heap-allocated array. +/// Free with `platform_address_wallet_free_address_balances`. +#[no_mangle] +pub unsafe extern "C" fn platform_address_wallet_addresses_with_balances_for_account( + handle: Handle, + account_index: u32, + out_entries: *mut *mut AddressBalanceEntryFFI, + out_count: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(out_entries); + check_ptr!(out_count); + + let option = PLATFORM_ADDRESS_WALLET_STORAGE.with_item(handle, |wallet| { + let balances = + runtime().block_on(wallet.addresses_with_balances_for_account(account_index)); + balances + .into_iter() + .map(|(address, balance)| AddressBalanceEntryFFI { + address: address.into(), + balance, + nonce: 0, + account_index, + address_index: 0, + }) + .collect::>() + }); + let entries = unwrap_option_or_return!(option); + *out_count = entries.len(); + if entries.is_empty() { + *out_entries = std::ptr::null_mut(); + } else { + *out_entries = Box::into_raw(entries.into_boxed_slice()) as *mut AddressBalanceEntryFFI; + } + PlatformWalletFFIResult::ok() +} + // --------------------------------------------------------------------------- // Memory deallocation // --------------------------------------------------------------------------- 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..039a40e6cf7 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs @@ -299,6 +299,11 @@ 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). Callers that need a specific account must use + /// [`addresses_with_balances_for_account`](Self::addresses_with_balances_for_account) + /// so input selection and persistence agree on the source account. pub async fn addresses_with_balances(&self) -> Vec<(PlatformAddress, Credits)> { let wm = self.wallet_manager.read().await; wm.get_wallet_info(&self.wallet_id) @@ -313,6 +318,40 @@ impl PlatformAddressWallet { .unwrap_or_default() } + /// Get all platform addresses with their cached balances for a specific + /// platform-payment account. + /// + /// Account-scoped sibling of [`addresses_with_balances`](Self::addresses_with_balances): + /// resolves the account via `platform_payment_managed_account_at_index` + /// (key class 0) so the returned balances come from the requested + /// `account_index` rather than always the first account. The transfer path + /// builds its explicit inputs from this so the spent source account matches + /// the `account_index` it persists/nonces against — without this the public + /// `transfer(account_index, ..)` API would silently spend account 0 while + /// telling the chain a different account. + /// + /// Returns an empty vec (not an error) when the account has no addresses, + /// matching `addresses_with_balances`'s behaviour for a missing account. + pub async fn addresses_with_balances_for_account( + &self, + account_index: u32, + ) -> Vec<(PlatformAddress, Credits)> { + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(&self.wallet_id) + .and_then(|info| { + info.core_wallet + .platform_payment_managed_account_at_index(account_index) + }) + .map(|account| { + account + .address_balances + .iter() + .map(|(p2pkh, &bal)| (PlatformAddress::P2pkh(p2pkh.to_bytes()), bal)) + .collect() + }) + .unwrap_or_default() + } + /// Current incremental-sync watermark (`last_known_recent_block`) /// from the unified platform-address provider. /// 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 ef527ed1d07..6226317b719 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs @@ -88,14 +88,23 @@ impl PlatformAddressWallet { .await? } InputSelection::Auto => { - let inputs = self - .auto_select_inputs_for_withdrawal(account_index, &fee_strategy, version) + // 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. + let (inputs, auto_fee_strategy) = self + .auto_select_inputs_for_withdrawal(account_index, version) .await?; self.sdk .withdraw_address_funds( inputs, None, - fee_strategy, + auto_fee_strategy, core_fee_per_byte, Pooling::Never, output_script, @@ -176,18 +185,26 @@ impl PlatformAddressWallet { /// 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 funded address at its full balance, then, - /// for a `DeductFromInput`-based fee strategy, reduce the withdraw - /// amount on the fee-source input (the BTreeMap index-0 / lex-smallest - /// entry that `DeductFromInput(0)` resolves to) by the estimated fee so - /// that input keeps `≥ estimated_fee` of remaining balance for the chain - /// to deduct. The withdrawn total is the account balance minus the fee. + /// We therefore select every funded 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. + /// + /// Returns the adjusted withdraw-amount map together with the fee + /// strategy that 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). async fn auto_select_inputs_for_withdrawal( &self, account_index: u32, - fee_strategy: &[AddressFundsFeeStrategyStep], platform_version: &PlatformVersion, - ) -> Result, PlatformWalletError> { + ) -> Result<(BTreeMap, AddressFundsFeeStrategy), PlatformWalletError> + { let wm = self.wallet_manager.read().await; let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { PlatformWalletError::WalletNotFound(format!( @@ -227,33 +244,41 @@ impl PlatformAddressWallet { )); } - reserve_withdrawal_fee_on_fee_source(selected, fee_strategy, platform_version) + reserve_withdrawal_fee_on_largest_input(selected, platform_version) } } /// Convert a full-balance input map into a withdraw-amount map that leaves the -/// chain enough fee headroom on the fee-source input. +/// 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 fee-source input — the BTreeMap entry the first -/// `DeductFromInput(index)` step resolves to — 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`. +/// 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`. /// -/// Returns the adjusted withdraw-amount map, or a typed -/// [`PlatformWalletError::AddressOperation`] when no input can absorb the fee -/// while respecting the per-input minimum / minimum withdrawal amount. -fn reserve_withdrawal_fee_on_fee_source( +/// 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()`. +/// +/// Returns the adjusted withdraw-amount map and the fee strategy targeting the +/// fee-source input, or a typed [`PlatformWalletError::AddressOperation`] when +/// no input can absorb the fee while respecting the per-input minimum / +/// minimum withdrawal amount. +fn reserve_withdrawal_fee_on_largest_input( mut selected: BTreeMap, - fee_strategy: &[AddressFundsFeeStrategyStep], platform_version: &PlatformVersion, -) -> Result, PlatformWalletError> { +) -> Result<(BTreeMap, AddressFundsFeeStrategy), PlatformWalletError> { let accumulated: Credits = selected .values() .copied() @@ -267,33 +292,18 @@ fn reserve_withdrawal_fee_on_fee_source( platform_version, ); - // The fee-source input is the first `DeductFromInput` step's target index - // (production always sends `[DeductFromInput(0)]`). If the fee strategy - // never deducts from an input (e.g. `ReduceOutput`-only, which the auto - // path doesn't build today since there is no output), no input headroom is - // required and we withdraw every balance in full. - let fee_source_index = fee_strategy.iter().find_map(|s| match s { - AddressFundsFeeStrategyStep::DeductFromInput(index) => Some(*index as usize), - AddressFundsFeeStrategyStep::ReduceOutput(_) => None, - }); - - let Some(fee_source_index) = fee_source_index else { - return Ok(selected); - }; - - // Resolve the fee-source address by BTreeMap iteration order, matching how - // the chain's `deduct_fee_from_outputs_or_remaining_balance_of_inputs` - // resolves `DeductFromInput(index)` against the input map. - let Some((&fee_source_addr, &fee_source_balance)) = selected.iter().nth(fee_source_index) - else { - // Out-of-range index would be rejected by structure validation; surface - // a typed wallet-side error instead of shipping a doomed transition. - return Err(PlatformWalletError::AddressOperation(format!( - "Fee strategy DeductFromInput({}) is out of range for {} selected input(s)", - fee_source_index, - selected.len() - ))); - }; + // 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 @@ -316,20 +326,26 @@ fn reserve_withdrawal_fee_on_fee_source( 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: balance {} \ - minus estimated fee {} leaves {}, below the minimum input amount {}. \ - Consolidate funds onto fewer addresses or fund the smallest address \ - more before withdrawing.", + "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-0 resolution above) is + // 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); - Ok(selected) + let fee_strategy = vec![AddressFundsFeeStrategyStep::DeductFromInput( + fee_source_index as u16, + )]; + + Ok((selected, fee_strategy)) } #[cfg(test)] @@ -351,7 +367,8 @@ mod tests { /// 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). + /// 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(); @@ -361,74 +378,119 @@ mod tests { let mut input = BTreeMap::new(); input.insert(addr(1), balance); - let result = reserve_withdrawal_fee_on_fee_source( - input, - &[AddressFundsFeeStrategyStep::DeductFromInput(0)], - pv, - ) - .expect("single funded input above the fee should select"); + let (result, strategy) = reserve_withdrawal_fee_on_largest_input(input, pv) + .expect("single funded input above the fee should select"); assert_eq!(result.get(&addr(1)).copied(), Some(balance - fee)); + assert_eq!( + strategy, + vec![AddressFundsFeeStrategyStep::DeductFromInput(0)], + "the single input is the fee source at index 0" + ); } - /// The reviewer's scenario: input[0] (lex-smallest, the `DeductFromInput(0)` - /// target) is much smaller than the fee while a larger input exists. The fee - /// must be reserved on input[0] itself (the index the chain deducts from), so - /// input[0]'s withdraw amount drops by the fee and the larger input is - /// withdrawn in full. Both must stay ≥ `min_input_amount`. + /// 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_lex_smallest_input_even_when_a_larger_input_exists() { + fn reserves_fee_on_largest_input_even_when_lex_smallest_is_tiny() { let pv = PlatformVersion::latest(); let fee = estimated_fee(2, pv); - // Small input[0] still large enough to absorb the fee + keep min_input. - let small = fee + dpp::dash_to_credits!(0.5); + // 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 → index 0 - inputs.insert(addr(9), large); + inputs.insert(addr(1), small); // lex-smallest → BTreeMap index 0 + inputs.insert(addr(9), large); // larger → BTreeMap index 1 - let result = reserve_withdrawal_fee_on_fee_source( - inputs, - &[AddressFundsFeeStrategyStep::DeductFromInput(0)], - pv, - ) - .expect("fee-source input can absorb the fee"); + let (result, strategy) = reserve_withdrawal_fee_on_largest_input(inputs, pv) + .expect("the larger peer can absorb the fee"); assert_eq!( result.get(&addr(1)).copied(), - Some(small - fee), - "fee is reserved on the lex-smallest (index-0) input" + Some(small), + "the small lex-smallest input is withdrawn in full" ); assert_eq!( result.get(&addr(9)).copied(), - Some(large), - "the larger non-fee-source input is withdrawn in full" + Some(large - fee), + "the fee is reserved on the largest input" + ); + assert_eq!( + strategy, + vec![AddressFundsFeeStrategyStep::DeductFromInput(1)], + "the emitted DeductFromInput index points at the largest input (BTreeMap index 1)" ); } - /// When the fee-source input cannot retain `estimated_fee` while keeping its - /// withdraw amount ≥ `min_input_amount`, we error rather than ship a - /// guaranteed-rejected transition (mirrors the transfer path's headroom error). + /// 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 errors_when_fee_source_input_too_small_to_absorb_fee() { + fn emitted_index_points_at_largest_input_not_last() { let pv = PlatformVersion::latest(); - let fee = estimated_fee(2, pv); - let min_input = pv.dpp.state_transitions.address_funds.min_input_amount; - // Fee-source balance leaves < min_input after the fee is reserved. - let small = fee + min_input - 1; + 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), small); + 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 (result, strategy) = reserve_withdrawal_fee_on_largest_input(inputs, pv) + .expect("the largest input can absorb the fee"); + + assert_eq!( + strategy, + vec![AddressFundsFeeStrategyStep::DeductFromInput(0)], + "the largest input is at BTreeMap index 0, so the fee deducts from index 0" + ); + assert_eq!(result.get(&addr(1)).copied(), Some(large - fee)); + assert_eq!(result.get(&addr(5)).copied(), Some(small_a)); + assert_eq!(result.get(&addr(9)).copied(), Some(small_b)); + } + + /// 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); - let err = reserve_withdrawal_fee_on_fee_source( - inputs, - &[AddressFundsFeeStrategyStep::DeductFromInput(0)], - pv, - ) - .expect_err("fee-source input below fee + min_input must error"); + // 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(_))); } @@ -442,12 +504,8 @@ mod tests { let mut inputs = BTreeMap::new(); inputs.insert(addr(1), fee - 1); - let err = reserve_withdrawal_fee_on_fee_source( - inputs, - &[AddressFundsFeeStrategyStep::DeductFromInput(0)], - pv, - ) - .expect_err("balance below the fee must error"); + let err = reserve_withdrawal_fee_on_largest_input(inputs, pv) + .expect_err("balance below the fee must error"); assert!(matches!(err, PlatformWalletError::AddressOperation(_))); } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift index 28959ee079b..d374fb9fde5 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift @@ -65,6 +65,41 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { } } + /// Get all platform addresses with their cached balances for a specific + /// platform-payment account (`accountIndex`, key class 0). + /// + /// Account-scoped sibling of `addressesWithBalances()`. The transfer + /// wrapper builds its explicit inputs from this so the spent source + /// account matches the `accountIndex` it persists/nonces against; + /// `addressesWithBalances()` always resolves account 0, which would let + /// `transfer(accountIndex: != 0)` spend account 0 while telling the chain + /// a different account. + public func addressesWithBalances(forAccount accountIndex: UInt32) throws -> [AddressBalance] { + var entriesPtr: UnsafeMutablePointer? + var count: UInt = 0 + try platform_address_wallet_addresses_with_balances_for_account( + handle, accountIndex, &entriesPtr, &count + ).check() + + defer { + platform_address_wallet_free_address_balances(entriesPtr, count) + } + + guard let entries = entriesPtr, count > 0 else { + return [] + } + + return (0.. 0 } .filter { !recipientHashes.contains($0.hash) } .sorted { $0.balance > $1.balance } @@ -220,11 +271,11 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { if let cc = changeAddress, b.hash == cc.hash { continue } selectedInputs.append(b) totalInputs += b.balance - if totalInputs >= totalRecipientCredits + Self.feeBuffer { break } + if totalInputs >= totalNeeded { break } } - guard totalInputs >= totalRecipientCredits + Self.feeBuffer else { + guard totalInputs >= totalNeeded else { throw PlatformWalletError.walletOperation( - "Insufficient platform balance: have \(totalInputs) credits across \(selectedInputs.count) input(s), need at least \(totalRecipientCredits + Self.feeBuffer)" + "Insufficient platform balance: have \(totalInputs) credits across \(selectedInputs.count) input(s), need at least \(totalNeeded)" ) } let selectedHashes = Set(selectedInputs.map { $0.hash }) @@ -282,27 +333,34 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { 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. + let result = withExtendedLifetime(signer) { + 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 + ) + } } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift index ca12a68df41..505f367d43e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift @@ -286,27 +286,20 @@ struct TransferPlatformAddressView: View { /// Source accounts the transfer can actually spend from. /// - /// CONSTRAINED to the single *first* platform-payment account - /// (account index 0, key class 0). The `transfer` wrapper builds its - /// explicit inputs from `addressesWithBalances()`, which the Rust - /// `platform-wallet` crate resolves via - /// `first_platform_payment_managed_account()` — i.e. always account - /// (0, 0) — regardless of the `accountIndex` it is told to persist - /// against. Offering any other `accountType == 14` account here would - /// let the picker's choice diverge from the wrapper's real source: - /// picking account #1 would build inputs from account #0's addresses - /// while telling Rust `account_index = 1`, drifting persistence and - /// the sufficiency gate. Until an account-scoped input-selection FFI - /// exists in the Rust library (input selection belongs there, not in - /// Swift — see swift-sdk/CLAUDE.md), the picker must not offer an - /// account the stack won't honor. The withdraw flow has no such - /// divergence: its `INPUT_SELECTION_TYPE_AUTO` path is account-scoped - /// on the Rust side (`auto_select_inputs_for_withdrawal(account_index)`), - /// so its picker stays multi-account. + /// Offers every DIP-17 platform-payment account (`accountType == 14`, + /// key class 0) on this wallet. The `transfer` wrapper now builds its + /// explicit inputs from `addressesWithBalances(forAccount:)`, which the + /// Rust `platform-wallet` crate resolves via + /// `platform_payment_managed_account_at_index(account_index)` — i.e. the + /// chosen account — so the spent source matches the `accountIndex` the + /// transfer persists/nonces against. (Earlier this picker was pinned to + /// account 0 because the wrapper always resolved the first account; that + /// divergence is fixed end-to-end, so the picker is multi-account again, + /// matching the withdraw flow.) private var platformAccountOptions: [PlatformAccountOption] { let accounts = allAccounts .filter { $0.wallet.walletId == wallet.walletId } - .filter { $0.accountType == 14 && $0.accountIndex == 0 && $0.keyClass == 0 } + .filter { $0.accountType == 14 && $0.keyClass == 0 } .sorted { $0.accountIndex < $1.accountIndex } return accounts.map { acct in let total = allPlatformAddresses From 5382a74e0ac58d6baf08d8077378fcdb2ddbbcb0 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 16 Jun 2026 21:48:16 +0100 Subject: [PATCH 06/21] fix(swift-sdk): filter dust from withdrawal inputs, scope pickers to key class 0 Address third-pass review findings on the platform-address transfer/withdraw path: - AUTO withdrawal now filters inputs to balance >= min_input_amount before building the input map (new select_withdrawable_inputs helper), so a single sub-min "dust" address no longer makes DPP reject the whole transition. Returns typed OnlyDustInputs when only dust exists, the generic no-funds error otherwise; composes with reserve_withdrawal_fee_on_largest_input (largest input guaranteed >= min, post-reservation floor still enforced). Withdrawal therefore takes the full withdrawable (>=min) balance. Adds 3 unit tests. - Drop the dead [DeductFromInput(0)] the withdraw wrapper passed (the Rust AUTO branch owns its own largest-input fee strategy and ignores the caller's) and correct the now-stale doc/inline comments to describe the actual behavior. - Scope the withdraw source picker to accountType == 14 && keyClass == 0, matching the transfer sheet and what Rust (platform_payment_managed_account_at_index, key class 0) actually spends. - Filter the displayed source-account balance sum through account?.keyClass == 0 in both sheets so the total can't be inflated by non-zero-key-class addresses at the same accountIndex. - Reject embedded NULs in coreAddress at the withdraw wrapper before withCString, so a "valid\0suffix" string can't withdraw to a different address than the Swift value (CStr stops at the first NUL). platform-wallet lib tests 210 passed; clippy clean; build_ios.sh BUILD SUCCEEDED; SwiftExampleApp simulator build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../wallet/platform_addresses/withdrawal.rs | 193 ++++++++++++++++-- .../ManagedPlatformAddressWallet.swift | 78 +++---- .../Views/TransferPlatformAddressView.swift | 9 +- .../Views/WithdrawPlatformAddressView.swift | 17 +- 4 files changed, 238 insertions(+), 59 deletions(-) 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 6226317b719..ea6261769c7 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs @@ -173,7 +173,22 @@ impl PlatformAddressWallet { Ok(cs) } - /// Auto-select all funded addresses for withdrawal. + /// Auto-select the withdrawable funded addresses for withdrawal. + /// + /// 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 returned map is the amount to /// *withdraw* from that address, not its on-chain balance. The chain @@ -185,7 +200,7 @@ impl PlatformAddressWallet { /// 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 funded address at its full balance, then + /// 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 @@ -223,29 +238,85 @@ 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)?; - if selected.is_empty() { - return Err(PlatformWalletError::AddressOperation( - "No funded addresses available for withdrawal".to_string(), - )); + 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); } + } - reserve_withdrawal_fee_on_largest_input(selected, platform_version) + 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(), + )); } + + Ok(selected) } /// Convert a full-balance input map into a withdraw-amount map that leaves the @@ -508,4 +579,84 @@ mod tests { .expect_err("balance below the fee must error"); assert!(matches!(err, PlatformWalletError::AddressOperation(_))); } + + /// 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 d374fb9fde5..7e8d550f2de 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift @@ -445,20 +445,24 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { // MARK: - Withdraw - /// Withdraw this platform-payment account's credit balance to a Core - /// L1 address (less the transition fee). + /// 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 selects every funded - /// address on `accountIndex`, and `DeductFromInput(0)` so the fee - /// comes out of the fee-source input. The Rust auto-selector reserves - /// the estimated fee on that input (the lexicographically-smallest - /// address, which `DeductFromInput(0)` resolves to) — it withdraws - /// `balance − estimated_fee` there 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`. + /// 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 @@ -489,6 +493,15 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { 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 @@ -497,13 +510,10 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { return try await Task.detached(priority: .userInitiated) { () -> [UpdatedBalance] in - // Withdrawals have no change output, so the fee is deducted - // from the inputs. The Rust auto-selector reserves the - // estimated fee on the index-0 (fee-source) input so the chain - // can cover it; see this wrapper's doc comment. - let feeRows: [FeeStrategyStepFFI] = [ - FeeStrategyStepFFI(step_type: 0, index: 0) // 0 = DeductFromInput - ] + // 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 @@ -512,23 +522,21 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { // causing a use-after-free in the vtable callback. let result = withExtendedLifetime(signer) { address.withCString { addrCStr in - feeRows.withUnsafeBufferPointer { feeBp in - platform_address_wallet_withdraw_to_address( - handle, - accountIndex, - INPUT_SELECTION_TYPE_AUTO, - nil, - 0, - nil, - 0, - addrCStr, - feePerByte, - feeBp.baseAddress, - UInt(feeBp.count), - signerHandle, - &changeset - ) - } + 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() diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift index 505f367d43e..29a1a0133fa 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift @@ -302,9 +302,16 @@ struct TransferPlatformAddressView: View { .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`). Rust spends the key-class-0 + // account at this index, so summing every row at `accountIndex` + // regardless of key class would inflate the total and let + // `canSubmit` promise more than Rust will spend. let total = allPlatformAddresses .filter { - $0.walletId == wallet.walletId && $0.accountIndex == acct.accountIndex + $0.walletId == wallet.walletId + && $0.accountIndex == acct.accountIndex + && $0.account?.keyClass == 0 } .reduce(into: UInt64(0)) { acc, addr in acc &+= addr.balance } return PlatformAccountOption(accountIndex: acct.accountIndex, totalCredits: total) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift index adce583ad4b..47cdadc0a8f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift @@ -311,15 +311,28 @@ struct WithdrawPlatformAddressView: View { 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`); summing every row + /// at `accountIndex` regardless of key class would inflate the total + /// and let `canSubmit` promise more than Rust (key class 0) will spend. private var platformAccountOptions: [PlatformAccountOption] { let accounts = allAccounts .filter { $0.wallet.walletId == wallet.walletId } - .filter { $0.accountType == 14 } + .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.walletId == wallet.walletId + && $0.accountIndex == acct.accountIndex + && $0.account?.keyClass == 0 } .reduce(into: UInt64(0)) { acc, addr in acc &+= addr.balance } return PlatformAccountOption(accountIndex: acct.accountIndex, totalCredits: total) From c1e291b47411b143a30074dd69d04a28e5ffb0c6 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 16 Jun 2026 22:14:41 +0100 Subject: [PATCH 07/21] fix(swift-sdk): scope transfer change address to key class 0 and persist withdrawal changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - autoChangeAddress in the transfer sheet now filters account?.keyClass == 0, matching the source picker / balance sum tightened earlier in this PR. Without it, a sibling PlatformPayment account at the same accountIndex with keyClass != 0 could be picked as the change destination, but Rust routes change only through key class 0, stranding the change on an address the production UI no longer surfaces. - PlatformAddressWallet::withdraw now persists its changeset before returning, mirroring the sibling flows (transfer.rs:308-317, fund_from_asset_lock.rs:299-308, sync.rs:158-162): drop(wm) → if !cs.is_empty() → persister.store(cs.clone().into()) with tracing::error! log-on-error (the on-chain transition already succeeded). Without it, non-Swift callers or hosts without the SwiftData write side-channel would restart with pre-withdrawal balances and could build invalid follow-up spends. platform-wallet lib tests 210 passed; clippy clean; build_ios.sh BUILD SUCCEEDED; SwiftExampleApp simulator build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/wallet/platform_addresses/withdrawal.rs | 14 ++++++++++++++ .../Views/TransferPlatformAddressView.swift | 11 +++++++++++ 2 files changed, 25 insertions(+) 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 ea6261769c7..01c1b408c67 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs @@ -11,6 +11,7 @@ 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; @@ -169,6 +170,19 @@ impl PlatformAddressWallet { } } } + drop(wm); + + // Mirror `transfer.rs` / `sync.rs`: persist post-broadcast balances so a + // restart doesn't reseed `auto_select_inputs_for_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) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift index 29a1a0133fa..b54e571b7c2 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift @@ -361,12 +361,23 @@ struct TransferPlatformAddressView: View { /// Lowest-index unused, zero-balance address on the source account — /// the change destination. Picked internally; never exposed in the UI. + /// + /// Scoped to `account?.keyClass == 0` to match the Rust transfer path, + /// which routes change through `platform_payment_managed_account_at_index` + /// (key class 0). A sibling PlatformPayment account at the same + /// `accountIndex` with a different key class can legitimately own an + /// unused, zero-balance row (PersistentAccount's unique constraint + /// includes keyClass); without this scope the picker could route change to + /// a key-class != 0 address that Rust never surfaces, stranding the funds. + /// Mirrors the `keyClass == 0` filter applied to the source picker / + /// balance sum. private var autoChangeAddress: PersistentPlatformAddress? { guard let acctIdx = sourceAccountIndex else { return nil } return allPlatformAddresses .filter { $0.walletId == wallet.walletId && $0.accountIndex == acctIdx + && $0.account?.keyClass == 0 && !$0.isUsed && $0.balance == 0 } From 8ab7c009b022061822d77363efdfbc84a9cdb3dc Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 16 Jun 2026 22:23:18 +0100 Subject: [PATCH 08/21] fix(platform-wallet): guard fee-source index u16 narrowing in withdrawal fee_source_index as u16 could silently wrap if a withdrawal ever selected more than u16::MAX inputs, targeting the wrong fee-source input. Use try_into and return a typed AddressOperation error instead. Defensive only (an account with >65535 funded addresses is not reachable in practice). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/wallet/platform_addresses/withdrawal.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 01c1b408c67..94bef2ed675 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs @@ -426,8 +426,18 @@ fn reserve_withdrawal_fee_on_largest_input( // 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 as u16, + fee_source_index_u16, )]; Ok((selected, fee_strategy)) From 15cd3d241bb8081c25471c81415c23eee2d21988 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 16 Jun 2026 22:40:28 +0100 Subject: [PATCH 09/21] fix(swift-example-app): restrict withdraw Core fee rate to protocol-valid Fibonacci values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DPP's AddressCreditWithdrawalTransitionV0::validate_structure requires core_fee_per_byte to be a non-zero Fibonacci number, but the Withdraw sheet accepted any 1–10,000 free-text value, so common entries (4, 6, 10, 100) enabled the button and then deterministically failed structure validation at submit. Replace the free-text fee field with an accessible picker offering only the valid non-zero Fibonacci rates <= the 10,000 app ceiling (1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181, 6765), default 1, so invalid rates are unselectable. Extract the rate set into a pure, testable WithdrawalCoreFeeRates helper (mirrors the validator's Fibonacci definition) with WithdrawalCoreFeeRatesTests (6 cases). Help text updated; single-flight guard, Core-not-ready gate, and the maxFeePerByte ceiling intact. Simulator build exit 0; WithdrawalCoreFeeRatesTests 6 passed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Views/WithdrawPlatformAddressView.swift | 50 +++++++++------ .../Views/WithdrawalCoreFeeRates.swift | 41 ++++++++++++ .../WithdrawalCoreFeeRatesTests.swift | 62 +++++++++++++++++++ 3 files changed, 133 insertions(+), 20 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawalCoreFeeRates.swift create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/WithdrawalCoreFeeRatesTests.swift diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift index 47cdadc0a8f..5f9e86202cc 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift @@ -45,7 +45,9 @@ struct WithdrawPlatformAddressView: View { @State private var myWalletAddress: String? = nil /// Pasted/scanned external Core address (mode == .external). @State private var externalAddress: String = "" - @State private var coreFeePerByte: String = "1" + /// 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 // MARK: - Core readiness @@ -66,9 +68,21 @@ struct WithdrawPlatformAddressView: View { /// 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. + /// 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 { @@ -224,25 +238,19 @@ struct WithdrawPlatformAddressView: View { @ViewBuilder private var feeSection: some View { Section { - HStack { - TextField("Fee per byte", text: $coreFeePerByte) - .keyboardType(.numberPad) - .textFieldStyle(.roundedBorder) - .disabled(isSubmitting) - .accessibilityIdentifier("withdrawPlatform.feePerByteField") - Text("duffs/byte") - .foregroundColor(.secondary) + 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: { - if !coreFeePerByte.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - && parsedFeePerByte == nil { - Text("Enter a whole number between 1 and \(Self.maxFeePerByte) duffs/byte. A higher rate would eat the withdrawal, since the fee is deducted from the payout.") - .foregroundColor(.red) - } else { - Text("Fee rate for the eventual L1 payout transaction. Default is 1.") - } + 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.") } } @@ -344,10 +352,12 @@ struct WithdrawPlatformAddressView: View { 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? { - let raw = coreFeePerByte.trimmingCharacters(in: .whitespacesAndNewlines) - guard let v = UInt32(raw), v > 0, v <= Self.maxFeePerByte else { return nil } - return v + Self.validFeeRates.contains(coreFeePerByte) ? coreFeePerByte : nil } /// Resolved Core destination address for the current mode. 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/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]) + } +} From 51f0c44c8c72fce34f56ed09816da1cadb29af2c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 16 Jun 2026 22:56:26 +0100 Subject: [PATCH 10/21] fix(swift-sdk): make platform-address transfer P2PKH-only contract accurate (not P2SH) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PlatformAddressFFI documented address_type 1 = P2SH and From emits it, but the shared TryFrom used by the transfer/withdraw parse paths (parse_outputs / parse_explicit_inputs[_with_nonces]) rejects 1 — and rightly so: the FFI signer returns an error for P2SH platform-address witnesses and the KeychainSigner holds P2PKH key material only, so P2SH can't be signed/spent on this surface, and the UI never produces P2SH outputs. The contract advertised a value the surface can't honor. Narrow the contract to match reality rather than relocate the failure: - TryFrom now returns a specific, accurate message for P2SH ("...support P2PKH (address_type 0) only; P2SH cannot be signed or spent on this surface") vs a generic message for other values; doc explains the input-signing/output-practice rationale and the identity-sibling contrast (those accept 1 because the address is a pure recipient signed by the identity key). - PlatformAddressFFI + Swift TransferOutput/ChangeAddress/FundFromAssetLockRecipient addressType docs changed from "0 = P2PKH, 1 = P2SH" to "Must be 0 (P2PKH)". - Refresh stale error-string references in SendViewModel / fundFromAssetLockPreflight. Runtime accept/reject behavior unchanged (P2SH was already rejected); only docs + message text became accurate. Adds 2 FFI unit tests. platform-wallet-ffi 99 passed; build_ios.sh BUILD SUCCEEDED; SwiftExampleApp simulator build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/platform_address_types.rs | 153 +++++++++++++++++- .../ManagedPlatformAddressWallet.swift | 28 +++- .../Core/ViewModels/SendViewModel.swift | 9 +- 3 files changed, 177 insertions(+), 13 deletions(-) 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..959afdf93b6 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)", + ), } } } @@ -516,6 +562,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/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift index 7e8d550f2de..4b8b33c60a7 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift @@ -104,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 @@ -122,7 +128,11 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { /// 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. + /// Must be `0` (P2PKH). Same P2PKH-only constraint as + /// `TransferOutput.addressType`: the change row becomes a transfer + /// output, which the Rust FFI accepts as P2PKH only. The field + /// stays `UInt8` to mirror the Rust discriminant layout, but `1` + /// (P2SH) will be rejected by the FFI. public let addressType: UInt8 /// 20-byte address hash. public let hash: Data @@ -555,7 +565,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 @@ -782,11 +796,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..d4dc4b2f986 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -497,10 +497,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 From a62d1fa35d206845cbec331e092257f43eda61ec Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 16 Jun 2026 23:13:53 +0100 Subject: [PATCH 11/21] refactor(swift-sdk): move platform-address transfer input selection into Rust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Swift transfer wrapper was doing coin selection itself — fetching per-account balances, choosing which/how many inputs, building a separate change output to a fresh change address, and computing the fee index — then calling the FFI with INPUT_SELECTION_TYPE_EXPLICIT. That's exactly the "deciding which/how many/which index/which order" that swift-sdk/CLAUDE.md says must live in Rust. In the platform credit-balance model, InputSelection::Auto partial-spends inputs (the surplus simply stays on the source addresses), so no change output or change address is needed — the explicit-input + change-address ceremony was Bitcoin-UTXO habit. The transfer Auto path already filters dust (balance >= min_input_amount) and reserves fee headroom; it is the reference the withdrawal auto-selector mirrors. - Swift transfer(...) now calls platform_address_wallet_transfer with INPUT_SELECTION_TYPE_AUTO + accountIndex + recipient outputs + default fee strategy. Removes the Swift coin-selection loop, the changeAddress parameter and ChangeAddress struct, feeBuffer, and buildSortedFFIOutputs. This makes the fee-index misrouting bug class structurally impossible in Swift. - Deletes the Rust addresses_with_balances_for_account method and its FFI platform_address_wallet_addresses_with_balances_for_account (added only to feed the Swift selector). The no-account addresses_with_balances() stays (used by PlatformBalanceSyncService). - TransferPlatformAddressView drops autoChangeAddress + change-collision checks; keeps keyClass==0 source scoping, funded-source-input recipient exclusion, exact decimal parsing + overflow guards, single-flight, and persist+resync. - SendViewModel/SendTransactionView drop the changeAddress wiring. Deletes the now-dead ManagedPlatformAddressWalletTests (only covered buildSortedFFIOutputs; the fee-index regression is covered Rust-side in auto_select_tests). platform-wallet lib 210 passed, platform-wallet-ffi 99 passed; clippy clean; build_ios.sh BUILD SUCCEEDED (deleted symbol gone from header); SwiftExampleApp clean build + build-for-testing exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/platform_addresses/wallet.rs | 46 --- .../src/wallet/platform_addresses/wallet.rs | 42 +-- .../ManagedPlatformAddressWallet.swift | 345 ++++-------------- .../Core/ViewModels/SendViewModel.swift | 14 +- .../Core/Views/SendTransactionView.swift | 20 +- .../Views/TransferPlatformAddressView.swift | 137 +++---- .../ManagedPlatformAddressWalletTests.swift | 82 ----- .../swift-sdk/SwiftExampleApp/TEST_PLAN.md | 2 +- 8 files changed, 135 insertions(+), 553 deletions(-) delete mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ManagedPlatformAddressWalletTests.swift 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 da694725cbf..89df3884151 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_addresses/wallet.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_addresses/wallet.rs @@ -112,52 +112,6 @@ pub unsafe extern "C" fn platform_address_wallet_addresses_with_balances( PlatformWalletFFIResult::ok() } -/// Get all platform addresses with their cached balances for a specific -/// platform-payment account (`account_index`, key class 0). -/// -/// Account-scoped sibling of -/// [`platform_address_wallet_addresses_with_balances`]: resolves the requested -/// account rather than always the first one, and stamps each returned entry's -/// `account_index` with the requested value. Callers that build explicit -/// transfer inputs must use this so the spent source account matches the -/// `account_index` the transfer persists/nonces against. -/// -/// On success, `out_entries` and `out_count` are set to a heap-allocated array. -/// Free with `platform_address_wallet_free_address_balances`. -#[no_mangle] -pub unsafe extern "C" fn platform_address_wallet_addresses_with_balances_for_account( - handle: Handle, - account_index: u32, - out_entries: *mut *mut AddressBalanceEntryFFI, - out_count: *mut usize, -) -> PlatformWalletFFIResult { - check_ptr!(out_entries); - check_ptr!(out_count); - - let option = PLATFORM_ADDRESS_WALLET_STORAGE.with_item(handle, |wallet| { - let balances = - runtime().block_on(wallet.addresses_with_balances_for_account(account_index)); - balances - .into_iter() - .map(|(address, balance)| AddressBalanceEntryFFI { - address: address.into(), - balance, - nonce: 0, - account_index, - address_index: 0, - }) - .collect::>() - }); - let entries = unwrap_option_or_return!(option); - *out_count = entries.len(); - if entries.is_empty() { - *out_entries = std::ptr::null_mut(); - } else { - *out_entries = Box::into_raw(entries.into_boxed_slice()) as *mut AddressBalanceEntryFFI; - } - PlatformWalletFFIResult::ok() -} - // --------------------------------------------------------------------------- // Memory deallocation // --------------------------------------------------------------------------- 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 039a40e6cf7..1026d7c8932 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs @@ -301,9 +301,11 @@ impl PlatformAddressWallet { /// [`transfer`](Self::transfer), or [`withdraw`](Self::withdraw). /// /// Resolves against the **first** platform-payment account (account index 0, - /// key class 0). Callers that need a specific account must use - /// [`addresses_with_balances_for_account`](Self::addresses_with_balances_for_account) - /// so input selection and persistence agree on the source account. + /// 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) @@ -318,40 +320,6 @@ impl PlatformAddressWallet { .unwrap_or_default() } - /// Get all platform addresses with their cached balances for a specific - /// platform-payment account. - /// - /// Account-scoped sibling of [`addresses_with_balances`](Self::addresses_with_balances): - /// resolves the account via `platform_payment_managed_account_at_index` - /// (key class 0) so the returned balances come from the requested - /// `account_index` rather than always the first account. The transfer path - /// builds its explicit inputs from this so the spent source account matches - /// the `account_index` it persists/nonces against — without this the public - /// `transfer(account_index, ..)` API would silently spend account 0 while - /// telling the chain a different account. - /// - /// Returns an empty vec (not an error) when the account has no addresses, - /// matching `addresses_with_balances`'s behaviour for a missing account. - pub async fn addresses_with_balances_for_account( - &self, - account_index: u32, - ) -> Vec<(PlatformAddress, Credits)> { - let wm = self.wallet_manager.read().await; - wm.get_wallet_info(&self.wallet_id) - .and_then(|info| { - info.core_wallet - .platform_payment_managed_account_at_index(account_index) - }) - .map(|account| { - account - .address_balances - .iter() - .map(|(p2pkh, &bal)| (PlatformAddress::P2pkh(p2pkh.to_bytes()), bal)) - .collect() - }) - .unwrap_or_default() - } - /// Current incremental-sync watermark (`last_known_recent_block`) /// from the unified platform-address provider. /// diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift index 4b8b33c60a7..58039bb25f9 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift @@ -65,41 +65,6 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { } } - /// Get all platform addresses with their cached balances for a specific - /// platform-payment account (`accountIndex`, key class 0). - /// - /// Account-scoped sibling of `addressesWithBalances()`. The transfer - /// wrapper builds its explicit inputs from this so the spent source - /// account matches the `accountIndex` it persists/nonces against; - /// `addressesWithBalances()` always resolves account 0, which would let - /// `transfer(accountIndex: != 0)` spend account 0 while telling the chain - /// a different account. - public func addressesWithBalances(forAccount accountIndex: UInt32) throws -> [AddressBalance] { - var entriesPtr: UnsafeMutablePointer? - var count: UInt = 0 - try platform_address_wallet_addresses_with_balances_for_account( - handle, accountIndex, &entriesPtr, &count - ).check() - - defer { - platform_address_wallet_free_address_balances(entriesPtr, count) - } - - guard let entries = entriesPtr, count > 0 else { - return [] - } - - return (0.. [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( @@ -209,9 +150,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 { @@ -228,119 +169,26 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { totalRecipientCredits = sum.partialValue } - // The sufficiency check needs `recipients + feeBuffer`. Compute it once - // with overflow checking: a near-UInt64.max recipient sum would trap the - // process on an unchecked `+`. Reject the overflow the same way the - // recipient-sum loop above rejects its own. - let neededSum = totalRecipientCredits.addingReportingOverflow(Self.feeBuffer) - if neededSum.overflow { - throw PlatformWalletError.invalidParameter( - "Recipient credits + fee buffer overflowed UInt64" - ) - } - let totalNeeded = neededSum.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." - ) - } - } - - // Account-scoped: inputs come from the SAME account this transfer - // persists/nonces against. `addressesWithBalances()` (no account) always - // resolves account 0 in Rust, so spending from it while telling the - // chain `accountIndex` would drift the source vs. the persisted account. - let balanced = try addressesWithBalances(forAccount: accountIndex) - .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 >= totalNeeded { break } - } - guard totalInputs >= totalNeeded else { - throw PlatformWalletError.walletOperation( - "Insufficient platform balance: have \(totalInputs) credits across \(selectedInputs.count) input(s), need at least \(totalNeeded)" - ) - } - 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 var changeset = PlatformAddressChangeSetFFI(updated: nil, updated_count: 0) @@ -350,92 +198,37 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { // 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) { - 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 - ) - } - } + 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 diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index d4dc4b2f986..8407062752a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -430,7 +430,6 @@ class SendViewModel: ObservableObject { platformAddressWallet: ManagedPlatformAddressWallet?, signer: KeychainSigner?, senderAccountIndex: UInt32, - changeAddressRow: PersistentPlatformAddress?, modelContext: ModelContext ) async { guard let flow = detectedFlow else { return } @@ -512,19 +511,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 ) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift index a25d8abc630..5214c483807 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift @@ -235,21 +235,10 @@ struct SendTransactionView: View { 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 - } - .min(by: { $0.addressIndex < $1.addressIndex }) + // 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 +252,6 @@ struct SendTransactionView: View { platformAddressWallet: platformAddressWallet, signer: signer, senderAccountIndex: senderAccountIndex, - changeAddressRow: changeAddressRow, modelContext: modelContext ) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift index b54e571b7c2..2c4e480593e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift @@ -7,13 +7,15 @@ // → Submit) and drives `ManagedPlatformAddressWallet.transfer(...)` // end-to-end with a `KeychainSigner`. // -// No private keys are ever entered here. Input selection, change -// routing, fee strategy, nonce selection (Auto), 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, -// the destination address, and which (unused) wallet address to route -// change to. Contrast with the raw `TransferAddressFundsView` debug -// form, which pastes a 64-char private key. +// 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 @@ -64,10 +66,17 @@ struct TransferPlatformAddressView: View { /// and rejected rather than truncated. private static let creditFractionDigits = 11 - /// Mirror of `ManagedPlatformAddressWallet.feeBuffer` (held back so - /// the change output survives the on-chain fee). Used here only to - /// gate the submit button with the same accounting the wrapper - /// enforces — the wrapper still throws if this is violated. + /// 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 { @@ -200,7 +209,7 @@ struct TransferPlatformAddressView: View { } header: { Text("Destination Address") } footer: { - Text("Send to another address on this wallet, or paste a 20-byte P2PKH address hash. Change routes automatically to a fresh unused address.") + 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.") } } @@ -226,7 +235,7 @@ struct TransferPlatformAddressView: View { 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 held back from change).") + Text("\(formatCredits(credits)) will be transferred (plus a small on-chain fee taken from the source balance).") } } else { Text("Enter an amount in DASH.") @@ -287,15 +296,12 @@ struct TransferPlatformAddressView: View { /// Source accounts the transfer can actually spend from. /// /// Offers every DIP-17 platform-payment account (`accountType == 14`, - /// key class 0) on this wallet. The `transfer` wrapper now builds its - /// explicit inputs from `addressesWithBalances(forAccount:)`, which the - /// Rust `platform-wallet` crate resolves via - /// `platform_payment_managed_account_at_index(account_index)` — i.e. the - /// chosen account — so the spent source matches the `accountIndex` the - /// transfer persists/nonces against. (Earlier this picker was pinned to - /// account 0 because the wrapper always resolved the first account; that - /// divergence is fixed end-to-end, so the picker is multi-account again, - /// matching the withdraw flow.) + /// 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] { let accounts = allAccounts .filter { $0.wallet.walletId == wallet.walletId } @@ -324,13 +330,13 @@ struct TransferPlatformAddressView: View { } /// Funded addresses on the selected source account for this wallet. - /// The `transfer` wrapper picks its inputs from these (balance > 0), + /// The Rust Auto selector picks its inputs from these (balance > 0), /// and the `AddressFundsTransferTransition` protocol forbids any - /// output address from also being an input. The wrapper excludes - /// recipient hashes from input selection *before* its sufficiency - /// check, so a recipient that collides with a funded source input - /// would enable the button here, then fail Rust-side once that input - /// is removed. Gate on this set so the collision is caught up front. + /// output address from also being an input. The selector excludes + /// recipient addresses from its input set, so a recipient that + /// collides with a funded 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. private var sourceInputHashes: Set { guard let acctIdx = sourceAccountIndex else { return [] } return Set( @@ -344,47 +350,22 @@ struct TransferPlatformAddressView: View { ) } - /// Own-wallet recipients: any address on the wallet that is NOT the - /// auto-selected change address and 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 FFI wrapper rejects a recipient that collides with an input. + /// 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. private var ownWalletRecipientCandidates: [PersistentPlatformAddress] { - let changeHash = autoChangeAddress?.addressHash let inputs = sourceInputHashes return allPlatformAddresses .filter { $0.walletId == wallet.walletId } - .filter { $0.addressHash != changeHash } .filter { !inputs.contains($0.addressHash) } .sorted { ($0.accountIndex, $0.addressIndex) < ($1.accountIndex, $1.addressIndex) } } - /// Lowest-index unused, zero-balance address on the source account — - /// the change destination. Picked internally; never exposed in the UI. - /// - /// Scoped to `account?.keyClass == 0` to match the Rust transfer path, - /// which routes change through `platform_payment_managed_account_at_index` - /// (key class 0). A sibling PlatformPayment account at the same - /// `accountIndex` with a different key class can legitimately own an - /// unused, zero-balance row (PersistentAccount's unique constraint - /// includes keyClass); without this scope the picker could route change to - /// a key-class != 0 address that Rust never surfaces, stranding the funds. - /// Mirrors the `keyClass == 0` filter applied to the source picker / - /// balance sum. - private var autoChangeAddress: PersistentPlatformAddress? { - guard let acctIdx = sourceAccountIndex else { return nil } - return allPlatformAddresses - .filter { - $0.walletId == wallet.walletId - && $0.accountIndex == acctIdx - && $0.account?.keyClass == 0 - && !$0.isUsed - && $0.balance == 0 - } - .sorted { $0.addressIndex < $1.addressIndex } - .first - } - /// Parse the pasted external hash (40 hex chars → 20 bytes). private var parsedExternalHash: Data? { let raw = externalHashHex.trimmingCharacters(in: .whitespacesAndNewlines) @@ -467,18 +448,19 @@ struct TransferPlatformAddressView: View { !isSubmitting, sourceAccountIndex != nil, let credits = parsedCredits, credits > 0, - let dest = resolvedDestination, - autoChangeAddress != nil + let dest = resolvedDestination else { return false } - // Reject change-address / recipient collision up front (the - // wrapper rejects it too, but a dead button is worse UX). - if dest.hash == autoChangeAddress?.addressHash { return false } // Reject a recipient that collides with a funded source input. - // The wrapper drops it from input selection before its - // sufficiency check, so the button would enable then fail - // Rust-side. Covers both own-wallet picks and pasted externals. + // 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 buffer <= account balance. + // 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 @@ -506,17 +488,9 @@ struct TransferPlatformAddressView: View { guard let sourceAccount = sourceAccountIndex, let credits = parsedCredits, - let dest = resolvedDestination, - let change = autoChangeAddress + let dest = resolvedDestination else { return } - guard dest.hash != change.addressHash else { - submitError = SubmitError( - message: "The destination collides with the auto-selected change address. Pick a different recipient." - ) - 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." @@ -545,10 +519,6 @@ struct TransferPlatformAddressView: View { credits: credits ) ] - let changeAddress = ManagedPlatformAddressWallet.ChangeAddress( - addressType: change.addressType, - hash: change.addressHash - ) isSubmitting = true Task { @@ -557,7 +527,6 @@ struct TransferPlatformAddressView: View { let updated = try await addressWallet.transfer( accountIndex: sourceAccount, outputs: outputs, - changeAddress: changeAddress, signer: signer ) // Persist the post-transfer balances Rust reported BEFORE diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ManagedPlatformAddressWalletTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ManagedPlatformAddressWalletTests.swift deleted file mode 100644 index a34c0701ba2..00000000000 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/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) - } -} diff --git a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md index fcbb4bad29c..66dd7e9a9d0 100644 --- a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md +++ b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md @@ -160,7 +160,7 @@ 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 | ✅ | `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, change routing (auto fresh unused address), fee strategy, and Auto nonce all happen Rust-side — no private-key entry. Submit gated on amount + fee ≤ account balance and recipient ≠ change. 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-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 | ✅ | `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. | From 10a3da706390fae3863985e4ecb3931e856c51a0 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 16 Jun 2026 23:49:11 +0100 Subject: [PATCH 12/21] fix(swift-example-app): scope generic Send platform source account to key class 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SendTransactionView's platform->platform branch picked senderAccountIndex from the first balance-bearing PersistentPlatformAddress without filtering on the parent account's key class. The Rust transfer path resolves the source via platform_payment_managed_account_at_index (key class 0), so on a wallet with a sibling non-key-class-0 PlatformPayment account at the same accountIndex this could derive an index whose key-class-0 account Rust spends from is empty. Add account?.keyClass == 0 to the selection, matching the dedicated Transfer/Withdraw sheets. (The change-address half of this finding is already gone — the prior refactor removed change-address selection entirely, since Auto leaves surplus on the source addresses.) SwiftExampleApp simulator clean build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Core/Views/SendTransactionView.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift index 5214c483807..02f1abb766b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift @@ -231,8 +231,16 @@ struct SendTransactionView: View { // Pick the account holding the platform // balance. Most wallets have a single // PlatformPayment account (index 0); - // fallback handles that case too. + // fallback handles that case too. Scope to + // key class 0 because the Rust transfer path + // resolves the source via + // `platform_payment_managed_account_at_index` + // (key class 0); picking an index from a + // non-key-class-0 sibling account would tell + // Rust to spend a key-class-0 account that may + // be empty at that index. let senderAccountIndex = addressBalances + .filter { $0.account?.keyClass == 0 } .first(where: { $0.balance > 0 })? .accountIndex ?? 0 // Input selection and surplus handling are owned From c07e77a61174b0f7add3e6cbd82ab1bbd4b07aec Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 17 Jun 2026 00:09:35 +0100 Subject: [PATCH 13/21] fix(swift-example-app): fund-aware platform account selection, non-fatal cache-save handling, P2PKH-only recipients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CodeRabbit review on the platform-address send/transfer/withdraw flows: - SendTransactionView platform->platform now selects a key-class-0 PlatformPayment account whose own balance covers amount + fee (Rust AUTO selects within one account, not across), via a pure, unit-tested PlatformPaymentAccountSelection helper; if no single account covers it, it aborts with a clear error instead of silently handing Rust an underfunded account_index. (10 new tests.) - Local cache-save failures after a SUCCESSFUL on-chain transfer/withdrawal are no longer either silently swallowed (try?) or misreported as the op failing. The op stays marked successful and a distinct non-fatal caveat is surfaced ("Submitted successfully, but local balances couldn't be updated — they'll refresh on the next sync"), since performSync() runs immediately after and reconciles. Applied consistently to SendViewModel, WithdrawPlatformAddressView, and TransferPlatformAddressView. SendViewModel's post-transfer fetch is now scoped to walletId + addressHash. - TransferPlatformAddressView own-wallet recipient candidates and resolvedDestination are now filtered to P2PKH (addressType == 0), matching the transfer FFI's P2PKH-only contract, so a persisted P2SH row can't be selected and fail at submit. SwiftExampleApp simulator clean build exit 0; new PlatformPaymentAccountSelectionTests 10/10. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Core/ViewModels/SendViewModel.swift | 35 +++-- .../Core/Views/SendTransactionView.swift | 106 +++++++++++-- .../PlatformPaymentAccountSelection.swift | 85 +++++++++++ .../Views/TransferPlatformAddressView.swift | 53 ++++++- .../Views/WithdrawPlatformAddressView.swift | 38 ++++- ...PlatformPaymentAccountSelectionTests.swift | 143 ++++++++++++++++++ 6 files changed, 429 insertions(+), 31 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PlatformPaymentAccountSelection.swift create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/PlatformPaymentAccountSelectionTests.swift diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index 8407062752a..1d2747ee4e6 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -530,16 +530,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 @@ -549,15 +554,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 02f1abb766b..e3ba03aab95 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift @@ -228,21 +228,39 @@ 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. Scope to - // key class 0 because the Rust transfer path + // 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); picking an index from a - // non-key-class-0 sibling account would tell - // Rust to spend a key-class-0 account that may - // be empty at that index. - let senderAccountIndex = addressBalances - .filter { $0.account?.keyClass == 0 } - .first(where: { $0.balance > 0 })? - .accountIndex ?? 0 + // (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 + } + 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), @@ -426,6 +444,68 @@ struct SendTransactionView: View { return wallet.identities.reduce(UInt64(0)) { $0 + UInt64(bitPattern: $1.balance) } } + /// 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), 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 — not merely contribute to the aggregate the Send button gates + /// on. + /// + /// `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? { + // Aggregate balance per key-class-0 PlatformPayment account. + var totals: [UInt32: UInt64] = [:] + for row in addressBalances { + guard let account = row.account, + account.accountType == 14, + account.keyClass == 0 else { 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/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 index 2c4e480593e..c8193ef0251 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift @@ -55,6 +55,13 @@ struct TransferPlatformAddressView: View { @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 @@ -275,6 +282,12 @@ struct TransferPlatformAddressView: View { 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: { @@ -358,10 +371,18 @@ struct TransferPlatformAddressView: View { /// 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) } } @@ -392,7 +413,12 @@ struct TransferPlatformAddressView: View { guard let hash = selectedRecipientHash, let row = allPlatformAddresses.first(where: { $0.walletId == wallet.walletId && $0.addressHash == hash - }) + }), + // P2PKH only: the transfer FFI rejects `addressType == 1` + // (P2SH). `ownWalletRecipientCandidates` already filters + // these out of the picker, but guard here too so a stale + // `selectedRecipientHash` can't resolve to a P2SH row. + row.addressType == 0 else { return nil } return (row.addressType, row.addressHash) case .external: @@ -529,11 +555,24 @@ struct TransferPlatformAddressView: View { 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. - persistUpdatedBalances(updated) + // + // 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() @@ -549,9 +588,15 @@ struct TransferPlatformAddressView: View { /// 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 { @@ -569,7 +614,7 @@ struct TransferPlatformAddressView: View { } row.lastUpdated = Date() } - try? modelContext.save() + try modelContext.save() } // MARK: - Helpers diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift index 5f9e86202cc..71c03dcf03a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift @@ -60,6 +60,13 @@ struct WithdrawPlatformAddressView: View { @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 @@ -301,6 +308,12 @@ struct WithdrawPlatformAddressView: View { 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: { @@ -462,12 +475,25 @@ struct WithdrawPlatformAddressView: View { 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`). - persistUpdatedBalances(updated) + // + // 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 { @@ -481,9 +507,15 @@ struct WithdrawPlatformAddressView: View { /// 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 { @@ -501,7 +533,7 @@ struct WithdrawPlatformAddressView: View { } row.lastUpdated = Date() } - try? modelContext.save() + try modelContext.save() } // MARK: - Helpers 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) + ) + } +} From e0f106bab81e86f70b48e264b6372a6888456346 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 17 Jun 2026 00:26:57 +0100 Subject: [PATCH 14/21] fix(swift-example-app): scope transfer collision guard to key class 0, gate raw key forms to DEBUG - TransferPlatformAddressView.sourceInputHashes now also filters account?.keyClass == 0, matching platformAccountOptions and the Rust spend account (platform_payment_managed_account_at_index, key class 0). Without it a sibling non-key-class-0 row at the same accountIndex was treated as a source input and wrongly excluded as a destination on multi-key-class wallets. - Wrap the legacy raw private-key TransferAddressFundsView / WithdrawAddressFundsView NavigationLinks (TransitionCategoryView) in #if DEBUG so the 64-char-private-key paste surface is excluded from Release/TestFlight builds entirely, not just labeled debug-only. The production wallet-signed sheets remain off the WalletDetailView Platform Balance row. View definitions stay in AddressQueriesView; verified both Debug and Release simulator builds compile. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Views/TransferPlatformAddressView.swift | 9 +++++++++ .../SwiftExampleApp/Views/TransitionCategoryView.swift | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift index c8193ef0251..8d000adf85c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift @@ -350,6 +350,14 @@ struct TransferPlatformAddressView: View { /// collides with a funded 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. + /// + /// Scoped to key class 0 (`account?.keyClass == 0`), matching + /// `platformAccountOptions`: Rust resolves the source via + /// `platform_payment_managed_account_at_index(accountIndex)` (key + /// class 0) and only spends those rows, so a sibling non-key-class-0 + /// row at the same `accountIndex` is not an input. Including it here + /// would wrongly drop it as a destination candidate, blocking + /// legitimate own-wallet/pasted recipients on multi-key-class wallets. private var sourceInputHashes: Set { guard let acctIdx = sourceAccountIndex else { return [] } return Set( @@ -358,6 +366,7 @@ struct TransferPlatformAddressView: View { $0.walletId == wallet.walletId && $0.accountIndex == acctIdx && $0.balance > 0 + && $0.account?.keyClass == 0 } .map { $0.addressHash } ) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionCategoryView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionCategoryView.swift index e510fd676fc..0fca7cae36a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionCategoryView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionCategoryView.swift @@ -109,6 +109,14 @@ struct TransitionCategoryView: View { // `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) { @@ -138,6 +146,7 @@ struct TransitionCategoryView: View { } 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) From f808adf78ff40c142bfc9b7b98329dcac2f8993e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 17 Jun 2026 00:41:49 +0100 Subject: [PATCH 15/21] fix(swift-example-app): exclude recipient from Send account funding check; full accountType/keyClass parity - resolvePlatformSenderAccountIndex (generic Send screen) now excludes the recipient row from each key-class-0/type-14 PlatformPayment account's aggregate before PlatformPaymentAccountSelection.choose, mirroring the Rust Auto selector (which forbids an address being both input and output) and the dedicated sheet's sourceInputHashes gate. The recipient's 20-byte hash comes from a new read-only SendViewModel.platformRecipientHash that reuses the already-decoded detectedAddressType payload (no address decoding duplicated in the view). .platformToPlatform only; other flows unchanged. - sourceInputHashes (TransferPlatformAddressView) now also filters account?.accountType == 14, for full parity with platformAccountOptions and selectedSourceAccountCredits. SwiftExampleApp simulator clean build exit 0; PlatformPaymentAccountSelectionTests 10/10 and SendViewModelCoreRecipientsTests 9/9 still green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Core/ViewModels/SendViewModel.swift | 21 +++++++++++ .../Core/Views/SendTransactionView.swift | 36 +++++++++++++++---- .../Views/TransferPlatformAddressView.swift | 16 +++++---- 3 files changed, 60 insertions(+), 13 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index 1d2747ee4e6..3c87a656def 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -147,6 +147,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 diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift index e3ba03aab95..170cca240be 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift @@ -451,12 +451,14 @@ struct SendTransactionView: View { /// 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), 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 — not merely contribute to the aggregate the Send button gates - /// on. + /// transfer/withdraw sheets and the Rust source resolution) — but + /// 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. /// /// `viewModel.amountCredits` and `viewModel.estimatedFee` are both /// available on this path (`canSend` requires `amountCredits > 0` for @@ -465,12 +467,32 @@ struct SendTransactionView: View { /// account — strictly better than the prior "first positive" pick — /// rather than blocking the send. private func resolvePlatformSenderAccountIndex() -> UInt32? { - // Aggregate balance per key-class-0 PlatformPayment account. + // 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 + + // Aggregate balance per key-class-0 PlatformPayment account, + // excluding any row that IS the recipient (saturating subtraction). var totals: [UInt32: UInt64] = [:] for row in addressBalances { guard let account = row.account, account.accountType == 14, account.keyClass == 0 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 diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift index 8d000adf85c..f865308c756 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift @@ -351,13 +351,16 @@ struct TransferPlatformAddressView: View { /// then come up short Rust-side once that input is excluded. Gate on /// this set so the collision is caught up front. /// - /// Scoped to key class 0 (`account?.keyClass == 0`), matching - /// `platformAccountOptions`: Rust resolves the source via + /// 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) and only spends those rows, so a sibling non-key-class-0 - /// row at the same `accountIndex` is not an input. Including it here - /// would wrongly drop it as a destination candidate, blocking - /// legitimate own-wallet/pasted recipients on multi-key-class wallets. + /// 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 [] } return Set( @@ -366,6 +369,7 @@ struct TransferPlatformAddressView: View { $0.walletId == wallet.walletId && $0.accountIndex == acctIdx && $0.balance > 0 + && $0.account?.accountType == 14 && $0.account?.keyClass == 0 } .map { $0.addressHash } From 9b5b7d70021f822885d49df5fe1c586296fda131 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 17 Jun 2026 01:22:55 +0100 Subject: [PATCH 16/21] fix(swift-sdk): gate Transfer/Withdraw account totals on min_input_amount (version-locked via FFI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Transfer/Withdraw platform-address sheets summed every key-class-0 row into the per-account total and gated submit on it, but the Rust Auto/withdraw selectors drop rows with balance < min_input_amount (100,000 credits) and return OnlyDustInputs when none survive — so the UI could enable an op Rust then refuses. Expose the constant from Rust rather than hardcoding it in Swift (per swift-sdk/CLAUDE.md): new PlatformAddressWallet::min_input_amount reads sdk.version().dpp.state_transitions.address_funds.min_input_amount (the wallet's network-floored PlatformVersion), exposed via FFI platform_address_wallet_min_input_amount + a ManagedPlatformAddressWallet wrapper. Both sheets' platformAccountOptions now sum only rows with balance >= minInputAmount (resolved once per view on appear), so selectedSourceAccountCredits/canSubmit and the displayed totals match the input set Rust actually selects. Fallback when the constant can't be resolved is nil -> threshold UInt64.max (counts nothing) plus an explicit canSubmit guard, i.e. strictly non-under-gating; the view still renders. cargo: platform-wallet-ffi 99 passed, platform-wallet getter test passed, clippy clean; build_ios.sh BUILD SUCCEEDED (new symbol in header); SwiftExampleApp simulator clean build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/platform_addresses/wallet.rs | 23 +++++ .../src/wallet/platform_addresses/wallet.rs | 85 +++++++++++++++++++ .../ManagedPlatformAddressWallet.swift | 18 ++++ .../Views/TransferPlatformAddressView.swift | 69 +++++++++++++-- .../Views/WithdrawPlatformAddressView.swift | 70 ++++++++++++++- 5 files changed, 257 insertions(+), 8 deletions(-) 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..8599867891f 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,29 @@ 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 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/src/wallet/platform_addresses/wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs index 1026d7c8932..db0b852ff13 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,35 @@ 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 + } + /// 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. @@ -354,3 +383,59 @@ 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); + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift index 58039bb25f9..25b8bcc5646 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift @@ -40,6 +40,24 @@ 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 + } + /// Get all platform addresses with their cached balances. public func addressesWithBalances() throws -> [AddressBalance] { var entriesPtr: UnsafeMutablePointer? diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift index f865308c756..d734b18a578 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift @@ -50,6 +50,25 @@ struct TransferPlatformAddressView: View { @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 + // MARK: - Submit state @State private var submitError: SubmitError? = nil @@ -116,7 +135,10 @@ struct TransferPlatformAddressView: View { dismissButton: .default(Text("OK")) ) } - .onAppear(perform: autoSelectDefaults) + .onAppear { + resolveMinInputAmount() + 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. @@ -316,21 +338,34 @@ struct TransferPlatformAddressView: View { /// 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`). Rust spends the key-class-0 - // account at this index, so summing every row at `accountIndex` - // regardless of key class would inflate the total and let - // `canSubmit` promise more than Rust will spend. + // (`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) @@ -485,6 +520,12 @@ struct TransferPlatformAddressView: View { 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, sourceAccountIndex != nil, let credits = parsedCredits, credits > 0, let dest = resolvedDestination @@ -507,6 +548,24 @@ struct TransferPlatformAddressView: View { // 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 + } + } + private func autoSelectDefaults() { if sourceAccountIndex == nil { sourceAccountIndex = platformAccountOptions diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift index 71c03dcf03a..349544a996d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift @@ -49,6 +49,27 @@ struct WithdrawPlatformAddressView: View { /// 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 + // MARK: - Core readiness /// nil = not yet checked, true/false = Core wallet usable. @@ -125,6 +146,7 @@ struct WithdrawPlatformAddressView: View { } .onAppear { checkCoreReady() + resolveMinInputAmount() autoSelectDefaults() } .onChange(of: destinationMode) { _, mode in @@ -340,10 +362,23 @@ struct WithdrawPlatformAddressView: View { /// be the spent source. Mirrors `TransferPlatformAddressView`. /// /// The displayed per-account balance sums only addresses whose parent - /// account is key class 0 (`account?.keyClass == 0`); summing every row - /// at `accountIndex` regardless of key class would inflate the total - /// and let `canSubmit` promise more than Rust (key class 0) will spend. + /// 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 } @@ -354,6 +389,7 @@ struct WithdrawPlatformAddressView: View { $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) @@ -388,7 +424,17 @@ struct WithdrawPlatformAddressView: View { guard !isSubmitting, coreReady == true, + // The per-input minimum must be known before we can promise the + // account has anything withdrawable: `selectedSourceAccountCredits` + // sums only balances ≥ this floor, and an unresolved floor makes + // that figure 0. The `> 0` check below already closes the gate in + // that case; this makes the dependency explicit. + minInputAmount != nil, sourceAccountIndex != nil, + // Require the dust-FILTERED (withdrawable) total > 0, not the raw + // total: the Rust selector returns `OnlyDustInputs` when no + // address clears `min_input_amount`, so a purely-dust account + // (raw balance > 0) must not enable the button. selectedSourceAccountCredits > 0, parsedFeePerByte != nil, let addr = resolvedCoreAddress, !addr.isEmpty @@ -429,6 +475,24 @@ struct WithdrawPlatformAddressView: View { } } + /// 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 + } + } + private func resolveMyWalletAddress() { guard myWalletAddress == nil else { return } guard let managedHolder = walletManager.wallet(for: wallet.walletId) else { return } From 91e7c4b235d1951f679ee54bdd7d54f9ce48a950 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 17 Jun 2026 01:52:08 +0100 Subject: [PATCH 17/21] fix(swift-sdk): stop withdraw sheet burning Core receive addresses; gate transfer on min_output_amount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WithdrawPlatformAddressView.checkCoreReady() no longer probes nextReceiveAddress(advance: true) and discards it (which advanced the BIP-44 external pool and burned a receive address on every sheet open — two on first open via resolveMyWalletAddress). Readiness now uses non-mutating coreWallet() + network(); the My-Wallet receive address is resolved at most once and cached, so open/cancel/toggle never churns the pool. (No non-mutating account-present FFI exists; documented as a possible follow-up.) - Transfer submit gate now enforces DPP's min_output_amount (500,000 credits): new version-locked PlatformAddressWallet::min_output_amount + FFI platform_address_wallet_min_output_amount + ManagedPlatformAddressWallet .minOutputAmount(), mirroring the min_input_amount getter. canSubmit requires credits >= minOutputAmount with a clear footer; nil keeps the gate closed. platform-wallet lib 212 passed (+ min_output getter test), platform-wallet-ffi 99 passed; clippy clean; build_ios.sh BUILD SUCCEEDED (new symbol in header); SwiftExampleApp simulator clean build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/platform_addresses/wallet.rs | 23 ++++++++ .../src/wallet/platform_addresses/wallet.rs | 45 ++++++++++++++ .../ManagedPlatformAddressWallet.swift | 17 ++++++ .../Views/TransferPlatformAddressView.swift | 59 ++++++++++++++++++- .../Views/WithdrawPlatformAddressView.swift | 35 +++++++++-- 5 files changed, 174 insertions(+), 5 deletions(-) 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 8599867891f..c9351c282d3 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_addresses/wallet.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_addresses/wallet.rs @@ -99,6 +99,29 @@ pub unsafe extern "C" fn platform_address_wallet_min_input_amount( 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/src/wallet/platform_addresses/wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs index db0b852ff13..976d1b725a8 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs @@ -191,6 +191,32 @@ impl PlatformAddressWallet { .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. @@ -438,4 +464,23 @@ mod tests { .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/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift index 25b8bcc5646..272ad04bf64 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift @@ -58,6 +58,23 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { 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? diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift index d734b18a578..6e36ec792a4 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift @@ -69,6 +69,26 @@ struct TransferPlatformAddressView: View { /// 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 @@ -137,6 +157,7 @@ struct TransferPlatformAddressView: View { } .onAppear { resolveMinInputAmount() + resolveMinOutputAmount() autoSelectDefaults() } // Block swipe-to-dismiss while a transfer is in flight — only @@ -258,9 +279,17 @@ struct TransferPlatformAddressView: View { 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 needed.overflow || needed.partialValue > available { + 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 { @@ -526,8 +555,18 @@ struct TransferPlatformAddressView: View { // 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. @@ -566,6 +605,24 @@ struct TransferPlatformAddressView: View { } } + /// 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 diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift index 349544a996d..5057699ef92 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift @@ -456,9 +456,23 @@ struct WithdrawPlatformAddressView: View { } /// Gate the whole flow on the Core (SPV) wallet being usable. - /// `coreWallet()` throws if the Core side isn't initialized; we - /// also probe `nextReceiveAddress` so a half-initialized wallet - /// surfaces here rather than at submit time. + /// + /// 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 @@ -467,7 +481,7 @@ struct WithdrawPlatformAddressView: View { } do { let core = try managedHolder.coreWallet() - _ = try core.nextReceiveAddress(accountIndex: 0) + _ = try core.network() coreReady = true } catch { coreReady = false @@ -493,6 +507,19 @@ struct WithdrawPlatformAddressView: View { } } + /// 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 } From 6dd8ef7824530f4594c9e6d281471f789bd28e84 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 21 Jun 2026 13:08:10 +0300 Subject: [PATCH 18/21] feat(swift-sdk): withdrawal preflight so the Withdraw gate matches Rust's fee/min checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WithdrawPlatformAddressView enabled Withdraw whenever the dust-filtered account total was > 0, but the Rust AUTO path reserves the address-credit-withdrawal fee (~400M base + per-input cost) on the largest input and rejects when the fee-source input can't keep min_input_amount, or the aggregate minus fee falls below system_limits.min_withdrawal_amount. So a small-but-non-dust account lit the button then failed deterministically after signing. Keep the decision Rust-owned (per swift-sdk/CLAUDE.md) via a shared planner: - New WithdrawalPlan + plan_withdrawal (select dust-filtered inputs, estimate fee, reserve on the largest input, check min_withdrawal) and preflight_withdrawal in platform-wallet. withdraw()'s AUTO arm now executes the SAME plan, so the preflight gate and the spend path can never drift. The plan is a pure in-memory computation (fee depends only on input/output counts, not the destination script), so it never consumes a Core receive address. - FFI platform_address_wallet_preflight_withdrawal → WithdrawalPreflightFFI {can_withdraw, net_withdrawable, estimated_fee}; a genuine can't-fund is a normal can_withdraw=false result (typed reason in the message), only structural failures are FFI errors. - ManagedPlatformAddressWallet.preflightWithdrawal wrapper; WithdrawPlatformAddressView recomputes on account/fee change + appear, gates canSubmit on canWithdraw, and shows the net withdrawable, estimated fee, and a clear reason when it can't fund. A thrown preflight keeps submit disabled (non-under-gating). platform-wallet lib 215 passed (+3 planner tests), platform-wallet-ffi 99 passed; clippy clean; build_ios.sh BUILD SUCCEEDED (new symbol in header); SwiftExampleApp simulator clean build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/rs-platform-wallet-ffi/src/error.rs | 18 ++ .../src/platform_address_types.rs | 38 +++ .../src/platform_addresses/withdrawal.rs | 89 ++++++ .../src/wallet/platform_addresses/mod.rs | 1 + .../wallet/platform_addresses/withdrawal.rs | 296 +++++++++++++++--- .../ManagedPlatformAddressWallet.swift | 65 ++++ .../Views/WithdrawPlatformAddressView.swift | 142 ++++++++- 7 files changed, 600 insertions(+), 49 deletions(-) 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 959afdf93b6..f4f5a615133 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_address_types.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_address_types.rs @@ -266,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) // --------------------------------------------------------------------------- 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 166a2296344..3d05d08966c 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_addresses/withdrawal.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_addresses/withdrawal.rs @@ -6,6 +6,7 @@ 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; @@ -178,6 +179,94 @@ pub unsafe extern "C" fn platform_address_wallet_withdraw_to_address( 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, 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 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::*; 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/withdrawal.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs index 94bef2ed675..b10008f7bba 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs @@ -16,6 +16,46 @@ 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. /// @@ -98,14 +138,19 @@ impl PlatformAddressWallet { // which resolves to the lex-smallest address regardless of // balance) would reserve the fee on an arbitrarily small // input and reject otherwise-fundable withdrawals. - let (inputs, auto_fee_strategy) = self - .auto_select_inputs_for_withdrawal(account_index, version) - .await?; + // + // 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, - auto_fee_strategy, + plan.fee_strategy, core_fee_per_byte, Pooling::Never, output_script, @@ -173,10 +218,10 @@ impl PlatformAddressWallet { drop(wm); // Mirror `transfer.rs` / `sync.rs`: persist post-broadcast balances so a - // restart doesn't reseed `auto_select_inputs_for_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 + // 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()) { @@ -187,7 +232,38 @@ impl PlatformAddressWallet { Ok(cs) } - /// Auto-select the withdrawable funded addresses 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 @@ -204,8 +280,8 @@ impl PlatformAddressWallet { /// [`PlatformWalletError::OnlyDustInputs`], matching the transfer path's /// `detect_no_selectable_inputs`. /// - /// The per-input `Credits` value in the returned map is the amount to - /// *withdraw* from that address, not its on-chain balance. The chain + /// 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 @@ -223,17 +299,15 @@ impl PlatformAddressWallet { /// entry) avoids rejecting an otherwise-fundable withdrawal when the /// lex-smallest input happens to be tiny. /// - /// Returns the adjusted withdraw-amount map together with the fee - /// strategy that 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)` + /// 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). - async fn auto_select_inputs_for_withdrawal( + pub(crate) async fn plan_withdrawal( &self, account_index: u32, platform_version: &PlatformVersion, - ) -> Result<(BTreeMap, AddressFundsFeeStrategy), PlatformWalletError> - { + ) -> Result { let wm = self.wallet_manager.read().await; let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { PlatformWalletError::WalletNotFound(format!( @@ -356,14 +430,15 @@ where /// when a much larger peer trivially could. We therefore locate the largest /// input, then emit `DeductFromInput()`. /// -/// Returns the adjusted withdraw-amount map and the fee strategy targeting the -/// fee-source input, or a typed [`PlatformWalletError::AddressOperation`] when -/// no input can absorb the fee while respecting the per-input minimum / +/// 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 / /// minimum withdrawal amount. fn reserve_withdrawal_fee_on_largest_input( mut selected: BTreeMap, platform_version: &PlatformVersion, -) -> Result<(BTreeMap, AddressFundsFeeStrategy), PlatformWalletError> { +) -> Result { let accumulated: Credits = selected .values() .copied() @@ -440,7 +515,16 @@ fn reserve_withdrawal_fee_on_largest_input( fee_source_index_u16, )]; - Ok((selected, fee_strategy)) + 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)] @@ -473,15 +557,19 @@ mod tests { let mut input = BTreeMap::new(); input.insert(addr(1), balance); - let (result, strategy) = reserve_withdrawal_fee_on_largest_input(input, pv) + let plan = reserve_withdrawal_fee_on_largest_input(input, pv) .expect("single funded input above the fee should select"); - assert_eq!(result.get(&addr(1)).copied(), Some(balance - fee)); + assert_eq!(plan.inputs.get(&addr(1)).copied(), Some(balance - fee)); assert_eq!( - strategy, + 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 @@ -503,24 +591,28 @@ mod tests { inputs.insert(addr(1), small); // lex-smallest → BTreeMap index 0 inputs.insert(addr(9), large); // larger → BTreeMap index 1 - let (result, strategy) = reserve_withdrawal_fee_on_largest_input(inputs, pv) + let plan = reserve_withdrawal_fee_on_largest_input(inputs, pv) .expect("the larger peer can absorb the fee"); assert_eq!( - result.get(&addr(1)).copied(), + plan.inputs.get(&addr(1)).copied(), Some(small), "the small lex-smallest input is withdrawn in full" ); assert_eq!( - result.get(&addr(9)).copied(), + plan.inputs.get(&addr(9)).copied(), Some(large - fee), "the fee is reserved on the largest input" ); assert_eq!( - strategy, + 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 @@ -539,17 +631,19 @@ mod tests { inputs.insert(addr(5), small_a); // index 1 inputs.insert(addr(9), small_b); // index 2 - let (result, strategy) = reserve_withdrawal_fee_on_largest_input(inputs, pv) + let plan = reserve_withdrawal_fee_on_largest_input(inputs, pv) .expect("the largest input can absorb the fee"); assert_eq!( - strategy, + plan.fee_strategy, vec![AddressFundsFeeStrategyStep::DeductFromInput(0)], "the largest input is at BTreeMap index 0, so the fee deducts from index 0" ); - assert_eq!(result.get(&addr(1)).copied(), Some(large - fee)); - assert_eq!(result.get(&addr(5)).copied(), Some(small_a)); - assert_eq!(result.get(&addr(9)).copied(), Some(small_b)); + 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 @@ -604,6 +698,136 @@ mod tests { 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; + + // 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; + + // 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" + ); + } + /// 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 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift index 272ad04bf64..44b7a7a6ce2 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift @@ -283,6 +283,71 @@ 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, or net below the minimum + /// 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 + } + + /// 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 + ) + try platform_address_wallet_preflight_withdrawal( + handle, + accountIndex, + coreFeePerByte, + &out + ).check() + return WithdrawalPreflight( + canWithdraw: out.can_withdraw, + netWithdrawable: out.net_withdrawable, + estimatedFee: out.estimated_fee + ) + } + /// Withdraw this platform-payment account's withdrawable credit /// balance to a Core L1 address (less the transition fee). /// diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift index 5057699ef92..b8f198778d7 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift @@ -70,6 +70,23 @@ struct WithdrawPlatformAddressView: View { /// 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. @@ -148,10 +165,23 @@ struct WithdrawPlatformAddressView: View { 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. @@ -283,18 +313,49 @@ struct WithdrawPlatformAddressView: View { } } + @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("Total to Withdraw", systemImage: "dollarsign.circle") + 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 full remaining balance is converted to Core duffs and paid out on L1 (minus the L1 fee).") + 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).") } } @@ -424,24 +485,43 @@ struct WithdrawPlatformAddressView: View { guard !isSubmitting, coreReady == true, - // The per-input minimum must be known before we can promise the - // account has anything withdrawable: `selectedSourceAccountCredits` - // sums only balances ≥ this floor, and an unresolved floor makes - // that figure 0. The `> 0` check below already closes the gate in - // that case; this makes the dependency explicit. - minInputAmount != nil, sourceAccountIndex != nil, - // Require the dust-FILTERED (withdrawable) total > 0, not the raw - // total: the Rust selector returns `OnlyDustInputs` when no - // address clears `min_input_amount`, so a purely-dust account - // (raw balance > 0) must not enable the button. - selectedSourceAccountCredits > 0, + // 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`. + /// + /// Distinguishes the two actionable cases so the user knows what to do: + /// a purely-dust account (every balance below the per-input minimum) needs + /// funds consolidated, while a small non-dust account simply can't cover + /// the fee / minimum withdrawal. We classify from the dust-filtered + /// `selectedSourceAccountCredits` (0 ⇒ dust-only or empty) rather than + /// re-deriving protocol constants in Swift. + private var cantWithdrawReason: String? { + guard let preflight, !preflight.canWithdraw else { return nil } + if selectedSourceAccountCredits == 0 { + return "This account has no withdrawable balance — its funds are " + + "below the per-input minimum. Consolidate funds onto fewer " + + "addresses and try again." + } + return "This account's balance can't cover the withdrawal fee and the " + + "minimum withdrawal amount. Add more funds and try again." + } + // MARK: - Actions private func autoSelectDefaults() { @@ -507,6 +587,42 @@ struct WithdrawPlatformAddressView: View { } } + /// 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. /// From 471811bd2dca03d145a2f3d224e25dd1505eaeea Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 21 Jun 2026 13:39:39 +0300 Subject: [PATCH 19/21] fix(swift-sdk): withdrawal planner enforces all structure-validator limits; surface typed reason MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close gaps in the withdrawal preflight invariant (gate == AUTO planner == DPP structure validator): - BLOCKING: the planner only checked min_withdrawal_amount, but the DPP structure validator also rejects inputs.len() > dpp.state_transitions.max_address_inputs (16) and net withdrawal > system_limits.max_withdrawal_amount (500 DASH). An account with >16 funded addresses or >500 DASH preflighted as withdrawable then failed deterministically after signing. Added both as typed can't-fund errors before signing (max folded into the min-withdrawal range check), with consolidate/split guidance rather than auto-capping. +2 planner tests. - Unify the planning version: withdraw()'s None fallback now uses self.sdk.version() (was LATEST_PLATFORM_VERSION) — the same accessor preflight_withdrawal and the min_input/min_output getters use — so the gate and spend path can't diverge on a non-latest-pinned SDK at a protocol-version boundary. - Surface the typed Rust reason: WithdrawalPreflight now carries reason: String?, read from the FFI result message before the wrapper frees it, and shown verbatim. Removes the Swift-side dust-vs-fee re-derivation (which misclassified "no funded addresses" and was protocol decisioning in Swift, against swift-sdk/CLAUDE.md). Made the OnlyDustInputs Display text user-presentable since it's now shown directly. platform-wallet lib 217 passed (+2), platform-wallet-ffi 102 passed; clippy clean; build_ios.sh BUILD SUCCEEDED; SwiftExampleApp simulator clean build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/platform_addresses/withdrawal.rs | 24 +-- packages/rs-platform-wallet/src/error.rs | 14 +- .../wallet/platform_addresses/withdrawal.rs | 153 +++++++++++++++++- .../ManagedPlatformAddressWallet.swift | 42 +++-- .../Views/WithdrawPlatformAddressView.swift | 22 ++- 5 files changed, 212 insertions(+), 43 deletions(-) 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 3d05d08966c..4fd0caa5cdb 100644 --- a/packages/rs-platform-wallet-ffi/src/platform_addresses/withdrawal.rs +++ b/packages/rs-platform-wallet-ffi/src/platform_addresses/withdrawal.rs @@ -196,14 +196,15 @@ pub unsafe extern "C" fn platform_address_wallet_withdraw_to_address( /// /// 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, 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. +/// 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 @@ -242,9 +243,10 @@ pub unsafe extern "C" fn platform_address_wallet_preflight_withdrawal( // // `OnlyDustInputs` (every funded address below `min_input_amount`) and // `AddressOperation` (the fee / per-input / min-withdrawal headroom - // failures inside `reserve_withdrawal_fee_on_largest_input`, plus the - // "no funded addresses" case in `select_withdrawable_inputs`) are all - // genuine can't-fund states. + // 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(_)), 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/withdrawal.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/withdrawal.rs index b10008f7bba..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,7 +6,6 @@ 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; @@ -62,8 +61,11 @@ impl PlatformAddressWallet { /// 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 @@ -87,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) => { @@ -430,15 +443,50 @@ where /// 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 / -/// minimum withdrawal amount. +/// 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(), + max_address_inputs + ))); + } + let accumulated: Credits = selected .values() .copied() @@ -474,6 +522,15 @@ fn reserve_withdrawal_fee_on_largest_input( .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 { @@ -483,6 +540,18 @@ fn reserve_withdrawal_fee_on_largest_input( 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 { @@ -828,6 +897,78 @@ mod tests { ); } + /// 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" + ); + + 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 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift index 44b7a7a6ce2..b2adbb0d17a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformAddressWallet.swift @@ -289,8 +289,9 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { 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, or net below the minimum - /// withdrawal). The numeric fields are `0` when `false`. + /// 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`. @@ -298,6 +299,14 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { /// 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 @@ -335,16 +344,31 @@ public final class ManagedPlatformAddressWallet: @unchecked Sendable { net_withdrawable: 0, estimated_fee: 0 ) - try platform_address_wallet_preflight_withdrawal( - handle, - accountIndex, - coreFeePerByte, - &out - ).check() + // 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 + estimatedFee: out.estimated_fee, + reason: reason ) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift index b8f198778d7..3787a26d11d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawPlatformAddressView.swift @@ -505,21 +505,17 @@ struct WithdrawPlatformAddressView: View { /// `nil` when the preflight is unresolved or the account CAN withdraw. Used /// only for display; the authoritative gate is `preflight?.canWithdraw`. /// - /// Distinguishes the two actionable cases so the user knows what to do: - /// a purely-dust account (every balance below the per-input minimum) needs - /// funds consolidated, while a small non-dust account simply can't cover - /// the fee / minimum withdrawal. We classify from the dust-filtered - /// `selectedSourceAccountCredits` (0 ⇒ dust-only or empty) rather than - /// re-deriving protocol constants in Swift. + /// 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 } - if selectedSourceAccountCredits == 0 { - return "This account has no withdrawable balance — its funds are " - + "below the per-input minimum. Consolidate funds onto fewer " - + "addresses and try again." - } - return "This account's balance can't cover the withdrawal fee and the " - + "minimum withdrawal amount. Add more funds and try again." + return preflight.reason ?? "This account can't fund a withdrawal right now." } // MARK: - Actions From 921211031ef936167c8ab9807763c07136e6d378 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 21 Jun 2026 14:08:08 +0300 Subject: [PATCH 20/21] fix(swift-sdk): transfer AUTO selector mirrors withdrawal's max-inputs gate + version unification Apply the same preflight==spend-path==validator hardening (just done for withdrawal) to the production ADDR-02 transfer path: - BLOCKING: transfer's AUTO selector produced an unbounded covering set, but DPP's AddressFundsTransferTransition v0 validator rejects inputs.len() > dpp.state_transitions.max_address_inputs (16) with TransitionOverMaxInputsError. An account whose covering prefix exceeds 16 funded inputs would sign then fail structure validation. Added enforce_max_address_inputs at the single auto_select_inputs chokepoint (covers both DeductFromInput(0) and ReduceOutput(0) fee shapes), returning a typed AddressOperation with consolidate/smaller-amount guidance rather than auto-capping (which would break the cover-the-output contract). +1 unit test. (Transfer's validator has no max-amount system limit, unlike withdrawal.) - transfer()'s None version fallback now uses self.sdk.version() (was LATEST_PLATFORM_VERSION), so the auto-selector sizes min_input_amount/fee against the same version the signed transition is validated under. - TransferPlatformAddressView.sourceInputHashes now floors at minInputAmount (not balance > 0): a dust source address isn't a candidate Rust input, so it's no longer wrongly excluded as a recipient. Conservative balance > 0 fallback when minInputAmount is unresolved (submit gate is independently closed then); affects only the recipient picker/collision guard, not the Rust input set. platform-wallet lib 218 passed (+1); clippy clean; build_ios.sh BUILD SUCCEEDED; SwiftExampleApp simulator clean build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/wallet/platform_addresses/transfer.rs | 127 ++++++++++++++++-- .../Views/TransferPlatformAddressView.swift | 46 +++++-- 2 files changed, 153 insertions(+), 20 deletions(-) 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..a0b2fcfd8cc 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs @@ -138,7 +138,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 +202,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) => { @@ -547,27 +561,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 +706,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. @@ -1532,6 +1590,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/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift index 6e36ec792a4..aec97bf55f0 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransferPlatformAddressView.swift @@ -406,14 +406,32 @@ struct TransferPlatformAddressView: View { return platformAccountOptions.first(where: { $0.accountIndex == idx })?.totalCredits ?? 0 } - /// Funded addresses on the selected source account for this wallet. - /// The Rust Auto selector picks its inputs from these (balance > 0), - /// and the `AddressFundsTransferTransition` protocol forbids any - /// output address from also being an input. The selector excludes - /// recipient addresses from its input set, so a recipient that - /// collides with a funded 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. + /// 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 @@ -427,12 +445,22 @@ struct TransferPlatformAddressView: View { /// 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 - && $0.balance > 0 + && isPossibleInput($0) && $0.account?.accountType == 14 && $0.account?.keyClass == 0 } From 332afbd720a3de8822e25a9e1db50734b67d732f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 21 Jun 2026 14:34:56 +0300 Subject: [PATCH 21/21] fix(swift-example-app): bring generic Send flow to parity with Rust transfer limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the dedicated Transfer sheet's version-locked gating on the generic Send flow (SendTransactionView + SendViewModel), plus a Rust defense-in-depth default: - SendTransactionView resolves minInputAmount on appear (ManagedPlatformAddressWallet .minInputAmount()); resolvePlatformSenderAccountIndex now floors per-account aggregation at balance >= minInputAmount (conservative nil -> balance > 0 fallback), so a dust-heavy account can't outrank a sibling whose spendable (>=min) balance actually covers amount+fee — matching Rust's build_auto_select_candidates. - SendViewModel.canSend's .platformToPlatform branch now requires amountCredits >= platformMinOutputAmount (resolved from minOutputAmount() and pushed onto the VM by the view); nil keeps the platform send gate CLOSED. Core/shielded flows unchanged. - transfer_with_change_address now defaults its None version to self.sdk.version() (was LATEST_PLATFORM_VERSION), parity with transfer(); this wrapper rejects AUTO so the production UI doesn't reach it — defense-in-depth against the Explicit path. platform-wallet lib 218 passed; clippy clean; build_ios.sh BUILD SUCCEEDED; SwiftExampleApp simulator clean build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/wallet/platform_addresses/transfer.rs | 15 ++- .../Core/ViewModels/SendViewModel.swift | 31 +++++- .../Core/Views/SendTransactionView.swift | 101 +++++++++++++++++- 3 files changed, 140 insertions(+), 7 deletions(-) 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 a0b2fcfd8cc..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; @@ -419,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) => { @@ -1266,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]) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index 3c87a656def..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) { @@ -335,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 } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift index 170cca240be..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 — @@ -308,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() } @@ -444,6 +476,32 @@ 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. @@ -452,14 +510,30 @@ struct SendTransactionView: View { /// the BLAST-synced `addressBalances` rows (scoping by /// `accountType == 14 && keyClass == 0`, matching the dedicated /// transfer/withdraw sheets and the Rust source resolution) — but - /// 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 + /// 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`). @@ -481,13 +555,32 @@ struct SendTransactionView: View { // 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, - // excluding any row that IS the recipient (saturating subtraction). + // 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),