spb/os-vault Public
Self-custody, multi-chain crypto wallet for macOS. One recovery phrase, six chain families, zero API keys — nothing leaves your Mac.
Swift 96%
Shell 3.4%
Makefile 0.6%
1//2// SolanaService.swift3// OS Vault4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//89import Foundation10import SolanaSwift1112/// Solana support via solana-swift (macOS-native, maintained — see13/// docs/RESEARCH-MULTICHAIN.md): SOL + USDC (SPL) from the same vault14/// mnemonic, ed25519 at m/44'/501'/0'/0' (Phantom-compatible bip44Change).15///16/// Security model matches the rest of the app: only the public address stays17/// in memory; sends re-derive the keypair from the vault password, sign, and18/// discard. RPC is keyless (PublicNode mainnet / official devnet). Default19/// network is devnet (testnet-first rule).20public actor SolanaService {2122 public enum SOLNetwork: String, CaseIterable, Codable, Sendable {23 case mainnet24 case devnet2526 var solanaNetwork: SolanaSwift.Network {27 switch self {28 case .mainnet: return .mainnetBeta29 case .devnet: return .devnet30 }31 }3233 public var rpcURL: String {34 switch self {35 // PublicNode: keyless, MEV-protected; official endpoint fallback.36 case .mainnet: return "https://solana-rpc.publicnode.com"37 case .devnet: return "https://api.devnet.solana.com"38 }39 }4041 var fallbackRPCURL: String? {42 switch self {43 case .mainnet: return "https://api.mainnet-beta.solana.com"44 case .devnet: return nil45 }46 }4748 /// Circle-issued USDC mint (devnet mint is the Circle faucet's).49 public var usdcMint: String {50 switch self {51 case .mainnet: return "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"52 case .devnet: return "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"53 }54 }5556 public func explorerTxURL(_ signature: String) -> URL {57 switch self {58 case .mainnet: return URL(string: "https://explorer.solana.com/tx/\(signature)")!59 case .devnet: return URL(string: "https://explorer.solana.com/tx/\(signature)?cluster=devnet")!60 }61 }6263 public func explorerAddressURL(_ address: String) -> URL {64 switch self {65 case .mainnet: return URL(string: "https://explorer.solana.com/address/\(address)")!66 case .devnet: return URL(string: "https://explorer.solana.com/address/\(address)?cluster=devnet")!67 }68 }6970 public var displayName: String {71 switch self {72 case .mainnet: return "Solana"73 case .devnet: return "Solana Devnet"74 }75 }7677 public var isTestnet: Bool { self == .devnet }78 }7980 public static let networkKey = "osvault.solana.network"81 public static let usdcDecimals = 682 static let derivablePath = DerivablePath(type: .bip44Change, walletIndex: 0, accountIndex: 0)8384 public struct SOLBalances: Sendable {85 public var lamports: UInt64 = 0 // SOL, 9 decimals86 public var usdcUnits: UInt64 = 0 // USDC, 6 decimals87 }8889 public struct PreparedSOLSend: Sendable {90 public let recipient: String91 public let amountUnits: UInt6492 public let isUSDC: Bool93 /// Signature fee + ATA rent if the recipient needs a token account.94 public let estimatedFeeLamports: UInt6495 public let createsTokenAccount: Bool96 public let network: SOLNetwork97 }9899 private var address: String?100 private var network: SOLNetwork = .devnet101102 // MARK: - Setup103104 public func configure(mnemonic: String) async throws {105 let stored = UserDefaults.standard.string(forKey: Self.networkKey)106 network = stored.flatMap(SOLNetwork.init(rawValue:)) ?? .devnet107 let keyPair = try await Self.deriveKeyPair(mnemonic: mnemonic, network: network)108 address = keyPair.publicKey.base58EncodedString109 }110111 static func deriveKeyPair(mnemonic: String, network: SOLNetwork) async throws -> KeyPair {112 try await KeyPair(113 phrase: mnemonic.split(separator: " ").map(String.init),114 network: network.solanaNetwork,115 derivablePath: derivablePath116 )117 }118119 public var isConfigured: Bool { address != nil }120 public var currentNetwork: SOLNetwork { network }121 public var publicAddress: String? { address }122123 /// The address is derivation-only, so switching clusters needs no password.124 public func switchNetwork(to newNetwork: SOLNetwork) {125 UserDefaults.standard.set(newNetwork.rawValue, forKey: Self.networkKey)126 network = newNetwork127 }128129 public static func validate(address: String) -> Bool {130 // solana-swift's PublicKey(string:) is lax; enforce strict base58131 // alphabet and an exact 32-byte decode.132 guard address.count >= 32, address.count <= 44,133 address.allSatisfy({ "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".contains($0) }),134 Base58.decode(address).count == 32 else {135 return false136 }137 return true138 }139140 // MARK: - RPC141142 private func apiClient() -> JSONRPCAPIClient {143 JSONRPCAPIClient(endpoint: APIEndPoint(address: network.rpcURL, network: network.solanaNetwork))144 }145146 private func fallbackClient() -> JSONRPCAPIClient? {147 network.fallbackRPCURL.map {148 JSONRPCAPIClient(endpoint: APIEndPoint(address: $0, network: network.solanaNetwork))149 }150 }151152 private func usdcATA() throws -> PublicKey {153 guard let address else { throw WalletError.internalError("Solana not configured.") }154 return try PublicKey.associatedTokenAddress(155 walletAddress: PublicKey(string: address),156 tokenMintAddress: PublicKey(string: network.usdcMint),157 tokenProgramId: TokenProgram.id158 )159 }160161 // MARK: - Balances162163 public func fetchBalances() async throws -> SOLBalances {164 guard let address else { throw WalletError.internalError("Solana not configured.") }165 let client = apiClient()166 var balances = SOLBalances()167 do {168 balances.lamports = try await client.getBalance(account: address, commitment: "confirmed")169 } catch {170 guard let fallback = fallbackClient() else { throw WalletError.rpc(error.localizedDescription) }171 balances.lamports = try await fallback.getBalance(account: address, commitment: "confirmed")172 }173 if let ata = try? usdcATA(),174 let tokenBalance = try? await client.getTokenAccountBalance(175 pubkey: ata.base58EncodedString, commitment: "confirmed"),176 let units = UInt64(tokenBalance.amount) {177 balances.usdcUnits = units178 }179 return balances180 }181182 // MARK: - Send183184 /// Fee estimate without touching key material: signature fee + ATA rent185 /// when sending USDC to a wallet that has no token account yet (the186 /// sender funds ~0.002 SOL of rent — a Solana-specific gotcha).187 public func estimateSend(to recipient: String, amountUnits: UInt64, isUSDC: Bool) async throws -> PreparedSOLSend {188 guard Self.validate(address: recipient) else { throw WalletError.invalidAddress }189 let client = apiClient()190 let lamportsPerSignature: UInt64 = 5_000191 var createsATA = false192 var fee = lamportsPerSignature193 if isUSDC {194 let recipientATA = try PublicKey.associatedTokenAddress(195 walletAddress: PublicKey(string: recipient),196 tokenMintAddress: PublicKey(string: network.usdcMint),197 tokenProgramId: TokenProgram.id198 )199 let info: BufferInfo<TokenAccountState>? = try? await client.getAccountInfo(200 account: recipientATA.base58EncodedString)201 if info == nil {202 createsATA = true203 let rent = (try? await client.getMinimumBalanceForRentExemption(204 dataLength: 165, commitment: "confirmed")) ?? 2_039_280205 fee += rent206 }207 }208 return PreparedSOLSend(209 recipient: recipient, amountUnits: amountUnits, isUSDC: isUSDC,210 estimatedFeeLamports: fee, createsTokenAccount: createsATA, network: network211 )212 }213214 /// Derives the keypair from the mnemonic, builds + signs + broadcasts,215 /// returns the transaction signature. Key material is never retained.216 public func send(_ prepared: PreparedSOLSend, mnemonic: String) async throws -> String {217 let account = try await Self.deriveKeyPair(mnemonic: mnemonic, network: network)218 let client = apiClient()219 let blockchain = BlockchainClient(apiClient: client)220221 if prepared.isUSDC {222 let sourceATA = try usdcATA()223 let lamportsPerSignature: UInt64 = 5_000224 let minRent = (try? await client.getMinimumBalanceForRentExemption(225 dataLength: 165, commitment: "confirmed")) ?? 2_039_280226 let (tx, _) = try await blockchain.prepareSendingSPLTokens(227 account: account,228 mintAddress: network.usdcMint,229 tokenProgramId: TokenProgram.id,230 decimals: UInt8(Self.usdcDecimals),231 from: sourceATA.base58EncodedString,232 to: prepared.recipient,233 amount: prepared.amountUnits,234 lamportsPerSignature: lamportsPerSignature,235 minRentExemption: minRent236 )237 return try await blockchain.sendTransaction(preparedTransaction: tx)238 } else {239 let tx = try await blockchain.prepareSendingNativeSOL(240 from: account,241 to: prepared.recipient,242 amount: prepared.amountUnits243 )244 return try await blockchain.sendTransaction(preparedTransaction: tx)245 }246 }247248 // MARK: - Formatting249250 public static func formatSOL(_ lamports: UInt64) -> String {251 TokenAmount.format(.init(lamports), decimals: 9, maxFractionDigits: 6)252 }253254 public static func parseSOL(_ input: String) -> UInt64? {255 guard let units = TokenAmount.parse(input, decimals: 9), units <= .init(UInt64.max) else { return nil }256 return UInt64(units)257 }258259 public static func parseUSDC(_ input: String) -> UInt64? {260 guard let units = TokenAmount.parse(input, decimals: usdcDecimals), units <= .init(UInt64.max) else { return nil }261 return UInt64(units)262 }263}264