Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import SwiftDashSDK
/// - `.platformToShielded` → `performShield(amountCredits:)`
/// - `.shieldedToCore` → `performWithdraw(amountCredits:)`
/// - `.shieldedToPlatform` → `performUnshield(amountCredits:)`
/// - `.coreToPlatform` → `performFundPlatform(amountDuffs:)`
/// - `.coreToPlatform` → `performFundPlatform(recipientAmountDuffs:)`
/// - `.platformToCore` → `performPlatformWithdrawAll()` (full balance)
struct InternalTransferConfirmSheet: View {

Expand Down Expand Up @@ -229,11 +229,11 @@ struct InternalTransferConfirmSheet: View {
case .shieldedToPlatform:
return try? PlatformWalletManager.estimateShieldedFee(kind: .unshield, numActions: 2)
case .coreToPlatform:
// Address-funding asset lock: the required processing balance
// (the same 50k-duff base the Rust side reserves for address
// funding). The funding ST's metered fee is extra and only
// knowable on-chain, so this is a lower bound.
return CoreToShieldedAmountPolicy.assetLockBaseCostCredits
// Address-funding asset lock: the headroom the coordinator adds
// ON TOP of the amount, so Amount + Network fee equals Total
// exactly. The funding ST's metered fee comes out of it; the
// unspent part lands back on the Platform balance.
return CoreToPlatformAmountPolicy.topUpHeadroomDuffs * 1000
Comment thread
llbartekll marked this conversation as resolved.
Outdated
case .platformToCore:
// The exact transition fee the preflight already netted out of
// the payout amount.
Expand All @@ -248,22 +248,31 @@ struct InternalTransferConfirmSheet: View {
return "~ " + CurrencyExchanger.shared.fiatAmountString(for: dash)
}

/// What actually leaves the source balance. Core→Shielded charges the
/// pool fee on top of the amount (the executed lock value); every other
/// route's total is the amount itself. "—" when the fee estimate is
/// unavailable — `canContinue` fails closed before that can be confirmed,
/// but the row must never show the un-inflated number.
/// What actually leaves the source balance. Both Core-funded asset-lock
/// routes charge their fee/headroom on top of the amount (the executed
/// lock value); every other route's total is the amount itself. "—" when
/// the fee estimate is unavailable — `canContinue` fails closed before
/// that can be confirmed, but the row must never show the un-inflated
/// number.
private var totalString: String {
guard route == .coreToShielded else {
switch route {
case .coreToShielded:
guard let poolFeeCredits = CoreToShieldedAmountPolicy.poolFeeCredits,
let lockDuffs = CoreToShieldedAmountPolicy.lockValueDuffs(
forAmountDuffs: amountDuffsUnsigned,
poolFeeCredits: poolFeeCredits),
let signedLockDuffs = Int64(exactly: lockDuffs)
else { return "—" }
return signedLockDuffs.formattedDashAmount
case .coreToPlatform:
guard let lockDuffs = CoreToPlatformAmountPolicy.lockValueDuffs(
forAmountDuffs: amountDuffsUnsigned),
let signedLockDuffs = Int64(exactly: lockDuffs)
else { return "—" }
return signedLockDuffs.formattedDashAmount
default:
return dashDuffs.formattedDashAmount
}
guard let poolFeeCredits = CoreToShieldedAmountPolicy.poolFeeCredits,
let lockDuffs = CoreToShieldedAmountPolicy.lockValueDuffs(
forAmountDuffs: amountDuffsUnsigned,
poolFeeCredits: poolFeeCredits),
let signedLockDuffs = Int64(exactly: lockDuffs)
else { return "—" }
return signedLockDuffs.formattedDashAmount
}


Expand Down Expand Up @@ -478,7 +487,7 @@ struct InternalTransferConfirmSheet: View {
amountCredits: creditsAmount,
sweepAll: isFullShieldedSweep)
case .coreToPlatform:
await coordinator.performFundPlatform(amountDuffs: amountDuffsUnsigned)
await coordinator.performFundPlatform(recipientAmountDuffs: amountDuffsUnsigned)
case .platformToCore:
await coordinator.performPlatformWithdraw(
amountCredits: creditsAmount,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,30 @@ enum CoreToShieldedAmountPolicy {
}
}

/// Shared fee-on-top amount boundary for Core → Platform address topups.
///
/// The address-funding ST's metered fee is deducted from the locked value
/// (the single remainder recipient absorbs `lock − fee`), so the lock is
/// inflated by a fixed headroom: the Platform balance receives at least the
/// typed amount, and the unspent headroom lands back on the same balance in
/// the user's favor. Keep the constant here as the one source of truth for
/// amount validation, Max, the confirm sheet, and the executed lock value.
@MainActor
enum CoreToPlatformAmountPolicy {
/// Duff headroom the lock carries on top of the typed amount — the
/// credit-denominated processing base cost expressed in whole duffs.
static let topUpHeadroomDuffs: UInt64 =
CoreToShieldedAmountPolicy.assetLockBaseCostCredits / 1000

/// Fee-on-top L1 lock value that delivers at least `amountDuffs` to the
/// wallet's Platform balance. `nil` on UInt64 overflow — callers fail
/// closed.
static func lockValueDuffs(forAmountDuffs amountDuffs: UInt64) -> UInt64? {
let (total, overflow) = amountDuffs.addingReportingOverflow(topUpHeadroomDuffs)
return overflow ? nil : total
}
}

/// Shared affordability boundary for every transfer route, whichever balance
/// it spends.
///
Expand Down Expand Up @@ -717,13 +741,14 @@ final class InternalTransferViewModel: ObservableObject {
? coreSpendableDuffs - feeDuffs : 0)

case .coreToPlatform:
// This asset-lock route carves its processing fee from the locked
// value, but the funding transaction is still an L1 spend — only
// confirmed UTXOs, and the miner fee comes off the top.
// The headroom rides on top of the amount, so the spendable
// envelope shrinks by it — same shape as Core → Shielded above.
let headroomDuffs = CoreToPlatformAmountPolicy.topUpHeadroomDuffs
return TransferSpendAmountPolicy.insufficientBalanceMessage(
balanceName: balanceName,
requestedDuffs: dashDuffsUnsigned,
spendableDuffs: coreSpendableDuffs)
spendableDuffs: coreSpendableDuffs > headroomDuffs
? coreSpendableDuffs - headroomDuffs : 0)

case .platformToShielded:
if awaitingPlatformShieldResync {
Expand Down Expand Up @@ -829,11 +854,14 @@ final class InternalTransferViewModel: ObservableObject {
else { return false }
return lockDuffs <= coreSpendableDuffs
case .coreToPlatform:
// This asset-lock route carves its processing fee from the locked
// value rather than charging it on top. What still bounds it is
// the funding spend itself — confirmed UTXOs minus the L1 fee
// reserve, which is exactly `coreSpendableDuffs`.
return dashDuffsUnsigned <= coreSpendableDuffs
// Fee-on-top: the lock value is amount + headroom (the funding
// ST's metered fee comes out of the headroom, not the amount),
// so the L1 spendable balance must cover both. Fails closed on
// overflow.
guard let lockDuffs = CoreToPlatformAmountPolicy.lockValueDuffs(
forAmountDuffs: dashDuffsUnsigned)
else { return false }
return lockDuffs <= coreSpendableDuffs
case .platformToShielded:
return !isPlatformShieldPreflightLoading
&& PlatformShieldAmountPolicy.canSubmit(
Expand Down Expand Up @@ -871,10 +899,10 @@ final class InternalTransferViewModel: ObservableObject {
private var feeReserveCredits: UInt64? {
switch route {
case .coreToShielded, .coreToPlatform:
// Core→Shielded's pool fee is duff-denominated and enforced in
// the route branches (`canContinue`, Max) directly; Core→Platform
// carves its fee from the locked value. Neither reserves credits
// from the source balance here.
// Both Core-funded routes charge their fee/headroom ON TOP of the
// amount, but duff-denominated and enforced in the route branches
// (`canContinue`, Max) directly. Neither reserves credits from
// the source balance here.
return 0
case .platformToShielded:
// Governed by the SDK's account/address-aware shield preflight.
Expand Down Expand Up @@ -1033,15 +1061,18 @@ final class InternalTransferViewModel: ObservableObject {
maxNotice = Self.coreHeldBackMessage(coreBalanceDuffs - sourceDuffs)
}
case .coreToPlatform:
// Fee-aware max: spendable minus the send fee reserve (mirrors
// DSAccount.maxOutputAmount), never the raw total — the asset-lock
// spends core UTXOs and still needs room for the L1 fee.
sourceDuffs = coreSpendableDuffs
if sourceDuffs == 0 {
// Fee-on-top Max: the lock is amount + headroom, so the largest
// topup is the L1-fee-aware spendable minus the headroom.
let headroomDuffs = CoreToPlatformAmountPolicy.topUpHeadroomDuffs
sourceDuffs = coreSpendableDuffs > headroomDuffs
? coreSpendableDuffs - headroomDuffs : 0
if coreSpendableDuffs == 0 {
maxNotice = Self.coreZeroMaxMessage(
totalDuffs: coreBalanceDuffs,
confirmedSpendableDuffs: SwiftDashSDKWalletState.shared.balance?.spendable ?? 0)
} else if sourceDuffs < coreBalanceDuffs {
} else if sourceDuffs == 0 {
maxNotice = Self.feeReserveExceedsBalanceMessage(route.source)
} else {
// The balance card shows the total, so a Max that lands below
// it reads as a bug unless the held-back part is accounted for.
maxNotice = Self.coreHeldBackMessage(coreBalanceDuffs - sourceDuffs)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -999,10 +999,27 @@ final class ShieldedTransferCoordinator: ObservableObject {
/// (IS/CL-locked) before the funding ST lands. On failure after the lock
/// committed, `lastAssetLockOutPoint` is captured so "Try again" resumes
/// that lock via `resumeFundPlatform` instead of stranding it.
func performFundPlatform(amountDuffs: UInt64) async {
///
/// Fee-on-top: the funding ST's metered fee is deducted from the locked
/// value (the remainder recipient absorbs `lock − fee`), so the lock is
/// inflated by `CoreToPlatformAmountPolicy.topUpHeadroomDuffs` here —
/// the Platform balance receives at least `recipientAmountDuffs`, plus
/// whatever the fee leaves of the headroom.
func performFundPlatform(recipientAmountDuffs: UInt64) async {
guard beginTransfer() else { return }
lastAssetLockOutPoint = nil
Self.logger.info("🛡️ SHIELD-TX :: core→platform fund route amount=\(amountDuffs)")

// The single fee-on-top point, mirroring `performAssetLock`. Fails
// closed — a zero amount or overflow must never submit an un-inflated
// lock (which could not cover its own processing cost).
guard recipientAmountDuffs > 0,
let lockValueDuffs = CoreToPlatformAmountPolicy.lockValueDuffs(
forAmountDuffs: recipientAmountDuffs)
else {
handleFailure(CoordinatorError.shieldedPoolFeeUnavailable)
return
}
Self.logger.info("🛡️ SHIELD-TX :: core→platform fund route recipient=\(recipientAmountDuffs) lock=\(lockValueDuffs)")

let env: BasicEnvironment
do {
Expand All @@ -1028,7 +1045,7 @@ final class ShieldedTransferCoordinator: ObservableObject {
fundingType: Self.addressAssetLockFundingType)

do {
try await PlatformAddressSyncCoordinator.shared.fundFromCore(amountDuffs: amountDuffs)
try await PlatformAddressSyncCoordinator.shared.fundFromCore(amountDuffs: lockValueDuffs)
} catch {
stopAssetLockPolling()
captureLatestAssetLockOutPoint(
Expand Down
22 changes: 22 additions & 0 deletions DashWalletTests/SwiftDashSDKCoreLifecycleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,28 @@ final class SwiftDashSDKCoreLifecycleTests: XCTestCase {
poolFeeCredits: 212_851_200))
}

func testCoreToPlatformHeadroomMatchesTheCreditBaseCost() {
// One source of truth: the duff headroom is the credit-denominated
// processing base cost, whole-duff exact (50k duffs ↔ 50M credits).
XCTAssertEqual(CoreToPlatformAmountPolicy.topUpHeadroomDuffs, 50_000)
XCTAssertEqual(
CoreToPlatformAmountPolicy.topUpHeadroomDuffs * 1000,
CoreToShieldedAmountPolicy.assetLockBaseCostCredits)
}

func testCoreToPlatformLockValueIsAmountPlusHeadroom() {
// Fee-on-top: the lock delivers at least the typed amount to the
// Platform balance; the ST fee comes out of the headroom.
XCTAssertEqual(
CoreToPlatformAmountPolicy.lockValueDuffs(forAmountDuffs: 5_000_000),
5_050_000)
}

func testCoreToPlatformLockValueFailsClosedOnOverflow() {
XCTAssertNil(
CoreToPlatformAmountPolicy.lockValueDuffs(forAmountDuffs: UInt64.max))
}

func testShieldedSweepChoosesPrefixWithLargestNetPayout() {
let fees: [Int: UInt64] = [2: 100, 3: 150]

Expand Down
Loading