Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,7 @@ final class InternalTransferViewModel: ObservableObject {
/// input-selection envelope.
private func routeDidChange() {
clearMaxSelection()
refreshShieldedSpendCeiling()

if route == .platformToCore {
startWithdrawalPreflightIfNeeded()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }
Expand Down Expand Up @@ -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: >)
Expand Down Expand Up @@ -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? {
Expand Down Expand Up @@ -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
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -740,6 +796,7 @@ final class ShieldedTransferCoordinator: ObservableObject {
return
}
} else {
if rejectIfAboveSpendCeiling(amountCredits, feeKind: .withdrawal) { return }
submittedAmount = amountCredits
}

Expand Down Expand Up @@ -845,6 +902,7 @@ final class ShieldedTransferCoordinator: ObservableObject {
return
}
} else {
if rejectIfAboveSpendCeiling(amountCredits, feeKind: .unshield) { return }
submittedAmount = amountCredits
}

Expand Down Expand Up @@ -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")

Expand All @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
} else {
if rejectIfAboveSpendCeiling(amountCredits, feeKind: .transfer) { return }
submittedAmount = amountCredits
}

do {
try await authorize()
} catch {
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions DashWallet/Sources/UI/Payments/Pay/SendScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading