diff --git a/Code.xcodeproj/project.pbxproj b/Code.xcodeproj/project.pbxproj index 59cb4411..1cb74ff1 100644 --- a/Code.xcodeproj/project.pbxproj +++ b/Code.xcodeproj/project.pbxproj @@ -14,7 +14,7 @@ 88FB56E62EE0AB810014EC5F /* 2025-11-27-swap-rpc-analysis.md in Resources */ = {isa = PBXBuildFile; fileRef = 88FB56E12EE0AB810014EC5F /* 2025-11-27-swap-rpc-analysis.md */; }; 88FB56E82EE0C3150014EC5F /* 2025-12-03-swap-models-startswap.md in Resources */ = {isa = PBXBuildFile; fileRef = 88FB56E72EE0C3150014EC5F /* 2025-12-03-swap-models-startswap.md */; }; 9A017D692EAC1DE200216925 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 9A017D682EAC1DE200216925 /* Kingfisher */; }; - 9A2396762E7DE27500D70699 /* SolanaSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 9A2396752E7DE27500D70699 /* SolanaSwift */; }; + 508AF9C6D67C4CD29DE10A16 /* TweetNacl in Frameworks */ = {isa = PBXBuildFile; productRef = 2B166ABF94584FEF9C368C81 /* TweetNacl */; }; 9A444EE72E8C414D002B1E39 /* BigDecimal in Frameworks */ = {isa = PBXBuildFile; productRef = 9A444EE62E8C414D002B1E39 /* BigDecimal */; }; 9A780F972EDF817D009BC4D9 /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 9A780F962EDF817D009BC4D9 /* CodeScanner */; }; 9ABDD1952D9D7B61006B6CDA /* FlipcashCore in Frameworks */ = {isa = PBXBuildFile; productRef = 9ABDD1942D9D7B61006B6CDA /* FlipcashCore */; }; @@ -96,7 +96,7 @@ 9A017D692EAC1DE200216925 /* Kingfisher in Frameworks */, 9A444EE72E8C414D002B1E39 /* BigDecimal in Frameworks */, 9AC011182DA4320F0030298E /* FlipcashUI in Frameworks */, - 9A2396762E7DE27500D70699 /* SolanaSwift in Frameworks */, + 508AF9C6D67C4CD29DE10A16 /* TweetNacl in Frameworks */, 9AC0156C2DA576FA0030298E /* opencv2.framework in Frameworks */, 9ABDD1952D9D7B61006B6CDA /* FlipcashCore in Frameworks */, 9ADEF1D92DD627C6001B260A /* Mixpanel in Frameworks */, @@ -217,7 +217,7 @@ 9AD1265D2DA98ADC0048141F /* SQLite */, 9ADEF1D62DD627C0001B260A /* Bugsnag */, 9ADEF1D82DD627C6001B260A /* Mixpanel */, - 9A2396752E7DE27500D70699 /* SolanaSwift */, + 2B166ABF94584FEF9C368C81 /* TweetNacl */, 9A444EE62E8C414D002B1E39 /* BigDecimal */, 9A017D682EAC1DE200216925 /* Kingfisher */, 9A780F962EDF817D009BC4D9 /* CodeScanner */, @@ -346,7 +346,7 @@ 9A476D482978884700C5B5CE /* XCRemoteSwiftPackageReference "bugsnag-cocoa" */, 9A9499D62CFB829300DC219B /* XCRemoteSwiftPackageReference "SQLite" */, 9A8D9A3D2D78C2E200E755B9 /* XCRemoteSwiftPackageReference "Kingfisher" */, - 9A2396742E7DE27500D70699 /* XCRemoteSwiftPackageReference "solana-swift" */, + 10A6C0E8F8974C1887219D41 /* XCRemoteSwiftPackageReference "tweetnacl-swiftwrap" */, 9A444EE52E8C414D002B1E39 /* XCRemoteSwiftPackageReference "BigDecimal" */, 9A780F952EDF817D009BC4D9 /* XCLocalSwiftPackageReference "CodeScanner" */, ); @@ -1177,12 +1177,12 @@ minimumVersion = 10.24.0; }; }; - 9A2396742E7DE27500D70699 /* XCRemoteSwiftPackageReference "solana-swift" */ = { + 10A6C0E8F8974C1887219D41 /* XCRemoteSwiftPackageReference "tweetnacl-swiftwrap" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/dbart01/solana-swift"; + repositoryURL = "https://github.com/bitmark-inc/tweetnacl-swiftwrap"; requirement = { - branch = main; - kind = branch; + kind = upToNextMajorVersion; + minimumVersion = 1.0.2; }; }; 9A444EE52E8C414D002B1E39 /* XCRemoteSwiftPackageReference "BigDecimal" */ = { @@ -1243,10 +1243,10 @@ package = 9A8D9A3D2D78C2E200E755B9 /* XCRemoteSwiftPackageReference "Kingfisher" */; productName = Kingfisher; }; - 9A2396752E7DE27500D70699 /* SolanaSwift */ = { + 2B166ABF94584FEF9C368C81 /* TweetNacl */ = { isa = XCSwiftPackageProductDependency; - package = 9A2396742E7DE27500D70699 /* XCRemoteSwiftPackageReference "solana-swift" */; - productName = SolanaSwift; + package = 10A6C0E8F8974C1887219D41 /* XCRemoteSwiftPackageReference "tweetnacl-swiftwrap" */; + productName = TweetNacl; }; 9A444EE62E8C414D002B1E39 /* BigDecimal */ = { isa = XCSwiftPackageProductDependency; diff --git a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2b093b5d..5818c936 100644 --- a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -171,24 +171,6 @@ "version" : "2.4.0" } }, - { - "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/dbart01/solana-swift", - "state" : { - "branch" : "main", - "revision" : "8716ddd28d8e1aaa3efa60b0dbca3b0f89217e63" - } - }, { "identity" : "sqlite.swift", "kind" : "remoteSourceControl", @@ -378,15 +360,6 @@ "version" : "1.0.4" } }, - { - "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", diff --git a/Flipcash/Core/Controllers/Deep Links/Wallet/WalletConnection.swift b/Flipcash/Core/Controllers/Deep Links/Wallet/WalletConnection.swift index 94ed792b..dc21c311 100644 --- a/Flipcash/Core/Controllers/Deep Links/Wallet/WalletConnection.swift +++ b/Flipcash/Core/Controllers/Deep Links/Wallet/WalletConnection.swift @@ -10,7 +10,6 @@ import UIKit import FlipcashUI import FlipcashCore import TweetNacl -import SolanaSwift private let logger = Logger(label: "flipcash.wallet-connection") @@ -75,7 +74,7 @@ public final class WalletConnection { private let owner: AccountCluster private let client: Client - private let rpc: WalletRPC + private let rpc: any SolanaRPC /// Pending swap info to use when Phantom returns with signed transaction private var pendingSwap: PendingSwap? @@ -102,15 +101,10 @@ public final class WalletConnection { // MARK: - Init - - init(owner: AccountCluster, client: Client, rpc: WalletRPC? = nil) { + init(owner: AccountCluster, client: Client, rpc: any SolanaRPC = SolanaJSONRPCClient()) { self.owner = owner self.client = client - self.rpc = rpc ?? JSONRPCAPIClient( - endpoint: .init( - address: "https://api.mainnet-beta.solana.com", - network: .mainnetBeta - ) - ) + self.rpc = rpc if let connectedWalletSession = Keychain.connectedWalletSession { self.session = connectedWalletSession @@ -357,10 +351,10 @@ public final class WalletConnection { // and flip `isFailed` so the processing screen surfaces the error. do { let signature = try await rpc.sendTransaction( - transaction: txBase64, - configs: .init(encoding: "base64")! + txBase64, + configuration: SolanaSendTransactionConfig() ) - logger.info("Transaction sent", metadata: ["signature": "\(signature)"]) + logger.info("Transaction sent", metadata: ["signature": "\(signature.base58)"]) Analytics.track(event: Analytics.WalletEvent.transactionsSubmitted) } catch { logger.error("Chain submission failed", metadata: ["error": "\(error)"]) @@ -384,22 +378,22 @@ public final class WalletConnection { ) async -> SimulationOutcome { do { _ = try await rpc.simulateTransaction( - transaction: txBase64, - configs: RequestConfiguration( - commitment: "confirmed", - encoding: "base64", + txBase64, + configuration: SolanaSimulateTransactionConfig( + commitment: .confirmed, + encoding: .base64, replaceRecentBlockhash: true - )! + ) ) return .proceed - } catch APIClientError.transactionSimulationError(let logs) { + } catch SolanaRPCError.transactionSimulationError(let logs) { return blockedOutcome( reason: "Phantom signed transaction failed simulation", logs: logs, extraMetadata: ["kind": "simulationErr"], swapMetadata: swapMetadata ) - } catch APIClientError.responseError(let response) { + } catch SolanaRPCError.responseError(let response) { var extra: [String: String] = ["kind": "preflightRejection"] if let code = response.code { extra["code"] = "\(code)" } if let message = response.message { extra["message"] = message } @@ -562,26 +556,12 @@ public final class WalletConnection { swapId: fundingSwapId.publicKey ) - let instructionsConverted = try instructions.map { instruction in - TransactionInstruction( - keys: try instruction.accounts.map { meta in - SolanaSwift.AccountMeta( - publicKey: try SolanaSwift.PublicKey(string: meta.publicKey.base58), - isSigner: meta.isSigner, - isWritable: meta.isWritable - ) - }, - programId: try SolanaSwift.PublicKey(string: instruction.program.base58), - data: [UInt8](instruction.data) - ) - } - - let recentBlockhash = try await rpc.getLatestBlockhash(commitment: "finalized") + let recentBlockhash = try await rpc.getLatestBlockhash(commitment: .finalized) - var transaction = Transaction( - instructions: instructionsConverted, + let transaction = SolanaTransaction( + payer: externalWallet, recentBlockhash: recentBlockhash, - feePayer: try SolanaSwift.PublicKey(string: externalWallet.base58) + instructions: instructions ) pendingSwap = PendingSwap( @@ -591,7 +571,7 @@ public final class WalletConnection { onCompleted: onCompleted ) - let txEncoded = Base58.fromBytes(Array(try transaction.serialize())) + let txEncoded = Base58.fromBytes(Array(transaction.encode())) let payload: [String: Any] = [ "transaction": txEncoded, @@ -845,15 +825,3 @@ extension WalletConnection { static let mock = WalletConnection(owner: .mock, client: .mock) } -// MARK: - WalletRPC - - -/// The slice of Solana RPC that `WalletConnection` depends on. A narrow -/// protocol (instead of the full `SolanaAPIClient` surface) keeps test stubs -/// small and makes the dependency honest at the call site. -protocol WalletRPC { - func getLatestBlockhash(commitment: Commitment?) async throws -> String - func sendTransaction(transaction: String, configs: RequestConfiguration) async throws -> TransactionID - func simulateTransaction(transaction: String, configs: RequestConfiguration) async throws -> SimulationResult -} - -extension JSONRPCAPIClient: WalletRPC {} diff --git a/FlipcashCore/Sources/FlipcashCore/Solana/LegacyMessage.swift b/FlipcashCore/Sources/FlipcashCore/Solana/LegacyMessage.swift index f80f4c43..5325a6a4 100644 --- a/FlipcashCore/Sources/FlipcashCore/Solana/LegacyMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Solana/LegacyMessage.swift @@ -17,13 +17,23 @@ public struct LegacyMessage: Equatable, Sendable { // MARK: - Init - public init(accounts: [AccountMeta], recentBlockhash: Hash, instructions: [Instruction]) { - // Sort the account meta's based on: - // 1. Payer is always the first account / signer. - // 1. All signers are before non-signers. - // 2. Writable accounts before read-only accounts. - // 3. Programs last - let uniqueAccounts = accounts.filterUniqueAccounts().sorted() - + // Canonical Solana legacy-message ordering: + // 1. Payer first. + // 2. Within signer/non-signer × writable/readonly groups, lex by pubkey. + // The sort intentionally does NOT use `AccountMeta.isProgram`. That flag + // is set heuristically (only on accounts added via `.program(publicKey:)` + // from `instructions[i].program`) and is false for transitive program + // references that arrive through `instructions[i].accounts`. Splitting + // readonly non-signers by that heuristic produces wire bytes that + // disagree with the canonical Solana order — verified against + // SolanaSwift's encoder via a byte-level parity test. + let uniqueAccounts = accounts.filterUniqueAccounts().sorted { lhs, rhs in + if lhs.isPayer != rhs.isPayer { return lhs.isPayer } + if lhs.isSigner != rhs.isSigner { return lhs.isSigner } + if lhs.isWritable != rhs.isWritable { return lhs.isWritable } + return lhs.publicKey.bytes.lexicographicallyPrecedes(rhs.publicKey.bytes) + } + let signers = uniqueAccounts.filter { $0.isSigner } let readOnlySigners = uniqueAccounts.filter { !$0.isWritable && $0.isSigner } let readOnly = uniqueAccounts.filter { !$0.isWritable && !$0.isSigner } diff --git a/FlipcashCore/Sources/FlipcashCore/Solana/RPC/SolanaRPC+Codable.swift b/FlipcashCore/Sources/FlipcashCore/Solana/RPC/SolanaRPC+Codable.swift new file mode 100644 index 00000000..c9dbcea5 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Solana/RPC/SolanaRPC+Codable.swift @@ -0,0 +1,157 @@ +// +// SolanaRPC+Codable.swift +// FlipcashCore +// +// Created by Raul Riera on 2026-05-07. +// + +import Foundation + +// MARK: - SolanaCommitment - + +public enum SolanaCommitment: String, Codable, Sendable { + case processed + case confirmed + case finalized +} + +// MARK: - SolanaTransactionEncoding - + +public enum SolanaTransactionEncoding: String, Codable, Sendable { + case base64 + case base58 +} + +// MARK: - SolanaSendTransactionConfig - + +public struct SolanaSendTransactionConfig: Encodable, Sendable { + + public let encoding: SolanaTransactionEncoding + + public init(encoding: SolanaTransactionEncoding = .base64) { + self.encoding = encoding + } +} + +// MARK: - SolanaSimulateTransactionConfig - + +public struct SolanaSimulateTransactionConfig: Encodable, Sendable { + + public let commitment: SolanaCommitment + public let encoding: SolanaTransactionEncoding + public let replaceRecentBlockhash: Bool + + public init( + commitment: SolanaCommitment = .confirmed, + encoding: SolanaTransactionEncoding = .base64, + replaceRecentBlockhash: Bool = false + ) { + self.commitment = commitment + self.encoding = encoding + self.replaceRecentBlockhash = replaceRecentBlockhash + } +} + +// MARK: - SolanaSimulationResult - + +/// Decoded `value` from a `simulateTransaction` response. `err` is intentionally +/// kept opaque (`AnyCodable`-shaped via `JSONValue`) — Solana's simulation +/// errors are a discriminated grab-bag (`InstructionError`, `BlockhashNotFound`, +/// strings, nested arrays). The client only branches on null vs non-null and +/// surfaces `logs` to callers. +public struct SolanaSimulationResult: Decodable, Sendable { + + public let err: JSONValue? + public let logs: [String]? + + public init(err: JSONValue? = nil, logs: [String]? = nil) { + self.err = err + self.logs = logs + } +} + +// MARK: - SolanaRPCError - + +public enum SolanaRPCError: Error, Sendable { + + /// The simulation completed but reported an `err` payload — `logs` are + /// forwarded so callers can render them without re-decoding. + case transactionSimulationError(logs: [String]) + + case responseError(SolanaRPCResponseError) + case transport(URLError) + + /// HTTP returned a non-2xx response without a JSON-RPC error envelope — + /// shouldn't happen against a healthy Solana RPC, included so the + /// failure surface is exhaustive. + case invalidHTTPStatus(code: Int) + + /// JSON-RPC envelope decoded without `result` or `error`. Unexpected. + case missingResult + + case encoding(Error) + case decoding(Error) +} + +// MARK: - SolanaRPCResponseError - + +public struct SolanaRPCResponseError: Decodable, Sendable, Error { + + public let code: Int? + public let message: String? + public let data: Payload? + + public init(code: Int?, message: String?, data: Payload?) { + self.code = code + self.message = message + self.data = data + } + + public struct Payload: Decodable, Sendable { + public let logs: [String]? + + public init(logs: [String]?) { + self.logs = logs + } + } +} + +// MARK: - JSONValue - + +/// Minimal JSON value sum type used to round-trip opaque payloads (e.g. the +/// `err` field on a simulation result) without committing to a schema we +/// don't read. +public enum JSONValue: Decodable, Sendable, Equatable { + + case null + case bool(Bool) + case number(Double) + case string(String) + case array([JSONValue]) + case object([String: JSONValue]) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else if let value = try? container.decode([String: JSONValue].self) { + self = .object(value) + } else { + throw DecodingError.typeMismatch( + JSONValue.self, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unsupported JSON value" + ) + ) + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Solana/RPC/SolanaRPC.swift b/FlipcashCore/Sources/FlipcashCore/Solana/RPC/SolanaRPC.swift new file mode 100644 index 00000000..18e05cf7 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Solana/RPC/SolanaRPC.swift @@ -0,0 +1,222 @@ +// +// SolanaRPC.swift +// FlipcashCore +// +// Created by Raul Riera on 2026-05-07. +// + +import Foundation +import Logging + +private let logger = Logger(label: "flipcash.solana-rpc") + +// MARK: - SolanaRPC - + +/// The narrow Solana JSON-RPC surface the app consumes today: fetch the +/// latest blockhash, simulate a signed transaction before submitting it, and +/// submit a signed transaction. Designed for use from any isolation context; +/// callers are expected to hop to `@MainActor` themselves for UI updates. +public protocol SolanaRPC: Sendable { + + func getLatestBlockhash(commitment: SolanaCommitment) async throws -> Hash + + func sendTransaction( + _ base64Transaction: String, + configuration: SolanaSendTransactionConfig + ) async throws -> Signature + + /// Throws `SolanaRPCError.transactionSimulationError(logs:)` when the + /// network accepts the request but the simulation itself reports an + /// `err` payload — propagating the failure through the type system so + /// callers cannot accidentally treat a failed simulation as a green-light. + func simulateTransaction( + _ base64Transaction: String, + configuration: SolanaSimulateTransactionConfig + ) async throws -> SolanaSimulationResult +} + +// MARK: - SolanaJSONRPCClient - + +/// Default `SolanaRPC` implementation backed by `URLSession`. Stateless: each +/// call builds a fresh `URLRequest`, performs the round trip, and decodes the +/// envelope. Safe to share across actors. +public struct SolanaJSONRPCClient: SolanaRPC { + + public static let mainnetBetaURL = URL(string: "https://api.mainnet-beta.solana.com")! + + private let endpoint: URL + private let urlSession: URLSession + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + public init( + endpoint: URL = SolanaJSONRPCClient.mainnetBetaURL, + urlSession: URLSession = .shared + ) { + self.endpoint = endpoint + self.urlSession = urlSession + } + + // MARK: - SolanaRPC - + + public func getLatestBlockhash(commitment: SolanaCommitment) async throws -> Hash { + let response: RPCContextValue = try await call( + method: "getLatestBlockhash", + params: GetLatestBlockhashParams(commitment: commitment) + ) + return try Hash(base58: response.value.blockhash) + } + + public func sendTransaction( + _ base64Transaction: String, + configuration: SolanaSendTransactionConfig + ) async throws -> Signature { + let signatureString: String = try await call( + method: "sendTransaction", + params: SendTransactionParams( + transaction: base64Transaction, + configuration: configuration + ) + ) + return try Signature(base58: signatureString) + } + + public func simulateTransaction( + _ base64Transaction: String, + configuration: SolanaSimulateTransactionConfig + ) async throws -> SolanaSimulationResult { + let response: RPCContextValue = try await call( + method: "simulateTransaction", + params: SimulateTransactionParams( + transaction: base64Transaction, + configuration: configuration + ) + ) + let result = response.value + if result.err != nil { + throw SolanaRPCError.transactionSimulationError(logs: result.logs ?? []) + } + return result + } + + // MARK: - Transport - + + private func call( + method: String, + params: Params + ) async throws -> Result { + let request = try makeRequest(method: method, params: params) + + let (data, urlResponse): (Data, URLResponse) + do { + (data, urlResponse) = try await urlSession.data(for: request) + } catch let error as URLError { + throw SolanaRPCError.transport(error) + } + + if let http = urlResponse as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + throw SolanaRPCError.invalidHTTPStatus(code: http.statusCode) + } + + let envelope: JSONRPCEnvelope + do { + envelope = try decoder.decode(JSONRPCEnvelope.self, from: data) + } catch { + throw SolanaRPCError.decoding(error) + } + + if let envelopeError = envelope.error { + throw SolanaRPCError.responseError(envelopeError) + } + + guard let result = envelope.result else { + throw SolanaRPCError.missingResult + } + + return result + } + + private func makeRequest( + method: String, + params: Params + ) throws -> URLRequest { + let body = JSONRPCRequest(method: method, params: params) + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + do { + request.httpBody = try encoder.encode(body) + } catch { + throw SolanaRPCError.encoding(error) + } + return request + } +} + +// MARK: - JSON-RPC envelopes - + +/// Outgoing JSON-RPC 2.0 request. `id` is fixed because Solana RPC nodes do +/// not pipeline requests over a single HTTP POST — we issue one POST per call. +private struct JSONRPCRequest: Encodable, Sendable { + let jsonrpc = "2.0" + let id = 1 + let method: String + let params: Params +} + +private struct JSONRPCEnvelope: Decodable, Sendable { + let result: Result? + let error: SolanaRPCResponseError? +} + +/// Solana wraps "stateful" RPC results in a `{ context, value }` shape so the +/// caller can correlate to the slot the response was computed against. +private struct RPCContextValue: Decodable, Sendable { + let value: Value +} + +// MARK: - getLatestBlockhash - + +private struct GetLatestBlockhashParams: Encodable, Sendable { + let commitment: SolanaCommitment + + func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + try container.encode(CommitmentObject(commitment: commitment)) + } + + private struct CommitmentObject: Encodable, Sendable { + let commitment: SolanaCommitment + } +} + +private struct GetLatestBlockhashValue: Decodable, Sendable { + let blockhash: String +} + +// MARK: - sendTransaction - + +private struct SendTransactionParams: Encodable, Sendable { + let transaction: String + let configuration: SolanaSendTransactionConfig + + func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + try container.encode(transaction) + try container.encode(configuration) + } +} + +// MARK: - simulateTransaction - + +private struct SimulateTransactionParams: Encodable, Sendable { + let transaction: String + let configuration: SolanaSimulateTransactionConfig + + func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + try container.encode(transaction) + try container.encode(configuration) + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/SolanaRPCDecodingTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/SolanaRPCDecodingTests.swift new file mode 100644 index 00000000..6abc7182 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/SolanaRPCDecodingTests.swift @@ -0,0 +1,177 @@ +// +// SolanaRPCDecodingTests.swift +// FlipcashCoreTests +// + +import Foundation +import os +import Testing +@testable import FlipcashCore + +@Suite("SolanaJSONRPCClient JSON-RPC decoding", .serialized) +struct SolanaRPCDecodingTests { + + // MARK: - Fixtures + + private static let endpoint = URL(string: "https://example-solana.test/rpc")! + private static let blockhashB58 = "EBDRoayCDDUvDgCimta45ajQeXbexv7aKqJubruqpyvu" + private static let signatureB58 = "5WuSx6eLmz26LxLzeaAKabtQ9xTpFjjEo8v2rCWHsAcxnGxmLuSav5rgb1JfWqXP2SaqtjLPUNBEXYTfGYdufjmt" + private static let unsignedTx = "AQ==" + + private static func client(serving body: String) -> SolanaJSONRPCClient { + StubURLProtocol.body.withLock { $0 = body } + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [StubURLProtocol.self] + return SolanaJSONRPCClient( + endpoint: endpoint, + urlSession: URLSession(configuration: configuration) + ) + } + + // MARK: - getLatestBlockhash + + @Test("getLatestBlockhash decodes value.blockhash into a Hash") + func getLatestBlockhash_decodesBlockhash() async throws { + let body = """ + { + "jsonrpc": "2.0", + "result": { + "context": { "slot": 1 }, + "value": { + "blockhash": "\(Self.blockhashB58)", + "lastValidBlockHeight": 1234 + } + }, + "id": 1 + } + """ + let hash = try await Self.client(serving: body).getLatestBlockhash(commitment: .finalized) + #expect(hash.base58 == Self.blockhashB58) + } + + // MARK: - sendTransaction + + @Test("sendTransaction decodes the result string into a Signature") + func sendTransaction_decodesSignature() async throws { + let body = """ + { + "jsonrpc": "2.0", + "result": "\(Self.signatureB58)", + "id": 1 + } + """ + let signature = try await Self.client(serving: body) + .sendTransaction(Self.unsignedTx, configuration: .init()) + #expect(signature.base58 == Self.signatureB58) + } + + // MARK: - simulateTransaction — success + + @Test("simulateTransaction returns logs when err is null") + func simulateTransaction_decodesLogs_whenErrIsNull() async throws { + let body = """ + { + "jsonrpc": "2.0", + "result": { + "context": { "slot": 1 }, + "value": { + "err": null, + "logs": ["Program A invoke [1]", "Program A success"] + } + }, + "id": 1 + } + """ + let result = try await Self.client(serving: body) + .simulateTransaction(Self.unsignedTx, configuration: .init()) + #expect(result.err == nil) + #expect(result.logs == ["Program A invoke [1]", "Program A success"]) + } + + // MARK: - simulateTransaction — err present → throws transactionSimulationError + + @Test("simulateTransaction throws transactionSimulationError carrying logs when err is non-null") + func simulateTransaction_throwsSimulationError_whenErrPresent() async throws { + let body = """ + { + "jsonrpc": "2.0", + "result": { + "context": { "slot": 1 }, + "value": { + "err": { "InstructionError": [0, "InsufficientFunds"] }, + "logs": ["Program A invoke [1]", "Program A failed: insufficient funds"] + } + }, + "id": 1 + } + """ + do { + _ = try await Self.client(serving: body) + .simulateTransaction(Self.unsignedTx, configuration: .init()) + Issue.record("simulateTransaction should have thrown") + } catch let SolanaRPCError.transactionSimulationError(logs) { + #expect(logs == ["Program A invoke [1]", "Program A failed: insufficient funds"]) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + // MARK: - JSON-RPC envelope error → throws responseError + + @Test("Envelope-level error is surfaced as SolanaRPCError.responseError with code, message, and logs") + func envelopeError_surfacesAsResponseError() async throws { + let body = """ + { + "jsonrpc": "2.0", + "error": { + "code": -32602, + "message": "Invalid params: bad encoding", + "data": { "logs": ["preflight: invalid base64"] } + }, + "id": 1 + } + """ + do { + _ = try await Self.client(serving: body).getLatestBlockhash(commitment: .finalized) + Issue.record("Expected SolanaRPCError.responseError") + } catch let SolanaRPCError.responseError(payload) { + #expect(payload.code == -32602) + #expect(payload.message == "Invalid params: bad encoding") + #expect(payload.data?.logs == ["preflight: invalid base64"]) + } catch { + Issue.record("Unexpected error: \(error)") + } + } +} + +// MARK: - URLProtocol stub + +/// `URLProtocol`-based stub so the suite runs hermetically — no live network, +/// CI-compatible. Body is shared via static state because `URLSession` +/// instantiates `URLProtocol` subclasses itself, leaving no constructor seam +/// for per-instance state. The suite is `.serialized` so the set-body → +/// request → read-body sequence stays atomic across test methods; the lock +/// keeps individual access Swift-6-clean and tolerates future per-test +/// parallelism if the stub is reworked to key by request URL. +private final class StubURLProtocol: URLProtocol { + + static let body = OSAllocatedUnfairLock(initialState: "") + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + let payload = Self.body.withLock { $0 } + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(payload.utf8)) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/SolanaTransactionEncodingTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/SolanaTransactionEncodingTests.swift new file mode 100644 index 00000000..c0f7c41b --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/SolanaTransactionEncodingTests.swift @@ -0,0 +1,78 @@ +// +// SolanaTransactionEncodingTests.swift +// FlipcashCoreTests +// + +import Foundation +import Testing +@testable import FlipcashCore + +@Suite("SolanaTransaction.encode wire-format pin") +struct SolanaTransactionEncodingTests { + + // MARK: - Fixtures + + private static let sender = try! PublicKey([UInt8](repeating: 1, count: 32)) + private static let owner = try! PublicKey([UInt8](repeating: 2, count: 32)) + private static let swapId = try! PublicKey([UInt8](repeating: 3, count: 32)) + private static let blockhashBytes = [UInt8](repeating: 99, count: 32) + private static let blockhash = try! Hash(blockhashBytes) + private static let amount: UInt64 = 20_000_000 + + /// Base64 of `SolanaTransaction(payer:recentBlockhash:instructions:).encode()` + /// for the USDC→USDF instruction set built below. Validated once against + /// `SolanaSwift.Transaction.from(data:)`. Any change to this fixture is a + /// wire-format regression — review before updating. + private static let expectedBase64 = """ + AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAsRAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQElKxrRNEbgnBPXjYIsz3543heVtrJ7qk60jnephMSLXSW1LhyrXvKR7ZrvLr3rsmS685lKl6lNUZldbIba6SQjOC53YOoR483gdl52u6nQIKu152HOENS474Mm9i7wRMHbfvI99eRIyyGS4m59c0LgJqE25ZYjms8D0axmFwMenOu0bQ63u+SVHuTupt0Tn5WfJP4aEUXaLXwGsE7QyU5nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADBkZv5SEXMv/srbpyw5vnvIzlu8X3EmssQ5s6QAAAAAVKU1D4XciC1hSlVnJ4iilt3x6rq9CmBniISTL07vagBqfVFxksXFEhjMlMPUrxf1ja7gibof1E49vZigAAAAAG3fbh12Whk9nL4UbO63msHLSF7V9bN5E6jPWFfv8AqQ2Lc7lEmSRk6i4IIysJGyanmbJ2Rxm4nOQ5fjYnlgt+PdPEgIo9T7OfrsnLpJtaY0pS7eEVSPUkqiDyzUbvgHF0Ty/Q1vVSYoO+s41dtnestYvXn68FewAAXoq4K9PCnIyXJY9OJInxuz0QKRSODYMLWhOZ2v8QhASOe9jb6fhZxvp6877brTo9ZfNqq8l0MbG75MLS9uDkfKYCA0UvXWHlmFP/nGQNRpk/zuVafE1Fcp/63ahMHcMVM7+lfr3orWNjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjCAcABQJADQMABwAJA+gDAAAAAAAADgcAAwAMBgoJAQEOBwACEAwGCgkBAQ4HAAUADwYKCQEBCAArQ2t0UnVRMm10dGdSR2tYSnR5a3NkS0hqVWRjMkM0VGdEenlCOThvRXp5OAsHAA0EAQMFCgoCAC0xAQAAAAAACgMDAgAJAwAtMQEAAAAA + """ + + private static let expectedBytes: Data = Data(base64Encoded: expectedBase64)! + + // MARK: - Test + + @Test("USDC→USDF instructions encode to a stable, wire-valid byte sequence") + func usdcToUsdf_encodesToFixture() throws { + let instructions = SwapInstructionBuilder.buildUsdcToUsdfSwapInstructions( + sender: Self.sender, + owner: Self.owner, + amount: Self.amount, + pool: .usdf, + swapId: Self.swapId + ) + + let transaction = SolanaTransaction( + payer: Self.sender, + recentBlockhash: Self.blockhash, + instructions: instructions + ) + + #expect(transaction.encode() == Self.expectedBytes) + } + + @Test("Round-trip: encoded bytes decode back into an equivalent transaction") + func encodeDecode_roundTrips() throws { + let instructions = SwapInstructionBuilder.buildUsdcToUsdfSwapInstructions( + sender: Self.sender, + owner: Self.owner, + amount: Self.amount, + pool: .usdf, + swapId: Self.swapId + ) + + let original = SolanaTransaction( + payer: Self.sender, + recentBlockhash: Self.blockhash, + instructions: instructions + ) + + let encoded = original.encode() + let decoded = try #require(SolanaTransaction(data: encoded)) + + #expect(decoded.encode() == encoded) + #expect(decoded.recentBlockhash == Self.blockhash) + #expect(decoded.message.accountKeys.count == original.message.accountKeys.count) + #expect(decoded.message.accountKeys.first == Self.sender) + #expect(decoded.message.instructions.count == instructions.count) + } +} diff --git a/FlipcashTests/WalletConnectionStateTests.swift b/FlipcashTests/WalletConnectionStateTests.swift index 47f32947..cf72a4f2 100644 --- a/FlipcashTests/WalletConnectionStateTests.swift +++ b/FlipcashTests/WalletConnectionStateTests.swift @@ -6,7 +6,6 @@ import Foundation import Testing import FlipcashCore -import SolanaSwift @testable import Flipcash @MainActor @@ -26,7 +25,7 @@ struct WalletConnectionStateTests { amount: ExchangedFiat.mockOne ) - private func makeConnection(rpc: WalletRPC? = nil) -> WalletConnection { + private func makeConnection(rpc: any SolanaRPC = SolanaJSONRPCClient()) -> WalletConnection { WalletConnection(owner: .mock, client: .mock, rpc: rpc) } @@ -140,7 +139,7 @@ struct WalletConnectionStateTests { @Test("Preflight rejection returns `.blocked` with a user-facing dialog") func simulationRejectionBlocks() async { let conn = makeConnection(rpc: StubRPC(simulate: .failure( - APIClientError.transactionSimulationError(logs: [ + SolanaRPCError.transactionSimulationError(logs: [ "Program 11111111 invoke [1]", "Transfer: insufficient lamports", "Program 11111111 failed: custom program error: 0x1", @@ -185,15 +184,15 @@ struct WalletConnectionStateTests { @Test( "Any RPC-reported preflight rejection halts the flow before server + chain submit", arguments: [ - APIClientError.transactionSimulationError(logs: ["insufficient funds"]), - APIClientError.responseError(ResponseError( + SolanaRPCError.transactionSimulationError(logs: ["insufficient funds"]), + SolanaRPCError.responseError(SolanaRPCResponseError( code: -32002, message: "insufficient funds", - data: ResponseErrorData(logs: ["Transfer: insufficient lamports"], numSlotsBehind: nil) + data: SolanaRPCResponseError.Payload(logs: ["Transfer: insufficient lamports"]) )), ] ) - func completeSwap_preflightRejectionHaltsFlow(_ rpcError: APIClientError) async { + func completeSwap_preflightRejectionHaltsFlow(_ rpcError: SolanaRPCError) async { let rpc = StubRPC( simulate: .failure(rpcError), send: .failure(TestError.shouldNotBeCalled) @@ -232,7 +231,7 @@ struct WalletConnectionStateTests { @Test("Chain submit success for `.buyExisting` leaves `.buying` clean") func completeSwap_buyExistingSuccessStaysBuying() async { let swapId = SwapId.generate() - let rpc = StubRPC(simulate: .succeeds, send: .succeeds(signature: "sig-1")) + let rpc = StubRPC(simulate: .succeeds, send: .succeeds) let conn = makeConnection(rpc: rpc) let pending = Self.makePendingSwap( fundingSwapId: swapId, @@ -254,7 +253,7 @@ struct WalletConnectionStateTests { let fundingId = SwapId.generate() let buyId = SwapId.generate() let mint = PublicKey.mock - let rpc = StubRPC(simulate: .succeeds, send: .succeeds(signature: "sig-2")) + let rpc = StubRPC(simulate: .succeeds, send: .succeeds) let conn = makeConnection(rpc: rpc) let pending = Self.makePendingSwap( fundingSwapId: fundingId, @@ -332,17 +331,17 @@ struct WalletConnectionStateTests { // MARK: - StubRPC - -/// Minimal `WalletRPC` for tests. Both `simulateTransaction` and +/// Minimal `SolanaRPC` for tests. Both `simulateTransaction` and /// `sendTransaction` are configurable; `getLatestBlockhash` traps because no /// current test exercises paths that fetch a blockhash. -private struct StubRPC: WalletRPC { +private struct StubRPC: SolanaRPC { enum SimulateBehavior { case succeeds case failure(Error) } enum SendBehavior { - case succeeds(signature: String) + case succeeds case failure(Error) } @@ -354,24 +353,23 @@ private struct StubRPC: WalletRPC { self.send = send } - func getLatestBlockhash(commitment: Commitment?) async throws -> String { + func getLatestBlockhash(commitment: SolanaCommitment) async throws -> Hash { fatalError("getLatestBlockhash not stubbed") } - func sendTransaction(transaction: String, configs: RequestConfiguration) async throws -> TransactionID { + func sendTransaction(_ base64Transaction: String, configuration: SolanaSendTransactionConfig) async throws -> Signature { switch send { - case .succeeds(let signature): - return signature + case .succeeds: + return .mock case .failure(let error): throw error } } - func simulateTransaction(transaction: String, configs: RequestConfiguration) async throws -> SimulationResult { + func simulateTransaction(_ base64Transaction: String, configuration: SolanaSimulateTransactionConfig) async throws -> SolanaSimulationResult { switch simulate { case .succeeds: - let payload = Data(#"{"err":null,"logs":[]}"#.utf8) - return try JSONDecoder().decode(SimulationResult.self, from: payload) + return SolanaSimulationResult(err: nil, logs: []) case .failure(let error): throw error }