kisstron.py (22181B)
1 #!/usr/bin/env python3 2 # kisstron: a simple to use TRON CLI wallet based on tronpy 3 # 4 # Supports: 5 # * BIP39 mnemonic wallet generation/restoration 6 # * converting private keys to BIP39 mnemonics and vice versa 7 # * basic TRX parameters display (balance, energy, bandwidth) 8 # * basic TRX operations (transfer, freeze for energy, unfreeze) 9 # * basic TRC20 token operations (balance display, transfer) for select tokens 10 # (contract addresses are hardcoded, see KT_TRC20_TOKENS dictionary) 11 # * ability to select testnets (nile, shasta) if necessary 12 # * ability to connect to custom mainnet nodes if necessary 13 # * switching to TronGrid API by providing your own API key 14 # * offline mode with -o switch and bro[adcast] operation 15 # 16 # As of now, the main goal is to be able to operate on TRX and stablecoins. 17 # For mnemonic phrase generation, only English is currently supported. 18 # 19 # See README for more information, run with -h flag for usage instructions 20 # 21 # Created by Luxferre in 2024, released into public domain 22 23 import sys, json 24 import warnings # to suppress NotOpenSSLWarning on BSDs 25 warnings.filterwarnings("ignore", module="urllib3") 26 from tronpy import Tron 27 from tronpy.tron import Transaction 28 from tronpy.keys import PrivateKey 29 from tronpy.providers import HTTPProvider 30 from tronpy.exceptions import (BadAddress, AddressNotFound, 31 BadKey, ValidationError, TransactionError) 32 from json.decoder import JSONDecodeError 33 import mnemonic, eth_utils 34 35 # some global parameters (hardcoded) 36 KT_TIMEOUT = 20.0 # network operation timeout 37 KT_FEE_LIMIT = 100000000 # transaction fee limit for mainnet (in Sun) 38 KT_TEST_FEE_LIMIT = 1000000000 # transaction fee limit for testnets (in Sun) 39 TRX_SCALE = 1000000.0 # TRX scale (million Suns in 1 TRX) 40 TG_API_KEY = None # TronGrid API Key (unused by default) 41 NODE_ADDR = 'http://18.196.99.16:8090' # public Tron node (Germany) 42 43 # TRC20 token definitions (also hardcoded) 44 # Make sure the mainnet tokens match the contract addresses 45 # specified on these pages: 46 # USDT: https://tron.network/usdt 47 # USDC: https://tron.network/usdc 48 49 KT_TRC20_TOKENS = { 50 'mainnet': { 51 'usdt': { 52 'contract': 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', 53 'ticker': 'USDT', 54 'show_balance': True 55 }, 56 'usdc': { 57 'contract': 'TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8', 58 'ticker': 'USDC', 59 'show_balance': True 60 } 61 }, 62 'nile': { 63 'usdt': { 64 'contract': 'TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj', 65 'ticker': 'USDT', 66 'show_balance': True 67 }, 68 'usdc': { 69 'contract': 'TEMVynQpntMqkPxP6wXTW2K7e4sM3cRmWz', 70 'ticker': 'USDC', 71 'show_balance': True 72 } 73 }, 74 'shasta': { 75 'usdt': { 76 'contract': 'TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs', 77 'ticker': 'USDT', 78 'show_balance': True 79 }, 80 'usdc': { 81 'contract': 'TSdZwNqpHofzP6BsBKGQUWdBeJphLmF6id', 82 'ticker': 'USDC', 83 'show_balance': True 84 } 85 } 86 } 87 88 # helper functions 89 90 # print operation result according to the below convention 91 def presult(resdict: dict): 92 if 'success' not in resdict: 93 resdict['success'] = False 94 if 'message' not in resdict: 95 resdict['message'] = '' 96 if resdict['success'] == True: 97 print ('Success:\n%s\n' % resdict['message']) 98 else: 99 print ('Error:\n%s\n' % resdict['message']) 100 101 # confirm transactions 102 def trconfirm(promptstr: str): 103 choice = input(promptstr).lower() 104 if choice != 'yes': 105 print('Transaction not confirmed, aborting!') 106 sys.exit(0) 107 108 # instantiate tronpy client 109 def tronclient(network='mainnet'): 110 network = network.lower() # lowercase network name 111 if network == 'mainnet': # use a public node or TronGrid API for mainnet 112 if TG_API_KEY is not None: # TG_API_KEY must be set 113 client = Tron(network=network, conf={'fee_limit': KT_FEE_LIMIT}, 114 provider=HTTPProvider(api_key=TG_API_KEY, timeout=KT_TIMEOUT)) 115 else: # NODE_ADDR must be set 116 client = Tron(network=network, conf={'fee_limit': KT_FEE_LIMIT}, 117 provider=HTTPProvider(NODE_ADDR, timeout=KT_TIMEOUT)) 118 else: # any default API is fine for a testnet 119 client = Tron(network=network, 120 conf={'fee_limit': KT_TEST_FEE_LIMIT, 'timeout': KT_TIMEOUT}) 121 return client 122 123 # get TRC20 token balance of an address (in float) 124 def trc20balance(client: Tron, contractaddr: str, targetaddr: str): 125 cntr = client.get_contract(contractaddr) 126 precision = cntr.functions.decimals() 127 return cntr.functions['balanceOf'](targetaddr) / (10 ** precision) 128 129 # operation definitions 130 # all operations return a dictionary with the following fields: 131 # success: True/False 132 # message: string 133 # (optional) result: dictionary 134 135 # create a new wallet 136 # input: network, passphrase, word count (12, 15, 18, 21, 24), derivation path 137 # output: private key, address, mnemonic 138 def newwallet(network: str, passphrase: str, wordcount: int, dpath: str): 139 res = {'success': False, 'message': '', 'result': {}} 140 if wordcount not in [12, 15, 18, 21, 24]: 141 res['message'] = 'Invalid word count for mnemonic generation' 142 return res 143 client = tronclient(network) 144 wallet, mnemo = client.generate_address_with_mnemonic(passphrase = passphrase, 145 num_words = wordcount, language = 'english', account_path = dpath) 146 wallet['mnemonic'] = mnemo 147 res['success'] = True 148 res['result'] = wallet 149 res['message'] = 'Wallet created, save this info in a secure place!\n\n' 150 res['message'] += 'Address: %s\n' % wallet['base58check_address'] 151 res['message'] += 'Address (hex): %s\n' % wallet['hex_address'] 152 res['message'] += 'Private key (hex): %s\n' % wallet['private_key'] 153 res['message'] += 'Public key (hex): %s\n' % wallet['public_key'] 154 res['message'] += 'Mnemonic: %s' % wallet['mnemonic'] 155 return res 156 157 # import a wallet 158 # input: network, mnemonic, passphrase, derivation path 159 # output: private key, address 160 def importwallet(network: str, mnemo: str, passphrase: str, dpath: str): 161 res = {'success': False, 'message': '', 'result': {}} 162 client = tronclient(network) 163 try: 164 wallet = client.generate_address_from_mnemonic(mnemonic = mnemo, 165 passphrase = passphrase, account_path = dpath) 166 except (mnemonic.mnemonic.ConfigurationError, 167 eth_utils.exceptions.ValidationError): 168 res['message'] = 'Invalid mnemonic phrase!' 169 return res 170 wallet['mnemonic'] = mnemo 171 res['success'] = True 172 res['result'] = wallet 173 res['message'] = 'Wallet imported, save this info in a secure place!\n\n' 174 res['message'] += 'Address: %s\n' % wallet['base58check_address'] 175 res['message'] += 'Address (hex): %s\n' % wallet['hex_address'] 176 res['message'] += 'Private key (hex): %s\n' % wallet['private_key'] 177 res['message'] += 'Public key (hex): %s\n' % wallet['public_key'] 178 res['message'] += 'Mnemonic: %s' % wallet['mnemonic'] 179 return res 180 181 # get account information by Tron address 182 # input: network, address 183 # output: info(dictionary) 184 def infobyaddr(network: str, address: str): 185 res = {'success': False, 'message': '', 'result': {}} 186 client = tronclient(network) 187 try: 188 rawinfo = client.get_account(address) 189 except BadAddress: 190 res['message'] = 'Invalid address! Set the correct one with -addr flag' 191 return res 192 except AddressNotFound: 193 res['message'] = 'Address not found on the blockchain! Set the correct one with -addr flag' 194 return res 195 # get bandwidth and energy information 196 rawresinfo = client.get_account_resource(address) 197 # some values used in bandwidth calculation 198 freenetlimit = 0 199 freenetused = 0 200 netlimit = 0 201 netused = 0 202 if 'freeNetLimit' in rawresinfo: 203 freenetlimit = rawresinfo['freeNetLimit'] 204 if 'freeNetUsed' in rawresinfo: 205 freenetlimit = rawresinfo['freeNetUsed'] 206 if 'NetLimit' in rawresinfo: 207 netlimit = rawresinfo['NetLimit'] 208 if 'NetUsed' in rawresinfo: 209 netused = rawresinfo['NetUsed'] 210 bandwidth = freenetlimit - freenetused + netlimit - netused 211 # some values used in energy calculation 212 energylimit = 0 213 energyused = 0 214 if 'EnergyLimit' in rawresinfo: 215 energylimit = rawresinfo['EnergyLimit'] 216 if 'EnergyUsed' in rawresinfo: 217 energyused = rawresinfo['EnergyUsed'] 218 # info dictionary received, parse it 219 info = { 220 'address': rawinfo['address'], # wallet address 221 'trxbalance': rawinfo['balance'], # TRX wallet balance (in Sun) 222 'frozen': [], # hold frozen TRX info here 223 'trc20balance': {}, # hold TRC20 token balances here 224 'bandwidth': bandwidth, # bandwidth 225 'energy': energylimit - energyused # energy 226 } 227 # iterate over TRX freeze records 228 for el in rawinfo['frozenV2']: 229 if 'type' in el and 'amount' in el and el['amount'] > 0: 230 info['frozen'].append({'type': el['type'], 'amount': el['amount']}) 231 # iterate over hardcoded TRC20 tokens 232 for tkn in KT_TRC20_TOKENS[network]: 233 tknobj = KT_TRC20_TOKENS[network][tkn] 234 if tknobj['show_balance'] == True: 235 info['trc20balance'][tknobj['ticker']] = trc20balance(client, 236 tknobj['contract'], address) 237 res['success'] = True 238 res['result'] = info 239 res['message'] = 'Address: %s\nBalance:\n' % info['address'] 240 res['message'] += '%f TRX\n' % (info['trxbalance'] / TRX_SCALE) 241 for ticker in info['trc20balance']: 242 res['message'] += '%f %s\n' % (info['trc20balance'][ticker], ticker) 243 res['message'] += 'Frozen assets:\n' 244 for el in info['frozen']: 245 res['message'] += '%s\t%f TRX\n' % (el['type'], el['amount'] / TRX_SCALE) 246 res['message'] += 'Remaining bandwidth: %s\n' % str(info['bandwidth']) 247 res['message'] += 'Remaining energy: %s' % str(info['energy']) 248 return res 249 250 # get own account information by private key 251 # input: network, private key 252 # output: info(dictionary) 253 def infobypk(network: str, pk: str): 254 res = {'success': False, 'message': '', 'result': {}} 255 try: 256 rpk = PrivateKey(bytes.fromhex(pk)) 257 except (BadKey, ValueError): 258 res['message'] = 'Invalid private key!' 259 return res 260 client = tronclient(network) 261 addr = client.generate_address(rpk)['base58check_address'] 262 return infobyaddr(network, addr) 263 264 # send TRX transaction (and wait for its completion) 265 # input: network, private key, target address, amount (in TRX) 266 # output: transaction receipt object 267 def sendtrx(network: str, pk: str, toaddr: str, amt: float, 268 offline: bool = False): 269 res = {'success': False, 'message': '', 'result': {}} 270 if amt < 1: 271 res['message'] = 'Nothing to transfer!' 272 return res 273 try: 274 rpk = PrivateKey(bytes.fromhex(pk)) 275 except (BadKey, ValueError): 276 res['message'] = 'Invalid private key!' 277 return res 278 client = tronclient(network) 279 fromaddr = client.generate_address(rpk)['base58check_address'] 280 # build and sign the transaction 281 txn = client.trx.transfer(fromaddr, toaddr, 282 int(amt * TRX_SCALE)).build().sign(rpk) 283 if offline == True: # generate transaction JSON 284 res['success'] = True 285 res['result'] = txn 286 res['message'] = json.dumps(txn.to_json(), separators=(',', ':')) 287 else: # send the transaction 288 try: 289 receipt = txn.broadcast().wait() 290 except ValidationError as e: 291 res['message'] = str(e) 292 return res 293 res['success'] = True 294 res['message'] = 'Sent %f TRX to %s\nTransaction ID %s' % (amt, 295 toaddr, receipt['id']) 296 res['result'] = receipt 297 return res 298 299 # send TRC20 token transaction (and wait for its completion) 300 # input: network, private key, token ticker, target address, amount (in tokens) 301 # output: transaction receipt object 302 def sendtrc20(network: str, pk: str, ticker: str, toaddr: str, 303 amt: float, offline: bool = False): 304 res = {'success': False, 'message': '', 'result': {}} 305 ticker = ticker.lower() 306 if ticker not in KT_TRC20_TOKENS[network]: 307 res['message'] = 'Token not supported by kisstron for %s!' % network 308 return res 309 if amt < 1: 310 res['message'] = 'Nothing to transfer!' 311 return res 312 try: 313 rpk = PrivateKey(bytes.fromhex(pk)) 314 except (BadKey, ValueError): 315 res['message'] = 'Invalid private key!' 316 return res 317 client = tronclient(network) 318 fromaddr = client.generate_address(rpk)['base58check_address'] 319 # instantiate the contract 320 contractaddr = KT_TRC20_TOKENS[network][ticker]['contract'] 321 cntr = client.get_contract(contractaddr) 322 precision = cntr.functions.decimals() 323 scaler = 10 ** precision 324 # build and sign the transaction 325 txn = cntr.functions.transfer(toaddr, 326 int(amt * scaler)).with_owner(fromaddr).build().sign(rpk) 327 if offline == True: # generate transaction JSON 328 res['success'] = True 329 res['result'] = txn 330 res['message'] = json.dumps(txn.to_json(), separators=(',', ':')) 331 else: # send the transaction 332 try: 333 receipt = txn.broadcast().wait() 334 except ValidationError as e: 335 res['message'] = str(e) 336 return res 337 res['result'] = receipt 338 if receipt['receipt']['result'] == 'OUT_OF_ENERGY': 339 res['message'] = 'Not enough energy for transaction!' 340 res['message'] += '\nTransaction ID %s' % receipt['id'] 341 return res 342 res['success'] = True 343 res['message'] = 'Sent %f %s to %s\nTransaction ID %s' % (amt, 344 KT_TRC20_TOKENS[network][ticker]['ticker'], 345 toaddr, receipt['id']) 346 return res 347 348 # freeze TRX for energy (and wait for its completion) 349 # input: network, private key, amount 350 # output: transaction receipt object 351 def freezetrx(network: str, pk: str, amt: float, offline: bool = False): 352 res = {'success': False, 'message': '', 'result': {}} 353 if amt < 1: 354 res['message'] = 'Nothing to freeze!' 355 return res 356 try: 357 rpk = PrivateKey(bytes.fromhex(pk)) 358 except (BadKey, ValueError): 359 res['message'] = 'Invalid private key!' 360 return res 361 client = tronclient(network) 362 myaddr = client.generate_address(rpk)['base58check_address'] 363 # build and sign the transaction 364 txn = client.trx.freeze_balance(myaddr, int(amt * TRX_SCALE), 365 "ENERGY").build().sign(rpk) 366 if offline == True: # generate transaction JSON 367 res['success'] = True 368 res['result'] = txn 369 res['message'] = json.dumps(txn.to_json(), separators=(',', ':')) 370 else: # send the transaction 371 try: 372 receipt = txn.broadcast().wait() 373 except ValidationError as e: 374 res['message'] = str(e) 375 return res 376 res['success'] = True 377 res['message'] = 'Frozen %f TRX at %s\nTransaction ID %s' % (amt, 378 myaddr, receipt['id']) 379 res['result'] = receipt 380 return res 381 382 # unfreeze TRX for energy (and wait for its completion) 383 # input: network, private key, amount 384 # output: transaction receipt object 385 def unfreezetrx(network: str, pk: str, amt: float, offline: bool = False): 386 res = {'success': False, 'message': '', 'result': {}} 387 if amt < 1: 388 res['message'] = 'Nothing to unfreeze!' 389 return res 390 try: 391 rpk = PrivateKey(bytes.fromhex(pk)) 392 except (BadKey, ValueError): 393 res['message'] = 'Invalid private key!' 394 return res 395 client = tronclient(network) 396 myaddr = client.generate_address(rpk)['base58check_address'] 397 # build and sign the transaction 398 txn = client.trx.unfreeze_balance(owner = myaddr, 399 unfreeze_balance = int(amt * TRX_SCALE), 400 resource = "ENERGY").build().sign(rpk) 401 if offline == True: # generate transaction JSON 402 res['success'] = True 403 res['result'] = txn 404 res['message'] = json.dumps(txn.to_json(), separators=(',', ':')) 405 else: # send the transaction 406 try: 407 receipt = txn.broadcast().wait() 408 except ValidationError as e: 409 res['message'] = str(e) 410 return res 411 res['success'] = True 412 res['message'] = 'Unfrozen %f TRX at %s\nTransaction ID %s' % (amt, 413 myaddr, receipt['id']) 414 res['result'] = receipt 415 return res 416 417 # Broadcast transaction JSON saved in kisstron offline mode 418 # input: network, transaction JSON string 419 # output: transaction receipt object 420 def broadcastjson(network, tjson): 421 res = {'success': False, 'message': '', 'result': {}} 422 # import the transaction from JSON 423 try: 424 txn = Transaction.from_json(json.loads(tjson)) 425 except (TypeError, ValidationError, TransactionError, 426 JSONDecodeError, KeyError): 427 res['message'] = 'Malformed transaction JSON!' 428 return res 429 # instantiate a client for the transaction 430 txn._client = tronclient(network) 431 # send the transaction 432 try: 433 receipt = txn.broadcast().wait() 434 except ValidationError as e: 435 res['message'] = str(e) 436 return res 437 except TransactionError as e: 438 res['message'] = str(e) 439 return res 440 res['result'] = receipt 441 if ('receipt' in receipt and 'result' in receipt['receipt'] 442 and receipt['receipt']['result'] == 'OUT_OF_ENERGY'): 443 res['message'] = 'Not enough energy for transaction!' 444 res['message'] += '\nTransaction ID %s' % receipt['id'] 445 return res 446 res['success'] = True 447 res['message'] = 'Transaction sent\nTransaction ID %s' % receipt['id'] 448 return res 449 450 if __name__ == '__main__': # main app start 451 from argparse import ArgumentParser 452 parser = ArgumentParser(description='kisstron: a simple CLI wallet for Tron blockchain', epilog='(c) Luxferre 2024 --- No rights reserved <https://unlicense.org>') 453 parser.add_argument('op', help='Operation: new, import, info, send, fre[eze], unf[reeze], bro[adcast]') 454 parser.add_argument('coin', help='Coin to operate on: trx (default), usdt, usdc', default='trx') 455 parser.add_argument('-net', '--network', default='mainnet', help='(op: all) Network: mainnet (default), nile, shasta') 456 parser.add_argument('-node', '--tron-node', default=NODE_ADDR, help='(op: all) Custom Tron node HTTP address (default %s)' % NODE_ADDR) 457 parser.add_argument('-tgkey', '--trongrid-api-key', default=None, help='(op: all) TronGrid API key (default None)') 458 parser.add_argument('-fee', '--fee-limit', type=float, default=100.0, help='(op: send, fre, unf) Custom fee limit for mainnet transactions, in TRX (default 100)') 459 parser.add_argument('-addr', '--address', default=None, help='(op: info, send) Tron address to send to or display information about (-pk overrides this to display own info)') 460 parser.add_argument('-amt', '--amount', type=float, default=0, help='(op: send, fre, unf) Amount to send/freeze/unfreeze') 461 parser.add_argument('-pk', '--private-key', default=None, help='(op: info, send, fre, unf) Private key for transactions or own information, in hex') 462 parser.add_argument('-o', '--offline', action='store_true', help='(op: send, fre, unf) Build and sign the transaction in offline mode and output its JSON') 463 parser.add_argument('-pp', '--passphrase', default='', help='(op: new, import) Mnemonic encryption passphrase (default empty)') 464 parser.add_argument('-wc', '--words-count', type=int, default=12, help='(op: new) Word count in the mnemonic: 12 (default), 15, 18, 21, 24') 465 parser.add_argument('-dp', '--derivation-path', default="m/44'/195'/0'/0/0", help="(op: new, import) BIP-39 derivation path for mnemonic (default is m/44'/195'/0'/0/0)") 466 parser.add_argument('-nc', '--no-confirm', action='store_true', help='(op: send, fre, unf) Do not ask to type "yes" for transaction confirmation (dangerous)') 467 468 args = parser.parse_args() 469 470 # fix all relevant parameters (and lowercase where applicable) 471 op = args.op.lower() # operation 472 coin = args.coin.lower() # coin/token 473 tronnet = args.network.lower() # Tron network (mainnet, nile, shasta) 474 targetaddr = args.address # address parameter 475 amount = float(args.amount) # amount to send/freeze/unfreeze 476 pk = args.private_key # private key (in hex) 477 passphrase = args.passphrase # mnemonic encryption passphrase 478 wcount = int(args.words_count) # mnemonic word count (for new operation) 479 dpath = args.derivation_path # mnemonic derivation path (for new/import) 480 trans_confirm = True # ask for transaction confirmation ("yes") 481 NODE_ADDR = args.tron_node # custom Tron node HTTP address 482 TG_API_KEY = args.trongrid_api_key # optional TronGrid API key 483 KT_FEE_LIMIT = int(args.fee_limit * TRX_SCALE) # custom fee limit 484 offline_mode = False # offline mode 485 if args.offline == True: 486 offline_mode = True 487 if args.no_confirm == True: 488 trans_confirm = False 489 490 # check the constraints for every action and call appropriate functions 491 492 opres = {} # operation result placeholder 493 if op == 'new': # new wallet 494 opres = newwallet(tronnet, passphrase, wcount, dpath) 495 elif op == 'import': # import wallet 496 mnemo = input('Your mnemonic phrase: ') 497 opres = importwallet(tronnet, mnemo, passphrase, dpath) 498 elif op == 'info': # info on own or foreign address 499 if offline_mode == True: 500 print('Info operation is not available in offline mode!') 501 sys.exit(1) 502 if pk == None: # public info, address must be set 503 opres = infobyaddr(tronnet, targetaddr) 504 else: # own info, private key must be set 505 opres = infobypk(tronnet, pk) 506 elif op == 'send': # send transaction 507 if trans_confirm == True: 508 trconfirm('Enter "yes" to confirm sending funds: ') 509 if coin == 'trx': # send TRX 510 opres = sendtrx(tronnet, pk, targetaddr, amount, offline_mode) 511 else: # send one of the predefined TRC20 tokens 512 opres = sendtrc20(tronnet, pk, coin, targetaddr, amount, offline_mode) 513 elif op == 'fre' or op == 'freeze': # freeze TRX for energy 514 if trans_confirm == True: 515 trconfirm('Enter "yes" to confirm freezing funds: ') 516 opres = freezetrx(tronnet, pk, amount, offline_mode) 517 elif op == 'unf' or op == 'unfreeze': # unfreeze TRX 518 if trans_confirm == True: 519 trconfirm('Enter "yes" to confirm unfreezing funds: ') 520 opres = unfreezetrx(tronnet, pk, amount, offline_mode) 521 elif op == 'bro' or op == 'broadcast': # broadcast signed JSON transaction 522 if offline_mode == True: 523 print('Broadcast operation is not available in offline mode!') 524 sys.exit(1) 525 transjson = input('Input transaction JSON: ') 526 if trans_confirm == True: 527 trconfirm('Enter "yes" to confirm sending the transaction: ') 528 opres = broadcastjson(tronnet, transjson) 529 else: # unknown operation 530 print('Unknown operation!') 531 print('Run the script with -h parameter to see available options') 532 sys.exit(1) 533 534 presult(opres) # print out the result