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 @@ -82,6 +82,11 @@ struct IdentityDetailView: View {
/// itself owns wallet / account / amount selection.
@State private var showingTopUp = false

/// Drives presentation of `TransferCreditsView`. Tapped from the
/// "Transfer Credits" button next to "Top Up Balance" — the flow
/// owns recipient + amount selection and signs via the Keychain.
@State private var showingTransferCredits = false

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

// Credit-to-credit transfer to another identity.
// Same gating as Top Up: on-chain identity backed
// by a loaded wallet so the signer can derive the
// state-transition key.
Button {
showingTransferCredits = true
} label: {
HStack {
Label("Transfer Credits", systemImage: "arrow.left.arrow.right.circle")
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
.font(.caption)
}
}
.buttonStyle(.plain)
}

HStack {
Expand Down Expand Up @@ -436,6 +458,10 @@ struct IdentityDetailView: View {
TopUpIdentityView(identity: identity)
.environmentObject(walletManager)
}
.sheet(isPresented: $showingTransferCredits) {
TransferCreditsView(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,315 @@
// TransferCreditsView.swift
// SwiftExampleApp
//
// Credit-to-credit transfer between two Platform identities. The
// sender is fixed to the `identity` this flow opened from; the
// recipient is chosen via the shared `RecipientPickerView` (Local /
// Paste / DPNS). Structurally mirrors `TokenTransferActionView`
// (recipient picker + submit guard + `managedWallet` derivation) and
// `TopUpIdentityView` (DASH-amount entry, `creditsPerDash` divisor,
// NavigationStack + Cancel toolbar, target + success sections).
//
// All orchestration lives in Rust: this view only parses the amount
// and validates it against the sender's cached balance, then hands a
// fresh `KeychainSigner` to `ManagedPlatformWallet.transferCredits`.
// On success the Rust persister callback deducts the sender's
// `PersistentIdentity.balance`, so the parent view's `@Query`
// refreshes the displayed balance automatically — this view returns
// nothing from the SDK call.

import SwiftUI
import SwiftDashSDK
import SwiftData

struct TransferCreditsView: View {
/// Identity sending the credits. 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 `TopUpIdentityView` and
/// `PersistentIdentity.formattedBalance` use for credit amounts.
private static let creditsPerDash: UInt64 = 100_000_000_000

// MARK: - Selection state

@State private var recipient: RecipientSelection?
@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
/// `TokenTransferActionView.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
recipientSection
amountSection
submitSection
}
}
.navigationTitle("Transfer Credits")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { dismiss() }
.disabled(isSubmitting)
}
}
.alert(item: $submitError) { err in
Alert(
title: Text("Transfer failed"),
message: Text(err.message),
dismissButton: .default(Text("OK"))
)
}
}
}

// 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 recipientSection: some View {
Section("Recipient") {
if let wallet = managedWallet {
RecipientPickerView(
selection: $recipient,
wallet: wallet,
network: identity.network,
exclude: identity.identityId
)
} else {
Text("The wallet that owns this identity isn't loaded.")
.font(.subheadline)
.foregroundColor(.red)
}
}
}

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 added to the recipient's.")
}
}

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

private var successSection: some View {
Section {
VStack(alignment: .leading, spacing: 8) {
Label("Transfer complete", systemImage: "checkmark.seal.fill")
.foregroundColor(.green)
.font(.headline)
if let credits = parsedCredits {
HStack {
Text("Transferred:")
.foregroundColor(.secondary)
Text(Self.formatDash(raw: credits))
.fontWeight(.medium)
.monospacedDigit()
}
}
if let recipient {
HStack {
Text("To:")
.foregroundColor(.secondary)
Text(recipient.label)
.lineLimit(1)
.truncationMode(.middle)
}
}
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)
}

/// Parse the user's DASH input and scale it to credits. Mirrors
/// `TopUpIdentityView.parsedAmountCredits`: 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)
}

/// Sender's 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 {
recipient != nil
&& 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 recipient/amount could have been
// cleared between render and tap. Same shape as
// `TokenTransferActionView.submit`.
guard
let wallet = managedWallet,
let recipient = recipient,
let credits = parsedCredits,
credits > 0,
credits <= senderBalanceCredits
else {
submitError = .init(message: "Amount is invalid or exceeds your balance.")
return
}

isSubmitting = true
submitGeneration &+= 1
let gen = submitGeneration
// Fresh `KeychainSigner` per submit pass, same as
// `TokenTransferActionView` / `RegisterNameView`: 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` values pass straight through with no conversion.
let fromId = identity.identityId
let toId = recipient.identityId

