diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift index 404adf467..4dd18a889 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift @@ -406,6 +406,26 @@ public final class PlatformAddressSyncCoordinator: NSObject, ObservableObject { // MARK: - Core ↔ Platform balance movement + /// SDK-owned affordability preflight for Platform Payment → Shielded. + /// + /// The Platform balance shown by this coordinator is the sum of every + /// derived address, but the shield transition can only spend the suffix + /// selected by the Rust wallet. Keep that selection rule in the SDK and + /// expose its exact result to the internal-transfer UI instead of + /// reimplementing it from `derivedAddresses` here. + public func preflightShield( + paymentAccount: UInt32 = 0 + ) async throws -> PlatformWalletManager.ShieldedShieldPreflight { + guard isRunning, + let manager = walletManager, + let walletId = wallet?.walletId + else { throw SendError.coordinatorNotReady } + + return try await manager.shieldedShieldPreflight( + walletId: walletId, + paymentAccount: paymentAccount) + } + /// Preflight of the full-balance Platform → Core withdrawal for the /// account holding the highest Platform address balance (the same /// account `transfer` spends from). See `withdrawAllToCore` for why diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferConfirmSheet.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferConfirmSheet.swift index 661149e85..433f6504d 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferConfirmSheet.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferConfirmSheet.swift @@ -40,10 +40,15 @@ struct InternalTransferConfirmSheet: View { /// Shielded reverse routes only: execute the note-aware Max plan and /// revalidate it immediately before proving. var isFullShieldedSweep: Bool = false + /// Frozen with the submitted amount so a capacity refresh can update only + /// a value the user explicitly derived via Platform Shield Max. + var platformShieldAmountWasMax: Bool = false var onCancel: () -> Void var onCompleted: () -> Void + var onPlatformShieldCapacityChanged: (UInt64?, Bool) -> Void @StateObject private var coordinator = ShieldedTransferCoordinator() + @State private var handledPlatformShieldCapacityChange = false var body: some View { VStack(spacing: 0) { @@ -67,6 +72,9 @@ struct InternalTransferConfirmSheet: View { } .background(Color.dash.primaryBackground) .interactiveDismissDisabled(isInFlight) + .onChange(of: coordinator.phase) { phase in + handlePlatformShieldCapacityChange(phase) + } } private var isInFlight: Bool { @@ -483,6 +491,21 @@ struct InternalTransferConfirmSheet: View { coordinator.reset() confirm() } + + private func handlePlatformShieldCapacityChange( + _ phase: ShieldedTransferCoordinator.Phase + ) { + guard !handledPlatformShieldCapacityChange, + case .failed = phase, + let error = coordinator.lastFailure as? ShieldedTransferCoordinator.CoordinatorError, + case .platformShieldCapacityChanged(let maxShieldableCredits) = error + else { return } + + handledPlatformShieldCapacityChange = true + onPlatformShieldCapacityChanged( + maxShieldableCredits, + platformShieldAmountWasMax) + } } /// Recovery sheet for a stuck "to Shielded" transfer (Core→Shielded). The diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferScreen.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferScreen.swift index 02d7d9130..d5ff4c704 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferScreen.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferScreen.swift @@ -6,6 +6,22 @@ import SwiftUI import DashUIKit +/// Immutable values the user saw when tapping Continue. Async balance/preflight +/// refreshes may keep updating the form underneath the sheet, but they must not +/// rewrite the amount already presented for confirmation. +private struct InternalTransferConfirmation: Identifiable { + let id = UUID() + let route: InternalTransferRoute + let dashDuffs: Int64 + let amountDuffsUnsigned: UInt64 + let creditsAmount: UInt64 + let fiatText: String + let withdrawalFeeCredits: UInt64? + let isFullPlatformWithdrawal: Bool + let isFullShieldedSweep: Bool + let platformShieldAmountWasMax: Bool +} + struct InternalTransferScreen: View { @ObservedObject var viewModel: InternalTransferViewModel @@ -30,7 +46,7 @@ struct InternalTransferScreen: View { /// precedence over `receiveInto`. var sendFrom: ChainNetwork? = nil - @State private var showConfirm: Bool = false + @State private var confirmation: InternalTransferConfirmation? var body: some View { VStack(spacing: 0) { @@ -77,20 +93,27 @@ struct InternalTransferScreen: View { keyboardSection } .background(Color.dash.primaryBackground) - .sheet(isPresented: $showConfirm) { + .sheet(item: $confirmation) { submission in InternalTransferConfirmSheet( - route: viewModel.route, - dashDuffs: viewModel.dashDuffs, - amountDuffsUnsigned: viewModel.dashDuffsUnsigned, - creditsAmount: viewModel.creditsPreview, - fiatText: viewModel.fiatAmountString, - withdrawalFeeCredits: viewModel.withdrawalPreflight?.estimatedFee, - isFullPlatformWithdrawal: viewModel.isFullPlatformWithdrawal, - isFullShieldedSweep: viewModel.isFullShieldedSweep, - onCancel: { showConfirm = false }, + route: submission.route, + dashDuffs: submission.dashDuffs, + amountDuffsUnsigned: submission.amountDuffsUnsigned, + creditsAmount: submission.creditsAmount, + fiatText: submission.fiatText, + withdrawalFeeCredits: submission.withdrawalFeeCredits, + isFullPlatformWithdrawal: submission.isFullPlatformWithdrawal, + isFullShieldedSweep: submission.isFullShieldedSweep, + platformShieldAmountWasMax: submission.platformShieldAmountWasMax, + onCancel: { confirmation = nil }, onCompleted: { - showConfirm = false + confirmation = nil onCompleted() + }, + onPlatformShieldCapacityChanged: { maxCredits, amountWasMax in + confirmation = nil + viewModel.handlePlatformShieldCapacityChanged( + maxShieldableCredits: maxCredits, + submittedAmountWasMax: amountWasMax) }) .presentationDetents([.large]) .presentationDragIndicator(.hidden) @@ -133,7 +156,7 @@ struct InternalTransferScreen: View { actionButtonText: NSLocalizedString("Continue", comment: ""), actionEnabled: viewModel.canContinue, inProgress: false, - actionHandler: { showConfirm = true } + actionHandler: presentConfirmation ) .padding(.top, 12) .padding(.horizontal, 16) @@ -143,6 +166,20 @@ struct InternalTransferScreen: View { .background(Color.dash.secondaryBackground, ignoresSafeAreaEdges: .bottom) } + private func presentConfirmation() { + guard viewModel.canContinue else { return } + confirmation = InternalTransferConfirmation( + route: viewModel.route, + dashDuffs: viewModel.dashDuffs, + amountDuffsUnsigned: viewModel.dashDuffsUnsigned, + creditsAmount: viewModel.creditsPreview, + fiatText: viewModel.fiatAmountString, + withdrawalFeeCredits: viewModel.withdrawalPreflight?.estimatedFee, + isFullPlatformWithdrawal: viewModel.isFullPlatformWithdrawal, + isFullShieldedSweep: viewModel.isFullShieldedSweep, + platformShieldAmountWasMax: viewModel.platformShieldAmountWasMax) + } + // MARK: - From / To cards @ViewBuilder diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift index dc2145279..391b92e38 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift @@ -136,6 +136,132 @@ enum TransferSpendAmountPolicy { } } +/// App-facing value copy of the SDK's Platform → Shielded preflight. Keeping +/// the policy below independent from FFI-owned types makes its duff flooring +/// and fail-closed behavior cheap to regression-test. +struct PlatformShieldCapacity: Equatable { + let canShield: Bool + let accountBalanceCredits: UInt64 + let usableBalanceCredits: UInt64 + let feeReserveCredits: UInt64 + let maxShieldableCredits: UInt64 + let reason: String? + + init(_ preflight: PlatformWalletManager.ShieldedShieldPreflight) { + canShield = preflight.canShield + accountBalanceCredits = preflight.accountBalanceCredits + usableBalanceCredits = preflight.usableBalanceCredits + feeReserveCredits = preflight.feeReserveCredits + maxShieldableCredits = preflight.maxShieldableCredits + reason = preflight.reason + } + + init( + canShield: Bool, + accountBalanceCredits: UInt64, + usableBalanceCredits: UInt64, + feeReserveCredits: UInt64, + maxShieldableCredits: UInt64, + reason: String? = nil + ) { + self.canShield = canShield + self.accountBalanceCredits = accountBalanceCredits + self.usableBalanceCredits = usableBalanceCredits + self.feeReserveCredits = feeReserveCredits + self.maxShieldableCredits = maxShieldableCredits + self.reason = reason + } +} + +enum PlatformShieldAmountPolicy { + enum PreflightRefreshEvent { + case balancePublished + case other + } + + /// A live Platform rejection proves the cache stale. While waiting for the + /// requested sync, route/Max events must not immediately read that same + /// cache again; only a balance publication re-arms preflight. + static func shouldRefreshPreflight( + after event: PreflightRefreshEvent, + awaitingPlatformResync: Bool + ) -> Bool { + guard awaitingPlatformResync else { return true } + if case .balancePublished = event { return true } + return false + } + + /// Cache freshness is independent of the currently displayed route. A + /// Platform balance publication completes the wait even if the user has + /// navigated elsewhere before the sync finishes. + static func awaitingPlatformResync( + current: Bool, + after event: PreflightRefreshEvent + ) -> Bool { + if case .balancePublished = event { return false } + return current + } + + /// Max is the explicit escape hatch while a live rejection waits for a + /// fresh Platform publication. At most one retry task may own `syncNow`; + /// when it finishes without a publication, another tap may retry. + static func shouldStartManualResync( + awaitingPlatformResync: Bool, + retryInFlight: Bool + ) -> Bool { + awaitingPlatformResync && !retryInFlight + } + + /// Amount input and confirmation are duff-denominated, so never round an + /// SDK credit ceiling up to an amount the transition cannot select. + static func maximumDuffs(capacity: PlatformShieldCapacity) -> UInt64 { + guard capacity.canShield else { return 0 } + return capacity.maxShieldableCredits / 1000 + } + + /// Unknown/loading/failed preflight is deliberately unaffordable. The SDK + /// preflight is the sole authority; the aggregate Platform balance is not. + static func canSubmit( + requestedCredits: UInt64, + capacity: PlatformShieldCapacity? + ) -> Bool { + guard requestedCredits > 0, + let capacity, + capacity.canShield + else { return false } + return requestedCredits <= capacity.maxShieldableCredits + } + + /// Informational remainder against the balance card's aggregate. The + /// preflight account remains the validation authority; `max` only avoids a + /// transient smaller published snapshot understating what is held back. + static func heldBackCredits( + displayedPlatformCredits: UInt64, + accountBalanceCredits: UInt64, + submittedDuffs: UInt64 + ) -> UInt64 { + let submitted = submittedDuffs.multipliedReportingOverflow(by: 1000) + guard !submitted.overflow else { return 0 } + let displayedAggregate = max(displayedPlatformCredits, accountBalanceCredits) + return displayedAggregate > submitted.partialValue + ? displayedAggregate - submitted.partialValue + : 0 + } + + /// A refreshed capacity may rewrite only an amount explicitly derived from + /// Max. Manually entered text must remain untouched for the user to review. + static func amountAfterCapacityChange( + currentDuffs: UInt64, + wasMaxDerived: Bool, + maxShieldableCredits: UInt64? + ) -> UInt64 { + guard wasMaxDerived, let maxShieldableCredits else { + return currentDuffs + } + return maxShieldableCredits / 1000 + } +} + @MainActor final class InternalTransferViewModel: ObservableObject { @@ -156,6 +282,9 @@ final class InternalTransferViewModel: ObservableObject { /// above (or below) the source's real spendable ceiling. private var maxAmountDuffs: UInt64? private var shieldedSweepAmountCredits: UInt64? + private var isPlatformShieldMaxDerived = false + private var isPlatformShieldMaxQueued = false + private var hasPlatformShieldCapacityChangeNotice = false @Published var unit: InternalTransferUnit = .dash { didSet { guard oldValue != unit else { return } @@ -208,7 +337,23 @@ final class InternalTransferViewModel: ObservableObject { /// reserved fee. `nil` while unknown (loading/failed) — affordability /// fails closed. @Published private(set) var withdrawalPreflight: ManagedPlatformAddressWallet.WithdrawalPreflight? - private var preflightTask: Task? + private var withdrawalPreflightTask: Task? + + /// SDK-owned selection capacity for Platform → Shielded. `nil` while a + /// request is loading or after it fails; both states fail closed rather + /// than falling back to the aggregate Platform balance. + @Published private(set) var platformShieldCapacity: PlatformShieldCapacity? + @Published private(set) var isPlatformShieldPreflightLoading = false + private var platformShieldPreflightTask: Task? + private var platformShieldPreflightGeneration: UInt64 = 0 + private var awaitingPlatformShieldResync = false + private var platformShieldManualResyncTask: Task? + + /// Captured by Confirm so a capacity-change response knows whether it may + /// replace the amount with the newly preflighted Max. + var platformShieldAmountWasMax: Bool { + route == .platformToShielded && isPlatformShieldMaxDerived + } /// Pins the route for the receive sheet: a transfer INTO `target`. /// The From rows then pick the source among the other two balances. @@ -304,18 +449,25 @@ final class InternalTransferViewModel: ObservableObject { } } - /// Refreshes route-dependent async state. The Platform → Core route - /// needs the withdrawal preflight — for the fee headroom that bounds a - /// partial withdrawal, and for the net payout a Max (full-balance, - /// AUTO-path) withdrawal pays out. + /// Refreshes route-dependent async state. Both Platform-funded routes use + /// SDK preflights so their UI amount always matches the builder's real + /// input-selection envelope. private func routeDidChange() { clearMaxSelection() - guard route == .platformToCore else { - preflightTask?.cancel() - preflightTask = nil - return + + if route == .platformToCore { + startWithdrawalPreflightIfNeeded() + } else { + withdrawalPreflightTask?.cancel() + withdrawalPreflightTask = nil + withdrawalPreflight = nil + } + + if route == .platformToShielded { + refreshPlatformShieldPreflight() + } else { + cancelPlatformShieldPreflight() } - startWithdrawalPreflightIfNeeded() } /// Net payout of the full-balance (Max) Platform → Core withdrawal, in @@ -404,7 +556,18 @@ final class InternalTransferViewModel: ObservableObject { PlatformAddressSyncCoordinator.shared.$platformBalance .receive(on: RunLoop.main) .sink { [weak self] credits in - self?.platformCredits = credits + guard let self else { return } + self.platformCredits = credits + // Clear the stale-cache barrier on publication regardless of + // route. If this route is inactive, the next route entry will + // preflight the now-refreshed cache normally. + self.awaitingPlatformShieldResync = + PlatformShieldAmountPolicy.awaitingPlatformResync( + current: self.awaitingPlatformShieldResync, + after: .balancePublished) + if self.route == .platformToShielded { + self.refreshPlatformShieldPreflight(after: .balancePublished) + } } .store(in: &cancellables) @@ -507,14 +670,23 @@ final class InternalTransferViewModel: ObservableObject { spendableDuffs: coreSpendableDuffs) case .platformToShielded: - guard let reserve = feeReserveCredits else { - return Self.feeEstimateUnavailableMessage + if awaitingPlatformShieldResync { + return Self.platformShieldCapacityRefreshRequiredMessage + } + if isPlatformShieldPreflightLoading { + return Self.platformShieldPreflightLoadingMessage + } + guard let capacity = platformShieldCapacity else { + return Self.platformShieldPreflightUnavailableMessage + } + guard capacity.canShield else { + return Self.platformShieldHeadroomUnavailableMessage } return TransferSpendAmountPolicy.insufficientBalanceMessage( balanceName: balanceName, requestedCredits: creditsPreview, - balanceCredits: platformCredits, - feeReserveCredits: reserve) + balanceCredits: capacity.maxShieldableCredits, + feeReserveCredits: 0) case .shieldedToCore, .shieldedToPlatform: // A Max sweep is planned against the real note set rather than the @@ -588,12 +760,10 @@ final class InternalTransferViewModel: ObservableObject { // reserve, which is exactly `coreSpendableDuffs`. return dashDuffsUnsigned <= coreSpendableDuffs case .platformToShielded: - // Shield (Type 15): the SDK's input selection requires - // balance ≥ amount + reserve. Fail closed if the reserve is - // unavailable. Subtraction keeps the UInt64 add overflow-safe. - guard let reserve = feeReserveCredits else { return false } - return platformCredits >= reserve - && creditsPreview <= platformCredits - reserve + return !isPlatformShieldPreflightLoading + && PlatformShieldAmountPolicy.canSubmit( + requestedCredits: creditsPreview, + capacity: platformShieldCapacity) case .shieldedToCore, .shieldedToPlatform: if isFullShieldedSweep { return shieldedSweepAmountCredits != nil @@ -615,14 +785,6 @@ final class InternalTransferViewModel: ObservableObject { } } - /// Fixed input-selection reserve the Shield route requires ON TOP of the - /// amount — mirrors Rust `FEE_RESERVE_CREDITS = 1_000_000_000` - /// (rs-platform-wallet `platform_wallet.rs`; `select_shield_inputs` rejects - /// `balance < amount + reserve`). It is a conservative selection headroom, - /// NOT the on-chain fee (which is ~6× smaller); the unclaimed remainder - /// stays on the source address rather than being spent. - private static let shieldSelectionReserveCredits: UInt64 = 1_000_000_000 - /// Fee/selection headroom (credits) the SDK requires ON TOP of the amount /// for the active route, used by `canContinue` and Max. `nil` means the /// requirement is currently unavailable for a fee-reserved route → callers @@ -634,8 +796,8 @@ final class InternalTransferViewModel: ObservableObject { // Asset-lock routes reserve nothing from the source balance. return 0 case .platformToShielded: - // Shield: fixed 1e9-credit selection reserve, not the (smaller) fee. - return Self.shieldSelectionReserveCredits + // Governed by the SDK's account/address-aware shield preflight. + return nil case .shieldedToCore: // The withdraw/unshield fee scales with the number of spent notes; // the SDK recomputes it from real note selection (up to the @@ -652,16 +814,6 @@ final class InternalTransferViewModel: ObservableObject { } } - /// A credit balance minus the route's fee reserve, floored at 0 — so a Max - /// fill leaves room for the fee/headroom the SDK requires on top of the - /// amount. Fails closed (returns 0) when the reserve is unavailable. - private func creditsMinusFeeReserve(_ balanceCredits: UInt64) -> UInt64 { - guard let fee = feeReserveCredits else { return 0 } - return TransferSpendAmountPolicy.spendableCredits( - balanceCredits: balanceCredits, - feeReserveCredits: fee) - } - /// `parsedDashAmount` expressed as Int64 duffs, for `DashAmount` views. var dashDuffs: Int64 { Int64(parsedDashAmount.plainDashAmount) @@ -764,6 +916,11 @@ final class InternalTransferViewModel: ObservableObject { /// Source-aware Max fill. Keeps the same unit semantics — DASH or fiat — /// but draws the upper bound from whichever bucket the user picked. func fillMaxFromWallet() { + if route == .platformToShielded { + fillPlatformShieldMax() + return + } + clearMaxSelection() let sourceDuffs: UInt64 switch route { @@ -782,17 +939,9 @@ final class InternalTransferViewModel: ObservableObject { maxNotice = Self.coreHeldBackMessage(coreBalanceDuffs - sourceDuffs) } case .platformToShielded: - // Reserve the fee the SDK charges on top of the amount so Max - // stays sendable (credits → duffs: integer divide by 1000). - let spendableCredits = creditsMinusFeeReserve(platformCredits) - sourceDuffs = spendableCredits / 1000 - if sourceDuffs == 0 { - maxNotice = platformCredits > 0 - ? Self.feeReserveExceedsBalanceMessage(route.source) - : Self.emptyBalanceMessage(route.source) - } else if platformCredits > spendableCredits { - maxNotice = Self.feeHeldBackMessage(platformCredits - spendableCredits) - } + // Handled above because an unresolved async preflight must preserve + // the user's current text while queueing the Max request. + return case .shieldedToCore, .shieldedToPlatform: let feeKind: PlatformWalletManager.ShieldedFeeKind = route == .shieldedToCore ? .withdrawal : .unshield @@ -836,6 +985,57 @@ final class InternalTransferViewModel: ObservableObject { applyMaxAmountText(sourceDuffs) } + /// Platform Max is asynchronous because only the Rust wallet knows which + /// address suffix its shield builder can select. If that answer is not + /// ready, remember the user's intent and leave the current text untouched. + private func fillPlatformShieldMax() { + if awaitingPlatformShieldResync { + isPlatformShieldMaxQueued = true + maxNotice = Self.platformShieldResyncInProgressMessage + startManualPlatformShieldResyncIfNeeded() + return + } + guard !isPlatformShieldPreflightLoading, + let capacity = platformShieldCapacity + else { + isPlatformShieldMaxQueued = true + maxNotice = Self.platformShieldPreflightLoadingMessage + refreshPlatformShieldPreflightIfNeeded() + return + } + + applyPlatformShieldMax(capacity) + } + + private func applyPlatformShieldMax(_ capacity: PlatformShieldCapacity) { + let preservesCapacityChangeNotice = hasPlatformShieldCapacityChangeNotice + let preservedNotice = preservesCapacityChangeNotice ? maxNotice : nil + clearMaxSelection() + + let sourceDuffs = PlatformShieldAmountPolicy.maximumDuffs(capacity: capacity) + isApplyingMax = true + applyMaxAmountText(sourceDuffs) + isApplyingMax = false + isPlatformShieldMaxDerived = true + + if let preservedNotice { + maxNotice = preservedNotice + hasPlatformShieldCapacityChangeNotice = true + } else if sourceDuffs == 0 { + maxNotice = capacity.accountBalanceCredits > 0 + ? Self.platformShieldHeadroomUnavailableMessage + : Self.emptyBalanceMessage(.platform) + } else { + let heldBackCredits = PlatformShieldAmountPolicy.heldBackCredits( + displayedPlatformCredits: platformCredits, + accountBalanceCredits: capacity.accountBalanceCredits, + submittedDuffs: sourceDuffs) + if heldBackCredits > 0 { + maxNotice = Self.platformShieldHeldBackMessage(heldBackCredits) + } + } + } + /// Render Max in the selected input unit while retaining `duffs` as the /// executable amount. In fiat mode the visible cents are approximate; /// converting those rounded cents back to DASH caused Max to exceed the @@ -866,6 +1066,9 @@ final class InternalTransferViewModel: ObservableObject { maxAmountDuffs = nil isFullShieldedSweep = false shieldedSweepAmountCredits = nil + isPlatformShieldMaxDerived = false + isPlatformShieldMaxQueued = false + hasPlatformShieldCapacityChangeNotice = false maxNotice = nil } @@ -873,12 +1076,168 @@ final class InternalTransferViewModel: ObservableObject { /// of `routeDidChange` so Max can retry a preflight that failed, instead of /// leaving the route stuck at 0 until the user toggles the route. private func startWithdrawalPreflightIfNeeded() { - guard route == .platformToCore, preflightTask == nil else { return } - preflightTask = Task { [weak self] in + guard route == .platformToCore, withdrawalPreflightTask == nil else { return } + withdrawalPreflightTask = Task { [weak self] in let result = try? await PlatformAddressSyncCoordinator.shared.preflightWithdrawal() guard let self, !Task.isCancelled else { return } self.withdrawalPreflight = result - self.preflightTask = nil + self.withdrawalPreflightTask = nil + } + } + + private func refreshPlatformShieldPreflightIfNeeded() { + guard route == .platformToShielded, + platformShieldPreflightTask == nil + else { return } + refreshPlatformShieldPreflight() + } + + /// User-driven escape for an offline/failed scheduled resync. It retries + /// Platform sync, never the cache-only preflight. The stale-cache barrier + /// remains until `$platformBalance` publishes; completion merely re-arms + /// the button so a later tap can try one more time. + private func startManualPlatformShieldResyncIfNeeded() { + guard PlatformShieldAmountPolicy.shouldStartManualResync( + awaitingPlatformResync: awaitingPlatformShieldResync, + retryInFlight: platformShieldManualResyncTask != nil) + else { return } + + platformShieldManualResyncTask = Task { [weak self] in + await PlatformAddressSyncCoordinator.shared.syncNow() + guard let self, !Task.isCancelled else { return } + self.platformShieldManualResyncTask = nil + if self.awaitingPlatformShieldResync { + self.maxNotice = Self.platformShieldCapacityRefreshRequiredMessage + } + } + } + + /// Replaces any in-flight result and advances a generation token. The SDK + /// call may not observe Swift task cancellation immediately, so the token + /// also prevents a late result from an older balance snapshot winning. + private func refreshPlatformShieldPreflight( + after event: PlatformShieldAmountPolicy.PreflightRefreshEvent = .other + ) { + guard route == .platformToShielded, + PlatformShieldAmountPolicy.shouldRefreshPreflight( + after: event, + awaitingPlatformResync: awaitingPlatformShieldResync) + else { return } + + if case .balancePublished = event { + awaitingPlatformShieldResync = false + } + + platformShieldPreflightGeneration &+= 1 + let generation = platformShieldPreflightGeneration + platformShieldPreflightTask?.cancel() + platformShieldCapacity = nil + isPlatformShieldPreflightLoading = true + if isPlatformShieldMaxDerived && !hasPlatformShieldCapacityChangeNotice { + maxNotice = Self.platformShieldPreflightLoadingMessage + } + + platformShieldPreflightTask = Task { [weak self] in + do { + let result = try await PlatformAddressSyncCoordinator.shared.preflightShield() + guard let self, + !Task.isCancelled, + self.route == .platformToShielded, + self.platformShieldPreflightGeneration == generation + else { return } + + let capacity = PlatformShieldCapacity(result) + self.platformShieldCapacity = capacity + self.isPlatformShieldPreflightLoading = false + self.platformShieldPreflightTask = nil + + if self.hasPlatformShieldCapacityChangeNotice { + self.maxNotice = Self.platformShieldCapacityChangedMessage( + maxShieldableCredits: capacity.maxShieldableCredits) + } + + if self.isPlatformShieldMaxDerived || self.isPlatformShieldMaxQueued { + self.applyPlatformShieldMax(capacity) + } + } catch { + guard let self, + !Task.isCancelled, + self.route == .platformToShielded, + self.platformShieldPreflightGeneration == generation + else { return } + + self.platformShieldCapacity = nil + self.isPlatformShieldPreflightLoading = false + self.platformShieldPreflightTask = nil + if !self.hasPlatformShieldCapacityChangeNotice + && (self.isPlatformShieldMaxQueued || self.isPlatformShieldMaxDerived) { + self.maxNotice = Self.platformShieldPreflightUnavailableMessage + } + } + } + } + + private func cancelPlatformShieldPreflight() { + platformShieldPreflightGeneration &+= 1 + platformShieldPreflightTask?.cancel() + platformShieldPreflightTask = nil + platformShieldCapacity = nil + isPlatformShieldPreflightLoading = false + isPlatformShieldMaxQueued = false + awaitingPlatformShieldResync = + PlatformShieldAmountPolicy.awaitingPlatformResync( + current: awaitingPlatformShieldResync, + after: .other) + } + + /// Called when Confirm's last-moment preflight no longer covers the frozen + /// submitted amount. The sheet is dismissed by its host. A Max-derived + /// amount follows the new ceiling; manual input remains verbatim and fails + /// validation until the user edits it. + func handlePlatformShieldCapacityChanged( + maxShieldableCredits: UInt64?, + submittedAmountWasMax: Bool + ) { + guard route == .platformToShielded else { return } + + let refreshedDuffs = PlatformShieldAmountPolicy.amountAfterCapacityChange( + currentDuffs: dashDuffsUnsigned, + wasMaxDerived: submittedAmountWasMax, + maxShieldableCredits: maxShieldableCredits) + + if submittedAmountWasMax, maxShieldableCredits != nil { + isApplyingMax = true + applyMaxAmountText(refreshedDuffs) + isApplyingMax = false + isPlatformShieldMaxDerived = true + } else if submittedAmountWasMax { + // The typed SDK failure proves the submitted Max is stale, but the + // cache cannot yet provide a truthful replacement. + // Preserve it visually and keep its Max provenance so the next + // successful refresh can replace it; affordability stays closed. + isPlatformShieldMaxDerived = true + } else { + maxAmountDuffs = nil + isPlatformShieldMaxDerived = false + } + + isPlatformShieldMaxQueued = false + hasPlatformShieldCapacityChangeNotice = true + if let maxShieldableCredits { + awaitingPlatformShieldResync = false + maxNotice = Self.platformShieldCapacityChangedMessage( + maxShieldableCredits: maxShieldableCredits) + refreshPlatformShieldPreflight() + } else { + maxNotice = Self.platformShieldCapacityRefreshRequiredMessage + // Fail closed until the coordinator's scheduled Platform sync + // publishes a balance snapshot. Do not re-read the cache here. + platformShieldPreflightGeneration &+= 1 + platformShieldPreflightTask?.cancel() + platformShieldPreflightTask = nil + platformShieldCapacity = nil + isPlatformShieldPreflightLoading = false + awaitingPlatformShieldResync = true } } @@ -911,12 +1270,49 @@ final class InternalTransferViewModel: ObservableObject { return feeReserveExceedsBalanceMessage(.core) } - private static func feeHeldBackMessage(_ credits: UInt64) -> String { + private static let platformShieldPreflightLoadingMessage = NSLocalizedString( + "Checking how much of your Platform balance can be moved…", + comment: "Platform to Shielded preflight in progress") + + private static let platformShieldPreflightUnavailableMessage = NSLocalizedString( + "Could not check the available Platform balance. Sync and try again.", + comment: "Platform to Shielded preflight failed") + + private static let platformShieldCapacityRefreshRequiredMessage = NSLocalizedString( + "Your available Platform balance changed, but the new maximum could not be checked. The amount was not changed. Sync and try again.", + comment: "Platform Shield capacity changed but refresh failed") + + private static let platformShieldResyncInProgressMessage = NSLocalizedString( + "Refreshing your Platform balance before checking the new maximum…", + comment: "Platform Shield manual resync in progress") + + private static let platformShieldHeadroomUnavailableMessage = NSLocalizedString( + "Your Platform balance cannot currently cover the Shield transfer selection headroom.", + comment: "Platform balance cannot fund shield selection headroom") + + private static func platformShieldHeldBackMessage(_ credits: UInt64) -> String { let formatted = (credits / 1000).formattedDashAmountWithoutCurrencySymbol return String.localizedStringWithFormat( NSLocalizedString( - "%@ DASH is reserved for the transfer fee.", - comment: "Max reserves the transfer fee"), + "%@ DASH remains in Platform because some address funds cannot be selected and transfer headroom is reserved.", + comment: "Platform Shield Max leaves selection headroom and unselectable funds"), + formatted) + } + + private static func platformShieldCapacityChangedMessage( + maxShieldableCredits: UInt64 + ) -> String { + let maxDuffs = maxShieldableCredits / 1000 + guard maxDuffs > 0 else { + return NSLocalizedString( + "Your available Platform balance changed and can no longer cover this Shield transfer. Review the amount and try again.", + comment: "Platform Shield capacity changed to zero") + } + let formatted = maxDuffs.formattedDashAmountWithoutCurrencySymbol + return String.localizedStringWithFormat( + NSLocalizedString( + "Your available Platform balance changed. The new maximum is %@ DASH. Review it and confirm again.", + comment: "Platform Shield capacity changed before confirmation"), formatted) } diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift index 15fc6fb40..b75a8b150 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift @@ -239,6 +239,7 @@ final class ShieldedTransferCoordinator: ObservableObject { case authCancelled case authFailed case amountBelowCoreToShieldedMinimum(UInt64) + case platformShieldCapacityChanged(maxShieldableCredits: UInt64?) case shieldedSweepWaiting(UInt64) case shieldedSweepChanged case transferFailed(Error) @@ -270,6 +271,19 @@ final class ShieldedTransferCoordinator: ObservableObject { "The minimum amount you can send is %@", comment: "Core to Shielded minimum amount"), formattedMinimum) + case .platformShieldCapacityChanged(let maxShieldableCredits): + guard let maxShieldableCredits else { + return NSLocalizedString( + "Your available Platform balance changed, but the new maximum could not be checked. Return to the amount and try again.", + comment: "Platform Shield capacity changed but refresh failed") + } + let maximum = (maxShieldableCredits / 1000) + .formattedDashAmountWithoutCurrencySymbol + return String.localizedStringWithFormat( + NSLocalizedString( + "Your available Platform balance changed. The new maximum is %@ DASH. Review and confirm the transfer again.", + comment: "Platform Shield capacity changed before authorization"), + maximum) case .shieldedSweepWaiting(let credits): let formatted = (credits / 1000).formattedDashAmountWithoutCurrencySymbol return String.localizedStringWithFormat( @@ -594,6 +608,26 @@ final class ShieldedTransferCoordinator: ObservableObject { return } + // Revalidate the frozen confirmation amount against the same Rust + // selector immediately before asking for authentication or building a + // proof. The form's cached preflight can become stale while Confirm is + // open, but the confirmed amount must never be reduced silently. + do { + let preflight = try await PlatformAddressSyncCoordinator.shared.preflightShield() + let capacity = PlatformShieldCapacity(preflight) + guard PlatformShieldAmountPolicy.canSubmit( + requestedCredits: amountCredits, + capacity: capacity) + else { + handleFailure(CoordinatorError.platformShieldCapacityChanged( + maxShieldableCredits: capacity.maxShieldableCredits)) + return + } + } catch { + handleFailure(CoordinatorError.transferFailed(error)) + return + } + do { try await authorize() } catch { @@ -615,6 +649,16 @@ final class ShieldedTransferCoordinator: ObservableObject { amount: amountCredits, addressSigner: signer) } catch { + if case PlatformWalletError.shieldedInsufficientBalance = error { + handleFailure(CoordinatorError.platformShieldCapacityChanged( + maxShieldableCredits: nil)) + // The rejection came from live Platform state while the + // preflight reads cache, so another immediate preflight would + // only repeat the stale maximum. Refresh the address cache and + // let its published balance re-arm the form preflight. + schedulePlatformResync() + return + } handleSpendError(error, manager: env.manager) return } diff --git a/DashWalletTests/SwiftDashSDKCoreLifecycleTests.swift b/DashWalletTests/SwiftDashSDKCoreLifecycleTests.swift index b058fca5f..fec7cdabf 100644 --- a/DashWalletTests/SwiftDashSDKCoreLifecycleTests.swift +++ b/DashWalletTests/SwiftDashSDKCoreLifecycleTests.swift @@ -376,6 +376,169 @@ final class SwiftDashSDKCoreLifecycleTests: XCTestCase { 0) } + func testPlatformShieldScreenshotRegressionUsesSDKSelectableCapacity() { + let capacity = PlatformShieldCapacity( + canShield: true, + accountBalanceCredits: 3_921_114_000, + usableBalanceCredits: 3_623_849_220, + feeReserveCredits: 1_000_000_000, + maxShieldableCredits: 2_623_849_220) + + // Old aggregate-balance Max from the report must be rejected. + XCTAssertFalse(PlatformShieldAmountPolicy.canSubmit( + requestedCredits: 2_921_114_000, + capacity: capacity)) + // The displayed Max is the SDK ceiling floored to whole duffs. + XCTAssertEqual( + PlatformShieldAmountPolicy.maximumDuffs(capacity: capacity), + 2_623_849) + XCTAssertTrue(PlatformShieldAmountPolicy.canSubmit( + requestedCredits: 2_623_849_000, + capacity: capacity)) + } + + func testPlatformShieldRejectsOneDuffAboveDisplayedMax() { + let capacity = PlatformShieldCapacity( + canShield: true, + accountBalanceCredits: 3_921_114_000, + usableBalanceCredits: 3_623_849_220, + feeReserveCredits: 1_000_000_000, + maxShieldableCredits: 2_623_849_220) + + XCTAssertFalse(PlatformShieldAmountPolicy.canSubmit( + requestedCredits: 2_623_850_000, + capacity: capacity)) + } + + func testPlatformShieldRejectsZeroAndUnshieldableCapacity() { + let unshieldable = PlatformShieldCapacity( + canShield: false, + accountBalanceCredits: 3_921_114_000, + usableBalanceCredits: 0, + feeReserveCredits: 1_000_000_000, + maxShieldableCredits: 0, + reason: "insufficient headroom") + + XCTAssertEqual( + PlatformShieldAmountPolicy.maximumDuffs(capacity: unshieldable), + 0) + XCTAssertFalse(PlatformShieldAmountPolicy.canSubmit( + requestedCredits: 1_000, + capacity: unshieldable)) + + let shieldable = PlatformShieldCapacity( + canShield: true, + accountBalanceCredits: 3_921_114_000, + usableBalanceCredits: 3_623_849_220, + feeReserveCredits: 1_000_000_000, + maxShieldableCredits: 2_623_849_220) + XCTAssertFalse(PlatformShieldAmountPolicy.canSubmit( + requestedCredits: 0, + capacity: shieldable)) + } + + func testPlatformShieldMaxFloorsSubDuffCredits() { + let capacity = PlatformShieldCapacity( + canShield: true, + accountBalanceCredits: 5_000, + usableBalanceCredits: 5_000, + feeReserveCredits: 1_000, + maxShieldableCredits: 3_999) + + XCTAssertEqual( + PlatformShieldAmountPolicy.maximumDuffs(capacity: capacity), + 3) + } + + func testPlatformShieldHeldBackNoticeUsesDisplayedAggregateBalance() { + XCTAssertEqual( + PlatformShieldAmountPolicy.heldBackCredits( + displayedPlatformCredits: 4_500_000_000, + accountBalanceCredits: 3_921_114_000, + submittedDuffs: 2_623_849), + 1_876_151_000) + + // If the published aggregate briefly lags, do not understate the + // account-level remainder reported by the coherent SDK preflight. + XCTAssertEqual( + PlatformShieldAmountPolicy.heldBackCredits( + displayedPlatformCredits: 3_000_000_000, + accountBalanceCredits: 3_921_114_000, + submittedDuffs: 2_623_849), + 1_297_265_000) + } + + func testPlatformShieldHeldBackIsZeroForOverflowAndFullySubmittedBalance() { + XCTAssertEqual( + PlatformShieldAmountPolicy.heldBackCredits( + displayedPlatformCredits: 4_500_000_000, + accountBalanceCredits: 3_921_114_000, + submittedDuffs: UInt64.max), + 0) + XCTAssertEqual( + PlatformShieldAmountPolicy.heldBackCredits( + displayedPlatformCredits: 2_623_849_000, + accountBalanceCredits: 2_623_849_000, + submittedDuffs: 2_623_849), + 0) + } + + func testPlatformShieldFailsClosedWithoutResolvedPreflight() { + XCTAssertFalse(PlatformShieldAmountPolicy.canSubmit( + requestedCredits: 1_000, + capacity: nil)) + } + + func testPlatformShieldStaleCacheWaitsForBalancePublication() { + XCTAssertFalse(PlatformShieldAmountPolicy.shouldRefreshPreflight( + after: .other, + awaitingPlatformResync: true)) + XCTAssertTrue(PlatformShieldAmountPolicy.shouldRefreshPreflight( + after: .balancePublished, + awaitingPlatformResync: true)) + XCTAssertTrue(PlatformShieldAmountPolicy.shouldRefreshPreflight( + after: .other, + awaitingPlatformResync: false)) + XCTAssertTrue(PlatformShieldAmountPolicy.awaitingPlatformResync( + current: true, + after: .other)) + XCTAssertFalse(PlatformShieldAmountPolicy.awaitingPlatformResync( + current: true, + after: .balancePublished)) + XCTAssertTrue(PlatformShieldAmountPolicy.shouldStartManualResync( + awaitingPlatformResync: true, + retryInFlight: false)) + XCTAssertFalse(PlatformShieldAmountPolicy.shouldStartManualResync( + awaitingPlatformResync: true, + retryInFlight: true)) + XCTAssertFalse(PlatformShieldAmountPolicy.shouldStartManualResync( + awaitingPlatformResync: false, + retryInFlight: false)) + } + + func testPlatformShieldCapacityChangeUpdatesOnlyMaxDerivedAmount() { + XCTAssertEqual( + PlatformShieldAmountPolicy.amountAfterCapacityChange( + currentDuffs: 2_921_114, + wasMaxDerived: true, + maxShieldableCredits: 2_623_849_220), + 2_623_849) + XCTAssertEqual( + PlatformShieldAmountPolicy.amountAfterCapacityChange( + currentDuffs: 2_700_000, + wasMaxDerived: false, + maxShieldableCredits: 2_623_849_220), + 2_700_000) + // A typed insufficient-balance failure followed by a failed preflight + // must not invent a zero Max or silently alter the confirmed value. + XCTAssertEqual( + PlatformShieldAmountPolicy.amountAfterCapacityChange( + currentDuffs: 2_921_114, + wasMaxDerived: true, + maxShieldableCredits: nil), + 2_921_114) + } + func testShieldedInsufficientBalanceMessageUsesSpendableAmount() { let message = TransferSpendAmountPolicy.insufficientBalanceMessage( balanceName: "Shielded",