diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift index c3476cf2e8f..88fca30a07e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift @@ -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 { @@ -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 { @@ -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)") diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawCreditsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawCreditsView.swift new file mode 100644 index 00000000000..0a13beebfc3 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/WithdrawCreditsView.swift @@ -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) + } + + // 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) + } + + /// 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) + } + + // 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" + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md index ecbc47e63cc..144c4be8ec7 100644 --- a/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md +++ b/packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md @@ -96,7 +96,7 @@ Most Platform actions have hard preconditions. Establish these fixtures before s | πŸ”Œ | FFI and/or Swift wrapper exists, but **no UI** to trigger it. | No (SDK only) | | 🚫 | Not implemented anywhere (no FFI, no UI). | No | -> **Entry-point reality check.** A set of Platform write transitions (identity credit withdrawal, document create/replace/delete/transfer/price/purchase, data-contract create/update, identity key-disable) are reachable in the app **only through `Settings β†’ Platform State Transitions` β†’ `TransitionDetailView`** (marked πŸ§ͺ). They broadcast for real, but there is no per-identity "happy path" button for them. The QA agent must navigate to the builder for those rows. (Identity credit *transfer*, `ID-04`, now has a production button in `IdentityDetailView` β€” see that row.) The builder and the read-only **Platform Queries** catalog both live under the **Settings** tab's **Platform** section (scroll past *Network* and *Data*). +> **Entry-point reality check.** A set of Platform write transitions (document create/replace/delete/transfer/price/purchase, data-contract create/update, identity key-disable) are reachable in the app **only through `Settings β†’ Platform State Transitions` β†’ `TransitionDetailView`** (marked πŸ§ͺ). They broadcast for real, but there is no per-identity "happy path" button for them. The QA agent must navigate to the builder for those rows. (Identity credit *transfer*, `ID-04`, and *withdrawal*, `ID-10`, now have production buttons in `IdentityDetailView` β€” see those rows.) The builder and the read-only **Platform Queries** catalog both live under the **Settings** tab's **Platform** section (scroll past *Network* and *Data*). --- @@ -150,7 +150,7 @@ The app is a full multi-wallet client: `PlatformWalletManager` holds N wallets c | ID-07 | Update identity β€” add public key | Platform | Common | βœ… | `AddIdentityKeyView` (from `KeysListView`) β†’ `updateIdentity(addPublicKeys:)`. | | ID-08 | Create identity (from Platform addresses) | Cross | Common | βœ… | `AddressQueriesView` β†’ CreateIdentityFromAddresses β†’ `dash_sdk_identity_create_from_addresses`. | | ID-09 | Set / edit local alias | Platform | Common | βœ… | `IdentityDetailView` (Add Alias). Local only β€” persists across relaunch; no broadcast. | -| ID-10 | Withdraw credits β†’ Dash L1 address | Cross | Common | πŸ§ͺ | *Settings builder β†’ Identity Credit Withdrawal* β†’ `dash_sdk_identity_withdraw`. Credits burned; L1 payout observed. | +| ID-10 | Withdraw credits β†’ Dash L1 address | Cross | Common | βœ… | `IdentityDetailView` β†’ **Withdraw Credits** (sheet, `WithdrawCreditsView`) β†’ `wallet.withdrawCredits` β†’ `platform_wallet_withdraw_credits_with_signer` (keychain-signed). Destination L1 address typed in + validated against the wallet's network; amount validated against balance. Identity credit balance drops by amount + fee; L1 payout is pooled and processed asynchronously by the network (no immediate txid). Requires the identity to have a TRANSFER/CRITICAL key β€” newly-derived identities get one (keyId 3); older identities may need one added first via `ID-07`. (Also reachable via the *Settings β†’ Platform State Transitions β†’ Identity Credit Withdrawal* builder β†’ `dash_sdk_identity_withdraw` with a test signer.) | | ID-11 | Transfer credits β†’ Platform addresses | Platform | Common | βœ… | `AddressQueriesView` β†’ TransferIdentityToAddresses β†’ `dash_sdk_identity_transfer_credits_to_addresses`. | | ID-12 | Update identity β€” disable key | Platform | Thorough | πŸ§ͺ | *Settings builder β†’ Identity Update* (disable path) β†’ `executeIdentityUpdate`. | | ID-13 | Top up identity (builder path) | Cross | β€” | πŸ§ͺ | Builder entry is a stub (`notImplemented`). Use `ID-05`/`ID-06`. Listed so QA doesn't mistake the stub for a defect. |