From a80f9d2c4f20591de2aa43743fb5021243194325 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 4 May 2026 13:01:11 +0200 Subject: [PATCH 01/19] fix(swift-sdk): correct token balance and decimal-amount handling on Transfer/Burn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Transfer/Burn screens read balance from `identity.tokenBalances` (SwiftData), which isn't reliably populated by the time the user opens the screen — so users with non-zero balances were seeing "Balance 0" and "Amount exceeds your balance." Forward the value the parent already fetched via `sdk.getIdentityTokenBalances` and use it as the source of truth, falling back to the persisted row when not provided. The Amount field also treated user input as raw u64 units while the balance is shown decimal-scaled, so typing "5" against a 4.44 balance would silently pass as 5 raw units (0.00000005 tokens). Parse input as a decimal in display units, scale to raw units by `token.decimals`, and accept both "." and "," as separators. Switch the keyboard to `.decimalPad` and add the missing balance guard to Transfer's submit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Views/IdentityDetailView.swift | 3 +- .../Views/TokenActionPermissionsView.swift | 33 +++++++- .../TokenActions/TokenBurnActionView.swift | 64 +++++++++++++++- .../TokenTransferActionView.swift | 76 +++++++++++++++++-- 4 files changed, 164 insertions(+), 12 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift index 1c9b614e3ed..9f1418a703f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift @@ -291,7 +291,8 @@ struct IdentityDetailView: View { NavigationLink( destination: TokenActionPermissionsView( token: entry.token, - identity: identity + identity: identity, + initialBalance: entry.balance ) ) { IdentityTokenRow(entry: entry) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift index 3d148937e79..bd4d6fb4d2f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift @@ -632,12 +632,22 @@ struct TokenActionPermissionsView: View { /// so the user can pick one to evaluate against. @State private var pickedIdentity: PersistentIdentity? private let initialIdentity: PersistentIdentity? + /// Forwarded to balance-gated action views (Transfer, Burn) so they + /// don't have to wait on a `PersistentTokenBalance` row that may + /// not exist yet. Only meaningful when paired with a pinned + /// `identity`; ignored when the caller lets the user pick. + private let initialBalance: UInt64? @Query private var localIdentities: [PersistentIdentity] - init(token: PersistentToken, identity: PersistentIdentity? = nil) { + init( + token: PersistentToken, + identity: PersistentIdentity? = nil, + initialBalance: UInt64? = nil + ) { self.token = token self.initialIdentity = identity + self.initialBalance = initialBalance self._pickedIdentity = State(initialValue: identity) // Filter to wallet-owned identities on the same network as // this token's parent contract; falls back to "any @@ -825,11 +835,28 @@ struct TokenActionPermissionsView: View { _ row: ResolvedTokenAction, identity: PersistentIdentity ) -> some View { + // The cached `initialBalance` was fetched for `initialIdentity`; + // if the user picked a different identity, fall back to the + // per-view PersistentTokenBalance lookup. + let forwardedBalance: UInt64? = { + guard let pinned = initialIdentity, + pinned.identityId == identity.identityId + else { return nil } + return initialBalance + }() switch row.kind { case .transfer: - TokenTransferActionView(token: token, identity: identity) + TokenTransferActionView( + token: token, + identity: identity, + initialBalance: forwardedBalance + ) case .burn: - TokenBurnActionView(token: token, identity: identity) + TokenBurnActionView( + token: token, + identity: identity, + initialBalance: forwardedBalance + ) case .mint: TokenMintActionView(token: token, identity: identity) case .claim: diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenBurnActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenBurnActionView.swift index d8c70adffb6..c5fb10a67d2 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenBurnActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenBurnActionView.swift @@ -12,6 +12,13 @@ import SwiftDashSDK struct TokenBurnActionView: View { let token: PersistentToken let identity: PersistentIdentity + /// Balance the parent screen already fetched for this token via + /// `sdk.getIdentityTokenBalances`. When non-nil, it's the source of + /// truth; when nil, we fall back to the `PersistentTokenBalance` + /// row, which may not be populated yet. Declared as `var` (not + /// `let`) so the synthesized memberwise init exposes it with the + /// default of `nil`. + var initialBalance: UInt64? = nil @EnvironmentObject var walletManager: PlatformWalletManager @Environment(\.modelContext) private var modelContext @@ -62,7 +69,7 @@ struct TokenBurnActionView: View { Section("Amount") { TextField("Amount", text: $amountText) - .keyboardType(.numberPad) + .keyboardType(.decimalPad) if let amountValue = parsedAmount, amountValue > balanceValue { Text("Amount exceeds your balance.") .font(.caption) @@ -113,11 +120,15 @@ struct TokenBurnActionView: View { } private var balanceValue: UInt64 { + if let initialBalance { return initialBalance } guard let balance = matchingBalance else { return 0 } return balance.balance < 0 ? 0 : UInt64(balance.balance) } private var balanceDisplay: String { + if let initialBalance { + return formatTokenAmount(initialBalance, decimals: token.decimals) + } guard let balance = matchingBalance else { return "0" } return balance.displayBalance } @@ -129,9 +140,10 @@ struct TokenBurnActionView: View { } } + /// See `parseTokenAmount` — input is in display units, scaled to + /// raw on-chain units by `token.decimals` before validation. private var parsedAmount: UInt64? { - let trimmed = amountText.trimmingCharacters(in: .whitespacesAndNewlines) - return UInt64(trimmed) + parseTokenAmount(amountText, decimals: token.decimals) } private var canSubmit: Bool { @@ -232,3 +244,49 @@ struct TokenBurnActionView: View { } } } + +/// Parse a user-entered amount in display units (e.g. "5" or "4.25" +/// for a token with 8 decimals) into raw on-chain units. Accepts both +/// "." and "," as the decimal separator so users in either locale can +/// type naturally. Returns nil for empty / unparseable / negative / +/// out-of-range input. +/// +/// Excess fractional digits beyond `decimals` are truncated (rounded +/// down) rather than rounded — silently rounding *up* would let the +/// user submit slightly more than they typed, which would surprise on +/// a balance edge. +fileprivate func parseTokenAmount(_ text: String, decimals: Int) -> UInt64? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let normalized = trimmed.replacingOccurrences(of: ",", with: ".") + guard let entered = Decimal(string: normalized), entered >= 0 else { + return nil + } + + let dec = max(0, decimals) + let multiplier = pow(Decimal(10), dec) + var scaled = entered * multiplier + var rounded = Decimal() + NSDecimalRound(&rounded, &scaled, 0, .down) + + if rounded < 0 || rounded > Decimal(UInt64.max) { return nil } + return (rounded as NSDecimalNumber).uint64Value +} + +/// Format a raw u64 token amount with the given decimals using +/// `Decimal` (not `Double`) so high-decimal tokens with large balances +/// don't lose precision. Mirrors `IdentityTokenRow.formattedBalance` +/// in `IdentityDetailView`. +fileprivate func formatTokenAmount(_ raw: UInt64, decimals: Int) -> String { + let dec = max(0, decimals) + let rawDecimal = Decimal(raw) + let divisor = pow(Decimal(10), dec) + let scaled = divisor == 0 ? rawDecimal : (rawDecimal / divisor) + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = dec + formatter.minimumFractionDigits = 0 + formatter.usesGroupingSeparator = true + return formatter.string(from: scaled as NSNumber) ?? "\(raw)" +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenTransferActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenTransferActionView.swift index 4e0fe9f0cad..15998981788 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenTransferActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenTransferActionView.swift @@ -14,6 +14,13 @@ import SwiftDashSDK struct TokenTransferActionView: View { let token: PersistentToken let identity: PersistentIdentity + /// Balance the parent screen already fetched for this token via + /// `sdk.getIdentityTokenBalances`. When non-nil, it's the source of + /// truth; when nil, we fall back to the `PersistentTokenBalance` + /// row, which may not be populated yet (see file header). Declared + /// as `var` (not `let`) so the synthesized memberwise init exposes + /// it with the default of `nil`. + var initialBalance: UInt64? = nil @EnvironmentObject var walletManager: PlatformWalletManager @Environment(\.modelContext) private var modelContext @@ -54,7 +61,7 @@ struct TokenTransferActionView: View { Section("Amount") { TextField("Amount", text: $amountText) - .keyboardType(.numberPad) + .keyboardType(.decimalPad) if let amountValue = parsedAmount, amountValue > balanceValue { Text("Amount exceeds your balance.") .font(.caption) @@ -104,6 +111,7 @@ struct TokenTransferActionView: View { } private var balanceValue: UInt64 { + if let initialBalance { return initialBalance } guard let balance = matchingBalance else { return 0 } // PersistentTokenBalance stores Int64; we treat it as // a UInt64 here (token amounts are non-negative on Platform). @@ -111,6 +119,9 @@ struct TokenTransferActionView: View { } private var balanceDisplay: String { + if let initialBalance { + return formatTokenAmount(initialBalance, decimals: token.decimals) + } guard let balance = matchingBalance else { return "0" } return balance.displayBalance } @@ -122,9 +133,14 @@ struct TokenTransferActionView: View { } } + /// Parse the user's input as a decimal number in display units and + /// scale it to raw on-chain units using `token.decimals`. Without + /// this, typing "5" against a token with 8 decimals would submit + /// 5 raw units (0.00000005 of a token) and silently sneak past the + /// balance check, since the displayed balance is also in display + /// units. private var parsedAmount: UInt64? { - let trimmed = amountText.trimmingCharacters(in: .whitespacesAndNewlines) - return UInt64(trimmed) + parseTokenAmount(amountText, decimals: token.decimals) } private var canSubmit: Bool { @@ -138,13 +154,17 @@ struct TokenTransferActionView: View { // MARK: - Submit private func submit() { + // Re-check balance at submit time: the user could have spent + // the underlying tokens between render and tap. Mirror the + // shape used by `TokenBurnActionView.submit`. guard let wallet = managedWallet, let recipient = recipient, let amount = parsedAmount, - amount > 0 + amount > 0, + amount <= balanceValue else { - submitError = .init(message: "Selection is incomplete.") + submitError = .init(message: "Amount is invalid or exceeds your balance.") return } @@ -187,3 +207,49 @@ struct TokenTransferActionView: View { } } } + +/// Parse a user-entered amount in display units (e.g. "5" or "4.25" +/// for a token with 8 decimals) into raw on-chain units. Accepts both +/// "." and "," as the decimal separator so users in either locale can +/// type naturally. Returns nil for empty / unparseable / negative / +/// out-of-range input. +/// +/// Excess fractional digits beyond `decimals` are truncated (rounded +/// down) rather than rounded — silently rounding *up* would let the +/// user submit slightly more than they typed, which would surprise on +/// a balance edge. +fileprivate func parseTokenAmount(_ text: String, decimals: Int) -> UInt64? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let normalized = trimmed.replacingOccurrences(of: ",", with: ".") + guard let entered = Decimal(string: normalized), entered >= 0 else { + return nil + } + + let dec = max(0, decimals) + let multiplier = pow(Decimal(10), dec) + var scaled = entered * multiplier + var rounded = Decimal() + NSDecimalRound(&rounded, &scaled, 0, .down) + + if rounded < 0 || rounded > Decimal(UInt64.max) { return nil } + return (rounded as NSDecimalNumber).uint64Value +} + +/// Format a raw u64 token amount with the given decimals using +/// `Decimal` (not `Double`) so high-decimal tokens with large balances +/// don't lose precision. Mirrors `IdentityTokenRow.formattedBalance` +/// in `IdentityDetailView`. +fileprivate func formatTokenAmount(_ raw: UInt64, decimals: Int) -> String { + let dec = max(0, decimals) + let rawDecimal = Decimal(raw) + let divisor = pow(Decimal(10), dec) + let scaled = divisor == 0 ? rawDecimal : (rawDecimal / divisor) + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = dec + formatter.minimumFractionDigits = 0 + formatter.usesGroupingSeparator = true + return formatter.string(from: scaled as NSNumber) ?? "\(raw)" +} From 0ea3b4cd00b81c0b3a9d106be48c01937b16bc6f Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 4 May 2026 13:38:33 +0200 Subject: [PATCH 02/19] fix(swift-sdk): decimal-aware amounts on Mint/Purchase/UpdateMaxSupply, refresh balance on Permissions screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mint, Direct Purchase, and Update Max Supply forms were treating user input as raw u64 token units while their displays show decimal-scaled values, so typing "1" against an 8-decimal token would mint/buy 1 raw unit (0.00000001 of a token) and barely move the recipient's balance. Parse input as a decimal in display units and scale by `token.decimals` before calling the FFI; switch the keyboards to `.decimalPad`. Format Mint's "Max supply" and Update Max Supply's "Current max supply" in display units too, so the on-screen comparison reads in the same unit the user is about to type. Extract the parse/format helpers to a shared `TokenAmountFormatting` utility now that five action views need them. Also drive balance refresh from the Permissions screen so paths that don't pre-fetch — e.g. opening Transfer/Burn from the Contracts tab — populate the balance themselves via `sdk.getIdentityTokenBalances` when the resolved identity changes, instead of falling back to the stale `PersistentTokenBalance` row. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Utils/TokenAmountFormatting.swift | 58 ++++++++++++++++++ .../Views/TokenActionPermissionsView.swift | 59 +++++++++++++------ .../TokenActions/TokenBurnActionView.swift | 45 -------------- .../TokenActions/TokenMintActionView.swift | 15 +++-- .../TokenPurchaseActionView.swift | 6 +- .../TokenTransferActionView.swift | 45 -------------- .../TokenUpdateMaxSupplyActionView.swift | 18 ++++-- 7 files changed, 125 insertions(+), 121 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/TokenAmountFormatting.swift diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/TokenAmountFormatting.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/TokenAmountFormatting.swift new file mode 100644 index 00000000000..eb672e667f6 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/TokenAmountFormatting.swift @@ -0,0 +1,58 @@ +import Foundation + +/// Conversions between a token's *display unit* (what the user types +/// and reads — e.g. `4.44`) and its *raw on-chain unit* (what the FFI +/// expects — `UInt64`, scaled by `decimals`). Without these, a token +/// with 8 decimals and a balance of `444667781` raw units would render +/// as `4.44667781` while a typed amount of `5` would be sent through +/// as `5` raw units (`0.00000005`), silently sneaking past every +/// `amount <= balance` check. +/// +/// Both helpers use `Decimal` (not `Double`) so high-decimal tokens +/// with large balances don't lose precision. + +/// Parse a user-entered amount in display units into raw on-chain +/// units by multiplying by `10^decimals`. Accepts both "." and "," +/// as the decimal separator so users in either locale can type +/// naturally. Returns `nil` for empty / unparseable / negative / +/// out-of-`UInt64`-range input. +/// +/// Excess fractional digits beyond `decimals` are truncated (rounded +/// down). Silently rounding *up* would let the user submit slightly +/// more than they typed, which would surprise on a balance edge. +func parseTokenAmount(_ text: String, decimals: Int) -> UInt64? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let normalized = trimmed.replacingOccurrences(of: ",", with: ".") + guard let entered = Decimal(string: normalized), entered >= 0 else { + return nil + } + + let dec = max(0, decimals) + let multiplier = pow(Decimal(10), dec) + var scaled = entered * multiplier + var rounded = Decimal() + NSDecimalRound(&rounded, &scaled, 0, .down) + + if rounded < 0 || rounded > Decimal(UInt64.max) { return nil } + return (rounded as NSDecimalNumber).uint64Value +} + +/// Format a raw u64 token amount with the given decimals. Uses the +/// current locale's grouping/decimal separators (so European users +/// see `4,44667781` and US users see `4.44667781`). Mirrors the +/// formatter used by `IdentityTokenRow.formattedBalance` in +/// `IdentityDetailView`. +func formatTokenAmount(_ raw: UInt64, decimals: Int) -> String { + let dec = max(0, decimals) + let rawDecimal = Decimal(raw) + let divisor = pow(Decimal(10), dec) + let scaled = divisor == 0 ? rawDecimal : (rawDecimal / divisor) + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = dec + formatter.minimumFractionDigits = 0 + formatter.usesGroupingSeparator = true + return formatter.string(from: scaled as NSNumber) ?? "\(raw)" +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift index bd4d6fb4d2f..ef8424ae95f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift @@ -632,12 +632,16 @@ struct TokenActionPermissionsView: View { /// so the user can pick one to evaluate against. @State private var pickedIdentity: PersistentIdentity? private let initialIdentity: PersistentIdentity? - /// Forwarded to balance-gated action views (Transfer, Burn) so they - /// don't have to wait on a `PersistentTokenBalance` row that may - /// not exist yet. Only meaningful when paired with a pinned - /// `identity`; ignored when the caller lets the user pick. - private let initialBalance: UInt64? + /// Live balance for `(resolvedIdentity, token)`. Seeded from the + /// caller's `initialBalance` so the first paint isn't blank when + /// the parent screen already has the value, then refreshed via + /// `sdk.getIdentityTokenBalances` whenever the identity changes. + /// `PersistentTokenBalance` rows aren't reliably populated by the + /// time this screen opens, so we can't depend on them. + @State private var fetchedBalance: UInt64? + + @EnvironmentObject var appState: AppState @Query private var localIdentities: [PersistentIdentity] init( @@ -647,8 +651,8 @@ struct TokenActionPermissionsView: View { ) { self.token = token self.initialIdentity = identity - self.initialBalance = initialBalance self._pickedIdentity = State(initialValue: identity) + self._fetchedBalance = State(initialValue: initialBalance) // Filter to wallet-owned identities on the same network as // this token's parent contract; falls back to "any // wallet-owned" if the contract isn't loaded. @@ -777,6 +781,34 @@ struct TokenActionPermissionsView: View { pickedIdentity = localIdentities.first } } + // Refresh the balance for whichever identity is resolved. + // `.task(id:)` re-runs when the user switches identity via the + // picker, so the value forwarded into Transfer/Burn always + // matches the current selection. + .task(id: resolvedIdentity?.identityId) { + await refreshTokenBalance() + } + } + + private func refreshTokenBalance() async { + guard let identity = resolvedIdentity, let sdk = appState.sdk else { + return + } + let tokenIdString = token.id.toBase58String() + do { + let balances = try await sdk.getIdentityTokenBalances( + identityId: identity.identityIdBase58, + tokenIds: [tokenIdString] + ) + await MainActor.run { + // Default missing entries to 0 — the SDK omits tokens + // the identity has never held. + self.fetchedBalance = balances[tokenIdString] ?? 0 + } + } catch { + // Keep the seeded value; per-action views still fall back + // to the persisted row when this stays nil. + } } private var identityPickerBinding: Binding { @@ -835,27 +867,20 @@ struct TokenActionPermissionsView: View { _ row: ResolvedTokenAction, identity: PersistentIdentity ) -> some View { - // The cached `initialBalance` was fetched for `initialIdentity`; - // if the user picked a different identity, fall back to the - // per-view PersistentTokenBalance lookup. - let forwardedBalance: UInt64? = { - guard let pinned = initialIdentity, - pinned.identityId == identity.identityId - else { return nil } - return initialBalance - }() + // `fetchedBalance` is refreshed by `.task(id: resolvedIdentity)` + // so it's always for the identity the action will run as. switch row.kind { case .transfer: TokenTransferActionView( token: token, identity: identity, - initialBalance: forwardedBalance + initialBalance: fetchedBalance ) case .burn: TokenBurnActionView( token: token, identity: identity, - initialBalance: forwardedBalance + initialBalance: fetchedBalance ) case .mint: TokenMintActionView(token: token, identity: identity) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenBurnActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenBurnActionView.swift index c5fb10a67d2..59da4af74cf 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenBurnActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenBurnActionView.swift @@ -245,48 +245,3 @@ struct TokenBurnActionView: View { } } -/// Parse a user-entered amount in display units (e.g. "5" or "4.25" -/// for a token with 8 decimals) into raw on-chain units. Accepts both -/// "." and "," as the decimal separator so users in either locale can -/// type naturally. Returns nil for empty / unparseable / negative / -/// out-of-range input. -/// -/// Excess fractional digits beyond `decimals` are truncated (rounded -/// down) rather than rounded — silently rounding *up* would let the -/// user submit slightly more than they typed, which would surprise on -/// a balance edge. -fileprivate func parseTokenAmount(_ text: String, decimals: Int) -> UInt64? { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - - let normalized = trimmed.replacingOccurrences(of: ",", with: ".") - guard let entered = Decimal(string: normalized), entered >= 0 else { - return nil - } - - let dec = max(0, decimals) - let multiplier = pow(Decimal(10), dec) - var scaled = entered * multiplier - var rounded = Decimal() - NSDecimalRound(&rounded, &scaled, 0, .down) - - if rounded < 0 || rounded > Decimal(UInt64.max) { return nil } - return (rounded as NSDecimalNumber).uint64Value -} - -/// Format a raw u64 token amount with the given decimals using -/// `Decimal` (not `Double`) so high-decimal tokens with large balances -/// don't lose precision. Mirrors `IdentityTokenRow.formattedBalance` -/// in `IdentityDetailView`. -fileprivate func formatTokenAmount(_ raw: UInt64, decimals: Int) -> String { - let dec = max(0, decimals) - let rawDecimal = Decimal(raw) - let divisor = pow(Decimal(10), dec) - let scaled = divisor == 0 ? rawDecimal : (rawDecimal / divisor) - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = dec - formatter.minimumFractionDigits = 0 - formatter.usesGroupingSeparator = true - return formatter.string(from: scaled as NSNumber) ?? "\(raw)" -} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenMintActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenMintActionView.swift index e9f0522ff05..e778c63b0e5 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenMintActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenMintActionView.swift @@ -35,8 +35,11 @@ struct TokenMintActionView: View { Form { Section("Token") { LabeledContent("Token", value: token.displayName) - if let maxSupply = token.maxSupply { - LabeledContent("Max supply", value: maxSupply) + if let maxSupplyRaw = parsedMaxSupply { + LabeledContent( + "Max supply", + value: formatTokenAmount(maxSupplyRaw, decimals: token.decimals) + ) } } @@ -89,7 +92,7 @@ struct TokenMintActionView: View { Section("Amount") { TextField("Amount", text: $amountText) - .keyboardType(.numberPad) + .keyboardType(.decimalPad) if let amountValue = parsedAmount, let maxSupplyValue = parsedMaxSupply, amountValue > maxSupplyValue { @@ -146,11 +149,13 @@ struct TokenMintActionView: View { return walletManager.wallet(for: walletId) } + /// User input is in display units; scale to raw on-chain units so + /// the `amount > maxSupply` check (both raw u64) is meaningful. private var parsedAmount: UInt64? { - let trimmed = amountText.trimmingCharacters(in: .whitespacesAndNewlines) - return UInt64(trimmed) + parseTokenAmount(amountText, decimals: token.decimals) } + /// `token.maxSupply` is a string-encoded raw u64. private var parsedMaxSupply: UInt64? { guard let raw = token.maxSupply else { return nil } return UInt64(raw) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenPurchaseActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenPurchaseActionView.swift index c31ad21e9f8..15336d84eaf 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenPurchaseActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenPurchaseActionView.swift @@ -43,7 +43,7 @@ struct TokenPurchaseActionView: View { Section("Amount") { TextField("Tokens to buy", text: $amountText) - .keyboardType(.numberPad) + .keyboardType(.decimalPad) if let amount = parsedAmount, amount == 0 { Text("Amount must be greater than zero.") .font(.caption) @@ -98,9 +98,9 @@ struct TokenPurchaseActionView: View { return walletManager.wallet(for: walletId) } + /// Tokens-to-buy is in display units; the FFI takes raw u64. private var parsedAmount: UInt64? { - let trimmed = amountText.trimmingCharacters(in: .whitespacesAndNewlines) - return UInt64(trimmed) + parseTokenAmount(amountText, decimals: token.decimals) } /// `PersistentToken` doesn't yet carry a direct-purchase price diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenTransferActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenTransferActionView.swift index 15998981788..44426268dd5 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenTransferActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenTransferActionView.swift @@ -208,48 +208,3 @@ struct TokenTransferActionView: View { } } -/// Parse a user-entered amount in display units (e.g. "5" or "4.25" -/// for a token with 8 decimals) into raw on-chain units. Accepts both -/// "." and "," as the decimal separator so users in either locale can -/// type naturally. Returns nil for empty / unparseable / negative / -/// out-of-range input. -/// -/// Excess fractional digits beyond `decimals` are truncated (rounded -/// down) rather than rounded — silently rounding *up* would let the -/// user submit slightly more than they typed, which would surprise on -/// a balance edge. -fileprivate func parseTokenAmount(_ text: String, decimals: Int) -> UInt64? { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - - let normalized = trimmed.replacingOccurrences(of: ",", with: ".") - guard let entered = Decimal(string: normalized), entered >= 0 else { - return nil - } - - let dec = max(0, decimals) - let multiplier = pow(Decimal(10), dec) - var scaled = entered * multiplier - var rounded = Decimal() - NSDecimalRound(&rounded, &scaled, 0, .down) - - if rounded < 0 || rounded > Decimal(UInt64.max) { return nil } - return (rounded as NSDecimalNumber).uint64Value -} - -/// Format a raw u64 token amount with the given decimals using -/// `Decimal` (not `Double`) so high-decimal tokens with large balances -/// don't lose precision. Mirrors `IdentityTokenRow.formattedBalance` -/// in `IdentityDetailView`. -fileprivate func formatTokenAmount(_ raw: UInt64, decimals: Int) -> String { - let dec = max(0, decimals) - let rawDecimal = Decimal(raw) - let divisor = pow(Decimal(10), dec) - let scaled = divisor == 0 ? rawDecimal : (rawDecimal / divisor) - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = dec - formatter.minimumFractionDigits = 0 - formatter.usesGroupingSeparator = true - return formatter.string(from: scaled as NSNumber) ?? "\(raw)" -} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenUpdateMaxSupplyActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenUpdateMaxSupplyActionView.swift index b4b4c9076c6..22b9081044a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenUpdateMaxSupplyActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenUpdateMaxSupplyActionView.swift @@ -71,7 +71,7 @@ struct TokenUpdateMaxSupplyActionView: View { Section("New max supply") { Toggle("Remove cap", isOn: $removeCap) TextField("Amount", text: $newMaxSupplyText) - .keyboardType(.numberPad) + .keyboardType(.decimalPad) .disabled(removeCap) .foregroundColor(removeCap ? .secondary : .primary) if !removeCap, let parsed = parsedNewMaxSupply, parsed == 0 { @@ -122,15 +122,21 @@ struct TokenUpdateMaxSupplyActionView: View { return walletManager.wallet(for: walletId) } - /// Display the current max supply as it's stored on `PersistentToken`. - /// `maxSupply` is a string-encoded u64; missing means "uncapped". + /// Display the current max supply scaled to display units, so the + /// number the user sees here is in the same unit they're about to + /// type in the input below. `token.maxSupply` is a string-encoded + /// raw u64; missing means "uncapped". private var currentMaxSupplyDisplay: String { - token.maxSupply ?? "Uncapped" + guard let raw = token.maxSupply, let value = UInt64(raw) else { + return "Uncapped" + } + return formatTokenAmount(value, decimals: token.decimals) } + /// User input is in display units; scale to raw on-chain units for + /// the FFI / config-change payload. private var parsedNewMaxSupply: UInt64? { - let trimmed = newMaxSupplyText.trimmingCharacters(in: .whitespacesAndNewlines) - return UInt64(trimmed) + parseTokenAmount(newMaxSupplyText, decimals: token.decimals) } private var canSubmit: Bool { From 87a58861092f5c07515142e69e8e06b1e1e4ccc8 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 4 May 2026 13:59:10 +0200 Subject: [PATCH 03/19] fix(swift-sdk): use canonical token id when fetching balance from Permissions screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PersistentToken.id` is a SwiftData uniqueness key (`contractId + position.bigEndian`), not the on-chain canonical token id the SDK's balance lookup expects. Passing that key meant the result map was always empty, so `fetchedBalance` defaulted to 0 on paths that didn't pre-seed `initialBalance` (e.g. opening Transfer from the Contracts tab). Derive the canonical id via `sdk.calculateTokenId(contractId:position:)` — same shape `IdentityDetailView` already uses — before calling `getIdentityTokenBalances`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Views/TokenActionPermissionsView.swift | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift index ef8424ae95f..b308d36b056 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift @@ -794,16 +794,26 @@ struct TokenActionPermissionsView: View { guard let identity = resolvedIdentity, let sdk = appState.sdk else { return } - let tokenIdString = token.id.toBase58String() + // `PersistentToken.id` is a SwiftData uniqueness key + // (`contractId + position.bigEndian`) — *not* the on-chain + // canonical token id. The SDK's balance lookup is keyed by + // the canonical id, so derive it via `calculateTokenId` here + // (same shape `IdentityDetailView` uses). + guard let position = UInt16(exactly: token.position) else { return } + let contractIdString = token.contractId.toBase58String() do { + let canonicalTokenId = try sdk.calculateTokenId( + contractId: contractIdString, + position: position + ) let balances = try await sdk.getIdentityTokenBalances( identityId: identity.identityIdBase58, - tokenIds: [tokenIdString] + tokenIds: [canonicalTokenId] ) await MainActor.run { // Default missing entries to 0 — the SDK omits tokens // the identity has never held. - self.fetchedBalance = balances[tokenIdString] ?? 0 + self.fetchedBalance = balances[canonicalTokenId] ?? 0 } } catch { // Keep the seeded value; per-action views still fall back From 23831df407e7f88057669a12d9e771daaf1336ab Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 4 May 2026 14:19:07 +0200 Subject: [PATCH 04/19] fix(swift-sdk): tighten token-amount parser, scope balance to current identity, drop dead matcher arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `parseTokenAmount`: `Decimal(string:)` accepts a valid prefix and drops the rest, so `"5abc"` parsed as 5 and a pasted `"1,234.56"` (after `,`→`.` normalization → `"1.234.56"`) parsed as 1.234, letting the user submit a materially different raw amount than they typed. Validate the normalized string against a strict `\d+(\.\d+)?` (or `\.\d+`) grammar before scaling. - `TokenActionPermissionsView.refreshTokenBalance`: clear `fetchedBalance` to nil on entry and capture the identity at start; only commit the result if `resolvedIdentity` still matches. Without this, switching identity via the picker would leave the previous identity's balance forwarded into Transfer/Burn until the new fetch completed (or forever if it failed silently). - Transfer/Burn `matchingBalance`: drop the always-false `tb.tokenId == token.id.toBase58String()` arm — `tb.tokenId` is the canonical on-chain token id while `token.id` is a SwiftData uniqueness key (`contractId + position`). Keep the relationship-based comparison, which works when SwiftData has linked the rows. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Utils/TokenAmountFormatting.swift | 30 +++++++++++++++++++ .../Views/TokenActionPermissionsView.swift | 16 ++++++++-- .../TokenActions/TokenBurnActionView.swift | 10 ++++--- .../TokenTransferActionView.swift | 10 ++++--- 4 files changed, 56 insertions(+), 10 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/TokenAmountFormatting.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/TokenAmountFormatting.swift index eb672e667f6..fe2fe22d5d2 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/TokenAmountFormatting.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/TokenAmountFormatting.swift @@ -17,6 +17,15 @@ import Foundation /// naturally. Returns `nil` for empty / unparseable / negative / /// out-of-`UInt64`-range input. /// +/// The grammar is deliberately strict: digits, optionally followed by +/// a single separator and more digits — *no* thousands separators, no +/// trailing junk, no scientific notation. `Decimal(string:)` on its +/// own happily accepts a valid prefix and silently drops the rest, so +/// `"5abc"` would parse as `5` and a pasted `"1,234.56"` (after the +/// `,` → `.` normalization) would become `"1.234.56"` and parse as +/// `1.234`. Either of those would let the user submit a materially +/// different raw amount than what they think they typed. +/// /// Excess fractional digits beyond `decimals` are truncated (rounded /// down). Silently rounding *up* would let the user submit slightly /// more than they typed, which would surprise on a balance edge. @@ -25,6 +34,11 @@ func parseTokenAmount(_ text: String, decimals: Int) -> UInt64? { guard !trimmed.isEmpty else { return nil } let normalized = trimmed.replacingOccurrences(of: ",", with: ".") + + // Strict grammar: `\d+(\.\d+)?` or `\.\d+` (a leading-dot like + // ".5" is what `.decimalPad` actually emits in some locales). + guard isWellFormedDecimal(normalized) else { return nil } + guard let entered = Decimal(string: normalized), entered >= 0 else { return nil } @@ -39,6 +53,22 @@ func parseTokenAmount(_ text: String, decimals: Int) -> UInt64? { return (rounded as NSDecimalNumber).uint64Value } +private func isWellFormedDecimal(_ s: String) -> Bool { + var sawDigit = false + var sawDot = false + for ch in s { + if ch.isASCII, let ascii = ch.asciiValue, ascii >= 0x30 && ascii <= 0x39 { + sawDigit = true + } else if ch == "." { + if sawDot { return false } + sawDot = true + } else { + return false + } + } + return sawDigit +} + /// Format a raw u64 token amount with the given decimals. Uses the /// current locale's grouping/decimal separators (so European users /// see `4,44667781` and US users see `4.44667781`). Mirrors the diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift index b308d36b056..5b739858f1f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift @@ -791,9 +791,16 @@ struct TokenActionPermissionsView: View { } private func refreshTokenBalance() async { + // Drop any stale value first so a slow / failed lookup can't + // forward the *previous* identity's balance into Transfer or + // Burn while the new fetch is in flight. + await MainActor.run { self.fetchedBalance = nil } + guard let identity = resolvedIdentity, let sdk = appState.sdk else { return } + let identityIdAtStart = identity.identityId + // `PersistentToken.id` is a SwiftData uniqueness key // (`contractId + position.bigEndian`) — *not* the on-chain // canonical token id. The SDK's balance lookup is keyed by @@ -811,13 +818,18 @@ struct TokenActionPermissionsView: View { tokenIds: [canonicalTokenId] ) await MainActor.run { + // The user may have switched identity while we were + // awaiting; only commit if the resolved identity still + // matches the one this fetch was for. + guard self.resolvedIdentity?.identityId == identityIdAtStart + else { return } // Default missing entries to 0 — the SDK omits tokens // the identity has never held. self.fetchedBalance = balances[canonicalTokenId] ?? 0 } } catch { - // Keep the seeded value; per-action views still fall back - // to the persisted row when this stays nil. + // `fetchedBalance` already cleared above; per-action views + // fall back to the persisted row when this stays nil. } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenBurnActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenBurnActionView.swift index 59da4af74cf..fdac6c706ae 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenBurnActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenBurnActionView.swift @@ -133,11 +133,13 @@ struct TokenBurnActionView: View { return balance.displayBalance } + /// Match against the SwiftData relationship key. The earlier + /// `tb.tokenId == token.id.toBase58String()` arm of this matcher + /// was always false: `tb.tokenId` holds the canonical on-chain + /// token id while `token.id` is a `contractId + position` SwiftData + /// uniqueness key. private var matchingBalance: PersistentTokenBalance? { - identity.tokenBalances.first { tb in - tb.tokenId == token.id.toBase58String() - || tb.token?.id == token.id - } + identity.tokenBalances.first { $0.token?.id == token.id } } /// See `parseTokenAmount` — input is in display units, scaled to diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenTransferActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenTransferActionView.swift index 44426268dd5..f5e83a87804 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenTransferActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenTransferActionView.swift @@ -126,11 +126,13 @@ struct TokenTransferActionView: View { return balance.displayBalance } + /// Match against the SwiftData relationship key. The earlier + /// `tb.tokenId == token.id.toBase58String()` arm of this matcher + /// was always false: `tb.tokenId` holds the canonical on-chain + /// token id while `token.id` is a `contractId + position` SwiftData + /// uniqueness key. private var matchingBalance: PersistentTokenBalance? { - identity.tokenBalances.first { tb in - tb.tokenId == token.id.toBase58String() - || tb.token?.id == token.id - } + identity.tokenBalances.first { $0.token?.id == token.id } } /// Parse the user's input as a decimal number in display units and From d96d9d40e1f61b0a127d0601fd2af20d028eff64 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 5 May 2026 10:07:33 +0200 Subject: [PATCH 05/19] fix(swift-sdk): Mint-to-self toggle passes caller identity instead of nil MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Mint screen's "Mint to self" toggle was passing `recipient: nil` to the FFI, which Rust interprets as "use the contract's configured `newTokensDestinationIdentity`" — not "send to the caller." On a token where `mintingAllowChoosingDestination` is true and the configured destination is null (a perfectly valid mintable contract), every mint-to-self attempt failed with "Destination identity for minting not set." Branch on the contract's `mintingAllowChoosingDestination`: when the contract permits a caller-supplied destination, pass our own `identity.identityId` explicitly so "to self" actually means "to the caller." When the contract forbids overriding the destination, keep the existing `nil` passthrough so fixed-destination contracts continue to mint to their configured `newTokensDestinationIdentity`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Views/TokenActions/TokenMintActionView.swift | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenMintActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenMintActionView.swift index e778c63b0e5..4aa1c4b8b63 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenMintActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenMintActionView.swift @@ -205,7 +205,20 @@ struct TokenMintActionView: View { let recipientId: Data? if mintToSelf { - recipientId = nil + if token.mintingAllowChoosingDestination { + // The contract permits a caller-supplied destination, + // so be explicit: pass our own identity id. Passing nil + // would defer to `newTokensDestinationIdentity`, which + // may be unset — the FFI then surfaces "Destination + // identity for minting not set" instead of minting to + // self. + recipientId = identity.identityId + } else { + // The contract forbids overriding the destination; any + // non-nil recipient would be rejected. Defer to the + // configured `newTokensDestinationIdentity` via nil. + recipientId = nil + } } else { guard let chosen = recipient else { submitError = .init(message: "Pick a recipient or enable mint-to-self.") From c825556cc025eb6c5f02d84f258b93661e8821ac Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 5 May 2026 11:18:11 +0200 Subject: [PATCH 06/19] fix(swift-sdk): truth-in-UI pass on token action resolver, mint label, and feature badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four small correctness bugs in the contracts surface where the UI told the user "you can do this" when the contract or the app actually couldn't. Grouped because they share a single theme — the action resolver, the mint screen, and the token-detail badges should all agree on what's actually possible. - Mint resolver: deny when authorized but no destination is reachable. Previously, a token with mint authorization but `mintingAllowChoosingDestination == false` and `newTokensDestinationIdentity == nil` showed Mint as allowed; the user could fill in an amount and submit, only to be rejected by Platform with "Destination identity for minting not set." - DirectPurchase resolver: deny with a clear "price not available locally yet" reason. `TokenPurchaseActionView.priceKnown` is hard-coded `false` until the configured purchase price lands on `PersistentToken`, so the Buy button was permanently disabled and routing the user to that screen via an "allowed" row was dishonest. - "Mint to self" toggle label: relabel to "Use configured destination" when `mintingAllowChoosingDestination == false`. The toggle is force-on/disabled in that case, but tokens go to the contract's configured destination, not the caller — the literal "Mint to self" label contradicted the surrounding info banner. - "Can be X" badges in TokenDetailsView: route through a new `ChangeControlRules.hasAuthorizedTakers` helper that checks both rule existence AND that `authorizedToMakeChange != "NoOne"`. A rule shipped-but-locked-off (the canonical pattern for safety-by-default contract templates) now correctly shows as not available, matching what the actions screen already says ("Freeze: no one is authorized") for the same token. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Views/TokenActionPermissionsView.swift | 32 +++++++++++++++---- .../TokenActions/TokenMintActionView.swift | 15 +++++++-- .../Views/TokenDetailsView.swift | 30 +++++++++++++---- 3 files changed, 62 insertions(+), 15 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift index 5b739858f1f..27d1beb9df6 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift @@ -327,7 +327,23 @@ enum TokenActionResolver { contract: contract, token: token ) - let final = applyPausedGuard(perm, token: token, allowWhenPaused: false) + // Even when the caller is authorized, mint is impossible + // when the contract neither allows runtime destination + // choice nor pins a fixed `newTokensDestinationIdentity` — + // Platform rejects with "Destination identity for minting + // not set". Surface that here instead of routing the user + // to a screen whose submission is guaranteed to fail. + let final: TokenActionPermission = { + let pausedGuarded = applyPausedGuard(perm, token: token, allowWhenPaused: false) + if case .allowed = pausedGuarded, + !token.mintingAllowChoosingDestination, + token.newTokensDestinationIdentity == nil { + return .denied( + reason: "Mint: no destination configured (contract forbids choosing one and didn't pin a recipient)" + ) + } + return pausedGuarded + }() rows.append(ResolvedTokenAction(kind: .mint, permission: final)) } else { rows.append(ResolvedTokenAction( @@ -604,16 +620,18 @@ enum TokenActionResolver { if token.isPaused { return .denied(reason: "Token is paused") } - // PersistentToken doesn't yet model the current "for sale" - // price, so we can't reject up front when the token isn't - // actively listed. Defer that check to Platform — the - // purchase form sends a sentinel `expectedTotalCost` and the - // server returns a readable error if the schedule is unset. + // Wave 1: PersistentToken doesn't carry the configured purchase + // price. `TokenPurchaseActionView.priceKnown` is hard-coded + // `false` until that field lands, so the Buy button is *always* + // disabled — routing the user to a permanently-broken screen + // is dishonest. Deny here with the same reason the action view + // shows. When the price field lands on PersistentToken, this + // becomes a real conditional check; until then it short-circuits. // TODO: surface direct-purchase price on PersistentToken and // gate this row on it (and pre-fill the form's total cost). _ = identity _ = contract - return .allowed + return .denied(reason: "Direct-purchase price not available locally yet") } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenMintActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenMintActionView.swift index 4aa1c4b8b63..6aa9ed06a3d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenMintActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenMintActionView.swift @@ -72,8 +72,19 @@ struct TokenMintActionView: View { } Section("Recipient") { - Toggle("Mint to self", isOn: $mintToSelf) - .disabled(!token.mintingAllowChoosingDestination) + // Label tracks what the toggle actually does: + // - When the contract permits a runtime destination, + // "Mint to self" is honest (toggle off → pick recipient). + // - When it doesn't, the toggle is force-on/disabled and + // tokens go to the contract's `newTokensDestinationIdentity`, + // not the caller — the literal "Mint to self" lies. + Toggle( + token.mintingAllowChoosingDestination + ? "Mint to self" + : "Use configured destination", + isOn: $mintToSelf + ) + .disabled(!token.mintingAllowChoosingDestination) if !mintToSelf { if let wallet = managedWallet { RecipientPickerView( diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenDetailsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenDetailsView.swift index 56384800118..b7c4d0c8a95 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenDetailsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenDetailsView.swift @@ -160,13 +160,19 @@ struct TokenDetailsView: View { SectionHeader(title: "Token Features") VStack(alignment: .leading, spacing: 8) { - TokenFeatureRow(label: "Can be minted", isEnabled: token.manualMintingRules != nil) - TokenFeatureRow(label: "Can be burned", isEnabled: token.manualBurningRules != nil) - TokenFeatureRow(label: "Can be frozen", isEnabled: token.freezeRules != nil) - TokenFeatureRow(label: "Can be unfrozen", isEnabled: token.unfreezeRules != nil) - TokenFeatureRow(label: "Can destroy frozen funds", isEnabled: token.destroyFrozenFundsRules != nil) + TokenFeatureRow(label: "Can be minted", + isEnabled: token.manualMintingRules?.hasAuthorizedTakers ?? false) + TokenFeatureRow(label: "Can be burned", + isEnabled: token.manualBurningRules?.hasAuthorizedTakers ?? false) + TokenFeatureRow(label: "Can be frozen", + isEnabled: token.freezeRules?.hasAuthorizedTakers ?? false) + TokenFeatureRow(label: "Can be unfrozen", + isEnabled: token.unfreezeRules?.hasAuthorizedTakers ?? false) + TokenFeatureRow(label: "Can destroy frozen funds", + isEnabled: token.destroyFrozenFundsRules?.hasAuthorizedTakers ?? false) TokenFeatureRow(label: "Transfer to frozen allowed", isEnabled: token.allowTransferToFrozenBalance) - TokenFeatureRow(label: "Emergency action available", isEnabled: token.emergencyActionRules != nil) + TokenFeatureRow(label: "Emergency action available", + isEnabled: token.emergencyActionRules?.hasAuthorizedTakers ?? false) TokenFeatureRow(label: "Started as paused", isEnabled: token.isPaused) } } @@ -394,3 +400,15 @@ struct ControlRuleView: View { } } } + +fileprivate extension ChangeControlRules { + /// `true` when this rule exists *and* names someone allowed to act + /// on it. A rule with `authorizedToMakeChange == "NoOne"` is a + /// feature that's been intentionally shipped-but-locked — for + /// truth-in-UI purposes that should read as "not available," the + /// same way `TokenActionEvaluator` already denies the matching + /// row on the actions screen. + var hasAuthorizedTakers: Bool { + authorizedToMakeChange != AuthorizedActionTakers.noOne.rawValue + } +} From 69c4e8bcdd6d50eb9eb83cd86d72ee12b1da4101 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 5 May 2026 11:53:32 +0200 Subject: [PATCH 07/19] fix(swift-sdk): generation guards on token-action submit + balance fetch Tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related async-state safety bugs across the token-action surface, unified under one theme: no late writes to stale view state. - TokenActionPermissionsView.refreshTokenBalance: the previous identity-equality guard didn't cover A → B → A. An older A-fetch resolving after a fresher one would pass the equality check and overwrite the newer value with stale data. Replace with a per-view generation counter; only the latest in-flight fetch may commit. The previously silent `catch { }` now also prints a diagnostic breadcrumb so failed fetches are observable from the console instead of disappearing. - All 12 token action views + CoSignProposalView: each `submit()` / `dispatch()` Task wrote back to `@State` from a late `MainActor.run` with no cancellation or generation guard. If the user popped the view and re-pushed it before the broadcast resolved, the late callback wrote on a stale view instance, producing intermittent "Modifying state during view update" warnings and misdirected `dismiss()` calls in nested-modal flows. Same per-view generation pattern across every site: increment in submit, capture locally, guard every late write. 14 files, near-identical 4-line additions. Pattern is inlined rather than abstracted because each submit's parameter capture, validation, and error wording diverges enough that a shared helper would obscure more than it deduplicates. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Views/TokenActionPermissionsView.swift | 28 +++++++++++++------ .../TokenActions/CoSignProposalView.swift | 8 ++++++ .../TokenActions/TokenBurnActionView.swift | 8 ++++++ .../TokenActions/TokenClaimActionView.swift | 8 ++++++ .../TokenDestroyFrozenFundsActionView.swift | 8 ++++++ .../TokenActions/TokenFreezeActionView.swift | 8 ++++++ .../TokenActions/TokenMintActionView.swift | 8 ++++++ .../TokenActions/TokenPauseActionView.swift | 8 ++++++ .../TokenPurchaseActionView.swift | 8 ++++++ .../TokenActions/TokenResumeActionView.swift | 8 ++++++ .../TokenSetPriceActionView.swift | 8 ++++++ .../TokenTransferActionView.swift | 8 ++++++ .../TokenUnfreezeActionView.swift | 8 ++++++ .../TokenUpdateMaxSupplyActionView.swift | 8 ++++++ 14 files changed, 124 insertions(+), 8 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift index 27d1beb9df6..5cfb95db5ba 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift @@ -658,6 +658,13 @@ struct TokenActionPermissionsView: View { /// `PersistentTokenBalance` rows aren't reliably populated by the /// time this screen opens, so we can't depend on them. @State private var fetchedBalance: UInt64? + /// Monotonic counter for in-flight balance fetches. The previous + /// "compare identityId at start vs at end" guard didn't cover the + /// A → B → A case (an older A-fetch passes the equality check and + /// overwrites a newer one). Capture this at the top of every + /// refresh and reject any late assignment whose generation no + /// longer matches. + @State private var balanceFetchGeneration: Int = 0 @EnvironmentObject var appState: AppState @Query private var localIdentities: [PersistentIdentity] @@ -809,6 +816,9 @@ struct TokenActionPermissionsView: View { } private func refreshTokenBalance() async { + balanceFetchGeneration &+= 1 + let gen = balanceFetchGeneration + // Drop any stale value first so a slow / failed lookup can't // forward the *previous* identity's balance into Transfer or // Burn while the new fetch is in flight. @@ -817,7 +827,6 @@ struct TokenActionPermissionsView: View { guard let identity = resolvedIdentity, let sdk = appState.sdk else { return } - let identityIdAtStart = identity.identityId // `PersistentToken.id` is a SwiftData uniqueness key // (`contractId + position.bigEndian`) — *not* the on-chain @@ -836,18 +845,21 @@ struct TokenActionPermissionsView: View { tokenIds: [canonicalTokenId] ) await MainActor.run { - // The user may have switched identity while we were - // awaiting; only commit if the resolved identity still - // matches the one this fetch was for. - guard self.resolvedIdentity?.identityId == identityIdAtStart - else { return } + // Generation guards against A → B → A: an older + // A-fetch resolving after a fresher one would + // otherwise pass an identity-equality check and + // overwrite the newer value with stale data. + guard self.balanceFetchGeneration == gen else { return } // Default missing entries to 0 — the SDK omits tokens // the identity has never held. self.fetchedBalance = balances[canonicalTokenId] ?? 0 } } catch { - // `fetchedBalance` already cleared above; per-action views - // fall back to the persisted row when this stays nil. + // Was previously a silent `catch { }`. Surface as a dev + // breadcrumb so failed fetches are at least observable + // from the console; per-action views still fall back to + // the persisted row when `fetchedBalance` stays nil. + print("⚠️ refreshTokenBalance failed for \(identity.identityIdBase58): \(error)") } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/CoSignProposalView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/CoSignProposalView.swift index 36a0442dabc..d98e647afda 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/CoSignProposalView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/CoSignProposalView.swift @@ -32,6 +32,10 @@ struct CoSignProposalView: View { @State private var loadError: String? @State private var isSubmitting: Bool = false @State private var submitMessage: AlertMessage? + /// Generation counter so a late `MainActor.run` from a previous + /// `dispatch()` Task can't write back to a re-entered view + /// instance after the user pops + repushes mid-broadcast. + @State private var submitGeneration: Int = 0 private struct AlertMessage: Identifiable { let id = UUID() @@ -335,6 +339,8 @@ struct CoSignProposalView: View { } isSubmitting = true + submitGeneration &+= 1 + let gen = submitGeneration let signer = KeychainSigner(modelContainer: modelContext.container) let mode = GroupActionMode.signExisting( position: UInt16(position), @@ -356,6 +362,7 @@ struct CoSignProposalView: View { tokenPosition: tokenPosition ) await MainActor.run { + guard self.submitGeneration == gen else { return } self.isSubmitting = false self.submitMessage = .init( title: "Signed", @@ -364,6 +371,7 @@ struct CoSignProposalView: View { } } catch { await MainActor.run { + guard self.submitGeneration == gen else { return } self.isSubmitting = false self.submitMessage = .init( title: "Co-sign failed", diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenBurnActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenBurnActionView.swift index fdac6c706ae..dd11465cdbd 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenBurnActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenBurnActionView.swift @@ -28,6 +28,10 @@ struct TokenBurnActionView: View { @State private var publicNote: String = "" @State private var isSubmitting: Bool = false @State private var submitError: AlertMessage? + /// 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. + @State private var submitGeneration: Int = 0 private struct AlertMessage: Identifiable { let id = UUID() @@ -216,6 +220,8 @@ struct TokenBurnActionView: View { } isSubmitting = true + submitGeneration &+= 1 + let gen = submitGeneration let signer = KeychainSigner(modelContainer: modelContext.container) let identityId = identity.identityId let contractId = token.contractId @@ -234,11 +240,13 @@ struct TokenBurnActionView: View { signer: signer ) await MainActor.run { + guard self.submitGeneration == gen else { return } self.isSubmitting = false self.dismiss() } } catch { await MainActor.run { + guard self.submitGeneration == gen else { return } self.submitError = .init(message: error.localizedDescription) self.isSubmitting = false } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenClaimActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenClaimActionView.swift index e481dbf17d0..83d13e7151b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenClaimActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenClaimActionView.swift @@ -22,6 +22,10 @@ struct TokenClaimActionView: View { @State private var publicNote: String = "" @State private var isSubmitting: Bool = false @State private var submitError: AlertMessage? + /// 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. + @State private var submitGeneration: Int = 0 private struct AlertMessage: Identifiable { let id = UUID() @@ -156,6 +160,8 @@ struct TokenClaimActionView: View { } isSubmitting = true + submitGeneration &+= 1 + let gen = submitGeneration let signer = KeychainSigner(modelContainer: modelContext.container) let identityId = identity.identityId let contractId = token.contractId @@ -174,11 +180,13 @@ struct TokenClaimActionView: View { signer: signer ) await MainActor.run { + guard self.submitGeneration == gen else { return } self.isSubmitting = false self.dismiss() } } catch { await MainActor.run { + guard self.submitGeneration == gen else { return } self.submitError = .init(message: error.localizedDescription) self.isSubmitting = false } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenDestroyFrozenFundsActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenDestroyFrozenFundsActionView.swift index 102a617622d..ca3a617ab69 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenDestroyFrozenFundsActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenDestroyFrozenFundsActionView.swift @@ -24,6 +24,10 @@ struct TokenDestroyFrozenFundsActionView: View { @State private var reason: String = "" @State private var isSubmitting: Bool = false @State private var submitError: AlertMessage? + /// 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. + @State private var submitGeneration: Int = 0 private struct AlertMessage: Identifiable { let id = UUID() @@ -179,6 +183,8 @@ struct TokenDestroyFrozenFundsActionView: View { } isSubmitting = true + submitGeneration &+= 1 + let gen = submitGeneration let signer = KeychainSigner(modelContainer: modelContext.container) let identityId = identity.identityId let contractId = token.contractId @@ -197,11 +203,13 @@ struct TokenDestroyFrozenFundsActionView: View { signer: signer ) await MainActor.run { + guard self.submitGeneration == gen else { return } self.isSubmitting = false self.dismiss() } } catch { await MainActor.run { + guard self.submitGeneration == gen else { return } self.submitError = .init(message: error.localizedDescription) self.isSubmitting = false } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenFreezeActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenFreezeActionView.swift index 06270673589..081b051ee6d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenFreezeActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenFreezeActionView.swift @@ -21,6 +21,10 @@ struct TokenFreezeActionView: View { @State private var publicNote: String = "" @State private var isSubmitting: Bool = false @State private var submitError: AlertMessage? + /// 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. + @State private var submitGeneration: Int = 0 private struct AlertMessage: Identifiable { let id = UUID() @@ -159,6 +163,8 @@ struct TokenFreezeActionView: View { } isSubmitting = true + submitGeneration &+= 1 + let gen = submitGeneration let signer = KeychainSigner(modelContainer: modelContext.container) let identityId = identity.identityId let contractId = token.contractId @@ -178,11 +184,13 @@ struct TokenFreezeActionView: View { signer: signer ) await MainActor.run { + guard self.submitGeneration == gen else { return } self.isSubmitting = false self.dismiss() } } catch { await MainActor.run { + guard self.submitGeneration == gen else { return } self.submitError = .init(message: error.localizedDescription) self.isSubmitting = false } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenMintActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenMintActionView.swift index 6aa9ed06a3d..da261de8bb7 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenMintActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenMintActionView.swift @@ -25,6 +25,10 @@ struct TokenMintActionView: View { @State private var recipient: RecipientSelection? @State private var isSubmitting: Bool = false @State private var submitError: AlertMessage? + /// 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. + @State private var submitGeneration: Int = 0 private struct AlertMessage: Identifiable { let id = UUID() @@ -255,6 +259,8 @@ struct TokenMintActionView: View { } isSubmitting = true + submitGeneration &+= 1 + let gen = submitGeneration let signer = KeychainSigner(modelContainer: modelContext.container) let identityId = identity.identityId let contractId = token.contractId @@ -274,11 +280,13 @@ struct TokenMintActionView: View { signer: signer ) await MainActor.run { + guard self.submitGeneration == gen else { return } self.isSubmitting = false self.dismiss() } } catch { await MainActor.run { + guard self.submitGeneration == gen else { return } self.submitError = .init(message: error.localizedDescription) self.isSubmitting = false } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenPauseActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenPauseActionView.swift index 911930caabd..d966bf7a2d1 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenPauseActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenPauseActionView.swift @@ -19,6 +19,10 @@ struct TokenPauseActionView: View { @State private var publicNote: String = "" @State private var isSubmitting: Bool = false @State private var submitError: AlertMessage? + /// 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. + @State private var submitGeneration: Int = 0 private struct AlertMessage: Identifiable { let id = UUID() @@ -145,6 +149,8 @@ struct TokenPauseActionView: View { } isSubmitting = true + submitGeneration &+= 1 + let gen = submitGeneration let signer = KeychainSigner(modelContainer: modelContext.container) let identityId = identity.identityId let contractId = token.contractId @@ -162,11 +168,13 @@ struct TokenPauseActionView: View { signer: signer ) await MainActor.run { + guard self.submitGeneration == gen else { return } self.isSubmitting = false self.dismiss() } } catch { await MainActor.run { + guard self.submitGeneration == gen else { return } self.submitError = .init(message: error.localizedDescription) self.isSubmitting = false } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenPurchaseActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenPurchaseActionView.swift index 15336d84eaf..640ea51688f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenPurchaseActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenPurchaseActionView.swift @@ -24,6 +24,10 @@ struct TokenPurchaseActionView: View { @State private var amountText: String = "" @State private var isSubmitting: Bool = false @State private var submitError: AlertMessage? + /// 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. + @State private var submitGeneration: Int = 0 private struct AlertMessage: Identifiable { let id = UUID() @@ -139,6 +143,8 @@ struct TokenPurchaseActionView: View { } isSubmitting = true + submitGeneration &+= 1 + let gen = submitGeneration let signer = KeychainSigner(modelContainer: modelContext.container) let identityId = identity.identityId let contractId = token.contractId @@ -158,11 +164,13 @@ struct TokenPurchaseActionView: View { signer: signer ) await MainActor.run { + guard self.submitGeneration == gen else { return } self.isSubmitting = false self.dismiss() } } catch { await MainActor.run { + guard self.submitGeneration == gen else { return } self.submitError = .init(message: error.localizedDescription) self.isSubmitting = false } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenResumeActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenResumeActionView.swift index d91301248cb..feb264489a3 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenResumeActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenResumeActionView.swift @@ -19,6 +19,10 @@ struct TokenResumeActionView: View { @State private var publicNote: String = "" @State private var isSubmitting: Bool = false @State private var submitError: AlertMessage? + /// 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. + @State private var submitGeneration: Int = 0 private struct AlertMessage: Identifiable { let id = UUID() @@ -141,6 +145,8 @@ struct TokenResumeActionView: View { } isSubmitting = true + submitGeneration &+= 1 + let gen = submitGeneration let signer = KeychainSigner(modelContainer: modelContext.container) let identityId = identity.identityId let contractId = token.contractId @@ -158,11 +164,13 @@ struct TokenResumeActionView: View { signer: signer ) await MainActor.run { + guard self.submitGeneration == gen else { return } self.isSubmitting = false self.dismiss() } } catch { await MainActor.run { + guard self.submitGeneration == gen else { return } self.submitError = .init(message: error.localizedDescription) self.isSubmitting = false } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenSetPriceActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenSetPriceActionView.swift index 148dc624d79..6296a147c09 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenSetPriceActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenSetPriceActionView.swift @@ -31,6 +31,10 @@ struct TokenSetPriceActionView: View { @State private var publicNote: String = "" @State private var isSubmitting: Bool = false @State private var submitError: AlertMessage? + /// 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. + @State private var submitGeneration: Int = 0 private struct AlertMessage: Identifiable { let id = UUID() @@ -179,6 +183,8 @@ struct TokenSetPriceActionView: View { } isSubmitting = true + submitGeneration &+= 1 + let gen = submitGeneration let signer = KeychainSigner(modelContainer: modelContext.container) let identityId = identity.identityId let contractId = token.contractId @@ -197,11 +203,13 @@ struct TokenSetPriceActionView: View { signer: signer ) await MainActor.run { + guard self.submitGeneration == gen else { return } self.isSubmitting = false self.dismiss() } } catch { await MainActor.run { + guard self.submitGeneration == gen else { return } self.submitError = .init(message: error.localizedDescription) self.isSubmitting = false } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenTransferActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenTransferActionView.swift index f5e83a87804..3a870b7326b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenTransferActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenTransferActionView.swift @@ -31,6 +31,10 @@ struct TokenTransferActionView: View { @State private var publicNote: String = "" @State private var isSubmitting: Bool = false @State private var submitError: AlertMessage? + /// 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. + @State private var submitGeneration: Int = 0 private struct AlertMessage: Identifiable { let id = UUID() @@ -178,6 +182,8 @@ struct TokenTransferActionView: View { } isSubmitting = true + submitGeneration &+= 1 + let gen = submitGeneration let signer = KeychainSigner(modelContainer: modelContext.container) let identityId = identity.identityId let contractId = token.contractId @@ -197,11 +203,13 @@ struct TokenTransferActionView: View { signer: signer ) await MainActor.run { + guard self.submitGeneration == gen else { return } self.isSubmitting = false self.dismiss() } } catch { await MainActor.run { + guard self.submitGeneration == gen else { return } self.submitError = .init(message: error.localizedDescription) self.isSubmitting = false } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenUnfreezeActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenUnfreezeActionView.swift index 64bbf47a130..cb6da6558c3 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenUnfreezeActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenUnfreezeActionView.swift @@ -20,6 +20,10 @@ struct TokenUnfreezeActionView: View { @State private var publicNote: String = "" @State private var isSubmitting: Bool = false @State private var submitError: AlertMessage? + /// 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. + @State private var submitGeneration: Int = 0 private struct AlertMessage: Identifiable { let id = UUID() @@ -155,6 +159,8 @@ struct TokenUnfreezeActionView: View { } isSubmitting = true + submitGeneration &+= 1 + let gen = submitGeneration let signer = KeychainSigner(modelContainer: modelContext.container) let identityId = identity.identityId let contractId = token.contractId @@ -174,11 +180,13 @@ struct TokenUnfreezeActionView: View { signer: signer ) await MainActor.run { + guard self.submitGeneration == gen else { return } self.isSubmitting = false self.dismiss() } } catch { await MainActor.run { + guard self.submitGeneration == gen else { return } self.submitError = .init(message: error.localizedDescription) self.isSubmitting = false } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenUpdateMaxSupplyActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenUpdateMaxSupplyActionView.swift index 22b9081044a..b3574196bd9 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenUpdateMaxSupplyActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenUpdateMaxSupplyActionView.swift @@ -33,6 +33,10 @@ struct TokenUpdateMaxSupplyActionView: View { @State private var publicNote: String = "" @State private var isSubmitting: Bool = false @State private var submitError: AlertMessage? + /// 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. + @State private var submitGeneration: Int = 0 private struct AlertMessage: Identifiable { let id = UUID() @@ -204,6 +208,8 @@ struct TokenUpdateMaxSupplyActionView: View { } isSubmitting = true + submitGeneration &+= 1 + let gen = submitGeneration let signer = KeychainSigner(modelContainer: modelContext.container) let identityId = identity.identityId let contractId = token.contractId @@ -222,11 +228,13 @@ struct TokenUpdateMaxSupplyActionView: View { signer: signer ) await MainActor.run { + guard self.submitGeneration == gen else { return } self.isSubmitting = false self.dismiss() } } catch { await MainActor.run { + guard self.submitGeneration == gen else { return } self.submitError = .init(message: error.localizedDescription) self.isSubmitting = false } From 35c09297c05de18e056ee25e635f556ba6f7d466 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 5 May 2026 12:09:07 +0200 Subject: [PATCH 08/19] remove dead code --- .../SwiftExampleApp/Views/ContractsView.swift | 378 ------------ .../Views/DynamicDocumentFormView.swift | 542 ------------------ .../SwiftExampleApp/Views/OptionsView.swift | 4 - 3 files changed, 924 deletions(-) delete mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ContractsView.swift delete mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DynamicDocumentFormView.swift diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ContractsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ContractsView.swift deleted file mode 100644 index 92c63f3b5e2..00000000000 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ContractsView.swift +++ /dev/null @@ -1,378 +0,0 @@ -import SwiftUI -import SwiftData -import SwiftDashSDK - -struct ContractsView: View { - @EnvironmentObject var appState: AppState - @Query(sort: \PersistentDataContract.createdAt, order: .reverse) - private var contracts: [PersistentDataContract] - - @State private var showingFetchContract = false - @State private var selectedContract: PersistentDataContract? - - var body: some View { - NavigationView { - List { - if contracts.isEmpty { - EmptyStateView( - systemImage: "doc.plaintext", - title: "No Contracts", - message: "Fetch contracts from the network to see them here" - ) - .listRowBackground(Color.clear) - } else { - ForEach(contracts) { contract in - ContractRow(contract: contract) { - selectedContract = contract - } - } - } - } - .navigationTitle("Contracts") - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button(action: { showingFetchContract = true }) { - Image(systemName: "arrow.down.circle") - } - } - } - .sheet(isPresented: $showingFetchContract) { - FetchContractView() - .environmentObject(appState) - } - .sheet(item: $selectedContract) { contract in - ContractDetailView(contract: contract) - } - } - } -} - -struct ContractRow: View { - let contract: PersistentDataContract - let onTap: () -> Void - - var body: some View { - Button(action: onTap) { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(contract.name) - .font(.headline) - .foregroundColor(.primary) - Spacer() - if let version = contract.version { - Text("v\(version)") - .font(.caption) - .foregroundColor(.secondary) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .background(Color.blue.opacity(0.2)) - .cornerRadius(4) - } - } - - Text(contract.idBase58) - .font(.caption) - .foregroundColor(.secondary) - .lineLimit(1) - .truncationMode(.middle) - - HStack { - Image(systemName: "doc.text") - .font(.caption) - .foregroundColor(.secondary) - Text("\(contract.documentTypesList.count) document types") - .font(.caption) - .foregroundColor(.secondary) - } - } - .padding(.vertical, 4) - } - .buttonStyle(PlainButtonStyle()) - } -} - -struct ContractDetailView: View { - let contract: PersistentDataContract - @Environment(\.dismiss) var dismiss - @State private var selectedDocumentType: String? - - var body: some View { - NavigationView { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - Section { - VStack(alignment: .leading, spacing: 8) { - DetailRow(label: "Contract Name", value: contract.name) - DetailRow(label: "Contract ID", value: contract.idBase58) - if let version = contract.version { - DetailRow(label: "Version", value: "\(version)") - } - if let ownerId = contract.ownerIdBase58 { - DetailRow(label: "Owner ID", value: ownerId) - } - } - .padding() - .background(Color.gray.opacity(0.1)) - .cornerRadius(10) - } - - Section { - VStack(alignment: .leading, spacing: 8) { - Text("Document Types") - .font(.headline) - - ForEach(contract.documentTypesList, id: \.self) { docType in - Button(action: { - selectedDocumentType = selectedDocumentType == docType ? nil : docType - }) { - HStack { - Image(systemName: "doc.text") - .foregroundColor(.blue) - Text(docType) - .foregroundColor(.primary) - Spacer() - Image(systemName: selectedDocumentType == docType ? "chevron.up" : "chevron.down") - .foregroundColor(.secondary) - } - .padding(.vertical, 8) - .padding(.horizontal, 12) - .background(Color.gray.opacity(0.1)) - .cornerRadius(8) - } - - if selectedDocumentType == docType { - Text(getSchemaForDocumentType(docType)) - .font(.system(.caption, design: .monospaced)) - .padding() - .background(Color.gray.opacity(0.05)) - .cornerRadius(8) - .padding(.horizontal) - } - } - } - .padding() - } - - Section { - VStack(alignment: .leading, spacing: 8) { - Text("Full Schema") - .font(.headline) - - Text(formattedSchema(contract.schema)) - .font(.system(.caption, design: .monospaced)) - .padding() - .background(Color.gray.opacity(0.1)) - .cornerRadius(8) - } - .padding() - } - } - } - .navigationTitle("Contract Details") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button("Done") { - dismiss() - } - } - } - } - } - - private func getSchemaForDocumentType(_ docType: String) -> String { - if let typeSchema = contract.schema[docType] { - guard let jsonData = try? JSONSerialization.data(withJSONObject: typeSchema, options: .prettyPrinted), - let jsonString = String(data: jsonData, encoding: .utf8) else { - return "Invalid schema" - } - return jsonString - } - return "Schema not available" - } - - private func formattedSchema(_ schema: [String: Any]) -> String { - guard let jsonData = try? JSONSerialization.data(withJSONObject: schema, options: .prettyPrinted), - let jsonString = String(data: jsonData, encoding: .utf8) else { - return "Invalid schema" - } - return jsonString - } -} - -struct FetchContractView: View { - @EnvironmentObject var appState: AppState - @Environment(\.modelContext) private var modelContext - @Environment(\.dismiss) var dismiss - @State private var contractIdToFetch = "" - @State private var isLoading = false - - var body: some View { - NavigationView { - Form { - Section("Fetch Contract from Network") { - TextField("Contract ID", text: $contractIdToFetch) - .textContentType(.none) - .autocapitalization(.none) - } - - if isLoading { - Section { - HStack { - ProgressView() - Text("Fetching contract...") - .foregroundColor(.secondary) - } - } - } - } - .navigationTitle("Fetch Contract") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button("Cancel") { - dismiss() - } - } - ToolbarItem(placement: .navigationBarTrailing) { - Button("Fetch") { - Task { - let didFetch = await fetchContract() - if didFetch { - dismiss() - } - } - } - .disabled(contractIdToFetch.isEmpty || isLoading) - } - } - } - } - - @MainActor - private func fetchContract() async -> Bool { - guard let sdk = appState.sdk else { - appState.showError(message: "SDK not initialized") - return false - } - - do { - isLoading = true - defer { isLoading = false } - - let trimmedId = contractIdToFetch.trimmingCharacters(in: .whitespacesAndNewlines) - let contractData = try await sdk.dataContractGet(id: trimmedId) - try persistFetchedContract(contractData, requestedId: trimmedId) - return true - } catch { - appState.showError(message: "Failed to fetch contract: \(error.localizedDescription)") - return false - } - } - - private func persistFetchedContract(_ contractData: [String: Any], requestedId: String) throws { - let serializedContract = try JSONSerialization.data(withJSONObject: contractData, options: []) - let contractId = try resolveContractId(from: contractData, fallbackId: requestedId) - - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.id == contractId } - ) - if try modelContext.fetch(descriptor).first != nil { - throw FetchContractError.contractAlreadySaved - } - - let documents = contractDocuments(from: contractData) - let tokens = contractData["tokens"] as? [String: Any] ?? [:] - let ownerId = (contractData["ownerId"] as? String).flatMap { - Data.identifier(fromBase58: $0) ?? Data(hexString: $0) - } - - let persistentContract = PersistentDataContract( - id: contractId, - name: contractName(from: contractData, documents: documents, tokens: tokens, fallbackId: requestedId), - serializedContract: serializedContract, - version: contractData["version"] as? Int, - ownerId: ownerId, - schema: documents, - documentTypesList: documents.keys.sorted(), - keywords: contractData["keywords"] as? [String] ?? [], - description: contractData["description"] as? String, - hasTokens: !tokens.isEmpty, - network: appState.currentNetwork - ) - - modelContext.insert(persistentContract) - try modelContext.save() - - try DataContractParser.parseDataContract( - contractData: contractData, - contractId: contractId, - modelContext: modelContext - ) - try modelContext.save() - } - - private func resolveContractId(from contractData: [String: Any], fallbackId: String) throws -> Data { - let idString = (contractData["id"] as? String) ?? fallbackId - if let id = Data.identifier(fromBase58: idString) ?? Data(hexString: idString) { - return id - } - throw FetchContractError.invalidContractId - } - - private func contractDocuments(from contractData: [String: Any]) -> [String: Any] { - contractData["documents"] as? [String: Any] - ?? contractData["documentSchemas"] as? [String: Any] - ?? [:] - } - - private func contractName( - from contractData: [String: Any], - documents: [String: Any], - tokens: [String: Any], - fallbackId: String - ) -> String { - if let name = contractData["name"] as? String, - !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return name - } - - if documents.isEmpty, - tokens.count == 1, - let tokenData = tokens.values.first as? [String: Any], - let tokenName = tokenName(from: tokenData) { - return "\(tokenName) Token Contract" - } - - if let documentType = documents.keys.sorted().first { - return "Contract with \(documentType)" - } - - return "Contract \(fallbackId.prefix(8))..." - } - - private func tokenName(from tokenData: [String: Any]) -> String? { - if let conventions = tokenData["conventions"] as? [String: Any], - let localizations = conventions["localizations"] as? [String: Any], - let english = localizations["en"] as? [String: Any], - let singularForm = english["singularForm"] as? String { - return singularForm - } - - return tokenData["description"] as? String ?? tokenData["name"] as? String - } -} - -private enum FetchContractError: LocalizedError { - case invalidContractId - case contractAlreadySaved - - var errorDescription: String? { - switch self { - case .invalidContractId: - return "Could not extract contract ID from response" - case .contractAlreadySaved: - return "This contract is already saved locally" - } - } -} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DynamicDocumentFormView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DynamicDocumentFormView.swift deleted file mode 100644 index 7c84bdb8206..00000000000 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DynamicDocumentFormView.swift +++ /dev/null @@ -1,542 +0,0 @@ -import SwiftUI - -struct DynamicDocumentFormView: View { - let contractId: String - let documentType: String - let schema: [String: Any]? - @Binding var documentData: [String: Any] - - @State private var formFields: [DocumentField] = [] - @State private var stringValues: [String: String] = [:] - @State private var numberValues: [String: Double] = [:] - @State private var boolValues: [String: Bool] = [:] - @State private var arrayValues: [String: [String]] = [:] - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - if let properties = getProperties() { - ForEach(Array(properties.keys.sorted()), id: \.self) { fieldName in - if let fieldSchema = properties[fieldName] as? [String: Any] { - fieldView(for: fieldName, schema: fieldSchema) - } - } - } else { - Text("No schema available for this document type") - .font(.caption) - .foregroundColor(.secondary) - .padding() - .frame(maxWidth: .infinity) - .background(Color.orange.opacity(0.1)) - .cornerRadius(8) - } - } - .onAppear { - parseSchema() - } - .modifier(DocumentFormChangeHandler(stringValues: $stringValues, - numberValues: $numberValues, - boolValues: $boolValues, - arrayValues: $arrayValues, - onChange: updateDocumentData)) - } - -// Helper ViewModifier to use new onChange signatures on iOS 17+ while keeping compatibility -private struct DocumentFormChangeHandler: ViewModifier { - @Binding var stringValues: [String: String] - @Binding var numberValues: [String: Double] - @Binding var boolValues: [String: Bool] - @Binding var arrayValues: [String: [String]] - let onChange: () -> Void - func body(content: Content) -> some View { - if #available(iOS 17.0, *) { - content - .onChange(of: stringValues) { _, _ in onChange() } - .onChange(of: numberValues) { _, _ in onChange() } - .onChange(of: boolValues) { _, _ in onChange() } - .onChange(of: arrayValues) { _, _ in onChange() } - } else { - content - .onChange(of: stringValues) { _ in onChange() } - .onChange(of: numberValues) { _ in onChange() } - .onChange(of: boolValues) { _ in onChange() } - .onChange(of: arrayValues) { _ in onChange() } - } - } -} - - @ViewBuilder - private func fieldView(for fieldName: String, schema: [String: Any]) -> some View { - VStack(alignment: .leading, spacing: 8) { - // Field label - HStack { - Text(fieldName.camelCaseToWords()) - .font(.subheadline) - .fontWeight(.medium) - - if isRequired(fieldName) { - Text("*") - .foregroundColor(.red) - } - } - - // Field input based on type - if let fieldType = schema["type"] as? String { - switch fieldType { - case "string": - stringField(for: fieldName, schema: schema) - case "number", "integer": - numberField(for: fieldName, schema: schema) - case "boolean": - booleanField(for: fieldName, schema: schema) - case "array": - arrayField(for: fieldName, schema: schema) - case "object": - objectField(for: fieldName, schema: schema) - default: - TextField("Enter \(fieldName)", text: binding(for: fieldName)) - .textFieldStyle(RoundedBorderTextFieldStyle()) - } - } - - // Field description/help - if let description = schema["description"] as? String, - !description.contains("NSManagedObject"), - !description.contains("@property") { - Text(description) - .font(.caption2) - .foregroundColor(.secondary) - } - } - } - - @ViewBuilder - private func stringField(for fieldName: String, schema: [String: Any]) -> some View { - let maxLength = schema["maxLength"] as? Int - let format = schema["format"] as? String - let enumValues = schema["enum"] as? [String] - - if let enumValues = enumValues { - // Dropdown for enum values - Picker(fieldName, selection: binding(for: fieldName)) { - Text("Select...").tag("") - ForEach(enumValues, id: \.self) { value in - Text(value).tag(value) - } - } - .pickerStyle(MenuPickerStyle()) - .padding() - .background(Color.gray.opacity(0.1)) - .cornerRadius(8) - } else if maxLength ?? 0 > 100 { - // Text area for long strings - TextEditor(text: binding(for: fieldName)) - .frame(minHeight: 100) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.gray.opacity(0.3), lineWidth: 1) - ) - } else { - // Regular text field - VStack(alignment: .leading) { - TextField(placeholder(for: fieldName, schema: schema), text: binding(for: fieldName)) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .keyboardType(keyboardType(for: format)) - - if let maxLength = maxLength { - Text("\(stringValues[fieldName]?.count ?? 0)/\(maxLength) characters") - .font(.caption2) - .foregroundColor(.secondary) - } - } - } - } - - @ViewBuilder - private func numberField(for fieldName: String, schema: [String: Any]) -> some View { - let minimum = schema["minimum"] as? Double - let maximum = schema["maximum"] as? Double - - HStack { - TextField(placeholder(for: fieldName, schema: schema), text: numberBinding(for: fieldName)) - .keyboardType(.decimalPad) - .textFieldStyle(RoundedBorderTextFieldStyle()) - - if let min = minimum, let max = maximum { - Text("(\(Int(min))-\(Int(max)))") - .font(.caption) - .foregroundColor(.secondary) - } - } - } - - @ViewBuilder - private func booleanField(for fieldName: String, schema: [String: Any]) -> some View { - Toggle(isOn: boolBinding(for: fieldName)) { - Text("") - } - .labelsHidden() - } - - @ViewBuilder - private func arrayField(for fieldName: String, schema: [String: Any]) -> some View { - VStack(alignment: .leading, spacing: 8) { - // Check if this is a byte array - if schema["byteArray"] as? Bool == true { - byteArrayField(for: fieldName, schema: schema) - } else { - // Regular array - simple comma-separated input for now - TextField("Enter comma-separated values", text: arrayBinding(for: fieldName)) - .textFieldStyle(RoundedBorderTextFieldStyle()) - - if let items = schema["items"] as? [String: Any], - let itemType = items["type"] as? String { - Text("Item type: \(itemType)") - .font(.caption2) - .foregroundColor(.secondary) - } - } - } - } - - @ViewBuilder - private func byteArrayField(for fieldName: String, schema: [String: Any]) -> some View { - let minItems = schema["minItems"] as? Int - let maxItems = schema["maxItems"] as? Int - let expectedBytes = minItems ?? maxItems ?? 32 // Default to 32 if not specified - let expectedHexLength = expectedBytes * 2 - - VStack(alignment: .leading, spacing: 8) { - HStack { - TextField("Hex Data", text: binding(for: fieldName)) - .font(.system(.body, design: .monospaced)) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .autocapitalization(.none) - .disableAutocorrection(true) - .onChange(of: stringValues[fieldName] ?? "") { _, newValue in - // Remove any non-hex characters and convert to lowercase - let cleaned = newValue.lowercased().filter { "0123456789abcdef".contains($0) } - if cleaned != newValue { - stringValues[fieldName] = cleaned - } - } - - // Validation indicator - if let currentValue = stringValues[fieldName], !currentValue.isEmpty { - Image(systemName: isValidHex(currentValue, expectedLength: expectedHexLength) ? "checkmark.circle.fill" : "xmark.circle.fill") - .foregroundColor(isValidHex(currentValue, expectedLength: expectedHexLength) ? .green : .red) - } - } - - // Help text - Text("Enter a valid \(expectedBytes) byte array in hex format (\(expectedHexLength) characters)") - .font(.caption2) - .foregroundColor(.secondary) - - // Current status - if let currentValue = stringValues[fieldName], !currentValue.isEmpty { - HStack { - Text("\(currentValue.count)/\(expectedHexLength) characters") - .font(.caption2) - .foregroundColor(currentValue.count == expectedHexLength ? .green : .orange) - - Spacer() - - if currentValue.count == expectedHexLength { - Text("✓ Valid hex data") - .font(.caption2) - .foregroundColor(.green) - } - } - } - } - } - - private func isValidHex(_ string: String, expectedLength: Int) -> Bool { - // Check if string contains only hex characters - let hexCharacterSet = CharacterSet(charactersIn: "0123456789abcdefABCDEF") - let stringCharacterSet = CharacterSet(charactersIn: string) - - return stringCharacterSet.isSubset(of: hexCharacterSet) && string.count == expectedLength - } - - @ViewBuilder - private func objectField(for fieldName: String, schema: [String: Any]) -> some View { - VStack(alignment: .leading, spacing: 8) { - Text("Object fields:") - .font(.caption) - .foregroundColor(.secondary) - - if let properties = schema["properties"] as? [String: Any] { - ForEach(Array(properties.keys.sorted()), id: \.self) { subFieldName in - if properties[subFieldName] is [String: Any] { - HStack { - Text("• \(subFieldName)") - .font(.caption) - Spacer() - } - } - } - } - - // For now, use JSON input for complex objects - TextEditor(text: binding(for: fieldName)) - .font(.system(.caption, design: .monospaced)) - .frame(minHeight: 100) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.gray.opacity(0.3), lineWidth: 1) - ) - } - } - - // MARK: - Helper Methods - - private func getProperties() -> [String: Any]? { - if let props = schema?["properties"] as? [String: Any] { - return props - } - return nil - } - - private func isRequired(_ fieldName: String) -> Bool { - if let required = schema?["required"] as? [String] { - return required.contains(fieldName) - } - return false - } - - private func parseSchema() { - guard let properties = getProperties() else { return } - - // Initialize form values from existing document data - for (fieldName, fieldSchema) in properties { - if let schema = fieldSchema as? [String: Any], - let fieldType = schema["type"] as? String { - - // Initialize with existing data or defaults - if let existingValue = documentData[fieldName] { - switch fieldType { - case "string": - stringValues[fieldName] = existingValue as? String ?? "" - case "number", "integer": - if let num = existingValue as? Double { - numberValues[fieldName] = num - } else if let num = existingValue as? Int { - numberValues[fieldName] = Double(num) - } - case "boolean": - boolValues[fieldName] = existingValue as? Bool ?? false - case "array": - // Check if it's a byte array - if schema["byteArray"] as? Bool == true { - // Convert byte array to hex string for display - if let byteArray = existingValue as? [UInt8] { - let data = Data(byteArray) - stringValues[fieldName] = data.toHexString() - } else if let intArray = existingValue as? [Int] { - let byteArray = intArray.map { UInt8($0 & 0xFF) } - let data = Data(byteArray) - stringValues[fieldName] = data.toHexString() - } - } else if let array = existingValue as? [String] { - arrayValues[fieldName] = array - } - default: - stringValues[fieldName] = "" - } - } else { - // Set defaults - switch fieldType { - case "string": - stringValues[fieldName] = "" - case "number", "integer": - numberValues[fieldName] = 0 - case "boolean": - boolValues[fieldName] = false - case "array": - // Check if it's a byte array - if schema["byteArray"] as? Bool == true { - // Store hex string in stringValues for byte arrays - stringValues[fieldName] = "" - } else { - arrayValues[fieldName] = [] - } - default: - stringValues[fieldName] = "" - } - } - } - } - } - - private func updateDocumentData() { - var newData: [String: Any] = [:] - - // Process string values, checking if they're byte arrays - if let properties = getProperties() { - for (key, value) in stringValues { - if !value.isEmpty { - // Check if this field is a byte array - if let fieldSchema = properties[key] as? [String: Any], - fieldSchema["type"] as? String == "array", - fieldSchema["byteArray"] as? Bool == true { - // Convert hex string to byte array - if let data = Data(hexString: value) { - // Convert to array of bytes for JSON - newData[key] = Array(data) - } - } else { - newData[key] = value - } - } - } - } else { - // Fallback if we can't get properties - for (key, value) in stringValues { - if !value.isEmpty { - newData[key] = value - } - } - } - - for (key, value) in numberValues { - newData[key] = value - } - - for (key, value) in boolValues { - newData[key] = value - } - - for (key, value) in arrayValues { - if !value.isEmpty { - newData[key] = value - } - } - - documentData = newData - } - - private func binding(for fieldName: String) -> Binding { - Binding( - get: { stringValues[fieldName] ?? "" }, - set: { stringValues[fieldName] = $0 } - ) - } - - private func numberBinding(for fieldName: String) -> Binding { - Binding( - get: { - if let value = numberValues[fieldName] { - return value.truncatingRemainder(dividingBy: 1) == 0 ? String(Int(value)) : String(value) - } - return "" - }, - set: { - if let value = Double($0) { - numberValues[fieldName] = value - } - } - ) - } - - private func boolBinding(for fieldName: String) -> Binding { - Binding( - get: { boolValues[fieldName] ?? false }, - set: { boolValues[fieldName] = $0 } - ) - } - - private func arrayBinding(for fieldName: String) -> Binding { - Binding( - get: { - arrayValues[fieldName]?.joined(separator: ", ") ?? "" - }, - set: { - arrayValues[fieldName] = $0.split(separator: ",").map { String($0.trimmingCharacters(in: .whitespaces)) } - } - ) - } - - private func placeholder(for fieldName: String, schema: [String: Any]) -> String { - if let placeholder = schema["placeholder"] as? String { - return placeholder - } - - if let format = schema["format"] as? String { - switch format { - case "email": - return "example@email.com" - case "uri", "url": - return "https://example.com" - case "date": - return "YYYY-MM-DD" - case "date-time": - return "YYYY-MM-DD HH:MM:SS" - default: - break - } - } - - return "Enter \(fieldName.camelCaseToWords().lowercased())" - } - - private func keyboardType(for format: String?) -> UIKeyboardType { - switch format { - case "email": - return .emailAddress - case "uri", "url": - return .URL - case "phone": - return .phonePad - default: - return .default - } - } -} - -// MARK: - String Extension - -extension String { - func camelCaseToWords() -> String { - return self.unicodeScalars.reduce("") { (result, scalar) in - if CharacterSet.uppercaseLetters.contains(scalar) { - return result + " " + String(scalar) - } else { - return result + String(scalar) - } - }.capitalized - } -} - -// MARK: - Document Field Model - -struct DocumentField: Identifiable { - let id = UUID() - let name: String - let type: String - let required: Bool - let schema: [String: Any] -} - -// MARK: - Preview - -struct DynamicDocumentFormView_Previews: PreviewProvider { - static var previews: some View { - DynamicDocumentFormView( - contractId: "test", - documentType: "note", - schema: [ - "type": "object", - "properties": [ - "message": [ - "type": "string", - "maxLength": 100 - ] - ], - "required": ["message"] - ], - documentData: .constant([:]) - ) - .padding() - } -} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift index 62272919947..ebd1839d3ca 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift @@ -163,10 +163,6 @@ struct OptionsView: View { Label("Wallet Memory Explorer", systemImage: "memorychip") } - NavigationLink(destination: ContractsView()) { - Label("Browse Contracts", systemImage: "doc.plaintext") - } - Button(action: { showingDataManagement = true }) { Label("Manage Local Data", systemImage: "internaldrive") } From 266a2fa8a26b1c32b5a16482e8eb39f144e64c99 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 5 May 2026 12:26:48 +0200 Subject: [PATCH 09/19] feat(swift-sdk): copyable identity ID on Identity Details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the identity hex string on the Identity Details screen copyable. Replaces the plain Label with an HStack rendering the same content plus an explicit clipboard-icon Button on the right that writes the hex to UIPasteboard and fires a success haptic. The hex Text also gets `.textSelection(.enabled)` so users who long-press get the standard iOS text selection menu as a secondary path. `.contextMenu` was tried first but doesn't fire reliably on a Label inside a List row — the row's own gesture eats the long-press. An explicit visible button is also more discoverable than long-press. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Views/IdentityDetailView.swift | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift index 9f1418a703f..1e2dd810a14 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift @@ -114,9 +114,30 @@ struct IdentityDetailView: View { .foregroundColor(.blue) } - Label(identity.identityIdString, systemImage: "number") - .font(.caption) - .foregroundColor(.secondary) + HStack(alignment: .top, spacing: 6) { + Image(systemName: "number") + .foregroundColor(.secondary) + .font(.caption) + Text(identity.identityIdString) + .font(.caption) + .foregroundColor(.secondary) + .textSelection(.enabled) + Spacer(minLength: 4) + // Explicit, discoverable copy button — `.contextMenu` + // on a `Label` inside a `List` row is unreliable (the + // row's own gesture eats the long-press). A visible + // tap target is also more obvious than long-press. + Button { + UIPasteboard.general.string = identity.identityIdString + UINotificationFeedbackGenerator().notificationOccurred(.success) + } label: { + Image(systemName: "doc.on.doc") + .font(.caption) + .foregroundColor(.blue) + } + .buttonStyle(.borderless) + .accessibilityLabel("Copy identity ID") + } } .padding(.vertical, 4) From 608a2fec7ef78e64b4e85842f98385a672e346df Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 6 May 2026 08:21:27 +0200 Subject: [PATCH 10/19] fix(swift-sdk): re-key contract `groups` array back to position-keyed map before FFI `RegisterContractSourceView` flattens the chain-shape `{ "": { ... } }` groups map into an array with an injected `id` for the form. The Rust V1 contract format expects a `BTreeMap` though, so the assembler rejected the array with "expected a map, got sequence" and no group-bearing contract could be registered. Restore the map shape in `executeDataContractCreate` right before the FFI call. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Views/TransitionDetailView.swift | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift index bd66ced4f58..b9075afb406 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift @@ -1651,14 +1651,29 @@ struct TransitionDetailView: View { tokenSchemas = parsed } - // Parse groups if provided - var groups: [[String: Any]]? = nil + // Parse groups if provided. `RegisterContractSourceView` flattens + // the chain-shape `{ "": { ... } }` map into an array + // with an injected `id`, so the form carries an array. The Rust + // V1 contract format wants a `BTreeMap` though, so re-key the array back into a position-keyed + // map before crossing the FFI — otherwise the assembler rejects + // it with "expected a map, got sequence". + var groups: [String: Any]? = nil if let groupsJson = formInputs["groups"], !groupsJson.isEmpty { guard let data = groupsJson.data(using: .utf8), let parsed = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { throw SDKError.serializationError("Invalid groups JSON") } - groups = parsed + var byPosition: [String: Any] = [:] + for var entry in parsed { + guard let rawId = entry.removeValue(forKey: "id") else { continue } + let key: String + if let intId = rawId as? Int { key = String(intId) } + else if let strId = rawId as? String { key = strId } + else { continue } + byPosition[key] = entry + } + groups = byPosition } // Build contract configuration From 7c0c8c74807cdb9b13aae0d647d2d8ae2849fd7c Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 6 May 2026 08:21:35 +0200 Subject: [PATCH 11/19] feat(swift-sdk): surface base58 identity ID alongside hex on Identity Details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hex row is the raw byte form most users see in logs and errors, but most platform tooling (contract JSON group members, FFI args, dashmate) consumes base58 — so it deserves equal visibility rather than being hidden behind a long-press or menu. Two rows, two one-tap copy buttons, both text-selectable. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Views/IdentityDetailView.swift | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift index 1e2dd810a14..a0cbff84481 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift @@ -114,6 +114,9 @@ struct IdentityDetailView: View { .foregroundColor(.blue) } + // Hex row: row icon mirrors the format (the `#` + // numeric glyph). Hex is the raw byte form most users + // see when an identity id surfaces in logs / errors. HStack(alignment: .top, spacing: 6) { Image(systemName: "number") .foregroundColor(.secondary) @@ -136,7 +139,32 @@ struct IdentityDetailView: View { .foregroundColor(.blue) } .buttonStyle(.borderless) - .accessibilityLabel("Copy identity ID") + .accessibilityLabel("Copy identity ID (hex)") + } + + // Base58 row. Most platform tooling (contract JSON + // group members, FFI args, dashmate) consumes the + // base58 form, so it deserves equal visibility next + // to hex rather than being hidden behind a menu. + HStack(alignment: .top, spacing: 6) { + Image(systemName: "textformat.abc") + .foregroundColor(.secondary) + .font(.caption) + Text(identity.identityIdBase58) + .font(.caption) + .foregroundColor(.secondary) + .textSelection(.enabled) + Spacer(minLength: 4) + Button { + UIPasteboard.general.string = identity.identityIdBase58 + UINotificationFeedbackGenerator().notificationOccurred(.success) + } label: { + Image(systemName: "doc.on.doc") + .font(.caption) + .foregroundColor(.blue) + } + .buttonStyle(.borderless) + .accessibilityLabel("Copy identity ID (base58)") } } .padding(.vertical, 4) From aa9570b89d6b02ad2b1284c7e6d58ffed4d78d8e Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 6 May 2026 08:49:58 +0200 Subject: [PATCH 12/19] fix(platform-wallet): drop mint recipient when contract forbids choosing destination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-signing a group token mint failed with "Choosing token mint recipient not allowed" because the chain stores `TokenEvent::Mint` with the resolved `newTokensDestinationIdentity` baked in (non-optional), the FFI surfaces that resolved id back as a non-optional recipient, and the co-sign view replays it as `issued_to_identity_id = Some(...)` — which the rs-drive transformer rejects when the contract has `minting_allow_choosing_destination = false`. Normalize at the wallet-helper chokepoint: in `token_mint_with_signer`, read the token configuration off the data contract and drop any non-`None` `recipient_id` when the rule forbids it. The chain then resolves the destination from `newTokensDestinationIdentity` as it already does for the proposer's `None`-shaped submission, so the co-sign replay round-trips cleanly. Chain consensus rules and the FFI signature are untouched. Module doc-comment also corrected — `recipient_id == None` defers to the contract's destination identity, not the caller's. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../wallet/identity/network/tokens/mint.rs | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/mint.rs b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/mint.rs index ce019e673ef..096277ed6b2 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/mint.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/mint.rs @@ -1,9 +1,17 @@ //! Token mint — issues `amount` of `(contract, token_position)`. -//! `recipient_id == None` mints to `identity_id`. Group-gateable. +//! `recipient_id == None` defers to the contract's +//! `newTokensDestinationIdentity`. When the contract sets +//! `minting_allow_choosing_destination = false`, this helper drops any +//! non-`None` `recipient_id` to keep co-sign replays from tripping the +//! chain-side `ChoosingTokenMintRecipientNotAllowed` validator. +//! Group-gateable. use std::sync::Arc; use dpp::balances::credits::TokenAmount; +use dpp::data_contract::accessors::v1::DataContractV1Getters; +use dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; +use dpp::data_contract::associated_token::token_distribution_rules::accessors::v0::TokenDistributionRulesV0Getters; use dpp::data_contract::{DataContract, TokenContractPosition}; use dpp::identity::signer::Signer; use dpp::identity::IdentityPublicKey; @@ -33,6 +41,32 @@ impl IdentityWallet { ) -> Result { use dash_sdk::platform::tokens::builders::mint::TokenMintTransitionBuilder; + // Contracts with `minting_allow_choosing_destination = false` + // reject any mint where `issued_to_identity_id.is_some()` at + // execution time (rs-drive's TokenMintTransitionTransformer + // rule). The proposer-mode submission is stored as a pending + // action and bypasses this check, but the chain stores + // `TokenEvent::Mint` with the resolved + // `newTokensDestinationIdentity` baked in. The co-sign path + // then surfaces that resolved id back to the caller as a + // non-optional recipient and replays it, tripping the + // validator. Normalize here so neither caller has to think + // about it: when the rule forbids choosing, drop the + // recipient and let the chain resolve to + // `newTokensDestinationIdentity` (or surface + // `DestinationIdentityForTokenMintingNotSet` if that's also + // unset). + let recipient_id = if !data_contract + .expected_token_configuration(token_position) + .map_err(dash_sdk::Error::Protocol)? + .distribution_rules() + .minting_allow_choosing_destination() + { + None + } else { + recipient_id + }; + let builder = TokenMintTransitionBuilder::new(data_contract, token_position, identity_id, amount); From 238f5e4b26e7c40256d21604635411977dcdc26e Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 6 May 2026 10:55:28 +0200 Subject: [PATCH 13/19] fix(swift-sdk): reconcile token isPaused with chain on Token Actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PersistentToken.isPaused` was only set once, from `startAsPaused`, during initial contract parse. Tokens paused in another session, on another device, or before this fix shipped left the local flag stuck at false — so `TokenActionPermissionsView` gated the Resume row on `!token.isPaused` and showed it locked with the misleading "Token is not paused" subtitle while the chain happily rejected any subsequent operation with "is paused". Two changes, layered: - `TokenActionPermissionsView` now fetches `getTokenStatuses` on appearance and reconciles the local flag against the chain truth. Cheap one-shot query (`{ paused: Bool }` per token id), idempotent write (only saves when the value actually changed), and silent fallback on failure. - `TokenPauseActionView` and `TokenResumeActionView` flip the flag locally in their single-signer success branches so the next view paint reflects the action without waiting for the next chain refetch. Group propose-mode submissions don't flip — those just store a pending action; the threshold-crossing co-signer path is out of scope here (see the broader audit notes). The action-side mutations are immediate-feedback shortcuts; the view-side refetch is the chain-truth backstop that fixes the "already paused before this fix" case. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Views/TokenActionPermissionsView.swift | 51 +++++++++++++++++++ .../TokenActions/TokenPauseActionView.swift | 11 ++++ .../TokenActions/TokenResumeActionView.swift | 11 ++++ 3 files changed, 73 insertions(+) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift index 5cfb95db5ba..a47a496c584 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift @@ -667,6 +667,7 @@ struct TokenActionPermissionsView: View { @State private var balanceFetchGeneration: Int = 0 @EnvironmentObject var appState: AppState + @Environment(\.modelContext) private var modelContext @Query private var localIdentities: [PersistentIdentity] init( @@ -813,6 +814,18 @@ struct TokenActionPermissionsView: View { .task(id: resolvedIdentity?.identityId) { await refreshTokenBalance() } + // Refresh the on-chain pause flag every time this screen + // appears. `PersistentToken.isPaused` is otherwise only + // populated from `startAsPaused` at initial contract parse and + // by the in-app pause/resume action success branches — it + // doesn't reflect a token paused in another session, on + // another device, or before this fix shipped. The + // Resume/Pause row gates depend on it, so without this fetch + // the user can land on this screen and see "Token is not + // paused" when the chain knows otherwise. + .task { + await refreshTokenStatus() + } } private func refreshTokenBalance() async { @@ -863,6 +876,44 @@ struct TokenActionPermissionsView: View { } } + /// Pull the token's pause flag from chain and reconcile it onto + /// the local `PersistentToken`. The action-row gates in this view + /// (Pause / Resume) read `token.isPaused` directly, so a stale + /// flag locks the user out of a legitimate next step. Cheap one- + /// shot query — `getTokenStatuses` returns just `{ paused: Bool }` + /// per token id — and idempotent (only writes when the value + /// actually changed). Failures fall back to the existing local + /// flag rather than wiping it. + private func refreshTokenStatus() async { + guard let sdk = appState.sdk else { return } + guard let position = UInt16(exactly: token.position) else { return } + let contractIdString = token.contractId.toBase58String() + do { + let canonicalTokenId = try sdk.calculateTokenId( + contractId: contractIdString, + position: position + ) + let statuses = try await sdk.getTokenStatuses(tokenIds: [canonicalTokenId]) + // Shape: `{ "": { "paused": Bool } | null }`. + // A `null` status means the chain has no pause record for + // this token yet (never paused) — treat as not-paused. + let chainPaused: Bool + if let entry = statuses[canonicalTokenId] as? [String: Any], + let paused = entry["paused"] as? Bool { + chainPaused = paused + } else { + chainPaused = false + } + await MainActor.run { + guard token.isPaused != chainPaused else { return } + token.isPaused = chainPaused + try? modelContext.save() + } + } catch { + print("⚠️ refreshTokenStatus failed for token at \(contractIdString):\(token.position): \(error)") + } + } + private var identityPickerBinding: Binding { Binding( get: { pickedIdentity?.identityId }, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenPauseActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenPauseActionView.swift index d966bf7a2d1..3cdd2f57478 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenPauseActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenPauseActionView.swift @@ -170,6 +170,17 @@ struct TokenPauseActionView: View { await MainActor.run { guard self.submitGeneration == gen else { return } self.isSubmitting = false + // Single-signer submissions execute on this call; + // the chain is now paused, so flip the local flag + // so TokenActionPermissionsView's Resume gate + // unlocks immediately. Propose-mode just stores a + // pending group action — the pause takes effect + // when the threshold-crossing co-signer submits, + // so leave isPaused alone in that branch. + if case .none = groupAction { + token.isPaused = true + try? modelContext.save() + } self.dismiss() } } catch { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenResumeActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenResumeActionView.swift index feb264489a3..beaa1b6192a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenResumeActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenResumeActionView.swift @@ -166,6 +166,17 @@ struct TokenResumeActionView: View { await MainActor.run { guard self.submitGeneration == gen else { return } self.isSubmitting = false + // Single-signer submissions execute on this call; + // the chain is now unpaused, so flip the local + // flag so TokenActionPermissionsView's Pause gate + // unlocks immediately. Propose-mode just stores a + // pending group action — the resume takes effect + // when the threshold-crossing co-signer submits, + // so leave isPaused alone in that branch. + if case .none = groupAction { + token.isPaused = false + try? modelContext.save() + } self.dismiss() } } catch { From dda584de366f4bc9cf440736b55f239653ed042b Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 6 May 2026 11:19:12 +0200 Subject: [PATCH 14/19] fix(swift-sdk): flip token maxSupply locally after successful UpdateMaxSupply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same family as the pause/resume staleness fix (`238f5e4b2`). The single-signer success branch on TokenUpdateMaxSupplyActionView used to just dismiss, leaving `PersistentToken.maxSupply` stuck at the value parsed from the contract at registration time. Re-opening the form showed "Current max supply: " until the contract was manually re-fetched. Materialize the submitted target (string-encoded raw u64, or nil for "no cap") into a local before the Task closure, then write it back onto `token.maxSupply` in the success branch. Save the model context explicitly to survive a main-context that may not autosave. Only applies for single-signer (.none) submissions — propose-mode just stores a pending group action and the config doesn't change until a co-signer crosses the threshold. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../TokenUpdateMaxSupplyActionView.swift | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenUpdateMaxSupplyActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenUpdateMaxSupplyActionView.swift index b3574196bd9..300bb531371 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenUpdateMaxSupplyActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenUpdateMaxSupplyActionView.swift @@ -215,6 +215,14 @@ struct TokenUpdateMaxSupplyActionView: View { let contractId = token.contractId let note = publicNote.trimmingCharacters(in: .whitespacesAndNewlines) let publicNoteOrNil: String? = note.isEmpty ? nil : note + // `PersistentToken.maxSupply` is a string-encoded raw u64 (see + // `currentMaxSupplyDisplay` — it parses via `UInt64(raw)`), + // with `nil` representing "no cap" / Unlimited. Materialize the + // post-success target value here so the Task closure can write + // it back without re-touching @State from a non-main context. + let newMaxSupplyValue: String? = removeCap + ? nil + : parsedNewMaxSupply.map(String.init) Task { do { @@ -230,6 +238,18 @@ struct TokenUpdateMaxSupplyActionView: View { await MainActor.run { guard self.submitGeneration == gen else { return } self.isSubmitting = false + // Single-signer submissions execute on this call — + // flip the local maxSupply so the view (and any + // surfaces reading `token.maxSupply`) reflects the + // new cap without waiting for a manual contract + // refetch. Propose-mode just stores a pending + // group action; the chain config doesn't change + // until the threshold-crossing co-signer submits, + // so leave the field alone in that branch. + if case .none = groupAction { + token.maxSupply = newMaxSupplyValue + try? modelContext.save() + } self.dismiss() } } catch { From 7380942abc583e9f178693b5e58969e224687f00 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 6 May 2026 11:45:21 +0200 Subject: [PATCH 15/19] chore(swift-sdk): drop trailing blank line at EOF in Burn/Transfer action views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `git diff --check origin/v3.1-dev...HEAD` flagged both files at EOF. One byte each — strip the extra newline so the file ends with the struct's closing brace plus a single trailing newline (POSIX shape). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SwiftExampleApp/Views/TokenActions/TokenBurnActionView.swift | 1 - .../Views/TokenActions/TokenTransferActionView.swift | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenBurnActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenBurnActionView.swift index dd11465cdbd..455d7459402 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenBurnActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenBurnActionView.swift @@ -254,4 +254,3 @@ struct TokenBurnActionView: View { } } } - diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenTransferActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenTransferActionView.swift index 3a870b7326b..2e3e8a88211 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenTransferActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenTransferActionView.swift @@ -217,4 +217,3 @@ struct TokenTransferActionView: View { } } } - From a9aae737725de405b759cd6700430a911fb87b75 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 6 May 2026 12:14:46 +0200 Subject: [PATCH 16/19] chore(swift-sdk): address CodeRabbit feedback on #3604 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight small follow-ups across the contracts integration touch points: - TokenDetailsView: "Started as paused" → "Currently paused" — the flag now reflects live chain state (post-isPaused-reconciliation), not the immutable startAsPaused config. - TokenDetailsView: "Max Supply Changeable" now gates on `hasAuthorizedTakers` so contracts whose maxSupplyChangeRules pin `authorizedToMakeChange: NoOne` (e.g. bartek05053-style permanently locked) read "No" instead of "Yes". - TokenAmountFormatting: drop unreachable `divisor == 0` ternary — pow(Decimal(10), dec) with dec >= 0 is always >= 1. - IdentityDetailView: extract IdentityIDCopyRow helper for the hex + base58 rows so future tweaks (haptics, sizing) are one-spot. - TokenActionPermissionsView: add tokenStatusGeneration mirror of balanceFetchGeneration so a future pull-to-refresh / post-action poll trigger can't reintroduce the A→B→A overwrite bug. - TokenPause / TokenResume / TokenUpdateMaxSupply / refreshTokenStatus: replace `try? modelContext.save()` with do-catch + named-view print breadcrumb. The chain action has already succeeded, so a SwiftData hiccup isn't user-facing — keep the silent UI dismiss but make the failure self-diagnosable from console logs. - TransitionDetailView: group re-keying now throws SDKError.serializationError on missing-id, non-Int/non-String id, or duplicate position. The upstream form guarantees these can't happen today, but silent corruption surfacing later as a confusing chain rejection is bad enough that defensive validation is cheap insurance. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Utils/TokenAmountFormatting.swift | 5 +- .../Views/IdentityDetailView.swift | 97 ++++++++++--------- .../Views/TokenActionPermissionsView.swift | 20 +++- .../TokenActions/TokenPauseActionView.swift | 11 ++- .../TokenActions/TokenResumeActionView.swift | 11 ++- .../TokenUpdateMaxSupplyActionView.swift | 11 ++- .../Views/TokenDetailsView.swift | 16 ++- .../Views/TransitionDetailView.swift | 25 ++++- 8 files changed, 137 insertions(+), 59 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/TokenAmountFormatting.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/TokenAmountFormatting.swift index fe2fe22d5d2..bee0d24f388 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/TokenAmountFormatting.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/TokenAmountFormatting.swift @@ -77,8 +77,11 @@ private func isWellFormedDecimal(_ s: String) -> Bool { func formatTokenAmount(_ raw: UInt64, decimals: Int) -> String { let dec = max(0, decimals) let rawDecimal = Decimal(raw) + // `dec` is clamped to `>= 0` above, so `pow(Decimal(10), dec)` is + // always >= 1 — the previous `divisor == 0 ? rawDecimal : …` guard + // was unreachable and only obscured the scaling intent. let divisor = pow(Decimal(10), dec) - let scaled = divisor == 0 ? rawDecimal : (rawDecimal / divisor) + let scaled = rawDecimal / divisor let formatter = NumberFormatter() formatter.numberStyle = .decimal formatter.maximumFractionDigits = dec diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift index a0cbff84481..bb1f7813a21 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift @@ -114,58 +114,22 @@ struct IdentityDetailView: View { .foregroundColor(.blue) } - // Hex row: row icon mirrors the format (the `#` - // numeric glyph). Hex is the raw byte form most users - // see when an identity id surfaces in logs / errors. - HStack(alignment: .top, spacing: 6) { - Image(systemName: "number") - .foregroundColor(.secondary) - .font(.caption) - Text(identity.identityIdString) - .font(.caption) - .foregroundColor(.secondary) - .textSelection(.enabled) - Spacer(minLength: 4) - // Explicit, discoverable copy button — `.contextMenu` - // on a `Label` inside a `List` row is unreliable (the - // row's own gesture eats the long-press). A visible - // tap target is also more obvious than long-press. - Button { - UIPasteboard.general.string = identity.identityIdString - UINotificationFeedbackGenerator().notificationOccurred(.success) - } label: { - Image(systemName: "doc.on.doc") - .font(.caption) - .foregroundColor(.blue) - } - .buttonStyle(.borderless) - .accessibilityLabel("Copy identity ID (hex)") - } - + // Hex row: raw byte form most users see when an + // identity id surfaces in logs / errors. + IdentityIDCopyRow( + icon: "number", + value: identity.identityIdString, + accessibilityLabel: "Copy identity ID (hex)" + ) // Base58 row. Most platform tooling (contract JSON // group members, FFI args, dashmate) consumes the // base58 form, so it deserves equal visibility next // to hex rather than being hidden behind a menu. - HStack(alignment: .top, spacing: 6) { - Image(systemName: "textformat.abc") - .foregroundColor(.secondary) - .font(.caption) - Text(identity.identityIdBase58) - .font(.caption) - .foregroundColor(.secondary) - .textSelection(.enabled) - Spacer(minLength: 4) - Button { - UIPasteboard.general.string = identity.identityIdBase58 - UINotificationFeedbackGenerator().notificationOccurred(.success) - } label: { - Image(systemName: "doc.on.doc") - .font(.caption) - .foregroundColor(.blue) - } - .buttonStyle(.borderless) - .accessibilityLabel("Copy identity ID (base58)") - } + IdentityIDCopyRow( + icon: "textformat.abc", + value: identity.identityIdBase58, + accessibilityLabel: "Copy identity ID (base58)" + ) } .padding(.vertical, 4) @@ -1077,6 +1041,43 @@ struct IdentityDetailView: View { } } +// MARK: - Identity ID row + +/// Compact tap-to-copy row for an identity id rendered in some +/// representation (hex, base58, …). Two of these stack in the Identity +/// Details header so users can grab whichever shape the next tool +/// downstream needs. `.contextMenu` on a `Label` inside a `List` row +/// is unreliable (the row's own gesture eats the long-press), so the +/// copy target is a visible borderless button instead. +private struct IdentityIDCopyRow: View { + let icon: String + let value: String + let accessibilityLabel: String + + var body: some View { + HStack(alignment: .top, spacing: 6) { + Image(systemName: icon) + .foregroundColor(.secondary) + .font(.caption) + Text(value) + .font(.caption) + .foregroundColor(.secondary) + .textSelection(.enabled) + Spacer(minLength: 4) + Button { + UIPasteboard.general.string = value + UINotificationFeedbackGenerator().notificationOccurred(.success) + } label: { + Image(systemName: "doc.on.doc") + .font(.caption) + .foregroundColor(.blue) + } + .buttonStyle(.borderless) + .accessibilityLabel(accessibilityLabel) + } + } +} + // MARK: - Token row /// One token + balance entry, keyed by the canonical base58 token id diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift index a47a496c584..b420221df92 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift @@ -665,6 +665,14 @@ struct TokenActionPermissionsView: View { /// refresh and reject any late assignment whose generation no /// longer matches. @State private var balanceFetchGeneration: Int = 0 + /// Mirrors `balanceFetchGeneration` for the on-chain pause-flag + /// reconciliation. Today the status `.task` only runs once per + /// view appearance, so a slow response can't be raced by a + /// fresher one — but adding a future trigger (pull-to-refresh, + /// post-action poll, etc.) without this guard would silently + /// reintroduce the same A→B→A overwrite bug the balance counter + /// exists to prevent. + @State private var tokenStatusGeneration: Int = 0 @EnvironmentObject var appState: AppState @Environment(\.modelContext) private var modelContext @@ -885,6 +893,8 @@ struct TokenActionPermissionsView: View { /// actually changed). Failures fall back to the existing local /// flag rather than wiping it. private func refreshTokenStatus() async { + tokenStatusGeneration &+= 1 + let gen = tokenStatusGeneration guard let sdk = appState.sdk else { return } guard let position = UInt16(exactly: token.position) else { return } let contractIdString = token.contractId.toBase58String() @@ -905,9 +915,17 @@ struct TokenActionPermissionsView: View { chainPaused = false } await MainActor.run { + // Late-arriving status responses are dropped if a fresher + // refresh has already started — same shape as the balance + // path's generation guard. + guard self.tokenStatusGeneration == gen else { return } guard token.isPaused != chainPaused else { return } token.isPaused = chainPaused - try? modelContext.save() + do { + try modelContext.save() + } catch { + print("⚠️ refreshTokenStatus: failed to persist isPaused flip for \(contractIdString):\(token.position): \(error)") + } } } catch { print("⚠️ refreshTokenStatus failed for token at \(contractIdString):\(token.position): \(error)") diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenPauseActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenPauseActionView.swift index 3cdd2f57478..d12a5ec0af9 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenPauseActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenPauseActionView.swift @@ -179,7 +179,16 @@ struct TokenPauseActionView: View { // so leave isPaused alone in that branch. if case .none = groupAction { token.isPaused = true - try? modelContext.save() + // The chain action has already succeeded, so a + // SwiftData persistence hiccup isn't user-facing + // (worst case the in-memory flip survives until + // the next contract re-parse reconciles). Log + // for diagnosability instead of swallowing. + do { + try modelContext.save() + } catch { + print("⚠️ TokenPauseActionView: failed to persist isPaused flip: \(error)") + } } self.dismiss() } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenResumeActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenResumeActionView.swift index beaa1b6192a..852bc77c7a7 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenResumeActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenResumeActionView.swift @@ -175,7 +175,16 @@ struct TokenResumeActionView: View { // so leave isPaused alone in that branch. if case .none = groupAction { token.isPaused = false - try? modelContext.save() + // The chain action has already succeeded, so a + // SwiftData persistence hiccup isn't user-facing + // (worst case the in-memory flip survives until + // the next contract re-parse reconciles). Log + // for diagnosability instead of swallowing. + do { + try modelContext.save() + } catch { + print("⚠️ TokenResumeActionView: failed to persist isPaused flip: \(error)") + } } self.dismiss() } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenUpdateMaxSupplyActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenUpdateMaxSupplyActionView.swift index 300bb531371..ca79429bd24 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenUpdateMaxSupplyActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenUpdateMaxSupplyActionView.swift @@ -248,7 +248,16 @@ struct TokenUpdateMaxSupplyActionView: View { // so leave the field alone in that branch. if case .none = groupAction { token.maxSupply = newMaxSupplyValue - try? modelContext.save() + // The chain action has already succeeded, so a + // SwiftData persistence hiccup isn't user-facing + // (worst case the in-memory write survives until + // the next contract re-parse reconciles). Log + // for diagnosability instead of swallowing. + do { + try modelContext.save() + } catch { + print("⚠️ TokenUpdateMaxSupplyActionView: failed to persist maxSupply: \(error)") + } } self.dismiss() } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenDetailsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenDetailsView.swift index b7c4d0c8a95..cb3504bd3f0 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenDetailsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenDetailsView.swift @@ -147,7 +147,14 @@ struct TokenDetailsView: View { InfoRow(label: "Max Supply:", value: "Unlimited") } - InfoRow(label: "Max Supply Changeable:", value: token.maxSupplyChangeRules != nil ? "Yes" : "No") + // `nil` rules and rules whose `authorizedToMakeChange` is + // `NoOne` are both effectively "not changeable" — the + // bartek05053-style permanently-locked rule (NoOne admin, + // NoOne taker, no escape clauses) shipped a non-nil rules + // object that the chain still rejects every change for. + // Use the same `hasAuthorizedTakers` predicate the + // features section already uses for truth-in-UI. + InfoRow(label: "Max Supply Changeable:", value: token.maxSupplyChangeRules?.hasAuthorizedTakers == true ? "Yes" : "No") } .padding() .background(Color(UIColor.secondarySystemBackground)) @@ -173,7 +180,12 @@ struct TokenDetailsView: View { TokenFeatureRow(label: "Transfer to frozen allowed", isEnabled: token.allowTransferToFrozenBalance) TokenFeatureRow(label: "Emergency action available", isEnabled: token.emergencyActionRules?.hasAuthorizedTakers ?? false) - TokenFeatureRow(label: "Started as paused", isEnabled: token.isPaused) + // `token.isPaused` reflects live chain state (reconciled + // via `TokenActionPermissionsView.refreshTokenStatus` and + // flipped on successful pause/resume), not the immutable + // `startAsPaused` config flag — present-tense label + // matches what's actually being read. + TokenFeatureRow(label: "Currently paused", isEnabled: token.isPaused) } } .padding() diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift index b9075afb406..33b7cb09da5 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift @@ -1664,13 +1664,30 @@ struct TransitionDetailView: View { let parsed = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { throw SDKError.serializationError("Invalid groups JSON") } + // `RegisterContractSourceView` injects an `id` for every entry + // (Int when the source map's key parsed as integer, String + // otherwise) and entries derive from a map so keys are unique + // by construction. The three failure modes below are therefore + // unreachable today — but silently dropping or overwriting + // entries here would corrupt the contract submission and + // surface as a confusing chain rejection rather than a clear + // registration error, so trip loudly with `SDKError` on each. var byPosition: [String: Any] = [:] for var entry in parsed { - guard let rawId = entry.removeValue(forKey: "id") else { continue } + guard let rawId = entry.removeValue(forKey: "id") else { + throw SDKError.serializationError("Group entry is missing an `id` field") + } let key: String - if let intId = rawId as? Int { key = String(intId) } - else if let strId = rawId as? String { key = strId } - else { continue } + if let intId = rawId as? Int { + key = String(intId) + } else if let strId = rawId as? String { + key = strId + } else { + throw SDKError.serializationError("Group entry has unsupported `id` type — expected Int or String") + } + guard byPosition[key] == nil else { + throw SDKError.serializationError("Duplicate group position `\(key)` in groups payload") + } byPosition[key] = entry } groups = byPosition From 1a468a68d745f8858d6f0dccef7c707ecf65a217 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 6 May 2026 15:40:55 +0200 Subject: [PATCH 17/19] chore: address thepastaclaw review on #3604 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four follow-ups across the same files the previous chore commit touched. Two fixes for real bugs (one shipped in this PR), two inline cleanups while we're already in those files. - TokenAmountFormatting.formatTokenAmount: turn off `usesGroupingSeparator`. With it on, a US-locale balance of `1234.56` rendered as `"1,234.56"` while parseTokenAmount happily accepts `"1,234"` (after `,→.` normalization → `"1.234"`) as `1.234` — a thousand times smaller. Disabling grouping makes every formatter output a valid input for the parser. Decimal separator still honors locale. - TokenAmountFormatting.parseTokenAmount: drop the unreachable `entered >= 0` and `rounded < 0` guards. `isWellFormedDecimal` rejects anything outside `\d` and `.`, so the inputs to `Decimal(string:)` and `NSDecimalRound` are non-negative by construction. Keep the `> UInt64.max` overflow check next door — that one is genuinely reachable. - TokenActionPermissionsView.refreshTokenStatus: when the chain response yields no `paused: Bool` for the token, bail with a log breadcrumb instead of defaulting to `false` and persisting it. rs-drive writes a TokenStatus row at token creation, so a missing/malformed entry indicates a transient parse edge or FFI shape change — preserving the local flag matches the surrounding catch-block behavior on transport errors. - mint.token_mint_with_signer: emit `tracing::debug!` when the helper actually overrides a caller-supplied recipient_id to None (no log when it was already None). Pre-fix behavior was a chain rejection; post-fix is a silent successful mint to `newTokensDestinationIdentity`. The breadcrumb makes that observable in logs without changing semantics. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../wallet/identity/network/tokens/mint.rs | 13 +++++++++ .../Utils/TokenAmountFormatting.swift | 29 ++++++++++++------- .../Views/TokenActionPermissionsView.swift | 22 ++++++++------ 3 files changed, 45 insertions(+), 19 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/mint.rs b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/mint.rs index 096277ed6b2..dd9a8f6165b 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/mint.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/mint.rs @@ -62,6 +62,19 @@ impl IdentityWallet { .distribution_rules() .minting_allow_choosing_destination() { + // Surface the silent override in logs so downstream + // debugging doesn't have to reverse-engineer "the helper + // ignored my recipient" from a successful mint to + // `newTokensDestinationIdentity`. Only emit when we + // actually changed something — a pre-existing `None` + // is a no-op. + if let Some(supplied) = recipient_id.as_ref() { + tracing::debug!( + supplied = %bs58::encode(supplied.to_buffer()).into_string(), + token_position, + "token_mint normalizing caller-supplied recipient_id to None: contract forbids choosing destination" + ); + } None } else { recipient_id diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/TokenAmountFormatting.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/TokenAmountFormatting.swift index bee0d24f388..48ade56d398 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/TokenAmountFormatting.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/TokenAmountFormatting.swift @@ -39,9 +39,12 @@ func parseTokenAmount(_ text: String, decimals: Int) -> UInt64? { // ".5" is what `.decimalPad` actually emits in some locales). guard isWellFormedDecimal(normalized) else { return nil } - guard let entered = Decimal(string: normalized), entered >= 0 else { - return nil - } + guard let entered = Decimal(string: normalized) else { return nil } + // `isWellFormedDecimal` accepts only digits + a single `.`, so + // `Decimal(string:)` cannot return a negative value here. The + // previous `entered >= 0` and `rounded < 0` guards next to this + // site were dead code; only the `> UInt64.max` overflow check is + // reachable (large valid-shape input scaled by `10^decimals`). let dec = max(0, decimals) let multiplier = pow(Decimal(10), dec) @@ -49,7 +52,7 @@ func parseTokenAmount(_ text: String, decimals: Int) -> UInt64? { var rounded = Decimal() NSDecimalRound(&rounded, &scaled, 0, .down) - if rounded < 0 || rounded > Decimal(UInt64.max) { return nil } + if rounded > Decimal(UInt64.max) { return nil } return (rounded as NSDecimalNumber).uint64Value } @@ -69,11 +72,16 @@ private func isWellFormedDecimal(_ s: String) -> Bool { return sawDigit } -/// Format a raw u64 token amount with the given decimals. Uses the -/// current locale's grouping/decimal separators (so European users -/// see `4,44667781` and US users see `4.44667781`). Mirrors the -/// formatter used by `IdentityTokenRow.formattedBalance` in -/// `IdentityDetailView`. +/// Format a raw u64 token amount with the given decimals. Honors the +/// current locale's *decimal* separator (European users see +/// `1234,56`, US users see `1234.56`) but **does not** insert a +/// thousands grouping separator. Round-trip safety: `parseTokenAmount` +/// strict grammar + `,→.` normalization happily parses `"1,234"` as +/// `1.234` (a thousand times smaller than the user thought), so a user +/// copying any portion of a grouped display value into a Mint / Burn / +/// Transfer amount field could silently lose three orders of +/// magnitude. Disabling grouping in this formatter makes every value +/// it produces a valid input for the parser. func formatTokenAmount(_ raw: UInt64, decimals: Int) -> String { let dec = max(0, decimals) let rawDecimal = Decimal(raw) @@ -86,6 +94,7 @@ func formatTokenAmount(_ raw: UInt64, decimals: Int) -> String { formatter.numberStyle = .decimal formatter.maximumFractionDigits = dec formatter.minimumFractionDigits = 0 - formatter.usesGroupingSeparator = true + // Intentionally off — see the function-level doc comment. + formatter.usesGroupingSeparator = false return formatter.string(from: scaled as NSNumber) ?? "\(raw)" } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift index b420221df92..9c1d3f24bba 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift @@ -904,15 +904,19 @@ struct TokenActionPermissionsView: View { position: position ) let statuses = try await sdk.getTokenStatuses(tokenIds: [canonicalTokenId]) - // Shape: `{ "": { "paused": Bool } | null }`. - // A `null` status means the chain has no pause record for - // this token yet (never paused) — treat as not-paused. - let chainPaused: Bool - if let entry = statuses[canonicalTokenId] as? [String: Any], - let paused = entry["paused"] as? Bool { - chainPaused = paused - } else { - chainPaused = false + // Shape: `{ "": { "paused": Bool } }`. + // rs-drive writes a `TokenStatus` row at token creation + // (including for `start_as_paused = true`), so a missing + // or shape-mismatched entry is *not* "this token is + // unpaused" — it's a transient parse edge, an FFI shape + // change, or a partial response. Treat that the same way + // the surrounding `catch` treats a transport error: + // preserve the existing local flag rather than silently + // relaxing the Pause / Resume gate. + guard let entry = statuses[canonicalTokenId] as? [String: Any], + let chainPaused = entry["paused"] as? Bool else { + print("⚠️ refreshTokenStatus: missing/malformed status entry for \(canonicalTokenId) — preserving local isPaused=\(token.isPaused)") + return } await MainActor.run { // Late-arriving status responses are dropped if a fresher From e551334c72701b1a293b9a8b30e3b55fb0c97d71 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 6 May 2026 18:44:20 +0200 Subject: [PATCH 18/19] fix(swift-sdk): preserve seeded token balance until identity changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TokenActionPermissionsView.refreshTokenBalance` used to clear `fetchedBalance` to nil unconditionally at the top of every refresh to guard against an A→B identity switch leaking A's balance into B's Transfer/Burn pickers. But that wiped the parent-supplied `initialBalance` (seeded from `IdentityDetailView`) before the async fetch returned, so the first paint showed a transient zero, and a failed/slow fetch lost the seed permanently — which the per-action views then surfaced as a possibly-empty fallback. Track which identity the current `fetchedBalance` belongs to via a new `lastFetchedBalanceIdentityId` marker, seed it from the parent's identity when an `initialBalance` is supplied, and only clear when the active identity has actually changed. The pre-existing `balanceFetchGeneration` counter still guards the A→B→A overwrite race that motivated the original clear, so the safety property is preserved without the collateral damage. Stamps the marker on every successful fetch so subsequent refreshes on the same identity skip the clear path. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Views/TokenActionPermissionsView.swift | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift index 9c1d3f24bba..d90c05edc9f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift @@ -665,6 +665,16 @@ struct TokenActionPermissionsView: View { /// refresh and reject any late assignment whose generation no /// longer matches. @State private var balanceFetchGeneration: Int = 0 + /// Tracks which identity the current `fetchedBalance` belongs + /// to. The previous unconditional `fetchedBalance = nil` at the + /// top of every refresh wiped the parent-supplied + /// `initialBalance` before the async fetch returned and lost it + /// permanently on a failed fetch. Comparing against this marker + /// lets us only clear when the user has *actually* switched + /// identities — the case the clear was originally meant to guard + /// against. The `balanceFetchGeneration` counter still handles + /// the A→B→A overwrite race independently. + @State private var lastFetchedBalanceIdentityId: Data? /// Mirrors `balanceFetchGeneration` for the on-chain pause-flag /// reconciliation. Today the status `.task` only runs once per /// view appearance, so a slow response can't be raced by a @@ -687,6 +697,12 @@ struct TokenActionPermissionsView: View { self.initialIdentity = identity self._pickedIdentity = State(initialValue: identity) self._fetchedBalance = State(initialValue: initialBalance) + // Seed the identity marker only when the parent gave us an + // `initialBalance` to seed from — otherwise the first refresh + // has nothing worth preserving and should clear normally. + self._lastFetchedBalanceIdentityId = State( + initialValue: initialBalance != nil ? identity?.identityId : nil + ) // Filter to wallet-owned identities on the same network as // this token's parent contract; falls back to "any // wallet-owned" if the contract isn't loaded. @@ -840,10 +856,18 @@ struct TokenActionPermissionsView: View { balanceFetchGeneration &+= 1 let gen = balanceFetchGeneration - // Drop any stale value first so a slow / failed lookup can't - // forward the *previous* identity's balance into Transfer or - // Burn while the new fetch is in flight. - await MainActor.run { self.fetchedBalance = nil } + let currentIdentityId = resolvedIdentity?.identityId + + // Only drop the seeded/previous balance when the active + // identity actually changed. The previous unconditional + // clear wiped the parent-supplied `initialBalance` before + // the async fetch returned and lost it permanently on a + // failed fetch. The `balanceFetchGeneration` counter below + // still guards the A→B→A overwrite race that motivated the + // original clear. + if currentIdentityId != lastFetchedBalanceIdentityId { + await MainActor.run { self.fetchedBalance = nil } + } guard let identity = resolvedIdentity, let sdk = appState.sdk else { return @@ -874,6 +898,9 @@ struct TokenActionPermissionsView: View { // Default missing entries to 0 — the SDK omits tokens // the identity has never held. self.fetchedBalance = balances[canonicalTokenId] ?? 0 + // Stamp the marker so the next refresh on the same + // identity skips the clear-on-entry branch. + self.lastFetchedBalanceIdentityId = currentIdentityId } } catch { // Was previously a silent `catch { }`. Surface as a dev From 835fdd9db97d5577706240d099b75eed35597c25 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 6 May 2026 18:44:57 +0200 Subject: [PATCH 19/19] fix(swift-sdk): accept canonical groups map shape in transition builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The groups re-keying loop in TransitionDetailView.executeDataContractCreate (landed in `608a2fec7`, hardened in `a9aae7377`) hard-cast to `[[String: Any]]` and threw "Invalid groups JSON" on anything else. That's correct for `RegisterContractSourceView`'s flattened-array input, but it broke any caller passing the canonical chain-shape `{ "": { ... } }` map — including pasting normal contract JSON into the manual transition builder. Regression I introduced in `608a2fec7`. Switch on the parsed shape: accept the canonical map as a passthrough, keep the existing array branch (and its missing-id / unsupported-id-type / duplicate-key validation from `a9aae7377`) for the form-flattened input. Side benefit: `try?` becomes `try` on the `JSONSerialization` call, so genuine parse errors surface with their underlying message instead of getting collapsed into the same generic "Invalid groups JSON" as a shape mismatch. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Views/TransitionDetailView.swift | 72 ++++++++++++------- 1 file changed, 45 insertions(+), 27 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift index 33b7cb09da5..82d58062570 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift @@ -1660,37 +1660,55 @@ struct TransitionDetailView: View { // it with "expected a map, got sequence". var groups: [String: Any]? = nil if let groupsJson = formInputs["groups"], !groupsJson.isEmpty { - guard let data = groupsJson.data(using: .utf8), - let parsed = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { + guard let data = groupsJson.data(using: .utf8) else { throw SDKError.serializationError("Invalid groups JSON") } - // `RegisterContractSourceView` injects an `id` for every entry - // (Int when the source map's key parsed as integer, String - // otherwise) and entries derive from a map so keys are unique - // by construction. The three failure modes below are therefore - // unreachable today — but silently dropping or overwriting - // entries here would corrupt the contract submission and - // surface as a confusing chain rejection rather than a clear - // registration error, so trip loudly with `SDKError` on each. - var byPosition: [String: Any] = [:] - for var entry in parsed { - guard let rawId = entry.removeValue(forKey: "id") else { - throw SDKError.serializationError("Group entry is missing an `id` field") - } - let key: String - if let intId = rawId as? Int { - key = String(intId) - } else if let strId = rawId as? String { - key = strId - } else { - throw SDKError.serializationError("Group entry has unsupported `id` type — expected Int or String") - } - guard byPosition[key] == nil else { - throw SDKError.serializationError("Duplicate group position `\(key)` in groups payload") + // `RegisterContractSourceView` flattens the chain-shape map + // into an array with an injected `id`. Other callers of this + // transition builder (manual JSON paste, future refactors) + // may pass the canonical position-keyed map directly. Accept + // either, and let genuine `JSONSerialization` errors surface + // instead of getting collapsed into the same "Invalid groups + // JSON" as a shape mismatch. + let parsed = try JSONSerialization.jsonObject(with: data) + switch parsed { + case let byPosition as [String: Any]: + // Canonical chain shape — already what the FFI assembler + // wants, no re-keying needed. + groups = byPosition + case let entries as [[String: Any]]: + // Form-flattened shape from `RegisterContractSourceView`. + // The injected `id` is Int when the source map's key + // parsed as integer, String otherwise; entries derive from + // a map so keys are unique by construction. The three + // failure modes below are therefore unreachable from the + // current upstream — but silently dropping or overwriting + // entries here would corrupt the contract submission and + // surface as a confusing chain rejection rather than a + // clear registration error, so trip loudly with `SDKError` + // on each. + var byPosition: [String: Any] = [:] + for var entry in entries { + guard let rawId = entry.removeValue(forKey: "id") else { + throw SDKError.serializationError("Group entry is missing an `id` field") + } + let key: String + if let intId = rawId as? Int { + key = String(intId) + } else if let strId = rawId as? String { + key = strId + } else { + throw SDKError.serializationError("Group entry has unsupported `id` type — expected Int or String") + } + guard byPosition[key] == nil else { + throw SDKError.serializationError("Duplicate group position `\(key)` in groups payload") + } + byPosition[key] = entry } - byPosition[key] = entry + groups = byPosition + default: + throw SDKError.serializationError("Invalid groups JSON") } - groups = byPosition } // Build contract configuration