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// 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