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 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/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 } 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 a3917df..a2c5f88 100644 --- a/Sources/ParaSwift/Core/ParaManager+Signing.swift +++ b/Sources/ParaSwift/Core/ParaManager+Signing.swift @@ -1,77 +1,232 @@ -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 + } +} + +/// 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 + 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 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). + /// - rpcUrl: Optional RPC URL (required for Solana if recentBlockhash is not provided). + /// - Returns: A SignatureResult containing the signature and metadata. + func signTransaction( + walletId: String, + transaction: T, + chainId: String? = nil, + rpcUrl: String? = nil + ) async throws -> SignatureResult { + 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 + ) + } + + /// 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 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 EVM chains. + /// + /// This method handles ETH/ERC20 transfers on EVM-compatible chains. + /// The bridge handles transaction construction, signing, and broadcasting. + /// + /// - Parameters: + /// - walletId: The ID of the EVM wallet to transfer from. + /// - to: The recipient address. + /// - 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, - rlpEncodedTx: String, - chainId: String, - timeoutMs: Int? = nil - ) async throws -> String { + to: String, + amount: String, + chainId: String? = nil, + rpcUrl: String? = nil + ) async throws -> TransferResult { try await ensureWebViewReady() - struct SignTransactionParams: Encodable { + struct TransferParams: Encodable { let walletId: String - let rlpEncodedTxBase64: String - let chainId: String - let timeoutMs: Int? + let toAddress: String + let amount: String + let chainId: String? + let rpcUrl: String? } - let params = SignTransactionParams( + let params = TransferParams( walletId: walletId, - rlpEncodedTxBase64: rlpEncodedTx.toBase64(), + toAddress: to, + amount: amount, chainId: chainId, - timeoutMs: timeoutMs, + rpcUrl: rpcUrl ) - 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: "transfer", payload: params) + 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 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/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 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() - } }