From d0bd69a1e8fe7f10df9f151820b0a7bac892a676 Mon Sep 17 00:00:00 2001 From: Tyson Williams Date: Tue, 19 Aug 2025 09:01:03 -0700 Subject: [PATCH 1/6] phase 1 --- Package.swift | 2 - Sources/ParaSwift/Core/JSONValue.swift | 67 ++++ .../ParaSwift/Core/ParaManager+Signing.swift | 232 ++++++++++--- .../ParaSwift/Models/BridgeArguments.swift | 62 +--- .../ParaSwift/Signers/ParaCosmosSigner.swift | 205 ------------ Sources/ParaSwift/Signers/ParaEvmSigner.swift | 52 --- .../ParaSwift/Signers/ParaSolanaSigner.swift | 316 ------------------ .../Transactions/CosmosTransaction.swift | 103 ++++++ .../Transactions/EVMTransaction.swift | 5 - .../Transactions/SolanaTransaction.swift | 7 +- 10 files changed, 357 insertions(+), 694 deletions(-) create mode 100644 Sources/ParaSwift/Core/JSONValue.swift delete mode 100644 Sources/ParaSwift/Signers/ParaCosmosSigner.swift delete mode 100644 Sources/ParaSwift/Signers/ParaEvmSigner.swift delete mode 100644 Sources/ParaSwift/Signers/ParaSolanaSigner.swift create mode 100644 Sources/ParaSwift/Transactions/CosmosTransaction.swift diff --git a/Package.swift b/Package.swift index f748aee..7d11b6b 100644 --- a/Package.swift +++ b/Package.swift @@ -16,7 +16,6 @@ let package = Package( dependencies: [ .package(url: "https://github.com/attaswift/BigInt.git", .upToNextMinor(from: "5.4.0")), .package(url: "https://github.com/marmelroy/PhoneNumberKit", from: "4.0.0"), - .package(url: "https://github.com/p2p-org/solana-swift", from: "5.0.0"), ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. @@ -26,7 +25,6 @@ let package = Package( dependencies: [ "BigInt", "PhoneNumberKit", - .product(name: "SolanaSwift", package: "solana-swift"), ], ), ], diff --git a/Sources/ParaSwift/Core/JSONValue.swift b/Sources/ParaSwift/Core/JSONValue.swift new file mode 100644 index 0000000..b3bfb50 --- /dev/null +++ b/Sources/ParaSwift/Core/JSONValue.swift @@ -0,0 +1,67 @@ +import Foundation + +/// Helper type to encode arbitrary JSON values +struct JSONValue: Encodable { + let value: Any + + init(_ value: Any) { + self.value = value + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + if let dict = value as? [String: Any] { + try container.encode(JSONDictionary(dict)) + } else if let array = value as? [Any] { + try container.encode(array.map { JSONValue($0) }) + } else if let string = value as? String { + try container.encode(string) + } else if let bool = value as? Bool { + try container.encode(bool) + } else if let int = value as? Int { + try container.encode(int) + } else if let double = value as? Double { + try container.encode(double) + } else if value is NSNull { + try container.encodeNil() + } else { + throw EncodingError.invalidValue(value, EncodingError.Context( + codingPath: container.codingPath, + debugDescription: "Cannot encode value of type \(type(of: value))" + )) + } + } +} + +struct JSONDictionary: Encodable { + let dictionary: [String: Any] + + init(_ dictionary: [String: Any]) { + self.dictionary = dictionary + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: DynamicCodingKey.self) + + for (key, value) in dictionary { + let codingKey = DynamicCodingKey(stringValue: key)! + try container.encode(JSONValue(value), forKey: codingKey) + } + } +} + +struct DynamicCodingKey: CodingKey { + var stringValue: String + var intValue: Int? + + init?(stringValue: String) { + self.stringValue = stringValue + self.intValue = nil + } + + init?(intValue: Int) { + self.stringValue = String(intValue) + self.intValue = intValue + } +} \ No newline at end of file diff --git a/Sources/ParaSwift/Core/ParaManager+Signing.swift b/Sources/ParaSwift/Core/ParaManager+Signing.swift index caf4560..a214a3d 100644 --- a/Sources/ParaSwift/Core/ParaManager+Signing.swift +++ b/Sources/ParaSwift/Core/ParaManager+Signing.swift @@ -1,88 +1,222 @@ -import os -import SwiftUI // Keep if @MainActor is used, otherwise Foundation +import Foundation // MARK: - Signing Operations +/// Result of a signing operation +public struct SignatureResult { + /// The signature string + public let signature: String + /// The wallet ID that signed + public let walletId: String + /// The wallet type (e.g., "evm", "solana", "cosmos") + public let type: String + + public init(signature: String, walletId: String, type: String) { + self.signature = signature + self.walletId = walletId + self.type = type + } +} + +// Helper structs for formatting methods +struct FormatAndSignMessageParams: Encodable { + let walletId: String + let message: String +} + +struct FormatAndSignTransactionParams: Encodable { + let walletId: String + let transaction: [String: Any] + let chainId: String? + let rpcUrl: String? + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(walletId, forKey: .walletId) + try container.encode(JSONValue(transaction), forKey: .transaction) + try container.encodeIfPresent(chainId, forKey: .chainId) + try container.encodeIfPresent(rpcUrl, forKey: .rpcUrl) + } + + enum CodingKeys: String, CodingKey { + case walletId, transaction, chainId, rpcUrl + } +} + public extension ParaManager { - /// Signs a message with a wallet. + /// Signs a message with any wallet type. + /// + /// This unified method works with all wallet types (EVM, Solana, Cosmos, etc.). + /// The bridge handles the chain-specific signing logic internally. /// /// - Parameters: /// - walletId: The ID of the wallet to use for signing. - /// - message: The message to sign. - /// - timeoutMs: Optional timeout in milliseconds for the signing operation. - /// - Returns: The signature as a string. - func signMessage(walletId: String, message: String, timeoutMs: Int? = nil) async throws -> String { + /// - message: The message to sign (plain text). + /// - Returns: A SignatureResult containing the signature and metadata. + func signMessage(walletId: String, message: String) async throws -> SignatureResult { try await withErrorTracking( - methodName: "signMessage", + methodName: "formatAndSignMessage", userId: getCurrentUserId(), operation: { try await ensureWebViewReady() - struct SignMessageParams: Encodable { - let walletId: String - let messageBase64: String - let timeoutMs: Int? - } - - let messageBase64 = message.toBase64() - let params = SignMessageParams( + let params = FormatAndSignMessageParams( walletId: walletId, - messageBase64: messageBase64, - timeoutMs: timeoutMs, + message: message ) - let result = try await postMessage(method: "signMessage", payload: params) - return try decodeDictionaryResult( - result, - expectedType: String.self, - method: "signMessage", - key: "signature", + let result = try await postMessage(method: "formatAndSignMessage", payload: params) + + // Parse the response from bridge + let dict = try decodeResult(result, expectedType: [String: Any].self, method: "formatAndSignMessage") + + let signature = dict["signature"] as? String ?? "" + let returnedWalletId = dict["walletId"] as? String ?? walletId + let walletType = dict["type"] as? String ?? "unknown" + + return SignatureResult( + signature: signature, + walletId: returnedWalletId, + type: walletType ) } ) } - /// Signs a transaction with a wallet. + /// Signs a transaction with any wallet type. + /// + /// This unified method works with all wallet types. It accepts transaction parameters + /// as an Encodable object that will be formatted by the bridge based on wallet type. /// /// - Parameters: /// - walletId: The ID of the wallet to use for signing. - /// - rlpEncodedTx: The RLP-encoded transaction. - /// - chainId: The chain ID. - /// - timeoutMs: Optional timeout in milliseconds for the signing operation. - /// - Returns: The signature as a string. - func signTransaction( + /// - transaction: The transaction parameters (EVMTransaction, SolanaTransaction, etc.). + /// - chainId: Optional chain ID (primarily for EVM chains). + /// - Parameters: + /// - rpcUrl: Optional RPC URL (required for Solana if recentBlockhash is not provided). + /// - Returns: A SignatureResult containing the signature and metadata. + func signTransaction( walletId: String, - rlpEncodedTx: String, - chainId: String, - timeoutMs: Int? = nil - ) async throws -> String { + transaction: T, + chainId: String? = nil, + rpcUrl: String? = nil + ) async throws -> SignatureResult { try await withErrorTracking( - methodName: "signTransaction", + methodName: "formatAndSignTransaction", userId: getCurrentUserId(), operation: { try await ensureWebViewReady() - struct SignTransactionParams: Encodable { + // Encode the transaction object to JSON + let encoder = JSONEncoder() + let transactionData = try encoder.encode(transaction) + let transactionDict = try JSONSerialization.jsonObject(with: transactionData, options: []) as? [String: Any] ?? [:] + + let params = FormatAndSignTransactionParams( + walletId: walletId, + transaction: transactionDict, + chainId: chainId, + rpcUrl: rpcUrl + ) + + let result = try await postMessage(method: "formatAndSignTransaction", payload: params) + + // Parse the response from bridge + let dict = try decodeResult(result, expectedType: [String: Any].self, method: "formatAndSignTransaction") + + let signature = dict["signature"] as? String ?? "" + let returnedWalletId = dict["walletId"] as? String ?? walletId + let walletType = dict["type"] as? String ?? "unknown" + + return SignatureResult( + signature: signature, + walletId: returnedWalletId, + type: walletType + ) + } + ) + } + + /// Gets the balance for any wallet type. + /// + /// This unified method works with all wallet types. For token balances, + /// provide the token contract address or symbol. + /// + /// - Parameters: + /// - walletId: The ID of the wallet. + /// - token: Optional token identifier (contract address for EVM, mint address for Solana, etc.). + /// - rpcUrl: Optional RPC URL (recommended for Solana and Cosmos to avoid 403/CORS issues). + /// - chainPrefix: Optional bech32 prefix for Cosmos (e.g., "juno", "stars"). + /// - denom: Optional denom for Cosmos balances (e.g., "ujuno", "ustars"). + /// - Returns: The balance as a string (format depends on the chain). + func getBalance(walletId: String, token: String? = nil, rpcUrl: String? = nil, chainPrefix: String? = nil, denom: String? = nil) async throws -> String { + try await withErrorTracking( + methodName: "getBalance", + userId: getCurrentUserId(), + operation: { + try await ensureWebViewReady() + + struct GetBalanceParams: Encodable { let walletId: String - let rlpEncodedTxBase64: String - let chainId: String - let timeoutMs: Int? + let token: String? + let rpcUrl: String? + let chainPrefix: String? + let denom: String? } - let params = SignTransactionParams( + let params = GetBalanceParams( walletId: walletId, - rlpEncodedTxBase64: rlpEncodedTx.toBase64(), - chainId: chainId, - timeoutMs: timeoutMs, + token: token, + rpcUrl: rpcUrl, + chainPrefix: chainPrefix, + denom: denom ) - let result = try await postMessage(method: "signTransaction", payload: params) - return try decodeDictionaryResult( - result, - expectedType: String.self, - method: "signTransaction", - key: "signature", + let result = try await postMessage(method: "getBalance", payload: params) + return try decodeResult(result, expectedType: String.self, method: "getBalance") + } + ) + } + + /// High-level transfer method for any wallet type. + /// + /// This convenience method handles token transfers across all supported chains. + /// The bridge handles the chain-specific transaction construction internally. + /// + /// - Parameters: + /// - walletId: The ID of the wallet to transfer from. + /// - to: The recipient address. + /// - amount: The amount to transfer (as a string to handle large numbers). + /// - token: Optional token identifier (contract address for EVM, mint address for Solana, etc.). + /// - Returns: The transaction hash. + func transfer( + walletId: String, + to: String, + amount: String, + token: String? = nil + ) async throws -> String { + try await withErrorTracking( + methodName: "transfer", + userId: getCurrentUserId(), + operation: { + try await ensureWebViewReady() + + struct TransferParams: Encodable { + let walletId: String + let to: String + let amount: String + let token: String? + } + + let params = TransferParams( + walletId: walletId, + to: to, + amount: amount, + token: token ) + + let result = try await postMessage(method: "transfer", payload: params) + return try decodeResult(result, expectedType: String.self, method: "transfer") } ) } diff --git a/Sources/ParaSwift/Models/BridgeArguments.swift b/Sources/ParaSwift/Models/BridgeArguments.swift index ac3dfbc..f1e119b 100644 --- a/Sources/ParaSwift/Models/BridgeArguments.swift +++ b/Sources/ParaSwift/Models/BridgeArguments.swift @@ -60,74 +60,18 @@ struct SignTransactionArgs: Encodable { let timeoutMs: Int? } -// For Signers -struct EthersSignerInitArgs: Encodable { - let walletId: String - let providerUrl: String // Matches bridge name -} - -struct EthersSignMessageArgs: Encodable { - let message: String -} - -struct EthersSignTransactionArgs: Encodable { - let b64EncodedTx: String -} - -struct EthersSendTransactionArgs: Encodable { - let b64EncodedTx: String -} - -// Solana bridge arguments -struct SolanaSignerInitArgs: Encodable { - let walletId: String - let rpcUrl: String -} - -struct SolanaSignTransactionArgs: Encodable { - let b64EncodedTx: String -} - -struct SolanaSignVersionedTransactionArgs: Encodable { - let b64EncodedTx: String -} - -struct SolanaSendTransactionArgs: Encodable { - let b64EncodedTx: String -} +// Signer-specific arguments removed - using unified API struct DistributeNewWalletShareArgs: Encodable { let walletId: String let userShare: String } -// Cosmos bridge arguments -struct CosmosSignerInitArgs: Encodable { - let walletId: String - let prefix: String - let messageSigningTimeoutMs: Int? -} +// Cosmos-specific arguments removed - using unified API +// Keep only the generic display address args that might be used elsewhere struct GetDisplayAddressArgs: Encodable { let walletId: String let addressType: String let cosmosPrefix: String? } - -// Cosmos signing arguments for low-level bridge methods -// Note: "Direct" == "Proto" signing in CosmJS terminology -struct CosmJsSignDirectArgs: Encodable { - let signerAddress: String - let signDocBase64: String -} - -struct CosmJsSignAminoArgs: Encodable { - let signerAddress: String - let signDocBase64: String -} - -struct CosmJsGetBalanceArgs: Encodable { - let address: String - let denom: String - let rpcUrl: String -} diff --git a/Sources/ParaSwift/Signers/ParaCosmosSigner.swift b/Sources/ParaSwift/Signers/ParaCosmosSigner.swift deleted file mode 100644 index 2c2aa8c..0000000 --- a/Sources/ParaSwift/Signers/ParaCosmosSigner.swift +++ /dev/null @@ -1,205 +0,0 @@ -// -// ParaCosmosSigner.swift -// ParaSwift -// -// Created by Para AI on 6/17/25. -// - -import Foundation - -/// Errors specific to ParaCosmosSigner operations -public enum ParaCosmosSignerError: Error, LocalizedError { - case invalidWalletType - case missingWalletId - case invalidWalletAddress - case signingFailed(underlyingError: Error?) - case networkError(underlyingError: Error?) - case bridgeError(String) - case invalidTransaction(String) - case protoSigningFailed(underlyingError: Error?) - case aminoSigningFailed(underlyingError: Error?) - case invalidSignDoc(String) - - public var errorDescription: String? { - switch self { - case .invalidWalletType: "Invalid wallet type - expected Cosmos wallet" - case .missingWalletId: "Wallet ID is missing" - case .invalidWalletAddress: "Wallet address is missing or invalid" - case let .signingFailed(error): "Signing operation failed: \(error?.localizedDescription ?? "Unknown error")" - case let .networkError(error): "Network operation failed: \(error?.localizedDescription ?? "Unknown error")" - case let .bridgeError(message): "Bridge operation failed: \(message)" - case let .invalidTransaction(message): "Invalid transaction: \(message)" - case let .protoSigningFailed(error): "Proto/Direct signing failed: \(error?.localizedDescription ?? "Unknown error")" - case let .aminoSigningFailed(error): "Amino signing failed: \(error?.localizedDescription ?? "Unknown error")" - case let .invalidSignDoc(message): "Invalid SignDoc: \(message)" - } - } -} - -/// A signer for Cosmos blockchain operations using Para's secure key management via bridge -@MainActor -public class ParaCosmosSigner: ObservableObject { - private let paraManager: ParaManager - private var walletId: String? - private let rpcUrl: String - private let chainId: String - private let prefix: String - - /// Initialize a new ParaCosmosSigner - public init( - paraManager: ParaManager, - chainId: String = "cosmoshub-4", - rpcUrl: String = "https://rpc.provider-sentry-01.ics-testnet.polypore.xyz", - prefix: String = "cosmos", - walletId: String? = nil - ) throws { - self.paraManager = paraManager - self.chainId = chainId - self.rpcUrl = rpcUrl - self.prefix = prefix - - if let walletId { - Task { try await selectWallet(walletId: walletId) } - } - } - - /// Select a wallet for signing operations - public func selectWallet(walletId: String) async throws { - let args = CosmosSignerInitArgs(walletId: walletId, prefix: prefix, messageSigningTimeoutMs: nil) - _ = try await paraManager.postMessage(method: "initCosmJsSigners", payload: args) - self.walletId = walletId - } - - /// Get the current wallet's address for this chain - public func getAddress() async throws -> String { - guard let walletId else { throw ParaCosmosSignerError.missingWalletId } - - do { - // Use the bridge to get the proper address for this chain - let result = try await paraManager.postMessage(method: "getCosmosSignerAddress", payload: EmptyPayload()) - - if let addressResponse = result as? [String: Any], - let addressString = addressResponse["address"] as? String, - !addressString.isEmpty - { - return addressString - } - - // Fallback to wallet fetch if bridge method doesn't work - let wallets = try await paraManager.fetchWallets() - guard let wallet = wallets.first(where: { $0.id == walletId && $0.type == .cosmos }) else { - throw ParaCosmosSignerError.invalidWalletType - } - - guard let cosmosAddress = wallet.addressSecondary, !cosmosAddress.isEmpty else { - throw ParaCosmosSignerError.invalidWalletAddress - } - - // If the wallet address already has the correct prefix, use it - if cosmosAddress.hasPrefix(prefix + "1") { - return cosmosAddress - } - - // For now, return the original address and let the bridge handle conversion - return cosmosAddress - } catch let error as ParaWebViewError { - throw ParaCosmosSignerError.bridgeError("Failed to get address: \(error.localizedDescription)") - } - } - - /// Get the balance for this wallet - public func getBalance(denom: String? = nil) async throws -> String { - guard let _ = walletId else { throw ParaCosmosSignerError.missingWalletId } - - let address = try await getAddress() - let queryDenom = denom ?? getDefaultDenom() - - do { - let args = CosmJsGetBalanceArgs(address: address, denom: queryDenom, rpcUrl: rpcUrl) - let result = try await paraManager.postMessage(method: "cosmJsGetBalance", payload: args) - - guard let balanceInfo = result as? [String: Any], - let amount = balanceInfo["amount"] as? String - else { - throw ParaCosmosSignerError.bridgeError("Invalid balance response from bridge") - } - - return amount - } catch let error as ParaWebViewError { - throw ParaCosmosSignerError.networkError(underlyingError: error) - } catch { - throw ParaCosmosSignerError.networkError(underlyingError: error) - } - } - - /// Sign arbitrary message (for demo/testing purposes) - public func signMessage(_ message: String) async throws -> Data { - guard let walletId else { throw ParaCosmosSignerError.missingWalletId } - - do { - let messageBase64 = Data(message.utf8).base64EncodedString() - let signatureString = try await paraManager.signMessage(walletId: walletId, message: messageBase64) - - guard let signatureData = Data(hexString: signatureString) else { - throw ParaCosmosSignerError.bridgeError("Invalid signature format") - } - - return signatureData - } catch { - throw ParaCosmosSignerError.signingFailed(underlyingError: error) - } - } - - /// Sign a transaction using direct/proto signing - /// Uses the cosmJsSignDirect bridge method for direct access to CosmJS proto signing - /// Note: "Direct" and "Proto" refer to the same signing method in CosmJS - public func signDirect(signDocBase64: String) async throws -> [String: Any] { - guard let _ = walletId else { throw ParaCosmosSignerError.missingWalletId } - - let address = try await getAddress() - let args = CosmJsSignDirectArgs(signerAddress: address, signDocBase64: signDocBase64) - - do { - let result = try await paraManager.postMessage(method: "cosmJsSignDirect", payload: args) - guard let responseDict = result as? [String: Any] else { - throw ParaCosmosSignerError.bridgeError("Invalid response format from cosmJsSignDirect") - } - return responseDict - } catch { - throw ParaCosmosSignerError.protoSigningFailed(underlyingError: error) - } - } - - /// Sign a transaction using amino signing - /// Uses the cosmJsSignAmino bridge method for direct access to CosmJS amino signing - public func signAmino(signDocBase64: String) async throws -> [String: Any] { - guard let _ = walletId else { throw ParaCosmosSignerError.missingWalletId } - - let address = try await getAddress() - let args = CosmJsSignAminoArgs(signerAddress: address, signDocBase64: signDocBase64) - - do { - let result = try await paraManager.postMessage(method: "cosmJsSignAmino", payload: args) - guard let responseDict = result as? [String: Any] else { - throw ParaCosmosSignerError.bridgeError("Invalid response format from cosmJsSignAmino") - } - return responseDict - } catch { - throw ParaCosmosSignerError.aminoSigningFailed(underlyingError: error) - } - } - - // MARK: - Private Helpers - - private func getDefaultDenom() -> String { - switch chainId { - case "provider": "uatom" - case "osmo-test-5": "uosmo" - case "cosmoshub-4": "uatom" - case "osmosis-1": "uosmo" - case "juno-1": "ujuno" - case "stargaze-1": "ustars" - default: "uatom" - } - } -} diff --git a/Sources/ParaSwift/Signers/ParaEvmSigner.swift b/Sources/ParaSwift/Signers/ParaEvmSigner.swift deleted file mode 100644 index 7804562..0000000 --- a/Sources/ParaSwift/Signers/ParaEvmSigner.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// ParaEvmSigner.swift -// ParaSwift -// -// Created by Brian Corbin on 2/5/25. -// - -import Foundation - -@MainActor -public class ParaEvmSigner: ObservableObject { - private let paraManager: ParaManager - private let rpcUrl: String - - public init(paraManager: ParaManager, rpcUrl: String, walletId: String?) throws { - self.paraManager = paraManager - self.rpcUrl = rpcUrl - - if let walletId { - Task { - try await selectWallet(walletId: walletId) - } - } - } - - private func initEthersSigner(rpcUrl: String, walletId: String) async throws { - let args = EthersSignerInitArgs(walletId: walletId, providerUrl: rpcUrl) - let _ = try await paraManager.postMessage(method: "initEthersSigner", payload: args) - } - - public func selectWallet(walletId: String) async throws { - try await initEthersSigner(rpcUrl: rpcUrl, walletId: walletId) - } - - public func signMessage(message: String) async throws -> String { - let args = EthersSignMessageArgs(message: message) - let result = try await paraManager.postMessage(method: "ethersSignMessage", payload: args) - return try paraManager.decodeResult(result, expectedType: String.self, method: "ethersSignMessage") - } - - public func signTransaction(transactionB64: String) async throws -> String { - let args = EthersSignTransactionArgs(b64EncodedTx: transactionB64) - let result = try await paraManager.postMessage(method: "ethersSignTransaction", payload: args) - return try paraManager.decodeResult(result, expectedType: String.self, method: "ethersSignTransaction") - } - - public func sendTransaction(transactionB64: String) async throws -> Any { - let args = EthersSendTransactionArgs(b64EncodedTx: transactionB64) - let result = try await paraManager.postMessage(method: "ethersSendTransaction", payload: args) - return result! - } -} diff --git a/Sources/ParaSwift/Signers/ParaSolanaSigner.swift b/Sources/ParaSwift/Signers/ParaSolanaSigner.swift deleted file mode 100644 index a12320f..0000000 --- a/Sources/ParaSwift/Signers/ParaSolanaSigner.swift +++ /dev/null @@ -1,316 +0,0 @@ -// -// ParaSolanaSigner.swift -// ParaSwift -// -// Created by Para AI on 1/27/25. -// - -import Foundation -import SolanaSwift - -/// Errors specific to ParaSolanaSigner operations -public enum ParaSolanaSignerError: Error, LocalizedError { - /// Invalid wallet type - expected Solana wallet - case invalidWalletType - /// Wallet ID is missing - case missingWalletId - /// Wallet address is missing or invalid - case invalidWalletAddress - /// Signing operation failed - case signingFailed(underlyingError: Error?) - /// Network operation failed - case networkError(underlyingError: Error?) - /// Invalid signature format - case invalidSignature - /// Transaction compilation failed - case transactionCompilationFailed(underlyingError: Error?) - /// Bridge operation failed - case bridgeError(String) - - public var errorDescription: String? { - switch self { - case .invalidWalletType: - "Invalid wallet type - expected Solana wallet" - case .missingWalletId: - "Wallet ID is missing" - case .invalidWalletAddress: - "Wallet address is missing or invalid" - case let .signingFailed(error): - "Signing operation failed: \(error?.localizedDescription ?? "Unknown error")" - case let .networkError(error): - "Network operation failed: \(error?.localizedDescription ?? "Unknown error")" - case .invalidSignature: - "Invalid signature format" - case let .transactionCompilationFailed(error): - "Transaction compilation failed: \(error?.localizedDescription ?? "Unknown error")" - case let .bridgeError(message): - "Bridge operation failed: \(message)" - } - } -} - -/// A signer for Solana blockchain operations using Para's secure key management via bridge -@MainActor -public class ParaSolanaSigner: ObservableObject { - private let paraManager: ParaManager - private let rpcUrl: String - private var walletId: String? - - /// Initialize a new ParaSolanaSigner - /// - Parameters: - /// - paraManager: The ParaManager instance for bridge operations - /// - rpcUrl: The Solana RPC endpoint URL - /// - walletId: Optional specific wallet ID to use - public init(paraManager: ParaManager, rpcUrl: String, walletId: String? = nil) throws { - self.paraManager = paraManager - self.rpcUrl = rpcUrl - - if let walletId { - Task { - try await selectWallet(walletId: walletId) - } - } - } - - /// Initialize the Solana signer in the bridge with a specific wallet - private func initSolanaSigner(rpcUrl: String, walletId: String) async throws { - let args = SolanaSignerInitArgs(walletId: walletId, rpcUrl: rpcUrl) - let _ = try await paraManager.postMessage(method: "initSolanaWeb3Signer", payload: args) - self.walletId = walletId - } - - /// Select a wallet for signing operations - /// - Parameter walletId: The wallet ID to use - public func selectWallet(walletId: String) async throws { - try await initSolanaSigner(rpcUrl: rpcUrl, walletId: walletId) - } - - /// Get the current wallet's public key - /// - Returns: The Solana public key - /// - Throws: ParaSolanaSignerError if wallet not initialized - public func getPublicKey() async throws -> SolanaSwift.PublicKey { - guard let walletId else { - throw ParaSolanaSignerError.missingWalletId - } - - // Fetch wallets to get the address - let wallets = try await paraManager.fetchWallets() - guard let wallet = wallets.first(where: { $0.id == walletId && $0.type == .solana }) else { - throw ParaSolanaSignerError.invalidWalletType - } - - guard let address = wallet.address else { - throw ParaSolanaSignerError.invalidWalletAddress - } - - return try SolanaSwift.PublicKey(string: address) - } - - // MARK: - Public Methods - - /// Get the SOL balance for this wallet via bridge - /// - Returns: Balance in lamports - /// - Throws: ParaSolanaSignerError if network request fails - public func getBalance() async throws -> UInt64 { - guard walletId != nil else { - throw ParaSolanaSignerError.missingWalletId - } - - // Get the wallet address - let publicKey = try await getPublicKey() - let address = publicKey.base58EncodedString - - do { - // Pass address to the bridge (not walletId) - let args = ["address": address] - let result = try await paraManager.postMessage(method: "solanaWeb3GetBalance", payload: args) - - // Bridge returns balance as string - guard let balanceString = result as? String, - let balance = UInt64(balanceString) - else { - throw ParaSolanaSignerError.bridgeError("Invalid balance response from bridge") - } - return balance - } catch let error as ParaWebViewError { - // Check if the method is not implemented - if error.localizedDescription.contains("not implemented") { - throw ParaSolanaSignerError.bridgeError("Solana balance fetching is not yet supported in the bridge") - } - throw ParaSolanaSignerError.networkError(underlyingError: error) - } catch { - throw ParaSolanaSignerError.networkError(underlyingError: error) - } - } - - /// Sign arbitrary bytes (for demo/testing purposes) - /// - Parameter data: The data to sign - /// - Returns: The signature as Data - /// - Throws: ParaSolanaSignerError if signing fails - public func signBytes(_ data: Data) async throws -> Data { - guard let walletId else { - throw ParaSolanaSignerError.missingWalletId - } - - // Use Para's signMessage method directly - let messageBase64 = data.base64EncodedString() - - do { - let signatureString = try await paraManager.signMessage( - walletId: walletId, - message: messageBase64, - ) - - // Decode the signature - guard let signatureData = Data(base64Encoded: signatureString) else { - throw ParaSolanaSignerError.invalidSignature - } - - return signatureData - } catch { - throw ParaSolanaSignerError.signingFailed(underlyingError: error) - } - } - - /// Sign a Solana transaction using the bridge - /// - Parameter transaction: The SolanaTransaction to sign - /// - Returns: The base64 encoded signed transaction - /// - Throws: ParaSolanaSignerError if signing fails - public func signTransaction(_ transaction: SolanaTransaction) async throws -> String { - guard walletId != nil else { - throw ParaSolanaSignerError.missingWalletId - } - - do { - // Convert SolanaTransaction to actual SolanaSwift.Transaction - var solanaTransaction = try await buildSolanaTransaction(from: transaction) - - // Serialize the transaction to bytes - let serializedTx = try solanaTransaction.serialize() - let b64EncodedTx = serializedTx.base64EncodedString() - - // Call bridge method to sign - let args = SolanaSignTransactionArgs(b64EncodedTx: b64EncodedTx) - - do { - let result = try await paraManager.postMessage(method: "solanaWeb3SignTransaction", payload: args) - - // Bridge returns base64 encoded signed transaction - guard let signedTxBase64 = result as? String else { - throw ParaSolanaSignerError.bridgeError("Invalid response from bridge") - } - - return signedTxBase64 - } catch let error as ParaWebViewError { - if error.localizedDescription.contains("not implemented") { - throw ParaSolanaSignerError.bridgeError("Solana transaction signing is not yet supported in the bridge") - } - throw error - } - } catch let error as ParaSolanaSignerError { - throw error - } catch { - throw ParaSolanaSignerError.transactionCompilationFailed(underlyingError: error) - } - } - - /// Send a transaction to the network using the bridge - /// - Parameter transaction: The SolanaTransaction to send - /// - Returns: The transaction signature - /// - Throws: ParaSolanaSignerError if operation fails - public func sendTransaction(_ transaction: SolanaTransaction) async throws -> String { - guard walletId != nil else { - throw ParaSolanaSignerError.missingWalletId - } - - do { - // Convert SolanaTransaction to actual SolanaSwift.Transaction - var solanaTransaction = try await buildSolanaTransaction(from: transaction) - - // Serialize the transaction to bytes - let serializedTx = try solanaTransaction.serialize() - let b64EncodedTx = serializedTx.base64EncodedString() - - // Call bridge method to send (signs and sends in one operation) - let args = SolanaSendTransactionArgs(b64EncodedTx: b64EncodedTx) - - do { - let result = try await paraManager.postMessage(method: "solanaWeb3SendTransaction", payload: args) - - // Bridge returns transaction signature - guard let signature = result as? String else { - throw ParaSolanaSignerError.bridgeError("Invalid response from bridge") - } - - return signature - } catch let error as ParaWebViewError { - if error.localizedDescription.contains("not implemented") { - throw ParaSolanaSignerError.bridgeError("Solana transaction sending is not yet supported in the bridge") - } - throw error - } - } catch let error as ParaSolanaSignerError { - throw error - } catch { - throw ParaSolanaSignerError.networkError(underlyingError: error) - } - } - - /// Converts a SolanaTransaction abstraction to a proper SolanaSwift.Transaction - /// - Parameter transaction: The SolanaTransaction to convert - /// - Returns: A properly constructed SolanaSwift.Transaction - /// - Throws: ParaSolanaSignerError if conversion fails - private func buildSolanaTransaction(from transaction: SolanaTransaction) async throws -> SolanaSwift.Transaction { - do { - // Get the public key for fee payer - let publicKey = try await getPublicKey() - - // Create recipient public key - let recipientKey = try SolanaSwift.PublicKey(string: transaction.to) - - // Create transfer instruction - let instruction = SystemProgram.transferInstruction( - from: publicKey, - to: recipientKey, - lamports: transaction.lamports, - ) - - // Create transaction with instructions - var solanaTransaction = SolanaSwift.Transaction(instructions: [instruction]) - - // Set fee payer - if let feePayerAddress = transaction.feePayer { - solanaTransaction.feePayer = try SolanaSwift.PublicKey(string: feePayerAddress) - } else { - solanaTransaction.feePayer = publicKey - } - - // Set recent blockhash if provided, otherwise fetch it via bridge - if let blockhash = transaction.recentBlockhash { - solanaTransaction.recentBlockhash = blockhash - } else { - do { - // Bridge doesn't expect any parameters for getRecentBlockhash - let result = try await paraManager.postMessage(method: "solanaWeb3GetRecentBlockhash", payload: [String: String]()) - - // Bridge returns an object with blockhash and lastValidBlockHeight - guard let blockhashResult = result as? [String: Any], - let blockhash = blockhashResult["blockhash"] as? String - else { - throw ParaSolanaSignerError.bridgeError("Invalid blockhash response from bridge") - } - solanaTransaction.recentBlockhash = blockhash - } catch let error as ParaWebViewError { - if error.localizedDescription.contains("not implemented") { - throw ParaSolanaSignerError.bridgeError("Solana blockhash fetching is not yet supported in the bridge") - } - throw error - } - } - - return solanaTransaction - } catch { - throw ParaSolanaSignerError.transactionCompilationFailed(underlyingError: error) - } - } -} diff --git a/Sources/ParaSwift/Transactions/CosmosTransaction.swift b/Sources/ParaSwift/Transactions/CosmosTransaction.swift new file mode 100644 index 0000000..bc95843 --- /dev/null +++ b/Sources/ParaSwift/Transactions/CosmosTransaction.swift @@ -0,0 +1,103 @@ +// +// CosmosTransaction.swift +// ParaSwift +// +// Transaction parameters for Cosmos blockchain operations +// + +import Foundation + +/// Represents transaction parameters for Cosmos blockchain operations +/// +/// This struct encapsulates all the necessary information for creating +/// and signing Cosmos transactions through the Para bridge. +public struct CosmosTransaction: Codable { + /// The recipient address (bech32 format, e.g., "cosmos1...") + public let to: String + + /// The amount to send (in smallest denomination, e.g., "1000000" for 1 ATOM) + public let amount: String + + /// The denomination of the token (e.g., "uatom", "uosmo") + public let denom: String? + + /// Optional memo for the transaction + public let memo: String? + + /// Gas limit for the transaction (defaults to "200000") + public let gasLimit: String? + + /// Gas price/fee amount (defaults to "5000") + public let gasPrice: String? + + /// Transaction sequence number (defaults to 0) + public let sequence: Int? + + /// Account number (defaults to 0) + public let accountNumber: Int? + + /// Chain ID (e.g., "cosmoshub-4", "osmosis-1") + public let chainId: String? + + /// Transaction format: "proto" (default) or "amino" + public let format: String? + + /// Creates a new Cosmos transaction + /// + /// - Parameters: + /// - to: The recipient address in bech32 format + /// - amount: The amount to send in smallest denomination + /// - denom: The token denomination (defaults to "uatom") + /// - memo: Optional transaction memo + /// - gasLimit: Gas limit (defaults to "200000") + /// - gasPrice: Gas price/fee amount (defaults to "5000") + /// - sequence: Transaction sequence number + /// - accountNumber: Account number + /// - chainId: The chain ID + /// - format: Transaction format ("proto" or "amino", defaults to "proto") + public init( + to: String, + amount: String, + denom: String? = nil, + memo: String? = nil, + gasLimit: String? = nil, + gasPrice: String? = nil, + sequence: Int? = nil, + accountNumber: Int? = nil, + chainId: String? = nil, + format: String? = nil + ) { + self.to = to + self.amount = amount + self.denom = denom + self.memo = memo + self.gasLimit = gasLimit + self.gasPrice = gasPrice + self.sequence = sequence + self.accountNumber = accountNumber + self.chainId = chainId + self.format = format + } + + /// Creates a simple transfer transaction + /// + /// - Parameters: + /// - to: The recipient address + /// - amount: The amount to send + /// - denom: The token denomination (defaults to "uatom") + /// - chainId: The chain ID + /// - Returns: A configured CosmosTransaction for a simple transfer + public static func transfer( + to: String, + amount: String, + denom: String = "uatom", + chainId: String + ) -> CosmosTransaction { + return CosmosTransaction( + to: to, + amount: amount, + denom: denom, + chainId: chainId + ) + } +} \ No newline at end of file diff --git a/Sources/ParaSwift/Transactions/EVMTransaction.swift b/Sources/ParaSwift/Transactions/EVMTransaction.swift index 3465b58..760294d 100644 --- a/Sources/ParaSwift/Transactions/EVMTransaction.swift +++ b/Sources/ParaSwift/Transactions/EVMTransaction.swift @@ -123,11 +123,6 @@ public struct EVMTransaction: Codable { ) } - /// Encodes the transaction as base64 for bridge communication - public func b64Encoded() -> String { - let encodedTransaction = try! JSONEncoder().encode(self) - return encodedTransaction.base64EncodedString() - } } // MARK: - Codable Implementation diff --git a/Sources/ParaSwift/Transactions/SolanaTransaction.swift b/Sources/ParaSwift/Transactions/SolanaTransaction.swift index cb0078e..bded336 100644 --- a/Sources/ParaSwift/Transactions/SolanaTransaction.swift +++ b/Sources/ParaSwift/Transactions/SolanaTransaction.swift @@ -26,7 +26,7 @@ public enum SolanaTransactionError: Error, LocalizedError { /// /// This abstraction provides a clean interface for creating Solana transactions /// without requiring direct SolanaSwift knowledge in application code. -/// The ParaSolanaSigner converts this to proper SolanaSwift.Transaction internally. +/// The bridge handles the conversion to proper Solana transaction format internally. /// /// **Current Support:** /// - SOL transfers with proper validation @@ -109,9 +109,4 @@ public struct SolanaTransaction: Codable { try self.init(to: to, lamports: UInt64(sol * 1_000_000_000)) } - /// Encodes the transaction as base64 for bridge communication - public func b64Encoded() -> String { - let encodedTransaction = try! JSONEncoder().encode(self) - return encodedTransaction.base64EncodedString() - } } From 91373ab5f0449d1d62eee57a339058c88d6b3cbd Mon Sep 17 00:00:00 2001 From: Tyson Williams Date: Wed, 20 Aug 2025 15:09:27 -0700 Subject: [PATCH 2/6] Merge 2.0.0-alpha error handling improvements into wallet unification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Keep unified wallet signing approach with SignatureResult - Incorporate error handling clarity improvements from alpha - Remove ErrorReportingClient and ErrorTrackable as per alpha changes - Maintain formatAndSignMessage/formatAndSignTransaction methods 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- Sources/ParaSwift/Bridge/ParaWebView.swift | 25 ++- .../ParaSwift/Core/ErrorReportingClient.swift | 177 ---------------- Sources/ParaSwift/Core/ErrorTrackable.swift | 63 ------ Sources/ParaSwift/Core/ParaManager+Auth.swift | 52 ++--- .../ParaSwift/Core/ParaManager+Signing.swift | 193 ++++++++---------- Sources/ParaSwift/Core/ParaManager.swift | 65 +++--- Sources/ParaSwift/Core/ParaTypes.swift | 16 +- 7 files changed, 163 insertions(+), 428 deletions(-) delete mode 100644 Sources/ParaSwift/Core/ErrorReportingClient.swift delete mode 100644 Sources/ParaSwift/Core/ErrorTrackable.swift diff --git a/Sources/ParaSwift/Bridge/ParaWebView.swift b/Sources/ParaSwift/Bridge/ParaWebView.swift index c3a3930..f04602b 100644 --- a/Sources/ParaSwift/Bridge/ParaWebView.swift +++ b/Sources/ParaSwift/Bridge/ParaWebView.swift @@ -267,9 +267,12 @@ public class ParaWebView: NSObject, ObservableObject { if let errorValue = response["error"] { var errorMessage = "" if let dict = errorValue as? [String: Any] { - if let data = try? JSONSerialization.data(withJSONObject: dict, options: .prettyPrinted), - let jsonStr = String(data: data, encoding: .utf8) - { + // Prefer a concise detail message when available (e.g. { details: { message } }) + let details = dict["details"] as? [String: Any] + if let friendly = details?["message"] as? String ?? dict["message"] as? String, !friendly.isEmpty { + errorMessage = friendly + } else if let data = try? JSONSerialization.data(withJSONObject: dict, options: .prettyPrinted), + let jsonStr = String(data: data, encoding: .utf8) { errorMessage = jsonStr } else { errorMessage = String(describing: dict) @@ -368,7 +371,7 @@ extension ParaWebView: WKScriptMessageHandler { } /// Errors that can occur during WebView operations -enum ParaWebViewError: Error, CustomStringConvertible { +enum ParaWebViewError: Error, CustomStringConvertible, LocalizedError { /// The WebView is not ready to accept requests case webViewNotReady /// Invalid arguments were provided @@ -391,6 +394,20 @@ enum ParaWebViewError: Error, CustomStringConvertible { "Bridge error: \(msg)" } } + + // Provide nicer strings for SwiftUI alerts and NSError bridging + var errorDescription: String? { + switch self { + case .webViewNotReady: + return "WebView is not ready to accept requests." + case let .invalidArguments(msg): + return "Invalid arguments: \(msg)" + case .requestTimeout: + return "The request timed out." + case let .bridgeError(msg): + return msg // Return the raw error message without "Bridge error:" prefix + } + } } /// A helper class to avoid retain cycles in script message handling diff --git a/Sources/ParaSwift/Core/ErrorReportingClient.swift b/Sources/ParaSwift/Core/ErrorReportingClient.swift deleted file mode 100644 index 94ada38..0000000 --- a/Sources/ParaSwift/Core/ErrorReportingClient.swift +++ /dev/null @@ -1,177 +0,0 @@ -import Foundation -import os -import UIKit - -/// HTTP client for sending error reports to Para's error tracking service -@MainActor -internal class ErrorReportingClient { - private let baseURL: String - private let environment: String - private let logger = Logger(subsystem: "ParaSwift", category: "ErrorReporting") - - /// Initialize the error reporting client - /// - Parameters: - /// - baseURL: Base URL for the Para API - /// - environment: Environment name for context - init(baseURL: String, environment: String = "PROD") { - self.baseURL = baseURL - self.environment = environment - } - - /// Track an error by sending it to Para's error reporting service - /// - Parameters: - /// - methodName: Name of the method where the error occurred - /// - error: The error that occurred - /// - userId: Optional user ID for context - func trackError(methodName: String, error: Error, userId: String? = nil) async { - do { - let payload = ErrorPayload( - methodName: methodName, - error: ErrorInfo( - name: String(describing: type(of: error)), - message: extractErrorMessage(from: error) - ), - sdkType: "SWIFT", - sdkVersion: ParaPackage.version, - environment: environment, - deviceInfo: DeviceInfo( - model: deviceModel, - osVersion: osVersion - ), - userId: userId - ) - - try await sendErrorToAPI(payload: payload) - logger.info("Error reported successfully for method: \(methodName)") - } catch { - // Silent failure to prevent error reporting loops - logger.error("Failed to report error for method \(methodName): \(error)") - } - } - - /// Extract meaningful error message from various error types - private func extractErrorMessage(from error: Error) -> String { - // Check for ParaError with custom descriptions - if let paraError = error as? ParaError { - return paraError.description - } - - // Check for NSError for more detailed information - let nsError = error as NSError - - // Build detailed message from NSError components - var components: [String] = [] - - // Add the main error description if it's not the generic one - let description = nsError.localizedDescription - if !description.contains("The operation couldn't be completed") { - components.append(description) - } - - // Add failure reason if available - if let failureReason = nsError.localizedFailureReason { - components.append(failureReason) - } - - // Add recovery suggestion if available - if let recoverySuggestion = nsError.localizedRecoverySuggestion { - components.append(recoverySuggestion) - } - - // If we have no meaningful components, at least provide domain and code - if components.isEmpty { - components.append("\(nsError.domain) error \(nsError.code)") - } - - // Add underlying error if present - if let underlyingError = nsError.userInfo[NSUnderlyingErrorKey] as? NSError { - components.append("Underlying: \(underlyingError.localizedDescription)") - } - - return components.joined(separator: " - ") - } - - /// Get the iOS version string - private var osVersion: String { - let version = ProcessInfo.processInfo.operatingSystemVersion - return "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" - } - - /// Get the device model name - private var deviceModel: String { - var systemInfo = utsname() - uname(&systemInfo) - let modelCode = withUnsafePointer(to: &systemInfo.machine) { - $0.withMemoryRebound(to: CChar.self, capacity: 1) { - String(validatingUTF8: $0) - } - } - - // Return the raw model code (e.g., "iPhone15,2") - // You could map these to friendly names if needed - return modelCode ?? UIDevice.current.model - } - - /// Send error payload to the API - private func sendErrorToAPI(payload: ErrorPayload) async throws { - let urlString = "\(baseURL)/errors/sdk" - guard let url = URL(string: urlString) else { - logger.error("Invalid URL: \(urlString)") - throw ErrorReportingError.invalidURL - } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - let jsonData = try JSONEncoder().encode(payload) - request.httpBody = jsonData - - do { - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - logger.error("Response is not HTTPURLResponse") - throw ErrorReportingError.networkError - } - - if !(200...299).contains(httpResponse.statusCode) { - let responseBody = String(data: data, encoding: .utf8) ?? "Unable to decode response body" - logger.error("HTTP \(httpResponse.statusCode) error. Response: \(responseBody)") - throw ErrorReportingError.networkError - } - } catch { - logger.error("Failed to send error report: \(error.localizedDescription)") - throw error - } - } -} - -// MARK: - Error Payload Models - -private struct ErrorPayload: Codable { - let methodName: String - let error: ErrorInfo - let sdkType: String - let sdkVersion: String - let environment: String - let deviceInfo: DeviceInfo - let userId: String? -} - -private struct ErrorInfo: Codable { - let name: String - let message: String -} - -private struct DeviceInfo: Codable { - let model: String - let osVersion: String -} - -// MARK: - Error Types - -private enum ErrorReportingError: Error { - case invalidURL - case networkError -} \ No newline at end of file diff --git a/Sources/ParaSwift/Core/ErrorTrackable.swift b/Sources/ParaSwift/Core/ErrorTrackable.swift deleted file mode 100644 index f553fee..0000000 --- a/Sources/ParaSwift/Core/ErrorTrackable.swift +++ /dev/null @@ -1,63 +0,0 @@ -import Foundation - -/// Protocol for classes that support error tracking -@MainActor -internal protocol ErrorTrackable { - /// The error reporting client instance - var errorReportingClient: ErrorReportingClient? { get } - - /// Whether error tracking is enabled - var isErrorTrackingEnabled: Bool { get } - - /// Get the current user ID for error context - func getCurrentUserId() -> String? -} - -extension ErrorTrackable { - /// Track an error asynchronously without blocking the caller - /// - Parameters: - /// - methodName: Name of the method where the error occurred - /// - error: The error that occurred - /// - userId: Optional user ID for context (defaults to getCurrentUserId()) - func trackError(methodName: String, error: Error, userId: String? = nil) async { - guard isErrorTrackingEnabled, let client = errorReportingClient else { - return - } - - // Track the error asynchronously without blocking - Task { - await client.trackError(methodName: methodName, error: error, userId: userId ?? getCurrentUserId()) - } - } - - /// Wrap async methods with error tracking - /// This provides a functional approach to method wrapping without runtime method swizzling - func withErrorTracking( - methodName: String, - userId: String? = nil, - operation: () async throws -> T - ) async throws -> T { - do { - return try await operation() - } catch { - await trackError(methodName: methodName, error: error, userId: userId) - throw error - } - } - - /// Wrap synchronous methods with error tracking - func withErrorTracking( - methodName: String, - userId: String? = nil, - operation: () throws -> T - ) throws -> T { - do { - return try operation() - } catch { - Task { - await trackError(methodName: methodName, error: error, userId: userId) - } - throw error - } - } -} \ No newline at end of file diff --git a/Sources/ParaSwift/Core/ParaManager+Auth.swift b/Sources/ParaSwift/Core/ParaManager+Auth.swift index a2c56a6..871c004 100644 --- a/Sources/ParaSwift/Core/ParaManager+Auth.swift +++ b/Sources/ParaSwift/Core/ParaManager+Auth.swift @@ -111,24 +111,18 @@ public extension ParaManager { /// - Parameter auth: Authentication information (email or phone) /// - Returns: AuthState object containing information about the next steps internal func signUpOrLogIn(auth: Auth) async throws -> AuthState { - try await withErrorTracking( - methodName: "signUpOrLogIn", - userId: getCurrentUserId(), - operation: { - try await ensureWebViewReady() + try await ensureWebViewReady() - let payload = createSignUpOrLogInPayload(from: auth) + let payload = createSignUpOrLogInPayload(from: auth) - let result = try await postMessage(method: "signUpOrLogIn", payload: payload) - let authState = try parseAuthStateFromResult(result) + let result = try await postMessage(method: "signUpOrLogIn", payload: payload) + let authState = try parseAuthStateFromResult(result) - if authState.stage == .verify || authState.stage == .login { - sessionState = .active - } + if authState.stage == .verify || authState.stage == .login { + sessionState = .active + } - return authState - } - ) + return authState } /// Initiates the email/phone authentication flow (signup or login). @@ -199,25 +193,19 @@ public extension ParaManager { /// - Parameter verificationCode: The verification code sent to the user /// - Returns: AuthState object containing information about the next steps func verifyNewAccount(verificationCode: String) async throws -> AuthState { - try await withErrorTracking( - methodName: "verifyNewAccount", - userId: getCurrentUserId(), - operation: { - try await ensureWebViewReady() - - let result = try await postMessage(method: "verifyNewAccount", payload: VerifyNewAccountArgs(verificationCode: verificationCode)) - // Log the raw result from the bridge before parsing - let logger = Logger(subsystem: "com.paraSwift", category: "ParaManager.Verify") - logger.debug("Raw result from verifyNewAccount bridge call received") - let authState = try parseAuthStateFromResult(result) - - if authState.stage == .signup { - sessionState = .active - } + try await ensureWebViewReady() - return authState - } - ) + let result = try await postMessage(method: "verifyNewAccount", payload: VerifyNewAccountArgs(verificationCode: verificationCode)) + // Log the raw result from the bridge before parsing + let logger = Logger(subsystem: "com.paraSwift", category: "ParaManager.Verify") + logger.debug("Raw result from verifyNewAccount bridge call received") + let authState = try parseAuthStateFromResult(result) + + if authState.stage == .signup { + sessionState = .active + } + + return authState } /// Handles the verification step for a new user. diff --git a/Sources/ParaSwift/Core/ParaManager+Signing.swift b/Sources/ParaSwift/Core/ParaManager+Signing.swift index a214a3d..1374790 100644 --- a/Sources/ParaSwift/Core/ParaManager+Signing.swift +++ b/Sources/ParaSwift/Core/ParaManager+Signing.swift @@ -54,32 +54,26 @@ public extension ParaManager { /// - message: The message to sign (plain text). /// - Returns: A SignatureResult containing the signature and metadata. func signMessage(walletId: String, message: String) async throws -> SignatureResult { - try await withErrorTracking( - methodName: "formatAndSignMessage", - userId: getCurrentUserId(), - operation: { - try await ensureWebViewReady() - - let params = FormatAndSignMessageParams( - walletId: walletId, - message: message - ) - - let result = try await postMessage(method: "formatAndSignMessage", payload: params) - - // Parse the response from bridge - let dict = try decodeResult(result, expectedType: [String: Any].self, method: "formatAndSignMessage") - - let signature = dict["signature"] as? String ?? "" - let returnedWalletId = dict["walletId"] as? String ?? walletId - let walletType = dict["type"] as? String ?? "unknown" - - return SignatureResult( - signature: signature, - walletId: returnedWalletId, - type: walletType - ) - } + try await ensureWebViewReady() + + let params = FormatAndSignMessageParams( + walletId: walletId, + message: message + ) + + let result = try await postMessage(method: "formatAndSignMessage", payload: params) + + // Parse the response from bridge + let dict = try decodeResult(result, expectedType: [String: Any].self, method: "formatAndSignMessage") + + let signature = dict["signature"] as? String ?? "" + let returnedWalletId = dict["walletId"] as? String ?? walletId + let walletType = dict["type"] as? String ?? "unknown" + + return SignatureResult( + signature: signature, + walletId: returnedWalletId, + type: walletType ) } @@ -92,7 +86,6 @@ public extension ParaManager { /// - walletId: The ID of the wallet to use for signing. /// - transaction: The transaction parameters (EVMTransaction, SolanaTransaction, etc.). /// - chainId: Optional chain ID (primarily for EVM chains). - /// - Parameters: /// - rpcUrl: Optional RPC URL (required for Solana if recentBlockhash is not provided). /// - Returns: A SignatureResult containing the signature and metadata. func signTransaction( @@ -101,39 +94,33 @@ public extension ParaManager { chainId: String? = nil, rpcUrl: String? = nil ) async throws -> SignatureResult { - try await withErrorTracking( - methodName: "formatAndSignTransaction", - userId: getCurrentUserId(), - operation: { - try await ensureWebViewReady() - - // Encode the transaction object to JSON - let encoder = JSONEncoder() - let transactionData = try encoder.encode(transaction) - let transactionDict = try JSONSerialization.jsonObject(with: transactionData, options: []) as? [String: Any] ?? [:] - - let params = FormatAndSignTransactionParams( - walletId: walletId, - transaction: transactionDict, - chainId: chainId, - rpcUrl: rpcUrl - ) - - let result = try await postMessage(method: "formatAndSignTransaction", payload: params) - - // Parse the response from bridge - let dict = try decodeResult(result, expectedType: [String: Any].self, method: "formatAndSignTransaction") - - let signature = dict["signature"] as? String ?? "" - let returnedWalletId = dict["walletId"] as? String ?? walletId - let walletType = dict["type"] as? String ?? "unknown" - - return SignatureResult( - signature: signature, - walletId: returnedWalletId, - type: walletType - ) - } + try await ensureWebViewReady() + + // Encode the transaction object to JSON + let encoder = JSONEncoder() + let transactionData = try encoder.encode(transaction) + let transactionDict = try JSONSerialization.jsonObject(with: transactionData, options: []) as? [String: Any] ?? [:] + + let params = FormatAndSignTransactionParams( + walletId: walletId, + transaction: transactionDict, + chainId: chainId, + rpcUrl: rpcUrl + ) + + let result = try await postMessage(method: "formatAndSignTransaction", payload: params) + + // Parse the response from bridge + let dict = try decodeResult(result, expectedType: [String: Any].self, method: "formatAndSignTransaction") + + let signature = dict["signature"] as? String ?? "" + let returnedWalletId = dict["walletId"] as? String ?? walletId + let walletType = dict["type"] as? String ?? "unknown" + + return SignatureResult( + signature: signature, + walletId: returnedWalletId, + type: walletType ) } @@ -150,32 +137,26 @@ public extension ParaManager { /// - denom: Optional denom for Cosmos balances (e.g., "ujuno", "ustars"). /// - Returns: The balance as a string (format depends on the chain). func getBalance(walletId: String, token: String? = nil, rpcUrl: String? = nil, chainPrefix: String? = nil, denom: String? = nil) async throws -> String { - try await withErrorTracking( - methodName: "getBalance", - userId: getCurrentUserId(), - operation: { - try await ensureWebViewReady() - - struct GetBalanceParams: Encodable { - let walletId: String - let token: String? - let rpcUrl: String? - let chainPrefix: String? - let denom: String? - } - - let params = GetBalanceParams( - walletId: walletId, - token: token, - rpcUrl: rpcUrl, - chainPrefix: chainPrefix, - denom: denom - ) - - let result = try await postMessage(method: "getBalance", payload: params) - return try decodeResult(result, expectedType: String.self, method: "getBalance") - } + try await ensureWebViewReady() + + struct GetBalanceParams: Encodable { + let walletId: String + let token: String? + let rpcUrl: String? + let chainPrefix: String? + let denom: String? + } + + let params = GetBalanceParams( + walletId: walletId, + token: token, + rpcUrl: rpcUrl, + chainPrefix: chainPrefix, + denom: denom ) + + let result = try await postMessage(method: "getBalance", payload: params) + return try decodeResult(result, expectedType: String.self, method: "getBalance") } /// High-level transfer method for any wallet type. @@ -195,29 +176,23 @@ public extension ParaManager { amount: String, token: String? = nil ) async throws -> String { - try await withErrorTracking( - methodName: "transfer", - userId: getCurrentUserId(), - operation: { - try await ensureWebViewReady() - - struct TransferParams: Encodable { - let walletId: String - let to: String - let amount: String - let token: String? - } - - let params = TransferParams( - walletId: walletId, - to: to, - amount: amount, - token: token - ) - - let result = try await postMessage(method: "transfer", payload: params) - return try decodeResult(result, expectedType: String.self, method: "transfer") - } + try await ensureWebViewReady() + + struct TransferParams: Encodable { + let walletId: String + let to: String + let amount: String + let token: String? + } + + let params = TransferParams( + walletId: walletId, + to: to, + amount: amount, + token: token ) + + let result = try await postMessage(method: "transfer", payload: params) + return try decodeResult(result, expectedType: String.self, method: "transfer") } -} +} \ No newline at end of file diff --git a/Sources/ParaSwift/Core/ParaManager.swift b/Sources/ParaSwift/Core/ParaManager.swift index 4da43fd..2916ff9 100644 --- a/Sources/ParaSwift/Core/ParaManager.swift +++ b/Sources/ParaSwift/Core/ParaManager.swift @@ -10,7 +10,7 @@ import WebKit /// This class provides a comprehensive interface for applications to interact with Para wallet services. /// It handles authentication flows, wallet creation and management, and transaction signing operations. @MainActor -public class ParaManager: NSObject, ObservableObject, ErrorTrackable { +public class ParaManager: NSObject, ObservableObject { // MARK: - Properties /// Current package version. @@ -29,9 +29,6 @@ public class ParaManager: NSObject, ObservableObject, ErrorTrackable { didSet { passkeysManager.relyingPartyIdentifier = environment.relyingPartyId - // Reinitialize error reporting client when environment changes - let apiBaseURL = deriveApiBaseURL(from: environment) - errorReportingClient = ErrorReportingClient(baseURL: apiBaseURL, environment: environment.name) } } @@ -51,16 +48,6 @@ public class ParaManager: NSObject, ObservableObject, ErrorTrackable { /// App scheme for authentication callbacks. let appScheme: String - // MARK: - Error Reporting Properties - - /// Error reporting client for tracking SDK errors - internal var errorReportingClient: ErrorReportingClient? - - /// Whether error tracking is enabled (always enabled - backend decides what to log) - internal var isErrorTrackingEnabled: Bool { - true - } - // MARK: - Initialization /// Creates a new Para manager instance. @@ -79,10 +66,6 @@ public class ParaManager: NSObject, ObservableObject, ErrorTrackable { self.appScheme = appScheme ?? Bundle.main.bundleIdentifier! super.init() - - // Initialize error reporting client - let apiBaseURL = deriveApiBaseURL(from: environment) - errorReportingClient = ErrorReportingClient(baseURL: apiBaseURL, environment: environment.name) Task { @MainActor in await waitForParaReady() @@ -165,6 +148,28 @@ public class ParaManager: NSObject, ObservableObject, ErrorTrackable { do { let result: Any? = try await self.paraWebView.postMessage(method: method, payload: payload) return result + } catch let error as ParaWebViewError { + logger.error("Bridge error for \(method): \(error.localizedDescription)") + // Convert ParaWebViewError to ParaError for better user experience + switch error { + case .bridgeError(let message): + // Attempt to extract a concise message from a JSON payload produced by the bridge + if let data = message.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + let details = json["details"] as? [String: Any] + let userMessage = (details?["message"] as? String) + ?? (json["message"] as? String) + ?? message + throw ParaError.bridgeError(userMessage) + } + throw ParaError.bridgeError(message) + case .webViewNotReady: + throw ParaError.bridgeError("WebView is not ready") + case .requestTimeout: + throw ParaError.bridgeTimeoutError + case .invalidArguments(let message): + throw ParaError.error("Invalid arguments: \(message)") + } } catch { logger.error("Bridge error for \(method): \(error.localizedDescription)") throw error @@ -286,28 +291,4 @@ public class ParaManager: NSObject, ObservableObject, ErrorTrackable { username: authInfoDict["username"] as? String, ) } - - // MARK: - Error Reporting Support - - /// Derive API base URL from environment - private func deriveApiBaseURL(from environment: ParaEnvironment) -> String { - switch environment { - case .dev: - return "http://localhost:8080" - case .sandbox: - return "https://api.sandbox.getpara.com" - case .beta: - return "https://api.beta.getpara.com" - case .prod: - return "https://api.getpara.com" - } - } - - /// Get current user ID for error reporting context - func getCurrentUserId() -> String? { - // This is a synchronous version to avoid async complications in error tracking - // Return the userId from the first wallet if available - // All wallets for a user should have the same userId - wallets.first?.userId - } } diff --git a/Sources/ParaSwift/Core/ParaTypes.swift b/Sources/ParaSwift/Core/ParaTypes.swift index c01604e..459531a 100644 --- a/Sources/ParaSwift/Core/ParaTypes.swift +++ b/Sources/ParaSwift/Core/ParaTypes.swift @@ -13,7 +13,7 @@ public enum AuthMethod { } /// Errors that can occur during Para operations. -public enum ParaError: Error, CustomStringConvertible { +public enum ParaError: Error, CustomStringConvertible, LocalizedError { /// An error occurred while executing JavaScript bridge code. case bridgeError(String) /// The JavaScript bridge did not respond in time. @@ -35,6 +35,20 @@ public enum ParaError: Error, CustomStringConvertible { "Feature not implemented: \(feature)" } } + + // Provide concise strings for SwiftUI alerts and NSError bridging + public var errorDescription: String? { + switch self { + case let .bridgeError(info): + return info + case .bridgeTimeoutError: + return "Request timed out. Please try again." + case let .error(info): + return info + case let .notImplemented(feature): + return "Feature not implemented: \(feature)" + } + } } /// Response type for 2FA setup operation From 21f13762a9e032bfa604ed9779bb5a8c629a3d70 Mon Sep 17 00:00:00 2001 From: Tyson Williams Date: Wed, 20 Aug 2025 15:20:03 -0700 Subject: [PATCH 3/6] "Update Package.resolved after wallet unification --- Package.resolved | 38 +------------------------------------- 1 file changed, 1 insertion(+), 37 deletions(-) diff --git a/Package.resolved b/Package.resolved index a3a9294..e1790a0 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "60191dae5f3d497b1c7b963d3edc511b185bd99022632e9f8fe0a6d51c8e0d4d", + "originHash" : "a77c592bb613d7daade42f93fb5feac18979abbfafd574d1b93a0bc025c9bc5f", "pins" : [ { "identity" : "bigint", @@ -18,42 +18,6 @@ "revision" : "288b99e9e99926bc8be41220802df83c059fac9b", "version" : "4.0.2" } - }, - { - "identity" : "secp256k1.swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Boilertalk/secp256k1.swift.git", - "state" : { - "revision" : "cd187c632fb812fd93711a9f7e644adb7e5f97f0", - "version" : "0.1.7" - } - }, - { - "identity" : "solana-swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/p2p-org/solana-swift", - "state" : { - "revision" : "010a3e3a4cb583ccc3150a04d4270ad37d784ee8", - "version" : "5.0.0" - } - }, - { - "identity" : "task-retrying-swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/bigearsenal/task-retrying-swift.git", - "state" : { - "revision" : "208f1e8dfa93022a7d39ab5b334d5f43a934d4b1", - "version" : "2.0.0" - } - }, - { - "identity" : "tweetnacl-swiftwrap", - "kind" : "remoteSourceControl", - "location" : "https://github.com/bitmark-inc/tweetnacl-swiftwrap.git", - "state" : { - "revision" : "f8fd111642bf2336b11ef9ea828510693106e954", - "version" : "1.1.0" - } } ], "version" : 3 From 71663d32acb3ab08d7b0f52c7509ceb0c26ca8bb Mon Sep 17 00:00:00 2001 From: Tyson Williams Date: Wed, 20 Aug 2025 16:15:24 -0700 Subject: [PATCH 4/6] MM fix --- Sources/ParaSwift/Connectors/MetaMask/MetaMaskConnector.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/ParaSwift/Connectors/MetaMask/MetaMaskConnector.swift b/Sources/ParaSwift/Connectors/MetaMask/MetaMaskConnector.swift index d848ea7..9071cb6 100644 --- a/Sources/ParaSwift/Connectors/MetaMask/MetaMaskConnector.swift +++ b/Sources/ParaSwift/Connectors/MetaMask/MetaMaskConnector.swift @@ -373,7 +373,8 @@ extension EVMTransaction { if let gasPrice = toHexString(gasPrice) { tx["gasPrice"] = gasPrice } if let maxPriorityFeePerGas = toHexString(maxPriorityFeePerGas) { tx["maxPriorityFeePerGas"] = maxPriorityFeePerGas } if let maxFeePerGas = toHexString(maxFeePerGas) { tx["maxFeePerGas"] = maxFeePerGas } - if let nonce = toHexString(nonce) { tx["nonce"] = nonce } + // Note: Nonce is intentionally excluded to let MetaMask handle it automatically + // This prevents "nonce too low" errors when the external wallet has transaction history if let type { tx["type"] = "0x" + String(type, radix: 16) } if let chainId = toHexString(chainId) { tx["chainId"] = chainId } From b1280e7ecc2789ece9e7a54b35ef3d68c4b007eb Mon Sep 17 00:00:00 2001 From: Tyson Williams Date: Tue, 26 Aug 2025 16:53:59 -0700 Subject: [PATCH 5/6] EVM transfer --- .../ParaSwift/Core/ParaManager+Signing.swift | 62 ++++++++++++++----- 1 file changed, 48 insertions(+), 14 deletions(-) diff --git a/Sources/ParaSwift/Core/ParaManager+Signing.swift b/Sources/ParaSwift/Core/ParaManager+Signing.swift index 1374790..a2c5f88 100644 --- a/Sources/ParaSwift/Core/ParaManager+Signing.swift +++ b/Sources/ParaSwift/Core/ParaManager+Signing.swift @@ -18,6 +18,28 @@ public struct SignatureResult { } } +/// Result of a transfer operation +public struct TransferResult { + /// The transaction hash + public let hash: String + /// The sender address + public let from: String + /// The recipient address + public let to: String + /// The amount transferred (in wei for EVM) + public let amount: String + /// The chain ID + public let chainId: String + + public init(hash: String, from: String, to: String, amount: String, chainId: String) { + self.hash = hash + self.from = from + self.to = to + self.amount = amount + self.chainId = chainId + } +} + // Helper structs for formatting methods struct FormatAndSignMessageParams: Encodable { let walletId: String @@ -159,40 +181,52 @@ public extension ParaManager { return try decodeResult(result, expectedType: String.self, method: "getBalance") } - /// High-level transfer method for any wallet type. + /// High-level transfer method for EVM chains. /// - /// This convenience method handles token transfers across all supported chains. - /// The bridge handles the chain-specific transaction construction internally. + /// This method handles ETH/ERC20 transfers on EVM-compatible chains. + /// The bridge handles transaction construction, signing, and broadcasting. /// /// - Parameters: - /// - walletId: The ID of the wallet to transfer from. + /// - walletId: The ID of the EVM wallet to transfer from. /// - to: The recipient address. - /// - amount: The amount to transfer (as a string to handle large numbers). - /// - token: Optional token identifier (contract address for EVM, mint address for Solana, etc.). - /// - Returns: The transaction hash. + /// - amount: The amount to transfer in wei (as a string to handle large numbers). + /// - chainId: Optional chain ID (auto-detected if not provided). + /// - rpcUrl: Optional RPC URL (defaults to Ethereum mainnet if not provided). + /// - Returns: Transaction result containing hash and details. func transfer( walletId: String, to: String, amount: String, - token: String? = nil - ) async throws -> String { + chainId: String? = nil, + rpcUrl: String? = nil + ) async throws -> TransferResult { try await ensureWebViewReady() struct TransferParams: Encodable { let walletId: String - let to: String + let toAddress: String let amount: String - let token: String? + let chainId: String? + let rpcUrl: String? } let params = TransferParams( walletId: walletId, - to: to, + toAddress: to, amount: amount, - token: token + chainId: chainId, + rpcUrl: rpcUrl ) let result = try await postMessage(method: "transfer", payload: params) - return try decodeResult(result, expectedType: String.self, method: "transfer") + let dict = try decodeResult(result, expectedType: [String: Any].self, method: "transfer") + + return TransferResult( + hash: dict["hash"] as? String ?? "", + from: dict["from"] as? String ?? "", + to: dict["to"] as? String ?? "", + amount: dict["amount"] as? String ?? "", + chainId: dict["chainId"] as? String ?? "" + ) } } \ No newline at end of file From 650c164ae12437222a7e8fadbfc6e80aab70704c Mon Sep 17 00:00:00 2001 From: Tyson Williams Date: Wed, 27 Aug 2025 10:57:00 -0700 Subject: [PATCH 6/6] pre-serialized solana tx --- Sources/ParaSwift/ParaManager+Solana.swift | 39 ++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 Sources/ParaSwift/ParaManager+Solana.swift diff --git a/Sources/ParaSwift/ParaManager+Solana.swift b/Sources/ParaSwift/ParaManager+Solana.swift new file mode 100644 index 0000000..002132e --- /dev/null +++ b/Sources/ParaSwift/ParaManager+Solana.swift @@ -0,0 +1,39 @@ +// +// ParaManager+Solana.swift +// ParaSwift +// +// Solana-specific extensions for ParaManager +// + +import Foundation + +// Helper struct for pre-serialized transactions +private struct PreSerializedTransaction: Encodable { + let type = "serialized" + let data: String +} + +public extension ParaManager { + /// Sign a pre-serialized Solana transaction (for backward compatibility) + /// + /// This method supports customers who have pre-serialized base64 Solana transactions + /// that they need to sign. It uses the unified signTransaction API with a special + /// format that the bridge recognizes. + /// + /// - Parameters: + /// - walletId: The ID of the Solana wallet to use for signing + /// - base64Tx: The base64-encoded serialized transaction + /// - Returns: A SignatureResult containing the signature + /// - Throws: ParaWebViewError if signing fails + func signSolanaSerializedTransaction( + walletId: String, + base64Tx: String + ) async throws -> SignatureResult { + let transaction = PreSerializedTransaction(data: base64Tx) + + return try await signTransaction( + walletId: walletId, + transaction: transaction + ) + } +} \ No newline at end of file