SPB Git

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%
10.3 KB · 264 lines swift
Raw Blame History
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