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 @@ -1019,7 +1019,9 @@ final class InternalTransferViewModel: ObservableObject {
isFullShieldedSweep = true
shieldedSweepAmountCredits = plan.amountCredits
if plan.remainingCredits > 0 {
maxNotice = Self.shieldedRemainderMessage(plan.remainingCredits)
maxNotice = Self.shieldedRemainderMessage(
plan.remainingCredits,
followUpCredits: plan.followUpCredits)
}
sourceDuffs = plan.amountCredits / 1000
case .waitingForConfirmation(let credits):
Expand Down Expand Up @@ -1419,8 +1421,20 @@ final class InternalTransferViewModel: ObservableObject {
formatted)
}

private static func shieldedRemainderMessage(_ credits: UInt64) -> String {
private static func shieldedRemainderMessage(
_ credits: UInt64,
followUpCredits: UInt64
) -> String {
let formatted = (credits / 1000).formattedDashAmountWithoutCurrencySymbol
guard followUpCredits > 0 else {
// Spending these notes costs more than they hold, so no later
// sweep can move them — do not send the user round that loop.
return String.localizedStringWithFormat(
NSLocalizedString(
"%@ DASH stays in your Shielded balance: those notes are worth less than the fee to send them.",
comment: "Shielded Max dust remainder"),
formatted)
}
return String.localizedStringWithFormat(
NSLocalizedString(
"%@ DASH is held in notes that don't fit in one transaction. Use Max again after this one settles to send the rest.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,14 @@ struct ShieldedSweepPlan: Equatable {
let feeCredits: UInt64
let inputCredits: UInt64
/// Funds that necessarily stay in the pool after this bundle. Normally
/// zero; non-zero when more than 16 notes require another sweep.
/// zero; non-zero when the notes do not all fit, or when some are worth
/// less than the fee of the action that would spend them.
let remainingCredits: UInt64
/// What a FOLLOW-UP sweep of the leftover notes could actually pay out,
/// once its own fee is deducted. Zero when the leftovers are dust: they
/// cost more to spend than they carry, so no later sweep can move them
/// and telling the user to retry would loop forever.
let followUpCredits: UInt64
}

struct ShieldedSweepCandidate: Equatable {
Expand Down Expand Up @@ -393,12 +399,22 @@ final class ShieldedTransferCoordinator: ObservableObject {
return .waitingForConfirmation(publishedBalance)
}

// What a follow-up sweep of the untouched notes could pay out. The
// planner drops a note whose value is below the fee of the action that
// would carry it, so a leftover is not automatically sendable later —
// price it instead of assuming.
let leftovers = Array(noteValues.dropFirst(exact.noteCount))
let followUpCredits = ShieldedSweepPlanner.bestCandidate(
noteValues: leftovers,
feeForActions: feeForActions)?.amountCredits ?? 0

return .ready(
ShieldedSweepPlan(
amountCredits: exact.amountCredits,
feeCredits: exact.feeCredits,
inputCredits: exact.inputCredits,
remainingCredits: allCredits - exact.inputCredits))
remainingCredits: allCredits - exact.inputCredits,
followUpCredits: followUpCredits))
}

/// Largest amount the pool can fund inside ONE transition — the sweep
Expand Down
18 changes: 16 additions & 2 deletions DashWallet/Sources/UI/Payments/Pay/SendViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -708,7 +708,9 @@ final class SendViewModel: ObservableObject {
isFullShieldedSweep = true
shieldedSweepAmountCredits = plan.amountCredits
if plan.remainingCredits > 0 {
shieldedMaxNotice = Self.shieldedRemainderMessage(plan.remainingCredits)
shieldedMaxNotice = Self.shieldedRemainderMessage(
plan.remainingCredits,
followUpCredits: plan.followUpCredits)
}
sourceDuffs = plan.amountCredits / 1000
case .waitingForConfirmation(let credits):
Expand Down Expand Up @@ -776,8 +778,20 @@ final class SendViewModel: ObservableObject {
formatted)
}

private static func shieldedRemainderMessage(_ credits: UInt64) -> String {
private static func shieldedRemainderMessage(
_ credits: UInt64,
followUpCredits: UInt64
) -> String {
let formatted = (credits / 1000).formattedDashAmountWithoutCurrencySymbol
guard followUpCredits > 0 else {
// Spending these notes costs more than they hold, so no later
// sweep can move them — do not send the user round that loop.
return String.localizedStringWithFormat(
NSLocalizedString(
"%@ DASH stays in your Shielded balance: those notes are worth less than the fee to send them.",
comment: "Shielded Max dust remainder"),
formatted)
}
return String.localizedStringWithFormat(
NSLocalizedString(
"%@ DASH is held in notes that don't fit in one transaction. Use Max again after this one settles to send the rest.",
Expand Down
22 changes: 22 additions & 0 deletions DashWalletTests/SwiftDashSDKCoreLifecycleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,28 @@ final class SwiftDashSDKCoreLifecycleTests: XCTestCase {
XCTAssertEqual(candidate?.amountCredits, UInt64(budget) * 1_000 - 100)
}

func testShieldedSweepSkipsNotesWorthLessThanTheirAction() {
// A note below the marginal fee of the action that would spend it
// lowers the payout, so the planner must leave it. The 1-credit note
// here is exactly that case: taking it would cost 50 and gain 1.
let fees: [Int: UInt64] = [2: 100, 3: 150]

let candidate = ShieldedSweepPlanner.bestCandidate(
noteValues: [1_000, 500, 1],
feeForActions: { fees[$0] })

XCTAssertEqual(candidate?.noteCount, 2)
XCTAssertEqual(candidate?.inputCredits, 1_500)
XCTAssertEqual(candidate?.amountCredits, 1_400)

// And a follow-up sweep of that leftover pays out nothing, which is
// what tells the UI to stop inviting the user to retry.
XCTAssertNil(
ShieldedSweepPlanner.bestCandidate(
noteValues: [1],
feeForActions: { fees[$0] }))
}

func testShieldedSpendableBalanceSubtractsFeeReserve() {
XCTAssertEqual(
TransferSpendAmountPolicy.spendableCredits(
Expand Down
Loading