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%
12.2 KB · 298 lines swift
Raw Blame History
1//2//  TONService.swift3//  OS Vault4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import Foundation10import BigInt11import WalletCore1213/// TON support: native TON + USDT (jetton, TEP-74). Signing via wallet-core14/// (`Transfer` / `JettonTransfer`, wallet v4R2 — the address format15/// wallet-core derives). Networking via keyless toncenter (v2 for wallet16/// state and broadcast, v3 for jetton wallets), throttled to its documented17/// 1 req/s anonymous budget.18///19/// TON specifics: jetton balances live in a separate jetton-wallet contract;20/// a jetton transfer is a TON message to YOUR jetton wallet carrying21/// ~0.07 TON for fees (excess refunded). The wallet contract itself deploys22/// with the first outgoing transfer (seqno 0 → wallet-core adds stateInit).23public actor TONService {2425    public enum TONNetwork: String, CaseIterable, Codable, Sendable {26        case mainnet27        case testnet2829        public var v2Base: String {30            switch self {31            case .mainnet: return "https://toncenter.com/api/v2"32            case .testnet: return "https://testnet.toncenter.com/api/v2"33            }34        }3536        public var v3Base: String {37            switch self {38            case .mainnet: return "https://toncenter.com/api/v3"39            case .testnet: return "https://testnet.toncenter.com/api/v3"40            }41        }4243        /// Tether-issued USDT jetton master (mainnet only; no official44        /// testnet USDT).45        public var usdtMaster: String? {46            switch self {47            case .mainnet: return "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs"48            case .testnet: return nil49            }50        }5152        public func explorerTxURL(_ hash: String) -> URL {53            switch self {54            case .mainnet: return URL(string: "https://tonviewer.com/transaction/\(hash)")!55            case .testnet: return URL(string: "https://testnet.tonviewer.com/transaction/\(hash)")!56            }57        }5859        public func explorerAddressURL(_ address: String) -> URL {60            switch self {61            case .mainnet: return URL(string: "https://tonviewer.com/\(address)")!62            case .testnet: return URL(string: "https://testnet.tonviewer.com/\(address)")!63            }64        }6566        public var displayName: String {67            switch self {68            case .mainnet: return "TON"69            case .testnet: return "TON Testnet"70            }71        }7273        public var isTestnet: Bool { self == .testnet }74    }7576    public static let networkKey = "osvault.ton.network"77    public static let tonDecimals = 978    public static let usdtDecimals = 679    /// TON attached to a jetton transfer for fees (excess refunded).80    static let jettonAttachNanotons: UInt64 = 70_000_000   // 0.07 TON81    static let sendMode: UInt32 = 3   // pay fees separately + ignore errors8283    public struct TONBalances: Sendable {84        public var nanotons: UInt64 = 085        public var usdtUnits: UInt64 = 086        public var deployed = false87    }8889    public struct PreparedTONSend: Sendable {90        public let recipient: String91        public let amountUnits: UInt6492        public let isUSDT: Bool93        public let estimatedFeeNanotons: UInt6494        public let network: TONNetwork95    }9697    private var address: String?98    private var network: TONNetwork = .testnet99    private var lastRequest = Date.distantPast100101    // MARK: - Setup102103    public func configure(mnemonic: String) throws {104        let stored = UserDefaults.standard.string(forKey: Self.networkKey)105        network = stored.flatMap(TONNetwork.init(rawValue:)) ?? .testnet106        guard let wallet = HDWallet(mnemonic: mnemonic, passphrase: "") else {107            throw WalletError.invalidMnemonic108        }109        address = wallet.getAddressForCoin(coin: .ton)110    }111112    public var isConfigured: Bool { address != nil }113    public var currentNetwork: TONNetwork { network }114    public var publicAddress: String? { address }115116    public func switchNetwork(to newNetwork: TONNetwork) {117        UserDefaults.standard.set(newNetwork.rawValue, forKey: Self.networkKey)118        network = newNetwork119    }120121    public static func validate(address: String) -> Bool {122        AnyAddress.isValid(string: address, coin: .ton)123    }124125    // MARK: - HTTP (1 req/s budget)126127    private func throttle() async {128        let elapsed = Date().timeIntervalSince(lastRequest)129        if elapsed < 1.1 {130            try? await Task.sleep(nanoseconds: UInt64((1.1 - elapsed) * 1_000_000_000))131        }132        lastRequest = Date()133    }134135    private func getJSON(_ url: URL) async throws -> [String: Any] {136        await throttle()137        var request = URLRequest(url: url)138        request.timeoutInterval = 20139        let (data, _) = try await URLSession.shared.data(for: request)140        guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {141            throw WalletError.rpc("Malformed toncenter response.")142        }143        return json144    }145146    // MARK: - Balances147148    public func fetchBalances() async throws -> TONBalances {149        guard let address else { throw WalletError.internalError("TON not configured.") }150        var balances = TONBalances()151152        var components = URLComponents(string: network.v2Base + "/getWalletInformation")!153        components.queryItems = [URLQueryItem(name: "address", value: address)]154        let info = try await getJSON(components.url!)155        guard info["ok"] as? Bool == true, let result = info["result"] as? [String: Any] else {156            throw WalletError.rpc((info["error"] as? String) ?? "toncenter query failed.")157        }158        balances.nanotons = (result["balance"] as? String).flatMap(UInt64.init)159            ?? (result["balance"] as? NSNumber)?.uint64Value ?? 0160        balances.deployed = (result["account_state"] as? String) == "active"161162        if let master = network.usdtMaster {163            var jettonComponents = URLComponents(string: network.v3Base + "/jetton/wallets")!164            jettonComponents.queryItems = [165                URLQueryItem(name: "owner_address", value: address),166                URLQueryItem(name: "jetton_address", value: master),167                URLQueryItem(name: "limit", value: "1")168            ]169            if let json = try? await getJSON(jettonComponents.url!),170               let wallets = json["jetton_wallets"] as? [[String: Any]],171               let first = wallets.first,172               let balance = first["balance"] as? String, let units = UInt64(balance) {173                balances.usdtUnits = units174            }175        }176        return balances177    }178179    /// Raw-format jetton wallet address (0:hex) for this owner, if any.180    private func myJettonWallet() async throws -> String? {181        guard let address, let master = network.usdtMaster else { return nil }182        var components = URLComponents(string: network.v3Base + "/jetton/wallets")!183        components.queryItems = [184            URLQueryItem(name: "owner_address", value: address),185            URLQueryItem(name: "jetton_address", value: master),186            URLQueryItem(name: "limit", value: "1")187        ]188        let json = try await getJSON(components.url!)189        guard let wallets = json["jetton_wallets"] as? [[String: Any]],190              let raw = wallets.first?["address"] as? String else { return nil }191        // Convert raw 0:hex to the user-friendly bounceable form for the tx.192        return TONAddressConverter.toUserFriendly(address: raw, bounceable: true, testnet: network.isTestnet)193    }194195    // MARK: - Estimate196197    public func estimateSend(to recipient: String, amountUnits: UInt64, isUSDT: Bool) async throws -> PreparedTONSend {198        guard Self.validate(address: recipient) else { throw WalletError.invalidAddress }199        if isUSDT {200            guard network.usdtMaster != nil else {201                throw WalletError.internalError("USDT is not available on TON testnet.")202            }203            guard try await myJettonWallet() != nil else {204                throw WalletError.internalError("No USDT jetton wallet found for this account (balance is 0).")205            }206        }207        // Typical costs: plain transfer ~0.004 TON; jetton carries the208        // attached 0.07 TON of which the unused part is refunded.209        let fee: UInt64 = isUSDT ? Self.jettonAttachNanotons : 5_000_000210        return PreparedTONSend(211            recipient: recipient, amountUnits: amountUnits,212            isUSDT: isUSDT, estimatedFeeNanotons: fee, network: network213        )214    }215216    // MARK: - Sign + broadcast217218    public func send(_ prepared: PreparedTONSend, mnemonic: String) async throws -> String {219        guard let address else { throw WalletError.internalError("TON not configured.") }220        guard let wallet = HDWallet(mnemonic: mnemonic, passphrase: "") else {221            throw WalletError.invalidMnemonic222        }223224        // seqno (0 for an undeployed wallet → wallet-core adds stateInit).225        var components = URLComponents(string: network.v2Base + "/getWalletInformation")!226        components.queryItems = [URLQueryItem(name: "address", value: address)]227        let info = try await getJSON(components.url!)228        let result = info["result"] as? [String: Any] ?? [:]229        let seqno = (result["seqno"] as? NSNumber)?.uint32Value ?? 0230231        var transfer = TheOpenNetworkTransfer()232        transfer.mode = Self.sendMode233        if prepared.isUSDT {234            guard let jettonWallet = try await myJettonWallet() else {235                throw WalletError.internalError("USDT jetton wallet not found.")236            }237            transfer.dest = jettonWallet238            transfer.amount = BigUInt(Self.jettonAttachNanotons).serialize()239            transfer.bounceable = true240            var jetton = TheOpenNetworkJettonTransfer()241            jetton.jettonAmount = BigUInt(prepared.amountUnits).serialize()242            jetton.toOwner = prepared.recipient243            jetton.responseAddress = address244            jetton.forwardAmount = BigUInt(1).serialize()245            transfer.payload = .jettonTransfer(jetton)246        } else {247            transfer.dest = prepared.recipient248            transfer.amount = BigUInt(prepared.amountUnits).serialize()249            transfer.bounceable = false   // wallets use non-bounceable250        }251252        let key = wallet.getKeyForCoin(coin: .ton)253        var input = TheOpenNetworkSigningInput()254        input.privateKey = key.data255        input.walletVersion = .walletV4R2256        input.sequenceNumber = seqno257        input.expireAt = UInt32(Date().timeIntervalSince1970) + 300258        input.messages = [transfer]259260        let output: TheOpenNetworkSigningOutput = AnySigner.sign(input: input, coin: .ton)261        guard output.error == .ok, !output.encoded.isEmpty else {262            throw WalletError.signingFailed263        }264265        await throttle()266        var request = URLRequest(url: URL(string: network.v2Base + "/sendBoc")!)267        request.httpMethod = "POST"268        request.setValue("application/json", forHTTPHeaderField: "Content-Type")269        request.httpBody = try JSONSerialization.data(withJSONObject: ["boc": output.encoded])270        request.timeoutInterval = 20271        let (data, _) = try await URLSession.shared.data(for: request)272        guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],273              json["ok"] as? Bool == true else {274            let message = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["error"] as? String275            throw WalletError.rpc(message ?? "Broadcast failed.")276        }277        // toncenter returns the message hash; link the account view instead.278        let hash = ((json["result"] as? [String: Any])?["hash"] as? String) ?? ""279        return hash.isEmpty ? address : hash280    }281282    // MARK: - Formatting283284    public static func formatTON(_ nanotons: UInt64) -> String {285        TokenAmount.format(BigUInt(nanotons), decimals: tonDecimals, maxFractionDigits: 6)286    }287288    public static func parseTON(_ input: String) -> UInt64? {289        guard let units = TokenAmount.parse(input, decimals: tonDecimals), units <= BigUInt(UInt64.max) else { return nil }290        return UInt64(units)291    }292293    public static func parseUSDT(_ input: String) -> UInt64? {294        guard let units = TokenAmount.parse(input, decimals: usdtDecimals), units <= BigUInt(UInt64.max) else { return nil }295        return UInt64(units)296    }297}298