// // TronService.swift // OS Vault // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // import Foundation import BigInt import WalletCore import Web3Core /// Tron support — the largest USDT corridor. Signing via the vendored Trust /// wallet-core (`TransferContract` / `TransferTRC20Contract`), networking via /// keyless TronGrid REST with polite backoff (research: anonymous rate is /// throttled but ample for one wallet). /// /// The Tron trap is the fee model: TRC-20 transfers consume ~65k energy /// (~130k to a fresh recipient); without staked energy the network burns /// ~13–27 TRX. The estimate is computed pre-send via /// `triggerconstantcontract` and shown to the user; `fee_limit` caps the burn. /// /// Same security model: only the base58 address stays in memory; sends /// re-derive the key from the vault password and discard it. public actor TronService { public enum TronNetwork: String, CaseIterable, Codable, Sendable { case mainnet case nile // testnet, faucet at nileex.io public var apiBase: String { switch self { case .mainnet: return "https://api.trongrid.io" case .nile: return "https://nile.trongrid.io" } } /// USDT TRC-20 (verified live via symbol()/decimals(), 6 decimals). public var usdtContract: String { switch self { case .mainnet: return "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" case .nile: return "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf" } } public func explorerTxURL(_ txid: String) -> URL { switch self { case .mainnet: return URL(string: "https://tronscan.org/#/transaction/\(txid)")! case .nile: return URL(string: "https://nile.tronscan.org/#/transaction/\(txid)")! } } public func explorerAddressURL(_ address: String) -> URL { switch self { case .mainnet: return URL(string: "https://tronscan.org/#/address/\(address)")! case .nile: return URL(string: "https://nile.tronscan.org/#/address/\(address)")! } } public var displayName: String { switch self { case .mainnet: return "Tron" case .nile: return "Tron Nile" } } public var isTestnet: Bool { self == .nile } } public static let networkKey = "osvault.tron.network" public static let usdtDecimals = 6 public static let trxDecimals = 6 // 1 TRX = 1_000_000 sun static let energyPriceSun: UInt64 = 420 // current chain parameter static let feeLimitSun: Int64 = 100_000_000 // 100 TRX ceiling public struct TronBalances: Sendable { public var trxSun: UInt64 = 0 public var usdtUnits: UInt64 = 0 } public struct PreparedTronSend: Sendable { public let recipient: String public let amountUnits: UInt64 public let isUSDT: Bool /// Estimated burn in sun if no staked energy/bandwidth covers it. public let estimatedFeeSun: UInt64 public let network: TronNetwork } private var address: String? private var network: TronNetwork = .nile // MARK: - Setup public func configure(mnemonic: String) throws { let stored = UserDefaults.standard.string(forKey: Self.networkKey) network = stored.flatMap(TronNetwork.init(rawValue:)) ?? .nile guard let wallet = HDWallet(mnemonic: mnemonic, passphrase: "") else { throw WalletError.invalidMnemonic } address = wallet.getAddressForCoin(coin: .tron) } public var isConfigured: Bool { address != nil } public var currentNetwork: TronNetwork { network } public var publicAddress: String? { address } /// Address is network-independent; no password needed. public func switchNetwork(to newNetwork: TronNetwork) { UserDefaults.standard.set(newNetwork.rawValue, forKey: Self.networkKey) network = newNetwork } public static func validate(address: String) -> Bool { AnyAddress.isValid(string: address, coin: .tron) } // MARK: - HTTP private func post(_ path: String, body: [String: Any]) async throws -> [String: Any] { var request = URLRequest(url: URL(string: network.apiBase + path)!) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONSerialization.data(withJSONObject: body) request.timeoutInterval = 20 var lastError: Error = WalletError.rpc("TronGrid unreachable.") for attempt in 0..<3 { if attempt > 0 { try? await Task.sleep(nanoseconds: UInt64(attempt) * 1_200_000_000) } do { let (data, response) = try await URLSession.shared.data(for: request) if let http = response as? HTTPURLResponse, http.statusCode == 403 { lastError = WalletError.rpc("TronGrid rate limit — retrying.") continue } guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { lastError = WalletError.rpc("Malformed TronGrid response.") continue } return json } catch { lastError = WalletError.rpc(error.localizedDescription) } } throw lastError } private func get(_ path: String) async throws -> [String: Any] { var request = URLRequest(url: URL(string: network.apiBase + path)!) 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 TronGrid response.") } return json } // MARK: - Balances public func fetchBalances() async throws -> TronBalances { guard let address else { throw WalletError.internalError("Tron not configured.") } var balances = TronBalances() let json = try await get("/v1/accounts/\(address)") guard let accounts = json["data"] as? [[String: Any]] else { throw WalletError.rpc("TronGrid account query failed.") } guard let account = accounts.first else { return balances // unactivated account: all zero } if let sun = account["balance"] as? NSNumber { balances.trxSun = sun.uint64Value } if let trc20 = account["trc20"] as? [[String: String]] { for entry in trc20 { if let value = entry[network.usdtContract], let units = UInt64(value) { balances.usdtUnits = units } } } return balances } // MARK: - Estimate public func estimateSend(to recipient: String, amountUnits: UInt64, isUSDT: Bool) async throws -> PreparedTronSend { guard let address else { throw WalletError.internalError("Tron not configured.") } guard Self.validate(address: recipient) else { throw WalletError.invalidAddress } var feeSun: UInt64 = 0 if isUSDT { // ABI-encode transfer(address,uint256): hex address (21 bytes, // 0x41-prefixed) left-padded + amount. guard let recipientHex = TronService.base58ToHex(recipient) else { throw WalletError.invalidAddress } let param = String(repeating: "0", count: 24) + recipientHex.dropFirst(2) // strip 0x41 prefix byte + String(String(repeating: "0", count: 64 - String(amountUnits, radix: 16).count) + String(amountUnits, radix: 16)) let json = try await post("/wallet/triggerconstantcontract", body: [ "owner_address": address, "contract_address": network.usdtContract, "function_selector": "transfer(address,uint256)", "parameter": param, "visible": true ]) let energy = (json["energy_used"] as? NSNumber)?.uint64Value ?? 130_000 feeSun = energy * Self.energyPriceSun + 350_000 // + bandwidth burn margin } else { // Plain TRX transfer: bandwidth only (~270 bytes), plus 1 TRX // account-creation fee if the recipient is fresh. let account = try await post("/wallet/getaccount", body: ["address": recipient, "visible": true]) let isFresh = account.isEmpty || account["address"] == nil feeSun = (isFresh ? 1_100_000 : 300_000) } return PreparedTronSend( recipient: recipient, amountUnits: amountUnits, isUSDT: isUSDT, estimatedFeeSun: feeSun, network: network ) } // MARK: - Sign + broadcast (wallet-core) public func send(_ prepared: PreparedTronSend, mnemonic: String) async throws -> String { guard let address else { throw WalletError.internalError("Tron not configured.") } guard let wallet = HDWallet(mnemonic: mnemonic, passphrase: "") else { throw WalletError.invalidMnemonic } let key = wallet.getKeyForCoin(coin: .tron) // Reference block for the tx header. let now = try await post("/wallet/getnowblock", body: [:]) guard let header = (now["block_header"] as? [String: Any])?["raw_data"] as? [String: Any], let number = (header["number"] as? NSNumber)?.int64Value, let version = (header["version"] as? NSNumber)?.int32Value, let timestamp = (header["timestamp"] as? NSNumber)?.int64Value, let txTrieRoot = header["txTrieRoot"] as? String, let parentHash = header["parentHash"] as? String, let witness = header["witness_address"] as? String, let blockID = now["blockID"] as? String else { throw WalletError.rpc("Could not fetch the reference block.") } _ = blockID var block = TronBlockHeader() block.number = number block.version = version block.timestamp = timestamp block.txTrieRoot = Hex.data(txTrieRoot) ?? Data() block.parentHash = Hex.data(parentHash) ?? Data() block.witnessAddress = Hex.data(witness) ?? Data() var tx = TronTransaction() tx.timestamp = timestamp tx.expiration = timestamp + 10 * 60 * 1000 // 10 minutes tx.blockHeader = block if prepared.isUSDT { tx.feeLimit = Self.feeLimitSun var contract = TronTransferTRC20Contract() contract.ownerAddress = address contract.contractAddress = prepared.network.usdtContract contract.toAddress = prepared.recipient contract.amount = Hex.abiWord(BigUInt(prepared.amountUnits)) tx.contractOneof = .transferTrc20Contract(contract) } else { var contract = TronTransferContract() contract.ownerAddress = address contract.toAddress = prepared.recipient contract.amount = Int64(prepared.amountUnits) tx.contractOneof = .transfer(contract) } var input = TronSigningInput() input.transaction = tx input.privateKey = key.data let output: TronSigningOutput = AnySigner.sign(input: input, coin: .tron) guard output.error == .ok, !output.json.isEmpty else { throw WalletError.signingFailed } // Broadcast the signed JSON exactly as wallet-core produced it. var request = URLRequest(url: URL(string: network.apiBase + "/wallet/broadcasttransaction")!) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = output.json.data(using: .utf8) request.timeoutInterval = 20 let (data, _) = try await URLSession.shared.data(for: request) guard let result = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw WalletError.rpc("Broadcast failed.") } if let ok = result["result"] as? Bool, ok { return output.id.map { String(format: "%02x", $0) }.joined() } let message = (result["message"] as? String).flatMap { Data(base64Encoded: $0).flatMap { String(data: $0, encoding: .utf8) } } ?? (result["code"] as? String ?? "Broadcast rejected.") throw WalletError.rpc(message) } // MARK: - Helpers /// Base58check T-address → 0x41-prefixed hex (21 bytes, lowercase, no 0x). static func base58ToHex(_ address: String) -> String? { guard let decoded = Base58.decode(string: address), decoded.count == 21 else { return nil } return decoded.map { String(format: "%02x", $0) }.joined() } public static func formatTRX(_ sun: UInt64) -> String { TokenAmount.format(BigUInt(sun), decimals: trxDecimals) } public static func parseTRX(_ input: String) -> UInt64? { guard let units = TokenAmount.parse(input, decimals: trxDecimals), 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) } }