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// TronService.swift3// OS Vault4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//89import Foundation10import BigInt11import WalletCore12import Web3Core1314/// Tron support — the largest USDT corridor. Signing via the vendored Trust15/// wallet-core (`TransferContract` / `TransferTRC20Contract`), networking via16/// keyless TronGrid REST with polite backoff (research: anonymous rate is17/// throttled but ample for one wallet).18///19/// The Tron trap is the fee model: TRC-20 transfers consume ~65k energy20/// (~130k to a fresh recipient); without staked energy the network burns21/// ~13–27 TRX. The estimate is computed pre-send via22/// `triggerconstantcontract` and shown to the user; `fee_limit` caps the burn.23///24/// Same security model: only the base58 address stays in memory; sends25/// re-derive the key from the vault password and discard it.26public actor TronService {2728 public enum TronNetwork: String, CaseIterable, Codable, Sendable {29 case mainnet30 case nile // testnet, faucet at nileex.io3132 public var apiBase: String {33 switch self {34 case .mainnet: return "https://api.trongrid.io"35 case .nile: return "https://nile.trongrid.io"36 }37 }3839 /// USDT TRC-20 (verified live via symbol()/decimals(), 6 decimals).40 public var usdtContract: String {41 switch self {42 case .mainnet: return "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"43 case .nile: return "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf"44 }45 }4647 public func explorerTxURL(_ txid: String) -> URL {48 switch self {49 case .mainnet: return URL(string: "https://tronscan.org/#/transaction/\(txid)")!50 case .nile: return URL(string: "https://nile.tronscan.org/#/transaction/\(txid)")!51 }52 }5354 public func explorerAddressURL(_ address: String) -> URL {55 switch self {56 case .mainnet: return URL(string: "https://tronscan.org/#/address/\(address)")!57 case .nile: return URL(string: "https://nile.tronscan.org/#/address/\(address)")!58 }59 }6061 public var displayName: String {62 switch self {63 case .mainnet: return "Tron"64 case .nile: return "Tron Nile"65 }66 }6768 public var isTestnet: Bool { self == .nile }69 }7071 public static let networkKey = "osvault.tron.network"72 public static let usdtDecimals = 673 public static let trxDecimals = 6 // 1 TRX = 1_000_000 sun74 static let energyPriceSun: UInt64 = 420 // current chain parameter75 static let feeLimitSun: Int64 = 100_000_000 // 100 TRX ceiling7677 public struct TronBalances: Sendable {78 public var trxSun: UInt64 = 079 public var usdtUnits: UInt64 = 080 }8182 public struct PreparedTronSend: Sendable {83 public let recipient: String84 public let amountUnits: UInt6485 public let isUSDT: Bool86 /// Estimated burn in sun if no staked energy/bandwidth covers it.87 public let estimatedFeeSun: UInt6488 public let network: TronNetwork89 }9091 private var address: String?92 private var network: TronNetwork = .nile9394 // MARK: - Setup9596 public func configure(mnemonic: String) throws {97 let stored = UserDefaults.standard.string(forKey: Self.networkKey)98 network = stored.flatMap(TronNetwork.init(rawValue:)) ?? .nile99 guard let wallet = HDWallet(mnemonic: mnemonic, passphrase: "") else {100 throw WalletError.invalidMnemonic101 }102 address = wallet.getAddressForCoin(coin: .tron)103 }104105 public var isConfigured: Bool { address != nil }106 public var currentNetwork: TronNetwork { network }107 public var publicAddress: String? { address }108109 /// Address is network-independent; no password needed.110 public func switchNetwork(to newNetwork: TronNetwork) {111 UserDefaults.standard.set(newNetwork.rawValue, forKey: Self.networkKey)112 network = newNetwork113 }114115 public static func validate(address: String) -> Bool {116 AnyAddress.isValid(string: address, coin: .tron)117 }118119 // MARK: - HTTP120121 private func post(_ path: String, body: [String: Any]) async throws -> [String: Any] {122 var request = URLRequest(url: URL(string: network.apiBase + path)!)123 request.httpMethod = "POST"124 request.setValue("application/json", forHTTPHeaderField: "Content-Type")125 request.httpBody = try JSONSerialization.data(withJSONObject: body)126 request.timeoutInterval = 20127 var lastError: Error = WalletError.rpc("TronGrid unreachable.")128 for attempt in 0..<3 {129 if attempt > 0 {130 try? await Task.sleep(nanoseconds: UInt64(attempt) * 1_200_000_000)131 }132 do {133 let (data, response) = try await URLSession.shared.data(for: request)134 if let http = response as? HTTPURLResponse, http.statusCode == 403 {135 lastError = WalletError.rpc("TronGrid rate limit — retrying.")136 continue137 }138 guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {139 lastError = WalletError.rpc("Malformed TronGrid response.")140 continue141 }142 return json143 } catch {144 lastError = WalletError.rpc(error.localizedDescription)145 }146 }147 throw lastError148 }149150 private func get(_ path: String) async throws -> [String: Any] {151 var request = URLRequest(url: URL(string: network.apiBase + path)!)152 request.timeoutInterval = 20153 let (data, _) = try await URLSession.shared.data(for: request)154 guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {155 throw WalletError.rpc("Malformed TronGrid response.")156 }157 return json158 }159160 // MARK: - Balances161162 public func fetchBalances() async throws -> TronBalances {163 guard let address else { throw WalletError.internalError("Tron not configured.") }164 var balances = TronBalances()165 let json = try await get("/v1/accounts/\(address)")166 guard let accounts = json["data"] as? [[String: Any]] else {167 throw WalletError.rpc("TronGrid account query failed.")168 }169 guard let account = accounts.first else {170 return balances // unactivated account: all zero171 }172 if let sun = account["balance"] as? NSNumber {173 balances.trxSun = sun.uint64Value174 }175 if let trc20 = account["trc20"] as? [[String: String]] {176 for entry in trc20 {177 if let value = entry[network.usdtContract], let units = UInt64(value) {178 balances.usdtUnits = units179 }180 }181 }182 return balances183 }184185 // MARK: - Estimate186187 public func estimateSend(to recipient: String, amountUnits: UInt64, isUSDT: Bool) async throws -> PreparedTronSend {188 guard let address else { throw WalletError.internalError("Tron not configured.") }189 guard Self.validate(address: recipient) else { throw WalletError.invalidAddress }190191 var feeSun: UInt64 = 0192 if isUSDT {193 // ABI-encode transfer(address,uint256): hex address (21 bytes,194 // 0x41-prefixed) left-padded + amount.195 guard let recipientHex = TronService.base58ToHex(recipient) else {196 throw WalletError.invalidAddress197 }198 let param = String(repeating: "0", count: 24) + recipientHex.dropFirst(2) // strip 0x41 prefix byte199 + String(String(repeating: "0", count: 64 - String(amountUnits, radix: 16).count)200 + String(amountUnits, radix: 16))201 let json = try await post("/wallet/triggerconstantcontract", body: [202 "owner_address": address,203 "contract_address": network.usdtContract,204 "function_selector": "transfer(address,uint256)",205 "parameter": param,206 "visible": true207 ])208 let energy = (json["energy_used"] as? NSNumber)?.uint64Value ?? 130_000209 feeSun = energy * Self.energyPriceSun + 350_000 // + bandwidth burn margin210 } else {211 // Plain TRX transfer: bandwidth only (~270 bytes), plus 1 TRX212 // account-creation fee if the recipient is fresh.213 let account = try await post("/wallet/getaccount", body: ["address": recipient, "visible": true])214 let isFresh = account.isEmpty || account["address"] == nil215 feeSun = (isFresh ? 1_100_000 : 300_000)216 }217218 return PreparedTronSend(219 recipient: recipient, amountUnits: amountUnits,220 isUSDT: isUSDT, estimatedFeeSun: feeSun, network: network221 )222 }223224 // MARK: - Sign + broadcast (wallet-core)225226 public func send(_ prepared: PreparedTronSend, mnemonic: String) async throws -> String {227 guard let address else { throw WalletError.internalError("Tron not configured.") }228 guard let wallet = HDWallet(mnemonic: mnemonic, passphrase: "") else {229 throw WalletError.invalidMnemonic230 }231 let key = wallet.getKeyForCoin(coin: .tron)232233 // Reference block for the tx header.234 let now = try await post("/wallet/getnowblock", body: [:])235 guard let header = (now["block_header"] as? [String: Any])?["raw_data"] as? [String: Any],236 let number = (header["number"] as? NSNumber)?.int64Value,237 let version = (header["version"] as? NSNumber)?.int32Value,238 let timestamp = (header["timestamp"] as? NSNumber)?.int64Value,239 let txTrieRoot = header["txTrieRoot"] as? String,240 let parentHash = header["parentHash"] as? String,241 let witness = header["witness_address"] as? String,242 let blockID = now["blockID"] as? String else {243 throw WalletError.rpc("Could not fetch the reference block.")244 }245 _ = blockID246247 var block = TronBlockHeader()248 block.number = number249 block.version = version250 block.timestamp = timestamp251 block.txTrieRoot = Hex.data(txTrieRoot) ?? Data()252 block.parentHash = Hex.data(parentHash) ?? Data()253 block.witnessAddress = Hex.data(witness) ?? Data()254255 var tx = TronTransaction()256 tx.timestamp = timestamp257 tx.expiration = timestamp + 10 * 60 * 1000 // 10 minutes258 tx.blockHeader = block259 if prepared.isUSDT {260 tx.feeLimit = Self.feeLimitSun261 var contract = TronTransferTRC20Contract()262 contract.ownerAddress = address263 contract.contractAddress = prepared.network.usdtContract264 contract.toAddress = prepared.recipient265 contract.amount = Hex.abiWord(BigUInt(prepared.amountUnits))266 tx.contractOneof = .transferTrc20Contract(contract)267 } else {268 var contract = TronTransferContract()269 contract.ownerAddress = address270 contract.toAddress = prepared.recipient271 contract.amount = Int64(prepared.amountUnits)272 tx.contractOneof = .transfer(contract)273 }274275 var input = TronSigningInput()276 input.transaction = tx277 input.privateKey = key.data278 let output: TronSigningOutput = AnySigner.sign(input: input, coin: .tron)279 guard output.error == .ok, !output.json.isEmpty else {280 throw WalletError.signingFailed281 }282283 // Broadcast the signed JSON exactly as wallet-core produced it.284 var request = URLRequest(url: URL(string: network.apiBase + "/wallet/broadcasttransaction")!)285 request.httpMethod = "POST"286 request.setValue("application/json", forHTTPHeaderField: "Content-Type")287 request.httpBody = output.json.data(using: .utf8)288 request.timeoutInterval = 20289 let (data, _) = try await URLSession.shared.data(for: request)290 guard let result = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {291 throw WalletError.rpc("Broadcast failed.")292 }293 if let ok = result["result"] as? Bool, ok {294 return output.id.map { String(format: "%02x", $0) }.joined()295 }296 let message = (result["message"] as? String).flatMap {297 Data(base64Encoded: $0).flatMap { String(data: $0, encoding: .utf8) }298 } ?? (result["code"] as? String ?? "Broadcast rejected.")299 throw WalletError.rpc(message)300 }301302 // MARK: - Helpers303304 /// Base58check T-address → 0x41-prefixed hex (21 bytes, lowercase, no 0x).305 static func base58ToHex(_ address: String) -> String? {306 guard let decoded = Base58.decode(string: address), decoded.count == 21 else { return nil }307 return decoded.map { String(format: "%02x", $0) }.joined()308 }309310 public static func formatTRX(_ sun: UInt64) -> String {311 TokenAmount.format(BigUInt(sun), decimals: trxDecimals)312 }313314 public static func parseTRX(_ input: String) -> UInt64? {315 guard let units = TokenAmount.parse(input, decimals: trxDecimals), units <= BigUInt(UInt64.max) else { return nil }316 return UInt64(units)317 }318319 public static func parseUSDT(_ input: String) -> UInt64? {320 guard let units = TokenAmount.parse(input, decimals: usdtDecimals), units <= BigUInt(UInt64.max) else { return nil }321 return UInt64(units)322 }323}324