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..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 @@ -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,45 @@ 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() + { + // 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 + }; + let builder = TokenMintTransitionBuilder::new(data_contract, token_position, identity_id, amount); 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..48ade56d398 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/TokenAmountFormatting.swift @@ -0,0 +1,100 @@ +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. +/// +/// 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. +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: ".") + + // 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) 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) + var scaled = entered * multiplier + var rounded = Decimal() + NSDecimalRound(&rounded, &scaled, 0, .down) + + if rounded > Decimal(UInt64.max) { return nil } + 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. 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) + // `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 = rawDecimal / divisor + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = dec + formatter.minimumFractionDigits = 0 + // 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/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/IdentityDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift index 1c9b614e3ed..bb1f7813a21 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift @@ -114,9 +114,22 @@ struct IdentityDetailView: View { .foregroundColor(.blue) } - Label(identity.identityIdString, systemImage: "number") - .font(.caption) - .foregroundColor(.secondary) + // 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. + IdentityIDCopyRow( + icon: "textformat.abc", + value: identity.identityIdBase58, + accessibilityLabel: "Copy identity ID (base58)" + ) } .padding(.vertical, 4) @@ -291,7 +304,8 @@ struct IdentityDetailView: View { NavigationLink( destination: TokenActionPermissionsView( token: entry.token, - identity: identity + identity: identity, + initialBalance: entry.balance ) ) { IdentityTokenRow(entry: entry) @@ -1027,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/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") } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift index 3d148937e79..d90c05edc9f 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") } } @@ -633,12 +651,58 @@ struct TokenActionPermissionsView: View { @State private var pickedIdentity: PersistentIdentity? private let initialIdentity: PersistentIdentity? + /// 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? + /// 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 + /// 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 + /// 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 @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._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. @@ -767,6 +831,136 @@ 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() + } + // 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 { + balanceFetchGeneration &+= 1 + let gen = balanceFetchGeneration + + 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 + } + + // `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: [canonicalTokenId] + ) + await MainActor.run { + // 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 + // 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 + // 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)") + } + } + + /// 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 { + 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() + do { + let canonicalTokenId = try sdk.calculateTokenId( + contractId: contractIdString, + position: position + ) + let statuses = try await sdk.getTokenStatuses(tokenIds: [canonicalTokenId]) + // 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 + // 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 + 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)") + } } private var identityPickerBinding: Binding { @@ -825,11 +1019,21 @@ struct TokenActionPermissionsView: View { _ row: ResolvedTokenAction, identity: PersistentIdentity ) -> some View { + // `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) + TokenTransferActionView( + token: token, + identity: identity, + initialBalance: fetchedBalance + ) case .burn: - TokenBurnActionView(token: token, identity: identity) + TokenBurnActionView( + token: token, + identity: identity, + initialBalance: fetchedBalance + ) case .mint: TokenMintActionView(token: token, identity: identity) case .claim: 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 d8c70adffb6..455d7459402 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 @@ -21,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() @@ -62,7 +73,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,25 +124,32 @@ 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 } + /// 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 + /// 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 { @@ -202,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 @@ -220,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 e9f0522ff05..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() @@ -35,8 +39,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) + ) } } @@ -69,8 +76,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( @@ -89,7 +107,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 +164,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) @@ -200,7 +220,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.") @@ -226,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 @@ -245,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..d12a5ec0af9 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,33 @@ struct TokenPauseActionView: View { signer: signer ) 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 + // 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() } } 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 c31ad21e9f8..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() @@ -43,7 +47,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 +102,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 @@ -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..852bc77c7a7 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,33 @@ struct TokenResumeActionView: View { signer: signer ) 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 + // 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() } } 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 4e0fe9f0cad..2e3e8a88211 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 @@ -24,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() @@ -54,7 +65,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 +115,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,20 +123,30 @@ 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 } + /// 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 + /// 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 +160,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 } @@ -156,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 @@ -175,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 b4b4c9076c6..ca79429bd24 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() @@ -71,7 +75,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 +126,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 { @@ -198,11 +208,21 @@ struct TokenUpdateMaxSupplyActionView: View { } isSubmitting = true + submitGeneration &+= 1 + let gen = submitGeneration let signer = KeychainSigner(modelContainer: modelContext.container) let identityId = identity.identityId 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 { @@ -216,11 +236,34 @@ struct TokenUpdateMaxSupplyActionView: View { signer: signer ) 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 + // 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() } } 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/TokenDetailsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenDetailsView.swift index 56384800118..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)) @@ -160,14 +167,25 @@ 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: "Started as paused", isEnabled: token.isPaused) + TokenFeatureRow(label: "Emergency action available", + isEnabled: token.emergencyActionRules?.hasAuthorizedTakers ?? false) + // `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() @@ -394,3 +412,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 + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift index bd66ced4f58..82d58062570 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift @@ -1651,14 +1651,64 @@ 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 { + guard let data = groupsJson.data(using: .utf8) else { + throw SDKError.serializationError("Invalid groups JSON") + } + // `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 + } + groups = byPosition + default: throw SDKError.serializationError("Invalid groups JSON") } - groups = parsed } // Build contract configuration