diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift index 4d0023533..c1cdd7619 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferViewModel.swift @@ -472,6 +472,7 @@ final class InternalTransferViewModel: ObservableObject { /// input-selection envelope. private func routeDidChange() { clearMaxSelection() + refreshShieldedSpendCeiling() if route == .platformToCore { startWithdrawalPreflightIfNeeded() @@ -543,6 +544,14 @@ final class InternalTransferViewModel: ObservableObject { /// balance mirror. Updates whenever a shielded sync pass completes. @Published private(set) var shieldedBalance: UInt64 = 0 + /// Largest amount the pool can fund inside ONE transition — see + /// `ShieldedTransferCoordinator.spendCeilingCredits`. A typed amount above + /// this needs more notes than the 20 KiB state-transition limit admits and + /// would be rejected at broadcast, after the proof was built. `nil` while + /// the note set is reconciling, in which case the balance envelope is the + /// only bound this screen can apply; the coordinator still fails closed. + @Published private(set) var shieldedSpendCeilingCredits: UInt64? + /// Drives the one-time restore gate reactively. A normal catch-up may set /// this to false, but it only blocks while the recovery marker is active. @Published private(set) var isChainSynced = SyncingActivityMonitor.shared.state == .syncDone @@ -591,10 +600,30 @@ final class InternalTransferViewModel: ObservableObject { .receive(on: RunLoop.main) .sink { [weak self] credits in self?.shieldedBalance = credits + self?.refreshShieldedSpendCeiling() } .store(in: &cancellables) } + /// Fee kind for the pool-spending routes; `nil` for every other route. + private func shieldedFeeKind(for route: InternalTransferRoute) -> PlatformWalletManager.ShieldedFeeKind? { + switch route { + case .shieldedToCore: return .withdrawal + case .shieldedToPlatform: return .unshield + default: return nil + } + } + + /// Recomputes `shieldedSpendCeilingCredits` from the current note set. + /// Cached rather than computed per keystroke — it reads SwiftData. + private func refreshShieldedSpendCeiling() { + guard let feeKind = shieldedFeeKind(for: route) else { + shieldedSpendCeilingCredits = nil + return + } + shieldedSpendCeilingCredits = ShieldedTransferCoordinator.spendCeilingCredits(feeKind: feeKind) + } + /// The raw numeric value the user has typed, with locale comma normalised /// to a dot. Interpretation depends on `unit` — this is *not yet* the DASH /// amount when in `.fiat` mode. @@ -707,6 +736,23 @@ final class InternalTransferViewModel: ObservableObject { // A Max sweep is planned against the real note set rather than the // amount+reserve envelope, so it is affordable by construction. if isFullShieldedSweep { return nil } + // The ceiling is priced from the notes that would actually be + // spent, so it supersedes the flat reserve — which always charges + // a full-size bundle and would reject amounts a one- or two-note + // spend can afford. + if let ceiling = shieldedSpendCeilingCredits { + if creditsPreview > shieldedBalance { + // Simply more than the wallet holds: name that, rather than + // blaming note fragmentation. + return TransferSpendAmountPolicy.insufficientBalanceMessage( + balanceName: balanceName, + requestedDuffs: creditsPreview / 1000, + spendableDuffs: ceiling / 1000) + } + return creditsPreview > ceiling ? Self.shieldedCeilingMessage(ceiling) : nil + } + // Ceiling unavailable (notes reconciling): fall back to the flat + // worst-case reserve. guard let reserve = feeReserveCredits else { return Self.feeEstimateUnavailableMessage } @@ -786,6 +832,9 @@ final class InternalTransferViewModel: ObservableObject { // Unshield/withdraw: the SDK debits amount + fee from the shielded // pool (recipient receives the full amount), so the balance must // cover amount + fee. Fail closed if the reserve is unavailable. + if let ceiling = shieldedSpendCeilingCredits { + return creditsPreview <= ceiling + } guard let reserve = feeReserveCredits else { return false } return shieldedBalance >= reserve && creditsPreview <= shieldedBalance - reserve @@ -815,13 +864,18 @@ final class InternalTransferViewModel: ObservableObject { 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 - // 16-action `max_shielded_transition_actions` cap) at send time. - // Reserve that worst case so a fragmented wallet can't pass the - // affordability check and then fail SDK note selection. - return try? PlatformWalletManager.estimateShieldedFee(kind: .withdrawal, numActions: 16) + // the SDK recomputes it from real note selection at send time. + // Reserve the worst case the 20 KiB state-transition limit admits + // (`ShieldedActionBudget.maxActionsPerTransition`) so a fragmented + // wallet can't pass the affordability check and then fail SDK note + // selection. + return try? PlatformWalletManager.estimateShieldedFee( + kind: .withdrawal, + numActions: ShieldedActionBudget.maxActionsPerTransition) case .shieldedToPlatform: - return try? PlatformWalletManager.estimateShieldedFee(kind: .unshield, numActions: 16) + return try? PlatformWalletManager.estimateShieldedFee( + kind: .unshield, + numActions: ShieldedActionBudget.maxActionsPerTransition) case .platformToCore: // Full-balance withdrawal: the fee is already netted out of the // preflight's `netWithdrawable`; no reserve on top. @@ -973,7 +1027,7 @@ final class InternalTransferViewModel: ObservableObject { sourceDuffs = 0 case .unavailable: maxNotice = NSLocalizedString( - "Your Shielded balance is not ready to withdraw. Sync and try Max again.", + "Your Shielded balance is not ready to spend. Sync and try Max again.", comment: "Shielded Max unavailable") sourceDuffs = 0 } @@ -1351,16 +1405,25 @@ final class InternalTransferViewModel: ObservableObject { let formatted = (credits / 1000).formattedDashAmountWithoutCurrencySymbol return String.localizedStringWithFormat( NSLocalizedString( - "%@ DASH is still confirming. Withdraw again once it settles.", + "%@ DASH is still confirming. Use Max again once it settles.", comment: "Shielded Max pending change"), formatted) } + private static func shieldedCeilingMessage(_ credits: UInt64) -> String { + let formatted = (credits / 1000).formattedDashAmountWithoutCurrencySymbol + return String.localizedStringWithFormat( + NSLocalizedString( + "Your Shielded balance is split across notes, and at most %@ DASH of it can be sent in one transaction. Send the rest afterwards.", + comment: "Shielded amount above the single-transaction ceiling"), + formatted) + } + private static func shieldedRemainderMessage(_ credits: UInt64) -> String { let formatted = (credits / 1000).formattedDashAmountWithoutCurrencySymbol return String.localizedStringWithFormat( NSLocalizedString( - "%@ DASH requires another Shielded withdrawal. Use Max again after this transfer settles.", + "%@ DASH is held in notes that don't fit in one transaction. Use Max again after this one settles to send the rest.", comment: "Shielded Max multi-bundle remainder"), formatted) } diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift index 3434fb45f..709901ad1 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/ShieldedTransferCoordinator.swift @@ -42,11 +42,29 @@ import OSLog import SwiftData import SwiftDashSDK +/// How many Orchard actions the app may put in one shielded transition. +/// +/// Consensus allows 16 (`system_limits.max_shielded_transition_actions`), but +/// the 20 KiB `system_limits.max_state_transition_size` binds first: the Halo 2 +/// proof grows ~2,681 bytes per action. Platform's +/// `seed_pool_batch_fits_max_state_transition_size` test measures 2 actions → +/// 8,294 B, 6 → 19,018 B, 7 → 21,699 B — and the 7-action bundle is rejected, +/// which reaches the app as "State Transition exceeds maximum size of 20480 +/// bytes" from DAPI's broadcast check. Rust's pool seeder pins the same bound +/// as `MAX_ACTIONS_PER_BATCH` in `rs-platform-wallet/src/wallet/shielded/seed_pool.rs`. +/// +/// Spending more notes than this needs a second transition, which the sweep +/// planner reports as `ShieldedSweepPlan.remainingCredits`. +enum ShieldedActionBudget { + static let maxActionsPerTransition = 6 +} + /// A note-aware full-balance spend plan. Unlike the amount screens' normal /// affordability reserve, a sweep prices the fee from the notes that will /// actually enter the Orchard bundle. That keeps a one-note withdrawal from -/// reserving the 16-action worst-case fee and returning the difference as a -/// persistent change note. +/// reserving the worst-case fee for a full +/// `ShieldedActionBudget.maxActionsPerTransition` bundle and returning the +/// difference as a persistent change note. struct ShieldedSweepPlan: Equatable { let amountCredits: UInt64 let feeCredits: UInt64 @@ -69,7 +87,7 @@ struct ShieldedSweepCandidate: Equatable { enum ShieldedSweepPlanner { static func bestCandidate( noteValues: [UInt64], - maxActions: Int = 16, + maxActions: Int = ShieldedActionBudget.maxActionsPerTransition, feeForActions: (Int) -> UInt64? ) -> ShieldedSweepCandidate? { guard maxActions > 0 else { return nil } @@ -114,7 +132,7 @@ enum ShieldedSweepPlanner { static func revalidate( noteValues: [UInt64], amountCredits: UInt64, - maxActions: Int = 16, + maxActions: Int = ShieldedActionBudget.maxActionsPerTransition, feeForActions: (Int) -> UInt64? ) -> ShieldedSweepCandidate? { let values = noteValues.sorted(by: >) @@ -242,6 +260,7 @@ final class ShieldedTransferCoordinator: ObservableObject { case platformShieldCapacityChanged(maxShieldableCredits: UInt64?) case shieldedSweepWaiting(UInt64) case shieldedSweepChanged + case shieldedAmountExceedsBundle(UInt64) case transferFailed(Error) var errorDescription: String? { @@ -288,13 +307,20 @@ final class ShieldedTransferCoordinator: ObservableObject { let formatted = (credits / 1000).formattedDashAmountWithoutCurrencySymbol return String.localizedStringWithFormat( NSLocalizedString( - "%@ DASH is still confirming. Withdraw again once it settles.", + "%@ DASH is still confirming. Try again once it settles.", comment: "Shielded sweep pending change"), formatted) case .shieldedSweepChanged: return NSLocalizedString( "Your Shielded balance changed. Close this confirmation and tap Max again.", comment: "Shielded sweep changed before submit") + case .shieldedAmountExceedsBundle(let ceiling): + let formatted = (ceiling / 1000).formattedDashAmountWithoutCurrencySymbol + return String.localizedStringWithFormat( + NSLocalizedString( + "Your Shielded balance is split across notes, and at most %@ DASH of it can be sent in one transaction.", + comment: "Shielded amount above the single-transaction ceiling"), + formatted) case .transferFailed(let underlying): return underlying.localizedDescription } @@ -304,10 +330,10 @@ final class ShieldedTransferCoordinator: ObservableObject { // MARK: - Full-balance shielded sweep /// Builds the Max plan from the active wallet's persisted unspent notes. - /// The Rust selector is largest-first and a transition is capped at 16 - /// actions, so the first bundle consumes at most the 16 largest notes. - /// Any remainder is reported to the amount screen instead of being left - /// behind silently. + /// The Rust selector is largest-first and the app caps a transition at + /// `ShieldedActionBudget.maxActionsPerTransition` actions, so the first + /// bundle consumes at most that many of the largest notes. Any remainder is + /// reported to the amount screen instead of being left behind silently. static func sweepAvailability( feeKind: PlatformWalletManager.ShieldedFeeKind ) -> ShieldedSweepAvailability { @@ -375,6 +401,36 @@ final class ShieldedTransferCoordinator: ObservableObject { remainingCredits: allCredits - exact.inputCredits)) } + /// Largest amount the pool can fund inside ONE transition — the sweep + /// plan's payout, which is by construction the best `ShieldedActionBudget` + /// notes can do. A larger amount needs more notes than the 20 KiB + /// state-transition limit admits, so it would be rejected at broadcast + /// after the proof was built. + /// + /// `nil` while the note set is mid-reconcile — the caller then has no + /// note-aware bound and falls back to its balance envelope. + static func spendCeilingCredits( + feeKind: PlatformWalletManager.ShieldedFeeKind + ) -> UInt64? { + guard case .ready(let plan) = sweepAvailability(feeKind: feeKind) else { return nil } + return plan.amountCredits + } + + /// Fails closed when a non-sweep amount needs more notes than one + /// transition can carry. The amount screens check this too, but the guard + /// belongs here as well: it is the last point before authorization and + /// proof generation, and it covers callers that never ran that check. + private func rejectIfAboveSpendCeiling( + _ amountCredits: UInt64, + feeKind: PlatformWalletManager.ShieldedFeeKind + ) -> Bool { + guard let ceiling = Self.spendCeilingCredits(feeKind: feeKind), + amountCredits > ceiling + else { return false } + handleFailure(CoordinatorError.shieldedAmountExceedsBundle(ceiling)) + return true + } + // MARK: - Public API /// Route 1: BIP44 Core UTXOs → asset-lock → Type 18 shield. @@ -740,6 +796,7 @@ final class ShieldedTransferCoordinator: ObservableObject { return } } else { + if rejectIfAboveSpendCeiling(amountCredits, feeKind: .withdrawal) { return } submittedAmount = amountCredits } @@ -845,6 +902,7 @@ final class ShieldedTransferCoordinator: ObservableObject { return } } else { + if rejectIfAboveSpendCeiling(amountCredits, feeKind: .unshield) { return } submittedAmount = amountCredits } @@ -1067,7 +1125,11 @@ final class ShieldedTransferCoordinator: ObservableObject { /// Orchard address, decoded from their bech32m display form). Stages: /// `.signing → .proving → .broadcasting → .success` — same opaque-call /// shape as `performWithdraw`/`performUnshield`. - func performShieldedTransfer(amountCredits: UInt64, recipientRaw43: Data) async { + func performShieldedTransfer( + amountCredits: UInt64, + sweepAll: Bool = false, + recipientRaw43: Data + ) async { guard beginTransfer() else { return } Self.logger.info("🛡️ SHIELD-TX :: shielded→shielded send amount=\(amountCredits) credits") @@ -1079,6 +1141,27 @@ final class ShieldedTransferCoordinator: ObservableObject { return } + // Re-price the sweep against the note set as it stands now — same + // guard as `performWithdraw`, so a note that was spent or discovered + // between Max and confirm fails closed instead of building a bundle + // the pool can no longer fund exactly. + let submittedAmount: UInt64 + if sweepAll { + switch Self.sweepAvailability(feeKind: .transfer) { + case .ready(let plan) where plan.amountCredits == amountCredits: + submittedAmount = plan.amountCredits + case .waitingForConfirmation(let credits): + handleFailure(CoordinatorError.shieldedSweepWaiting(credits)) + return + case .ready, .unavailable: + handleFailure(CoordinatorError.shieldedSweepChanged) + return + } + } else { + if rejectIfAboveSpendCeiling(amountCredits, feeKind: .transfer) { return } + submittedAmount = amountCredits + } + do { try await authorize() } catch { @@ -1095,7 +1178,7 @@ final class ShieldedTransferCoordinator: ObservableObject { resolver: MnemonicResolver(), account: 0, recipientRaw43: recipientRaw43, - amount: amountCredits) + amount: submittedAmount) } catch { handleSpendError(error, manager: env.manager) return diff --git a/DashWallet/Sources/UI/Payments/Pay/SendScreen.swift b/DashWallet/Sources/UI/Payments/Pay/SendScreen.swift index 435dee006..8b2b72940 100644 --- a/DashWallet/Sources/UI/Payments/Pay/SendScreen.swift +++ b/DashWallet/Sources/UI/Payments/Pay/SendScreen.swift @@ -1099,6 +1099,7 @@ struct SendConfirmSheet: View { } await coordinator.performShieldedTransfer( amountCredits: creditsAmount, + sweepAll: isFullShieldedSweep, recipientRaw43: destinationRaw43) case .coreToCore: // Unreachable: the screen routes Core → Core through the L1 diff --git a/DashWallet/Sources/UI/Payments/Pay/SendViewModel.swift b/DashWallet/Sources/UI/Payments/Pay/SendViewModel.swift index ca15026fb..ebdc57afa 100644 --- a/DashWallet/Sources/UI/Payments/Pay/SendViewModel.swift +++ b/DashWallet/Sources/UI/Payments/Pay/SendViewModel.swift @@ -86,6 +86,15 @@ final class SendViewModel: ObservableObject { @Published private(set) var platformCredits: UInt64 = 0 @Published private(set) var shieldedBalance: UInt64 = 0 + /// Largest amount the pool can fund inside ONE transition — the same + /// note-aware number Max produces. A typed amount above this needs more + /// notes than `ShieldedActionBudget` admits, so the bundle would exceed the + /// 20 KiB state-transition limit and be rejected at broadcast, after the + /// proof was already built. `nil` while the note set is mid-reconcile + /// (`sweepAvailability` is not `.ready`), in which case the amount screen + /// falls back to the balance envelope alone. + @Published private(set) var shieldedSpendCeilingCredits: UInt64? + /// Live result of `preflightWithdrawal()` for the Platform → Core route — /// same semantics as the internal transfer's: `nil` while unknown, /// affordability fails closed. @@ -150,10 +159,31 @@ final class SendViewModel: ObservableObject { .receive(on: RunLoop.main) .sink { [weak self] credits in self?.shieldedBalance = credits + self?.refreshShieldedSpendCeiling() } .store(in: &cancellables) } + /// Fee kind for the pool-spending routes; `nil` for every other route. + private func shieldedFeeKind(for route: Route?) -> PlatformWalletManager.ShieldedFeeKind? { + switch route { + case .shieldedToCore: return .withdrawal + case .shieldedToPlatform: return .unshield + case .shieldedToShielded: return .transfer + default: return nil + } + } + + /// Recomputes `shieldedSpendCeilingCredits` from the current note set. + /// Cached rather than computed per keystroke — it reads SwiftData. + private func refreshShieldedSpendCeiling() { + guard let feeKind = shieldedFeeKind(for: route) else { + shieldedSpendCeilingCredits = nil + return + } + shieldedSpendCeilingCredits = ShieldedTransferCoordinator.spendCeilingCredits(feeKind: feeKind) + } + // MARK: - Destination classification /// Forwards to `DashAddressClassifier` — the single wire-form decoder, @@ -258,6 +288,7 @@ final class SendViewModel: ObservableObject { /// needs the withdrawal preflight (fee headroom + full-balance payout). private func routeDidChange() { clearShieldedMaxSelection() + refreshShieldedSpendCeiling() guard route == .platformToCore else { preflightTask?.cancel() preflightTask = nil @@ -420,13 +451,19 @@ final class SendViewModel: ObservableObject { case .platformToPlatform: return Self.platformTransferFeeReserveCredits case .shieldedToCore: - // Worst-case note selection (16 actions) — same reasoning as the - // internal transfer's reserve. - return try? PlatformWalletManager.estimateShieldedFee(kind: .withdrawal, numActions: 16) + // Worst-case note selection for a bundle the size limit actually + // admits — same reasoning as the internal transfer's reserve. + return try? PlatformWalletManager.estimateShieldedFee( + kind: .withdrawal, + numActions: ShieldedActionBudget.maxActionsPerTransition) case .shieldedToPlatform: - return try? PlatformWalletManager.estimateShieldedFee(kind: .unshield, numActions: 16) + return try? PlatformWalletManager.estimateShieldedFee( + kind: .unshield, + numActions: ShieldedActionBudget.maxActionsPerTransition) case .shieldedToShielded: - return try? PlatformWalletManager.estimateShieldedFee(kind: .transfer, numActions: 16) + return try? PlatformWalletManager.estimateShieldedFee( + kind: .transfer, + numActions: ShieldedActionBudget.maxActionsPerTransition) } } @@ -539,6 +576,23 @@ final class SendViewModel: ObservableObject { // A Max sweep is planned against the real note set rather than the // amount+reserve envelope, so it is affordable by construction. if isFullShieldedSweep { return nil } + // The ceiling is priced from the notes that would actually be + // spent, so it supersedes the flat reserve — which always charges + // a full-size bundle and would reject amounts a one- or two-note + // spend can afford. + if let ceiling = shieldedSpendCeilingCredits { + if creditsPreview > shieldedBalance { + // Simply more than the wallet holds: name that, rather than + // blaming note fragmentation. + return TransferSpendAmountPolicy.insufficientBalanceMessage( + balanceName: balanceName, + requestedDuffs: creditsPreview / 1000, + spendableDuffs: ceiling / 1000) + } + return creditsPreview > ceiling ? Self.shieldedCeilingMessage(ceiling) : nil + } + // Ceiling unavailable (notes reconciling): fall back to the flat + // worst-case reserve. guard let reserve = feeReserveCredits else { return Self.feeEstimateUnavailableMessage } @@ -619,6 +673,9 @@ final class SendViewModel: ObservableObject { if isFullShieldedSweep { return shieldedSweepAmountCredits != nil } + if let ceiling = shieldedSpendCeilingCredits { + return creditsPreview <= ceiling + } guard let reserve = feeReserveCredits else { return false } return shieldedBalance >= reserve && creditsPreview <= shieldedBalance - reserve @@ -641,9 +698,11 @@ final class SendViewModel: ObservableObject { sourceDuffs = creditsMinusFeeReserve(platformCredits) / 1000 case .platformToCore: sourceDuffs = platformWithdrawableDuffs ?? 0 - case .shieldedToCore, .shieldedToPlatform: - let feeKind: PlatformWalletManager.ShieldedFeeKind = - route == .shieldedToCore ? .withdrawal : .unshield + case .shieldedToCore, .shieldedToPlatform, .shieldedToShielded: + // All three spend the pool, so all three plan Max against the real + // note set. A flat reserve here would price a full-size bundle and + // hand the unspent difference back as a change note. + guard let feeKind = shieldedFeeKind(for: route) else { return } switch ShieldedTransferCoordinator.sweepAvailability(feeKind: feeKind) { case .ready(let plan): isFullShieldedSweep = true @@ -657,12 +716,10 @@ final class SendViewModel: ObservableObject { sourceDuffs = 0 case .unavailable: shieldedMaxNotice = NSLocalizedString( - "Your Shielded balance is not ready to withdraw. Sync and try Max again.", + "Your Shielded balance is not ready to spend. Sync and try Max again.", comment: "Shielded Max unavailable") sourceDuffs = 0 } - case .shieldedToShielded: - sourceDuffs = creditsMinusFeeReserve(shieldedBalance) / 1000 } isApplyingMax = true @@ -705,16 +762,25 @@ final class SendViewModel: ObservableObject { let formatted = (credits / 1000).formattedDashAmountWithoutCurrencySymbol return String.localizedStringWithFormat( NSLocalizedString( - "%@ DASH is still confirming. Withdraw again once it settles.", + "%@ DASH is still confirming. Use Max again once it settles.", comment: "Shielded Max pending change"), formatted) } + private static func shieldedCeilingMessage(_ credits: UInt64) -> String { + let formatted = (credits / 1000).formattedDashAmountWithoutCurrencySymbol + return String.localizedStringWithFormat( + NSLocalizedString( + "Your Shielded balance is split across notes, and at most %@ DASH of it can be sent in one transaction. Send the rest afterwards.", + comment: "Shielded amount above the single-transaction ceiling"), + formatted) + } + private static func shieldedRemainderMessage(_ credits: UInt64) -> String { let formatted = (credits / 1000).formattedDashAmountWithoutCurrencySymbol return String.localizedStringWithFormat( NSLocalizedString( - "%@ DASH requires another Shielded withdrawal. Use Max again after this transfer settles.", + "%@ DASH is held in notes that don't fit in one transaction. Use Max again after this one settles to send the rest.", comment: "Shielded Max multi-bundle remainder"), formatted) } diff --git a/DashWalletTests/SwiftDashSDKCoreLifecycleTests.swift b/DashWalletTests/SwiftDashSDKCoreLifecycleTests.swift index fec7cdabf..83cc276af 100644 --- a/DashWalletTests/SwiftDashSDKCoreLifecycleTests.swift +++ b/DashWalletTests/SwiftDashSDKCoreLifecycleTests.swift @@ -363,6 +363,23 @@ final class SwiftDashSDKCoreLifecycleTests: XCTestCase { noteCount: 2)) } + func testShieldedSweepStopsAtTheActionBudget() { + // The 20 KiB `max_state_transition_size` ceiling, not the 16-action + // consensus cap, is what bounds a bundle: a 7-action transition is + // ~21,699 B and DAPI rejects it. The planner must stop at the budget + // even when every further note would raise the payout. + let notes = Array(repeating: UInt64(1_000), count: 12) + let budget = ShieldedActionBudget.maxActionsPerTransition + + let candidate = ShieldedSweepPlanner.bestCandidate( + noteValues: notes, + feeForActions: { _ in 100 }) + + XCTAssertEqual(candidate?.noteCount, budget) + XCTAssertEqual(candidate?.inputCredits, UInt64(budget) * 1_000) + XCTAssertEqual(candidate?.amountCredits, UInt64(budget) * 1_000 - 100) + } + func testShieldedSpendableBalanceSubtractsFeeReserve() { XCTAssertEqual( TransferSpendAmountPolicy.spendableCredits(