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%
13.3 KB · 324 lines swift
Raw Blame History
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