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 @@ -87,6 +87,13 @@ struct IdentityDetailView: View {
/// owns recipient + amount selection and signs via the Keychain.
@State private var showingTransferCredits = false

/// Drives presentation of `WithdrawCreditsView`. Tapped from the
/// "Withdraw Credits" button below "Transfer Credits" — the flow
/// owns destination-address + amount entry and signs via the
/// Keychain. The L1 payout is processed asynchronously by the
/// network.
@State private var showingWithdrawCredits = false

var body: some View {
if let identity = identity {
List {
Expand Down Expand Up @@ -183,6 +190,24 @@ struct IdentityDetailView: View {
}
}
.buttonStyle(.plain)

// Withdraw credits to an L1 Dash address. Same
// gating as Top Up / Transfer: on-chain identity
// backed by a loaded wallet so the signer can derive
// the state-transition key. The L1 payout is
// processed asynchronously by the network.
Button {
showingWithdrawCredits = true
} label: {
HStack {
Label("Withdraw Credits", systemImage: "arrow.up.circle")
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
.font(.caption)
}
}
.buttonStyle(.plain)
}

HStack {
Expand Down Expand Up @@ -462,6 +487,10 @@ struct IdentityDetailView: View {
TransferCreditsView(identity: identity)
.environmentObject(walletManager)
}
.sheet(isPresented: $showingWithdrawCredits) {
WithdrawCreditsView(identity: identity)
.environmentObject(walletManager)
}
.onAppear {
print("🔵 IdentityDetailView onAppear - dpnsName: \(identity.dpnsName ?? "nil"), isLocal: \(identity.isLocal)")

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,346 @@
// WithdrawCreditsView.swift
// SwiftExampleApp
//
// Withdraw Platform credits from an identity to an L1 Dash address.
// The source is fixed to the `identity` this flow opened from; the
// destination is a network-aware L1 Dash address the user types in.
// Structurally mirrors `TransferCreditsView` (DASH-amount entry,
// `creditsPerDash` divisor, NavigationStack + Cancel toolbar, submit
// guard + `submitGeneration`, target + success sections) — the only
// material difference is a destination-address `TextField` in place of
// the recipient identity picker.
//
// All orchestration lives in Rust: this view only parses the amount,
// validates it against the source's cached balance, lightly sanity-
// checks the address (non-empty), then hands a fresh `KeychainSigner`
// to `ManagedPlatformWallet.withdrawCredits`. Authoritative Base58 +
// network validation happens in the FFI/Rust layer — any rejection is
// surfaced in the error UI. On success the Rust persister callback
// deducts this identity's `PersistentIdentity.balance`, so the parent
// view's `@Query` refreshes the displayed balance automatically — this
// view returns nothing from the SDK call.
//
// On the L1 side there is NO immediate transaction id: Platform
// withdrawals are pooled and processed asynchronously by the network,
// and the withdraw FFI path returns only the void / new balance. The
// success screen therefore shows the destination address + amount plus
// a note that the L1 payout is processed asynchronously, rather than a
// (non-existent) txid.

import SwiftUI
import SwiftDashSDK
import SwiftData

struct WithdrawCreditsView: View {
/// Identity the credits are withdrawn from. The owning wallet (and
/// thus the signer + the FFI handle) is derived from `identity.wallet`.
let identity: PersistentIdentity

@EnvironmentObject var walletManager: PlatformWalletManager
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss

/// Credits per DASH (1e11) — same divisor `TransferCreditsView` and
/// `PersistentIdentity.formattedBalance` use for credit amounts.
private static let creditsPerDash: UInt64 = 100_000_000_000

// MARK: - Selection state

@State private var toAddress: String = ""
@State private var amountDash: String = ""

// MARK: - Submit state

@State private var isSubmitting = false
@State private var submitError: SubmitError?
@State private var didComplete = false
/// Generation counter so a late `MainActor.run` from a previous
/// `submit()` Task can't write back to a re-entered view instance
/// after the user pops + repushes mid-broadcast. Mirrors
/// `TransferCreditsView.submitGeneration`.
@State private var submitGeneration = 0

private struct SubmitError: Identifiable {
let id = UUID()
let message: String
}

var body: some View {
NavigationStack {
Form {
if didComplete {
successSection
} else {
targetSection
addressSection
amountSection
submitSection
}
}
.navigationTitle("Withdraw Credits")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { dismiss() }
.disabled(isSubmitting)
}
}
.alert(item: $submitError) { err in
Alert(
title: Text("Withdrawal failed"),
message: Text(err.message),
dismissButton: .default(Text("OK"))
)
}
}
// Block swipe-to-dismiss while the withdrawal is in flight — the
// Cancel button is already disabled, but interactive dismissal
// would otherwise let the user drop the sheet mid-broadcast,
// reopen it, and fire a second withdrawal on this write path.
.interactiveDismissDisabled(isSubmitting)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// MARK: - Sections

private var targetSection: some View {
Section {
HStack {
Label("Identity", systemImage: "person.text.rectangle")
Spacer()
Text(identity.displayName)
.lineLimit(1)
.truncationMode(.middle)
.foregroundColor(.secondary)
}
HStack {
Label("Current Balance", systemImage: "dollarsign.circle")
Spacer()
Text(identity.formattedBalance)
.foregroundColor(.blue)
.fontWeight(.medium)
}
} header: {
Text("From")
}
}

@ViewBuilder
private var addressSection: some View {
Section {
if managedWallet != nil {
TextField("Dash address", text: $toAddress)
.textFieldStyle(.roundedBorder)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.disabled(isSubmitting)
} else {
Text("The wallet that owns this identity isn't loaded.")
.font(.subheadline)
.foregroundColor(.red)
}
} header: {
Text("Destination L1 Address")
} footer: {
Text("Enter a Dash (Layer 1) address to receive the withdrawn funds. The address is validated against this wallet's network when you submit.")
}
}

private var amountSection: some View {
Section {
HStack {
TextField("Amount", text: $amountDash)
.keyboardType(.decimalPad)
.textFieldStyle(.roundedBorder)
.disabled(isSubmitting)
Text("DASH")
.foregroundColor(.secondary)
}
if let credits = parsedCredits, credits > senderBalanceCredits {
Text("Amount exceeds your balance.")
.font(.caption)
.foregroundColor(.red)
}
} header: {
Text("Amount")
} footer: {
Text("Available: \(identity.formattedBalance). The amount entered here is deducted from this identity's credit balance and paid out to the destination address on Layer 1.")
}
}

private var submitSection: some View {
Section {
Button {
submit()
} label: {
HStack {
if isSubmitting {
ProgressView()
.controlSize(.small)
.tint(.white)
Text("Withdrawing…")
} else {
Text("Withdraw Credits")
}
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(!canSubmit || isSubmitting)
}
}

private var successSection: some View {
Section {
VStack(alignment: .leading, spacing: 8) {
Label("Withdrawal submitted", systemImage: "checkmark.seal.fill")
.foregroundColor(.green)
.font(.headline)
if let credits = parsedCredits {
HStack {
Text("Withdrawn:")
.foregroundColor(.secondary)
Text(Self.formatDash(raw: credits))
.fontWeight(.medium)
.monospacedDigit()
}
}
HStack(alignment: .top) {
Text("To:")
.foregroundColor(.secondary)
Text(trimmedAddress)
.lineLimit(2)
.truncationMode(.middle)
.monospaced()
}
// Platform withdrawals are pooled and broadcast to L1 by
// the network asynchronously, so there is no immediate
// L1 transaction id to show here. The credit-balance
// decrease reflects automatically via the
// persister → SwiftData → @Query path.
Text("The Layer 1 payout is processed asynchronously by the network and may take a while to appear on-chain.")
.font(.caption)
.foregroundColor(.secondary)
Button {
dismiss()
} label: {
Text("Done")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.padding(.top, 4)
}
}
}

// MARK: - Derived state

private var managedWallet: ManagedPlatformWallet? {
guard let walletId = identity.wallet?.walletId else { return nil }
return walletManager.wallet(for: walletId)
}

/// Destination address with surrounding whitespace removed. The
/// authoritative Base58 + network check is performed in Rust.
private var trimmedAddress: String {
toAddress.trimmingCharacters(in: .whitespacesAndNewlines)
}

/// Parse the user's DASH input and scale it to credits. Mirrors
/// `TransferCreditsView.parsedCredits`: require a finite, positive
/// value, round to the nearest credit, and reject any result below
/// 1 credit or beyond `UInt64.max`.
private var parsedCredits: UInt64? {
let trimmed = amountDash.trimmingCharacters(in: .whitespaces)
guard let dash = Double(trimmed), dash.isFinite, dash > 0 else {
return nil
}
let credits = (dash * Double(Self.creditsPerDash)).rounded()
guard credits >= 1, credits <= Double(UInt64.max) else { return nil }
return UInt64(credits)
Comment thread
QuantumExplorer marked this conversation as resolved.
Comment on lines +258 to +259

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.

🔴 Blocking: [Carried forward — STILL VALID] parsedCredits traps on amounts at the UInt64 boundary

Verified at current head (c4ce64c, lines 258–259). Double(UInt64.max) is not exactly representable in IEEE-754 binary64 and rounds up to 2^64 (18446744073709551616.0). The guard credits <= Double(UInt64.max) therefore admits a value equal to 2^64, which then traps in UInt64(credits) on line 259. Because SwiftUI recomputes parsedCredits on every keystroke (it is read from amountSection during render), a single typed character around the boundary crashes the withdrawal sheet before any submit-time backend validation can run. Reaching the bucket requires ~1.84×10^8 DASH, which is unrealistic on mainnet but trivially reachable on regtest/devnet and via paste/UI automation — and the failure mode is a hard process abort on a funds-moving screen. The fix is to make the upper bound strict so values that round to 2^64 are rejected. The same pattern lives in TransferCreditsView; that file is unchanged by this PR and is captured as an out-of-scope follow-up.

Suggested change
guard credits >= 1, credits <= Double(UInt64.max) else { return nil }
return UInt64(credits)
let credits = (dash * Double(Self.creditsPerDash)).rounded()
guard credits >= 1, credits < Double(UInt64.max) else { return nil }
return UInt64(credits)

source: ['claude', 'codex']

}

/// Source balance in credits. `PersistentIdentity.balance` is an
/// `Int64`; clamp any (unexpected) negative value to zero.
private var senderBalanceCredits: UInt64 {
identity.balance < 0 ? 0 : UInt64(identity.balance)
}

private var canSubmit: Bool {
!trimmedAddress.isEmpty
&& managedWallet != nil
&& (parsedCredits.map { $0 > 0 && $0 <= senderBalanceCredits } ?? false)
}
Comment thread
QuantumExplorer marked this conversation as resolved.
Comment on lines +268 to +272

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: [Carried forward — STILL VALID] Gate canSubmit on DashAddress.parse for local Core-address pre-validation

Verified at current head (c4ce64c, lines 268–272). canSubmit requires only !trimmedAddress.isEmpty, so the Withdraw button enables for malformed strings, wrong-network addresses, and fragments. Authoritative Base58Check + network validation does happen in Rust at submit time (and the addressSection footer advertises this), so this is not a funds-safety gap — it is a UX/defense-in-depth issue: users currently pay the round trip to discover an obviously-bad destination. The app already exposes DashAddress.parse(_:network:) (used throughout SendViewModel, SendTransactionView, and QRScannerView), which routes Core addresses through the Rust FFI for Base58Check and network validation. Requiring a parsed .core address matching identity.network in canSubmit (and the submit() pre-check) would catch the common cases locally while keeping the FFI submit path authoritative. This mirrors the precedent already established in the Core send flow.

Suggested change
private var canSubmit: Bool {
!trimmedAddress.isEmpty
&& managedWallet != nil
&& (parsedCredits.map { $0 > 0 && $0 <= senderBalanceCredits } ?? false)
}
private var isValidDestinationAddress: Bool {
if case .core = DashAddress.parse(trimmedAddress, network: identity.network).type {
return true
}
return false
}
private var canSubmit: Bool {
isValidDestinationAddress
&& managedWallet != nil
&& (parsedCredits.map { $0 > 0 && $0 <= senderBalanceCredits } ?? false)
}

source: ['claude', 'codex']


// MARK: - Submit

private func submit() {
// Re-validate at submit time — the balance could have moved
// (a concurrent sync) and the address/amount could have been
// cleared between render and tap. Same shape as
// `TransferCreditsView.submit`.
let address = trimmedAddress
guard
let wallet = managedWallet,
!address.isEmpty,
let credits = parsedCredits,
credits > 0,
credits <= senderBalanceCredits
else {
submitError = .init(message: "Amount or address is invalid, or the amount exceeds your balance.")
return
}

isSubmitting = true
submitGeneration &+= 1
let gen = submitGeneration
// Fresh `KeychainSigner` per submit pass, same as
// `TransferCreditsView`: the address signer trampoline derives
// the identity-state-transition signing key on demand — no
// bytes leave Rust.
let signer = KeychainSigner(modelContainer: modelContext.container)
// `Identifier` is a typealias for `Data`, so the 32-byte
// `identityId` value passes straight through with no conversion.
let fromId = identity.identityId

Task {
do {
try await wallet.withdrawCredits(
identityId: fromId,
amount: credits,
toAddress: address,
signer: signer
)
await MainActor.run {
guard self.submitGeneration == gen else { return }
self.isSubmitting = false
// No new balance is returned — the Rust persister
// callback deducts this identity's
// `PersistentIdentity.balance`, which the parent
// view's @Query reflects automatically.
self.didComplete = true
}
} catch {
await MainActor.run {
guard self.submitGeneration == gen else { return }
self.submitError = .init(message: error.localizedDescription)
self.isSubmitting = false
}
}
}
}

// MARK: - Helpers

/// Format a raw credit amount as a `… DASH` string. Mirrors
/// `TransferCreditsView.formatDash`.
private static func formatDash(raw: UInt64) -> String {
let dash = Double(raw) / Double(creditsPerDash)
let fmt = NumberFormatter()
fmt.minimumFractionDigits = 0
fmt.maximumFractionDigits = 8
fmt.numberStyle = .decimal
fmt.groupingSeparator = ","
fmt.decimalSeparator = "."
return (fmt.string(from: NSNumber(value: dash)) ?? String(format: "%.8f", dash)) + " DASH"
}
}
Loading
Loading