// // SolanaService.swift // OS Vault // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // import Foundation import SolanaSwift /// Solana support via solana-swift (macOS-native, maintained — see /// docs/RESEARCH-MULTICHAIN.md): SOL + USDC (SPL) from the same vault /// mnemonic, ed25519 at m/44'/501'/0'/0' (Phantom-compatible bip44Change). /// /// Security model matches the rest of the app: only the public address stays /// in memory; sends re-derive the keypair from the vault password, sign, and /// discard. RPC is keyless (PublicNode mainnet / official devnet). Default /// network is devnet (testnet-first rule). public actor SolanaService { public enum SOLNetwork: String, CaseIterable, Codable, Sendable { case mainnet case devnet var solanaNetwork: SolanaSwift.Network { switch self { case .mainnet: return .mainnetBeta case .devnet: return .devnet } } public var rpcURL: String { switch self { // PublicNode: keyless, MEV-protected; official endpoint fallback. case .mainnet: return "https://solana-rpc.publicnode.com" case .devnet: return "https://api.devnet.solana.com" } } var fallbackRPCURL: String? { switch self { case .mainnet: return "https://api.mainnet-beta.solana.com" case .devnet: return nil } } /// Circle-issued USDC mint (devnet mint is the Circle faucet's). public var usdcMint: String { switch self { case .mainnet: return "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" case .devnet: return "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" } } public func explorerTxURL(_ signature: String) -> URL { switch self { case .mainnet: return URL(string: "https://explorer.solana.com/tx/\(signature)")! case .devnet: return URL(string: "https://explorer.solana.com/tx/\(signature)?cluster=devnet")! } } public func explorerAddressURL(_ address: String) -> URL { switch self { case .mainnet: return URL(string: "https://explorer.solana.com/address/\(address)")! case .devnet: return URL(string: "https://explorer.solana.com/address/\(address)?cluster=devnet")! } } public var displayName: String { switch self { case .mainnet: return "Solana" case .devnet: return "Solana Devnet" } } public var isTestnet: Bool { self == .devnet } } public static let networkKey = "osvault.solana.network" public static let usdcDecimals = 6 static let derivablePath = DerivablePath(type: .bip44Change, walletIndex: 0, accountIndex: 0) public struct SOLBalances: Sendable { public var lamports: UInt64 = 0 // SOL, 9 decimals public var usdcUnits: UInt64 = 0 // USDC, 6 decimals } public struct PreparedSOLSend: Sendable { public let recipient: String public let amountUnits: UInt64 public let isUSDC: Bool /// Signature fee + ATA rent if the recipient needs a token account. public let estimatedFeeLamports: UInt64 public let createsTokenAccount: Bool public let network: SOLNetwork } private var address: String? private var network: SOLNetwork = .devnet // MARK: - Setup public func configure(mnemonic: String) async throws { let stored = UserDefaults.standard.string(forKey: Self.networkKey) network = stored.flatMap(SOLNetwork.init(rawValue:)) ?? .devnet let keyPair = try await Self.deriveKeyPair(mnemonic: mnemonic, network: network) address = keyPair.publicKey.base58EncodedString } static func deriveKeyPair(mnemonic: String, network: SOLNetwork) async throws -> KeyPair { try await KeyPair( phrase: mnemonic.split(separator: " ").map(String.init), network: network.solanaNetwork, derivablePath: derivablePath ) } public var isConfigured: Bool { address != nil } public var currentNetwork: SOLNetwork { network } public var publicAddress: String? { address } /// The address is derivation-only, so switching clusters needs no password. public func switchNetwork(to newNetwork: SOLNetwork) { UserDefaults.standard.set(newNetwork.rawValue, forKey: Self.networkKey) network = newNetwork } public static func validate(address: String) -> Bool { // solana-swift's PublicKey(string:) is lax; enforce strict base58 // alphabet and an exact 32-byte decode. guard address.count >= 32, address.count <= 44, address.allSatisfy({ "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".contains($0) }), Base58.decode(address).count == 32 else { return false } return true } // MARK: - RPC private func apiClient() -> JSONRPCAPIClient { JSONRPCAPIClient(endpoint: APIEndPoint(address: network.rpcURL, network: network.solanaNetwork)) } private func fallbackClient() -> JSONRPCAPIClient? { network.fallbackRPCURL.map { JSONRPCAPIClient(endpoint: APIEndPoint(address: $0, network: network.solanaNetwork)) } } private func usdcATA() throws -> PublicKey { guard let address else { throw WalletError.internalError("Solana not configured.") } return try PublicKey.associatedTokenAddress( walletAddress: PublicKey(string: address), tokenMintAddress: PublicKey(string: network.usdcMint), tokenProgramId: TokenProgram.id ) } // MARK: - Balances public func fetchBalances() async throws -> SOLBalances { guard let address else { throw WalletError.internalError("Solana not configured.") } let client = apiClient() var balances = SOLBalances() do { balances.lamports = try await client.getBalance(account: address, commitment: "confirmed") } catch { guard let fallback = fallbackClient() else { throw WalletError.rpc(error.localizedDescription) } balances.lamports = try await fallback.getBalance(account: address, commitment: "confirmed") } if let ata = try? usdcATA(), let tokenBalance = try? await client.getTokenAccountBalance( pubkey: ata.base58EncodedString, commitment: "confirmed"), let units = UInt64(tokenBalance.amount) { balances.usdcUnits = units } return balances } // MARK: - Send /// Fee estimate without touching key material: signature fee + ATA rent /// when sending USDC to a wallet that has no token account yet (the /// sender funds ~0.002 SOL of rent — a Solana-specific gotcha). public func estimateSend(to recipient: String, amountUnits: UInt64, isUSDC: Bool) async throws -> PreparedSOLSend { guard Self.validate(address: recipient) else { throw WalletError.invalidAddress } let client = apiClient() let lamportsPerSignature: UInt64 = 5_000 var createsATA = false var fee = lamportsPerSignature if isUSDC { let recipientATA = try PublicKey.associatedTokenAddress( walletAddress: PublicKey(string: recipient), tokenMintAddress: PublicKey(string: network.usdcMint), tokenProgramId: TokenProgram.id ) let info: BufferInfo? = try? await client.getAccountInfo( account: recipientATA.base58EncodedString) if info == nil { createsATA = true let rent = (try? await client.getMinimumBalanceForRentExemption( dataLength: 165, commitment: "confirmed")) ?? 2_039_280 fee += rent } } return PreparedSOLSend( recipient: recipient, amountUnits: amountUnits, isUSDC: isUSDC, estimatedFeeLamports: fee, createsTokenAccount: createsATA, network: network ) } /// Derives the keypair from the mnemonic, builds + signs + broadcasts, /// returns the transaction signature. Key material is never retained. public func send(_ prepared: PreparedSOLSend, mnemonic: String) async throws -> String { let account = try await Self.deriveKeyPair(mnemonic: mnemonic, network: network) let client = apiClient() let blockchain = BlockchainClient(apiClient: client) if prepared.isUSDC { let sourceATA = try usdcATA() let lamportsPerSignature: UInt64 = 5_000 let minRent = (try? await client.getMinimumBalanceForRentExemption( dataLength: 165, commitment: "confirmed")) ?? 2_039_280 let (tx, _) = try await blockchain.prepareSendingSPLTokens( account: account, mintAddress: network.usdcMint, tokenProgramId: TokenProgram.id, decimals: UInt8(Self.usdcDecimals), from: sourceATA.base58EncodedString, to: prepared.recipient, amount: prepared.amountUnits, lamportsPerSignature: lamportsPerSignature, minRentExemption: minRent ) return try await blockchain.sendTransaction(preparedTransaction: tx) } else { let tx = try await blockchain.prepareSendingNativeSOL( from: account, to: prepared.recipient, amount: prepared.amountUnits ) return try await blockchain.sendTransaction(preparedTransaction: tx) } } // MARK: - Formatting public static func formatSOL(_ lamports: UInt64) -> String { TokenAmount.format(.init(lamports), decimals: 9, maxFractionDigits: 6) } public static func parseSOL(_ input: String) -> UInt64? { guard let units = TokenAmount.parse(input, decimals: 9), units <= .init(UInt64.max) else { return nil } return UInt64(units) } public static func parseUSDC(_ input: String) -> UInt64? { guard let units = TokenAmount.parse(input, decimals: usdcDecimals), units <= .init(UInt64.max) else { return nil } return UInt64(units) } }