// // TONService.swift // OS Vault // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // import Foundation import BigInt import WalletCore /// TON support: native TON + USDT (jetton, TEP-74). Signing via wallet-core /// (`Transfer` / `JettonTransfer`, wallet v4R2 — the address format /// wallet-core derives). Networking via keyless toncenter (v2 for wallet /// state and broadcast, v3 for jetton wallets), throttled to its documented /// 1 req/s anonymous budget. /// /// TON specifics: jetton balances live in a separate jetton-wallet contract; /// a jetton transfer is a TON message to YOUR jetton wallet carrying /// ~0.07 TON for fees (excess refunded). The wallet contract itself deploys /// with the first outgoing transfer (seqno 0 → wallet-core adds stateInit). public actor TONService { public enum TONNetwork: String, CaseIterable, Codable, Sendable { case mainnet case testnet public var v2Base: String { switch self { case .mainnet: return "https://toncenter.com/api/v2" case .testnet: return "https://testnet.toncenter.com/api/v2" } } public var v3Base: String { switch self { case .mainnet: return "https://toncenter.com/api/v3" case .testnet: return "https://testnet.toncenter.com/api/v3" } } /// Tether-issued USDT jetton master (mainnet only; no official /// testnet USDT). public var usdtMaster: String? { switch self { case .mainnet: return "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs" case .testnet: return nil } } public func explorerTxURL(_ hash: String) -> URL { switch self { case .mainnet: return URL(string: "https://tonviewer.com/transaction/\(hash)")! case .testnet: return URL(string: "https://testnet.tonviewer.com/transaction/\(hash)")! } } public func explorerAddressURL(_ address: String) -> URL { switch self { case .mainnet: return URL(string: "https://tonviewer.com/\(address)")! case .testnet: return URL(string: "https://testnet.tonviewer.com/\(address)")! } } public var displayName: String { switch self { case .mainnet: return "TON" case .testnet: return "TON Testnet" } } public var isTestnet: Bool { self == .testnet } } public static let networkKey = "osvault.ton.network" public static let tonDecimals = 9 public static let usdtDecimals = 6 /// TON attached to a jetton transfer for fees (excess refunded). static let jettonAttachNanotons: UInt64 = 70_000_000 // 0.07 TON static let sendMode: UInt32 = 3 // pay fees separately + ignore errors public struct TONBalances: Sendable { public var nanotons: UInt64 = 0 public var usdtUnits: UInt64 = 0 public var deployed = false } public struct PreparedTONSend: Sendable { public let recipient: String public let amountUnits: UInt64 public let isUSDT: Bool public let estimatedFeeNanotons: UInt64 public let network: TONNetwork } private var address: String? private var network: TONNetwork = .testnet private var lastRequest = Date.distantPast // MARK: - Setup public func configure(mnemonic: String) throws { let stored = UserDefaults.standard.string(forKey: Self.networkKey) network = stored.flatMap(TONNetwork.init(rawValue:)) ?? .testnet guard let wallet = HDWallet(mnemonic: mnemonic, passphrase: "") else { throw WalletError.invalidMnemonic } address = wallet.getAddressForCoin(coin: .ton) } public var isConfigured: Bool { address != nil } public var currentNetwork: TONNetwork { network } public var publicAddress: String? { address } public func switchNetwork(to newNetwork: TONNetwork) { UserDefaults.standard.set(newNetwork.rawValue, forKey: Self.networkKey) network = newNetwork } public static func validate(address: String) -> Bool { AnyAddress.isValid(string: address, coin: .ton) } // MARK: - HTTP (1 req/s budget) private func throttle() async { let elapsed = Date().timeIntervalSince(lastRequest) if elapsed < 1.1 { try? await Task.sleep(nanoseconds: UInt64((1.1 - elapsed) * 1_000_000_000)) } lastRequest = Date() } private func getJSON(_ url: URL) async throws -> [String: Any] { await throttle() var request = URLRequest(url: url) request.timeoutInterval = 20 let (data, _) = try await URLSession.shared.data(for: request) guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw WalletError.rpc("Malformed toncenter response.") } return json } // MARK: - Balances public func fetchBalances() async throws -> TONBalances { guard let address else { throw WalletError.internalError("TON not configured.") } var balances = TONBalances() var components = URLComponents(string: network.v2Base + "/getWalletInformation")! components.queryItems = [URLQueryItem(name: "address", value: address)] let info = try await getJSON(components.url!) guard info["ok"] as? Bool == true, let result = info["result"] as? [String: Any] else { throw WalletError.rpc((info["error"] as? String) ?? "toncenter query failed.") } balances.nanotons = (result["balance"] as? String).flatMap(UInt64.init) ?? (result["balance"] as? NSNumber)?.uint64Value ?? 0 balances.deployed = (result["account_state"] as? String) == "active" if let master = network.usdtMaster { var jettonComponents = URLComponents(string: network.v3Base + "/jetton/wallets")! jettonComponents.queryItems = [ URLQueryItem(name: "owner_address", value: address), URLQueryItem(name: "jetton_address", value: master), URLQueryItem(name: "limit", value: "1") ] if let json = try? await getJSON(jettonComponents.url!), let wallets = json["jetton_wallets"] as? [[String: Any]], let first = wallets.first, let balance = first["balance"] as? String, let units = UInt64(balance) { balances.usdtUnits = units } } return balances } /// Raw-format jetton wallet address (0:hex) for this owner, if any. private func myJettonWallet() async throws -> String? { guard let address, let master = network.usdtMaster else { return nil } var components = URLComponents(string: network.v3Base + "/jetton/wallets")! components.queryItems = [ URLQueryItem(name: "owner_address", value: address), URLQueryItem(name: "jetton_address", value: master), URLQueryItem(name: "limit", value: "1") ] let json = try await getJSON(components.url!) guard let wallets = json["jetton_wallets"] as? [[String: Any]], let raw = wallets.first?["address"] as? String else { return nil } // Convert raw 0:hex to the user-friendly bounceable form for the tx. return TONAddressConverter.toUserFriendly(address: raw, bounceable: true, testnet: network.isTestnet) } // MARK: - Estimate public func estimateSend(to recipient: String, amountUnits: UInt64, isUSDT: Bool) async throws -> PreparedTONSend { guard Self.validate(address: recipient) else { throw WalletError.invalidAddress } if isUSDT { guard network.usdtMaster != nil else { throw WalletError.internalError("USDT is not available on TON testnet.") } guard try await myJettonWallet() != nil else { throw WalletError.internalError("No USDT jetton wallet found for this account (balance is 0).") } } // Typical costs: plain transfer ~0.004 TON; jetton carries the // attached 0.07 TON of which the unused part is refunded. let fee: UInt64 = isUSDT ? Self.jettonAttachNanotons : 5_000_000 return PreparedTONSend( recipient: recipient, amountUnits: amountUnits, isUSDT: isUSDT, estimatedFeeNanotons: fee, network: network ) } // MARK: - Sign + broadcast public func send(_ prepared: PreparedTONSend, mnemonic: String) async throws -> String { guard let address else { throw WalletError.internalError("TON not configured.") } guard let wallet = HDWallet(mnemonic: mnemonic, passphrase: "") else { throw WalletError.invalidMnemonic } // seqno (0 for an undeployed wallet → wallet-core adds stateInit). var components = URLComponents(string: network.v2Base + "/getWalletInformation")! components.queryItems = [URLQueryItem(name: "address", value: address)] let info = try await getJSON(components.url!) let result = info["result"] as? [String: Any] ?? [:] let seqno = (result["seqno"] as? NSNumber)?.uint32Value ?? 0 var transfer = TheOpenNetworkTransfer() transfer.mode = Self.sendMode if prepared.isUSDT { guard let jettonWallet = try await myJettonWallet() else { throw WalletError.internalError("USDT jetton wallet not found.") } transfer.dest = jettonWallet transfer.amount = BigUInt(Self.jettonAttachNanotons).serialize() transfer.bounceable = true var jetton = TheOpenNetworkJettonTransfer() jetton.jettonAmount = BigUInt(prepared.amountUnits).serialize() jetton.toOwner = prepared.recipient jetton.responseAddress = address jetton.forwardAmount = BigUInt(1).serialize() transfer.payload = .jettonTransfer(jetton) } else { transfer.dest = prepared.recipient transfer.amount = BigUInt(prepared.amountUnits).serialize() transfer.bounceable = false // wallets use non-bounceable } let key = wallet.getKeyForCoin(coin: .ton) var input = TheOpenNetworkSigningInput() input.privateKey = key.data input.walletVersion = .walletV4R2 input.sequenceNumber = seqno input.expireAt = UInt32(Date().timeIntervalSince1970) + 300 input.messages = [transfer] let output: TheOpenNetworkSigningOutput = AnySigner.sign(input: input, coin: .ton) guard output.error == .ok, !output.encoded.isEmpty else { throw WalletError.signingFailed } await throttle() var request = URLRequest(url: URL(string: network.v2Base + "/sendBoc")!) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONSerialization.data(withJSONObject: ["boc": output.encoded]) request.timeoutInterval = 20 let (data, _) = try await URLSession.shared.data(for: request) guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], json["ok"] as? Bool == true else { let message = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["error"] as? String throw WalletError.rpc(message ?? "Broadcast failed.") } // toncenter returns the message hash; link the account view instead. let hash = ((json["result"] as? [String: Any])?["hash"] as? String) ?? "" return hash.isEmpty ? address : hash } // MARK: - Formatting public static func formatTON(_ nanotons: UInt64) -> String { TokenAmount.format(BigUInt(nanotons), decimals: tonDecimals, maxFractionDigits: 6) } public static func parseTON(_ input: String) -> UInt64? { guard let units = TokenAmount.parse(input, decimals: tonDecimals), units <= BigUInt(UInt64.max) else { return nil } return UInt64(units) } public static func parseUSDT(_ input: String) -> UInt64? { guard let units = TokenAmount.parse(input, decimals: usdtDecimals), units <= BigUInt(UInt64.max) else { return nil } return UInt64(units) } }