Task {
do {
try await wallet.transferCredits(
fromIdentityId: fromId,
toIdentityId: toId,
amount: credits,
signer: signer
)
await MainActor.run {
guard self.submitGeneration == gen else { return }
self.isSubmitting = false
// No new balance is returned — the Rust persister
// callback deducts the sender'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
/// `TopUpIdentityView.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"
}
}
6 changes: 3 additions & 3 deletions packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,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 large set of Platform write transitions (identity credit transfer/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.
> **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*).

---

Expand Down Expand Up @@ -142,7 +142,7 @@ The app is a full multi-wallet client: `PlatformWalletManager` holds N wallets c
| ID-01 | Create identity (Core-funded asset lock) | Cross | Essential | ✅ | `CreateIdentityView` / `IdentityRegistrationController` → `platform_wallet_register_identity_with_signer`. New identity + credit balance appear. *Gateway to all Platform tests.* |
| ID-02 | Load / discover identity from wallet | Platform | Essential | ✅ | `LoadIdentityView` / `SearchWalletsForIdentitiesView` → `platform_wallet_discover_identities`. |
| ID-03 | View identity (info / balance / revision / keys) | Platform | Essential | ✅ | `IdentityDetailView`, `KeysListView`, `KeyDetailView`. |
| ID-04 | Transfer credits identity → identity | Platform | Essential | 🧪 | *SettingsPlatform State Transitions → Identity Credit Transfer* → `dash_sdk_identity_transfer_credits`. *Anchor: the "platform-to-platform" Essential action.* Recipient balance increases. |
| ID-04 | Transfer credits identity → identity | Platform | Essential | | `IdentityDetailView`**Transfer Credits** (sheet, `TransferCreditsView`) → `wallet.transferCredits` → `platform_wallet_transfer_credits_with_signer` (keychain-signed). Recipient entered via `RecipientPickerView` (local identity / paste base58 id / DPNS name). *Anchor: the "platform-to-platform" Essential action.* Recipient balance increases; sender's drops. (Also reachable via the *Settings → Platform State Transitions → Identity Credit Transfer* builder → `dash_sdk_identity_transfer_credits`.) |
| ID-05 | Top up identity (asset lock) | Cross | Common | ✅ | `TopUpIdentityView` (sheet from `IdentityDetailView`). *Anchor: top-up = Common.* |
| ID-06 | Top up identity (from Platform addresses) | Cross | Common | ✅ | `AddressQueriesView` → TopUpIdentityFromAddresses → `dash_sdk_identity_top_up_from_addresses`. |
| ID-07 | Update identity — add public key | Platform | Common | ✅ | `AddIdentityKeyView` (from `KeysListView`) → `updateIdentity(addPublicKeys:)`. |
Expand Down Expand Up @@ -294,7 +294,7 @@ Together with the wallet-lifecycle rows in §4.1 (`CORE-14..23`), these form the

| ID | Action | Layer | Tier | Status | Entry point & test notes |
|---|---|---|---|---|---|
| MW-01 | Credit transfer between two on-device identities (A → B) | Platform | Thorough | 🧪 | *SettingsPlatform State Transitions → Identity Credit Transfer* (`ID-04`), recipient = wallet B's identity. Switch to B; verify its credit balance rose and A's dropped. Fully local round-trip. |
| MW-01 | Credit transfer between two on-device identities (A → B) | Platform | Thorough | | `IdentityDetailView`**Transfer Credits** (`ID-04`), recipient = wallet B's identity (via `RecipientPickerView` — local / paste id / DPNS). Switch to B; verify its credit balance rose and A's dropped. Fully local round-trip. |
| MW-02 | Token transfer between two on-device identities | Platform | Thorough | ✅ | `TOK-02`, recipient = wallet B's identity. Switch to B; verify the token balance arrived. |
| MW-03 | DashPay request → accept → payment, both endpoints on device | Platform | Thorough | ✅ | A's identity sends a contact request (`DP-01`) to B's; switch to wallet B's identity and accept (`DP-02`); then pay (`DP-03`). Full bidirectional loop entirely local. |
| MW-04 | Document transfer / purchase across wallets | Platform | Uncommon | 🧪 | A creates + lists a document (`DOC-02`/`DOC-06`); B transfers/purchases it (`DOC-05`/`DOC-07`). Ownership and credits move between A and B. |
Expand Down
Loading