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 @@ -5,6 +5,7 @@ import SwiftDashSDK
/// Available send flow types based on source and destination.
enum SendFlow: Equatable {
case coreToCore // Standard L1 payment
case coreToShielded // Shield Core L1 funds to a shielded recipient
case platformToPlatform // Platform-address → platform-address transfer
case platformToShielded // Shield credits
case shieldedToShielded // Private transfer
Expand All @@ -14,6 +15,7 @@ enum SendFlow: Equatable {
var displayName: String {
switch self {
case .coreToCore: return "Core Payment"
case .coreToShielded: return "Shield from Core"
case .platformToPlatform: return "Platform Transfer"
case .platformToShielded: return "Shield Credits"
case .shieldedToShielded: return "Shielded Transfer"
Expand All @@ -25,6 +27,7 @@ enum SendFlow: Equatable {
var iconName: String {
switch self {
case .coreToCore: return "arrow.right"
case .coreToShielded: return "lock.shield"
case .platformToPlatform: return "arrow.right"
case .platformToShielded: return "lock.shield"
case .shieldedToShielded: return "arrow.left.arrow.right"
Expand All @@ -41,6 +44,7 @@ enum SendFlow: Equatable {
var estimatedFee: UInt64 {
switch self {
case .coreToCore: return 500_000 // ~0.005 DASH
case .coreToShielded: return 200_000
case .platformToPlatform: return 100_000_000 // ~0.001 DASH in credits
case .platformToShielded: return 200_000
case .shieldedToShielded: return 300_000
Expand Down Expand Up @@ -125,6 +129,13 @@ class SendViewModel: ObservableObject {
/// (Core uses duffs; Platform / shielded use credits).
var amountDuffs: UInt64? { amount }

/// Minimum L1 lock size (in duffs) for a `coreToShielded` send.
/// Mirrors `ShieldedFundFromAssetLockView.minDuffs` (1 mDASH): an
/// asset lock smaller than the Platform pool fee would build a lock
/// and a ~30s Halo 2 proof only to be rejected at submission, so we
/// gate it up front rather than burn the work.
static let minShieldFromCoreDuffs: UInt64 = 100_000

/// Maximum UTF-8 byte length of a shielded memo (the 32-byte
/// payload of the 36-byte `DashMemo`; the other 4 bytes are the
/// kind tag). Mirrors `dpp::shielded::MEMO_PAYLOAD_SIZE`.
Expand Down Expand Up @@ -157,6 +168,11 @@ class SendViewModel: ObservableObject {
switch flow {
case .coreToCore:
return (amountDuffs ?? 0) > 0
case .coreToShielded:
// Funded by an L1 asset lock denominated in duffs; gate on
// the lock floor so a doomed (sub-fee) amount can't kick off
// the lock-build + proof pipeline.
return (amountDuffs ?? 0) >= Self.minShieldFromCoreDuffs
Comment on lines +171 to +175

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: canSend / availableSources don't gate on the single funding account that executeSend will use

Two related mismatches against execution reality:

  1. canSend for .coreToShielded only checks amountDuffs >= minShieldFromCoreDuffs. But executeSend (lines 567–573) funds from a single account — the largest confirmed balance among BIP44 standard accounts (typeTag == 0 && standardTag == 0). A user whose Core funds are split across multiple BIP44 accounts can pass canSend, kick off the ~30s lock-build + Halo 2 proof pipeline, and only fail at the asset-lock submission step with 'No spendable Core account to fund the shield' or a backend error — exactly the late-failure scenario the minShieldFromCoreDuffs floor exists to prevent. The dedicated ShieldedFundFromAssetLockView already gates submit on selectedCoreAccountBalanceDuffs >= amount for this reason.

  2. availableSources (and coreBalanceSnapshot in SendTransactionView, which sums every entry from accountBalances) makes .core selectable for Orchard recipients whenever any account has balance, including non-BIP44 / non-standard accounts that the execute-time filter excludes. A wallet whose Core funds live entirely in other typeTag/standardTag combinations would see the Core source available and then fail with 'No spendable Core account to fund the shield' after the user commits.

Fix: expose the chosen funding account's confirmed balance to the view model and gate canSend on amountDuffs <= funding.confirmed; mirror the same BIP44-standard predicate in coreBalanceSnapshot/availableSources so what the UI offers matches what execute time can actually spend, or at minimum do a synchronous pre-check at the top of the .coreToShielded case that fails fast before the async asset-lock pipeline starts.

source: ['claude', 'codex']

case .platformToPlatform, .platformToShielded,
.shieldedToShielded, .shieldedToPlatform, .shieldedToCore:
return (amountCredits ?? 0) > 0
Expand All @@ -177,6 +193,10 @@ class SendViewModel: ObservableObject {
case .orchard:
if shieldedBalance > 0 { sources.append(.shielded) }
if platformBalance > 0 { sources.append(.platform) }
// Core L1 → shielded recipient via a Type 18
// ShieldFromAssetLock. Appended last so the private
// shielded → shielded path stays the auto-selected default.
if coreBalance > 0 { sources.append(.core) }
case .platform:
if platformBalance > 0 { sources.append(.platform) }
if shieldedBalance > 0 { sources.append(.shielded) }
Expand Down Expand Up @@ -212,6 +232,8 @@ class SendViewModel: ObservableObject {
detectedFlow = .shieldedToShielded
case (.orchard, .platform):
detectedFlow = .platformToShielded
case (.orchard, .core):
detectedFlow = .coreToShielded
case (.platform, .platform):
detectedFlow = .platformToPlatform
case (.platform, .shielded):
Expand All @@ -237,7 +259,11 @@ class SendViewModel: ObservableObject {
private func estimateFee(for flow: SendFlow) -> UInt64 {
let kind: PlatformWalletManager.ShieldedFeeKind?
switch flow {
case .shieldedToShielded, .platformToShielded:
case .shieldedToShielded, .platformToShielded, .coreToShielded:
// Type 18 ShieldFromAssetLock carves the same
// `compute_minimum_shielded_fee` base as the Shield/transfer
// path; its fee is denominated in credits (deducted from the
// locked value), so the view renders it with the credits unit.
kind = .transfer
Comment on lines +262 to 267

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Core-to-shielded fee estimate omits asset_lock_base_cost

Mapping .coreToShielded to ShieldedFeeKind.transfer dispatches the FFI to compute_minimum_shielded_fee only. The actual Type 18 pool fee that Rust carves (rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs:478) and drive-abci enforces is compute_minimum_shielded_fee(num_actions) + asset_lock_base_cost. The inline comment claiming Type 18 'carves the same compute_minimum_shielded_fee base' is misleading — it carves base + asset_lock_base_cost. The Send screen therefore displays a fee lower than the network will deduct from the lock value, and the implied recipient net (lock_value − pool_fee) is off by the same amount. The fix likely requires a new ShieldedFeeKind variant on the FFI side (e.g. .shieldFromAssetLock) since the existing three kinds don't include the asset-lock base cost; until that exists, at minimum drop the misleading comment and flag the displayed fee as a base-only estimate so users aren't led to expect the recipient to receive amount − base_fee.

source: ['codex']

case .shieldedToPlatform:
kind = .unshield
Expand Down Expand Up @@ -497,6 +523,64 @@ class SendViewModel: ObservableObject {
addressSigner: signer
)
successMessage = "Shielding complete"

case .coreToShielded:
// Core L1 → Shielded recipient (Type 18
// ShieldFromAssetLock): lock L1 duffs, then the
// network mints the locked value (minus the pool fee)
// as an Orchard note for the recipient. The typed
// amount is the **L1 lock size in duffs** — the funds
// leaving the wallet — and the recipient receives
// `lock_value − pool_fee` in credits. We mirror
// ShieldedFundFromAssetLockView's "lock size, not net
// amount" convention rather than grossing up the fee:
// Type 18's Orchard value_balance is baked into the
// Halo 2 proof at build time and can't be re-derived
// afterward.
//
// Note: this awaits the whole asset-lock pipeline
// (build lock → wait IS-lock/ChainLock → ~30s proof →
// submit ST), so the "Sending…" overlay can sit for a
// minute+. The dedicated ShieldedFundFromAssetLockView
// exposes the staged progress UI; this is the simple
// inline path. It bypasses the shield coordinator, but
// the Rust `shield_guard` mutex still serializes
// shield-class ops per wallet, so concurrent attempts
// block rather than corrupt.
guard let amountDuffs else {
error = "Invalid amount"
return
}
let trimmed = recipientAddress
.trimmingCharacters(in: .whitespacesAndNewlines)
let parsed = DashAddress.parse(trimmed, network: network)
guard case .orchard(let recipientRaw) = parsed.type else {
error = "Recipient is not a shielded address"
return
}
// Asset locks draw UTXOs from a SINGLE Core account, so
// fund from the standard BIP44 account (typeTag/standardTag
// 0) with the largest confirmed balance. The screen's
// "Core" total sums across accounts, so an amount under
// the displayed total can still fail here when the balance
// is split across several accounts.
let funding = walletManager.accountBalances(for: wallet.walletId)
.filter { $0.typeTag == 0 && $0.standardTag == 0 }
.max(by: { $0.confirmed < $1.confirmed })
guard let funding, funding.confirmed > 0 else {
error = "No spendable Core account to fund the shield"
return
}
try await walletManager.shieldedFundFromAssetLock(
walletId: wallet.walletId,
fundingAccountIndex: funding.index,
amountDuffs: amountDuffs,
recipients: [
ShieldedFundFromAssetLockRecipient(recipientRaw43: recipientRaw)
]
)
successMessage = "Shield submitted — the note arrives on "
+ "the recipient's next shielded sync."
}

} catch PlatformWalletError.shieldedSpendUnconfirmed {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ struct SendTransactionView: View {
HStack {
Text("Estimated Fee:")
Spacer()
Text("~\(formatBalance(fee, unit: unit(for: viewModel.selectedSource)))")
Text("~\(formatBalance(fee, unit: feeUnit(for: flow)))")
.foregroundColor(.secondary)
}
}
Expand Down Expand Up @@ -355,6 +355,7 @@ struct SendTransactionView: View {
private func flowColor(for flow: SendFlow) -> Color {
switch flow {
case .coreToCore: return .green
case .coreToShielded: return .purple
case .platformToPlatform: return .blue
case .platformToShielded: return .purple
case .shieldedToShielded: return .purple
Expand Down Expand Up @@ -388,6 +389,21 @@ struct SendTransactionView: View {
case .platform, .shielded: return .credits
}
}

/// Settlement unit of a flow's *fee*, which can differ from the
/// selected source's balance unit. `coreToShielded` spends Core
/// duffs but its Type 18 pool fee is denominated in Platform
/// credits, so the fee row must use credits even though the Core
/// source row uses duffs. Behaviour-preserving for every other
/// flow (their fee unit already matches their source unit).
private func feeUnit(for flow: SendFlow) -> SendBalanceUnit {
switch flow {
case .coreToCore: return .duffs
case .coreToShielded, .platformToPlatform, .platformToShielded,
.shieldedToShielded, .shieldedToPlatform, .shieldedToCore:
return .credits
}
}
}

// MARK: - Subviews
Expand Down
Loading