kisstron

KISS-friendly Tron CLI wallet
git clone git://git.luxferre.top/kisstron.git
Log | Files | Refs | README

commit db24b899521b06959ff4292b591cd35d10f2c85b
parent ce7ad5189c4d29344daa69779ebf5c2aec87152f
Author: Luxferre <lux@ferre>
Date:   Thu, 15 Feb 2024 10:44:32 +0200

Offline mode implemented

Diffstat:
MREADME | 24++++++++++++++++++++----
Mkisstron.py | 169+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
2 files changed, 139 insertions(+), 54 deletions(-)

diff --git a/README b/README @@ -16,6 +16,7 @@ similar third-party projects. * Sending TRX/USDT/USDC on all supported networks * Freezing (for energy) and unfreezing TRX on all supported networks * Ability to connect to custom nodes or TronGrid API (with your own API key) +* Offline mode for send/freeze/unfreeze transactions * No filesystem interaction (that's a feature, not a bug: see the FAQ section) * Full scriptability: send/freeze/unfreeze transactions require manual approval but it actually can be turned off with a special commandline parameter for the @@ -145,9 +146,25 @@ Again, the amount of TRX is entered as a floating point value. Keep in mind that it takes up to 72 hours to unfreeze your TRX assets. +-- Offline operations -- + +The send trx, send usdt, send usdc, fre trx and unf trx subcommands support the +-o flag that, after the "Success:" line, outputs a JSON of a signed transaction. +You can copy or otherwise transfer this output to a different machine running +kisstron and broadcast it to the network with bro trx subcommand: + +./kisstron.py bro trx [netopts] + +You can then paste the transaction JSON into the input field, press Enter and, +unless -nc flag is passed too, confirm sending the transaction by entering yes. +The transaction will be broadcast and processed as usual. + +Keep in mind that Tron transactions expire very quickly, so you have a limited +time window between signing and publishing them. + == FAQ == -- Can I trust it? +- Can I trust this wallet? The author trusts it. In fact, kisstron was created because of lack of decent FOSS Tron wallets for Linux/BSD desktop operating systems. However, it cannot @@ -179,9 +196,8 @@ No. The underlying tronpy library signs all transactions offline, so the private key never leaves your machine. It uses libsecp256k1 via coincurve library binding for transaction signing. -Ability to implement offline mode (transactions are created and signed on one -kisstron instance without Internet connectivity, then broadcast from another) -in the future versions is being studied. +You can also use offline mode to split signing and sending transactions, as +shown in this document. - Why doesn't kisstron support TRC-10, multisig, witnesses, history etc? diff --git a/kisstron.py b/kisstron.py @@ -11,6 +11,7 @@ # * ability to select testnets (nile, shasta) if necessary # * ability to connect to custom mainnet nodes if necessary # * switching to TronGrid API by providing your own API key +# * offline mode with -o switch and bro[adcast] operation # # As of now, the main goal is to be able to operate on TRX and stablecoins. # For mnemonic phrase generation, only English is currently supported. @@ -19,11 +20,14 @@ # # Created by Luxferre in 2024, released into public domain -import sys +import sys, json from tronpy import Tron +from tronpy.tron import Transaction from tronpy.keys import PrivateKey from tronpy.providers import HTTPProvider -from tronpy.exceptions import BadAddress, AddressNotFound, BadKey, ValidationError +from tronpy.exceptions import (BadAddress, AddressNotFound, + BadKey, ValidationError, TransactionError) +from json.decoder import JSONDecodeError import mnemonic, eth_utils # some global parameters (hardcoded) @@ -258,7 +262,8 @@ def infobypk(network: str, pk: str): # send TRX transaction (and wait for its completion) # input: network, private key, target address, amount (in TRX) # output: transaction receipt object -def sendtrx(network: str, pk: str, toaddr: str, amt: float): +def sendtrx(network: str, pk: str, toaddr: str, amt: float, + offline: bool = False): res = {'success': False, 'message': '', 'result': {}} if amt < 1: res['message'] = 'Nothing to transfer!' @@ -273,22 +278,27 @@ def sendtrx(network: str, pk: str, toaddr: str, amt: float): # build and sign the transaction txn = client.trx.transfer(fromaddr, toaddr, int(amt * TRX_SCALE)).build().sign(rpk) - # send the transaction - try: - receipt = txn.broadcast().wait() - except ValidationError: - res['message'] = 'Insufficient balance or other validation error!' - return res - res['success'] = True - res['message'] = 'Sent %f TRX to %s\nTransaction ID %s' % (amt, - toaddr, receipt['id']) - res['result'] = receipt + if offline == True: # generate transaction JSON + res['success'] = True + res['result'] = txn + res['message'] = json.dumps(txn.to_json(), separators=(',', ':')) + else: # send the transaction + try: + receipt = txn.broadcast().wait() + except ValidationError as e: + res['message'] = str(e) + return res + res['success'] = True + res['message'] = 'Sent %f TRX to %s\nTransaction ID %s' % (amt, + toaddr, receipt['id']) + res['result'] = receipt return res # send TRC20 token transaction (and wait for its completion) # input: network, private key, token ticker, target address, amount (in tokens) # output: transaction receipt object -def sendtrc20(network: str, pk: str, ticker: str, toaddr: str, amt: float): +def sendtrc20(network: str, pk: str, ticker: str, toaddr: str, + amt: float, offline: bool = False): res = {'success': False, 'message': '', 'result': {}} ticker = ticker.lower() if ticker not in KT_TRC20_TOKENS[network]: @@ -312,26 +322,30 @@ def sendtrc20(network: str, pk: str, ticker: str, toaddr: str, amt: float): # build and sign the transaction txn = cntr.functions.transfer(toaddr, int(amt * scaler)).with_owner(fromaddr).build().sign(rpk) - # send the transaction - try: - receipt = txn.broadcast().wait() - except ValidationError: - res['message'] = 'Insufficient balance or other validation error!' - return res - res['result'] = receipt - if receipt['receipt']['result'] == 'OUT_OF_ENERGY': - res['message'] = 'Not enough energy for transaction!' - return res - res['success'] = True - res['message'] = 'Sent %f %s to %s\nTransaction ID %s' % (amt, - KT_TRC20_TOKENS[network][ticker]['ticker'], - toaddr, receipt['id']) + if offline == True: # generate transaction JSON + res['success'] = True + res['result'] = txn + res['message'] = json.dumps(txn.to_json(), separators=(',', ':')) + else: # send the transaction + try: + receipt = txn.broadcast().wait() + except ValidationError as e: + res['message'] = str(e) + return res + res['result'] = receipt + if receipt['receipt']['result'] == 'OUT_OF_ENERGY': + res['message'] = 'Not enough energy for transaction!' + return res + res['success'] = True + res['message'] = 'Sent %f %s to %s\nTransaction ID %s' % (amt, + KT_TRC20_TOKENS[network][ticker]['ticker'], + toaddr, receipt['id']) return res # freeze TRX for energy (and wait for its completion) # input: network, private key, amount # output: transaction receipt object -def freezetrx(network: str, pk: str, amt: float): +def freezetrx(network: str, pk: str, amt: float, offline: bool = False): res = {'success': False, 'message': '', 'result': {}} if amt < 1: res['message'] = 'Nothing to freeze!' @@ -346,22 +360,26 @@ def freezetrx(network: str, pk: str, amt: float): # build and sign the transaction txn = client.trx.freeze_balance(myaddr, int(amt * TRX_SCALE), "ENERGY").build().sign(rpk) - # send the transaction - try: - receipt = txn.broadcast().wait() - except ValidationError: - res['message'] = 'Insufficient balance or other validation error!' - return res - res['success'] = True - res['message'] = 'Frozen %f TRX at %s\nTransaction ID %s' % (amt, - myaddr, receipt['id']) - res['result'] = receipt + if offline == True: # generate transaction JSON + res['success'] = True + res['result'] = txn + res['message'] = json.dumps(txn.to_json(), separators=(',', ':')) + else: # send the transaction + try: + receipt = txn.broadcast().wait() + except ValidationError as e: + res['message'] = str(e) + return res + res['success'] = True + res['message'] = 'Frozen %f TRX at %s\nTransaction ID %s' % (amt, + myaddr, receipt['id']) + res['result'] = receipt return res # unfreeze TRX for energy (and wait for its completion) # input: network, private key, amount # output: transaction receipt object -def unfreezetrx(network: str, pk: str, amt: float): +def unfreezetrx(network: str, pk: str, amt: float, offline: bool = False): res = {'success': False, 'message': '', 'result': {}} if amt < 1: res['message'] = 'Nothing to unfreeze!' @@ -377,22 +395,58 @@ def unfreezetrx(network: str, pk: str, amt: float): txn = client.trx.unfreeze_balance(owner = myaddr, unfreeze_balance = int(amt * TRX_SCALE), resource = "ENERGY").build().sign(rpk) + if offline == True: # generate transaction JSON + res['success'] = True + res['result'] = txn + res['message'] = json.dumps(txn.to_json(), separators=(',', ':')) + else: # send the transaction + try: + receipt = txn.broadcast().wait() + except ValidationError as e: + res['message'] = str(e) + return res + res['success'] = True + res['message'] = 'Unfrozen %f TRX at %s\nTransaction ID %s' % (amt, + myaddr, receipt['id']) + res['result'] = receipt + return res + +# Broadcast transaction JSON saved in kisstron offline mode +# input: network, transaction JSON string +# output: transaction receipt object +def broadcastjson(network, tjson): + res = {'success': False, 'message': '', 'result': {}} + # import the transaction from JSON + try: + txn = Transaction.from_json(json.loads(tjson)) + except (TypeError, ValidationError, TransactionError, + JSONDecodeError, KeyError): + res['message'] = 'Malformed transaction JSON!' + return res + # instantiate a client for the transaction + txn._client = tronclient(network) # send the transaction try: receipt = txn.broadcast().wait() - except ValidationError: - res['message'] = 'Insufficient balance or other validation error!' + except ValidationError as e: + res['message'] = str(e) + return res + except TransactionError as e: + res['message'] = str(e) return res - res['success'] = True - res['message'] = 'Unfrozen %f TRX at %s\nTransaction ID %s' % (amt, - myaddr, receipt['id']) res['result'] = receipt + if ('receipt' in receipt and 'result' in receipt['receipt'] + and receipt['receipt']['result'] == 'OUT_OF_ENERGY'): + res['message'] = 'Not enough energy for transaction!' + return res + res['success'] = True + res['message'] = 'Transaction sent\nTransaction ID %s' % receipt['id'] return res if __name__ == '__main__': # main app start from argparse import ArgumentParser parser = ArgumentParser(description='kisstron: a simple CLI wallet for Tron blockchain', epilog='(c) Luxferre 2024 --- No rights reserved <https://unlicense.org>') - parser.add_argument('op', help='Operation: new, import, info, send, fre[eze], unf[reeze]') + parser.add_argument('op', help='Operation: new, import, info, send, fre[eze], unf[reeze], bro[adcast]') parser.add_argument('coin', help='Coin to operate on: trx (default), usdt, usdc', default='trx') parser.add_argument('-net', '--network', default='mainnet', help='(op: all) Network: mainnet (default), nile, shasta') parser.add_argument('-node', '--tron-node', default=NODE_ADDR, help='(op: all) Custom Tron node HTTP address (default %s)' % NODE_ADDR) @@ -400,6 +454,7 @@ if __name__ == '__main__': # main app start 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)') parser.add_argument('-amt', '--amount', type=float, default=0, help='(op: send, fre, unf) Amount to send/freeze/unfreeze') parser.add_argument('-pk', '--private-key', default=None, help='(op: info, send, fre, unf) Private key for transactions or own information, in hex') + 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') parser.add_argument('-pp', '--passphrase', default='', help='(op: new, import) Mnemonic encryption passphrase (default empty)') parser.add_argument('-wc', '--words-count', type=int, default=12, help='(op: new) Word count in the mnemonic: 12 (default), 15, 18, 21, 24') 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)") @@ -420,6 +475,9 @@ if __name__ == '__main__': # main app start trans_confirm = True # ask for transaction confirmation ("yes") NODE_ADDR = args.tron_node # custom Tron node HTTP address TG_API_KEY = args.trongrid_api_key # optional TronGrid API key + offline_mode = False # offline mode + if args.offline == True: + offline_mode = True if args.no_confirm == True: trans_confirm = False @@ -432,6 +490,9 @@ if __name__ == '__main__': # main app start mnemo = input('Your mnemonic phrase: ') opres = importwallet(tronnet, mnemo, passphrase, dpath) elif op == 'info': # info on own or foreign address + if offline_mode == True: + print('Info operation is not available in offline mode!') + sys.exit(1) if pk == None: # public info, address must be set opres = infobyaddr(tronnet, targetaddr) else: # own info, private key must be set @@ -440,17 +501,25 @@ if __name__ == '__main__': # main app start if trans_confirm == True: trconfirm('Enter "yes" to confirm sending funds: ') if coin == 'trx': # send TRX - opres = sendtrx(tronnet, pk, targetaddr, amount) + opres = sendtrx(tronnet, pk, targetaddr, amount, offline_mode) else: # send one of the predefined TRC20 tokens - opres = sendtrc20(tronnet, pk, coin, targetaddr, amount) + opres = sendtrc20(tronnet, pk, coin, targetaddr, amount, offline_mode) elif op == 'fre' or op == 'freeze': # freeze TRX for energy if trans_confirm == True: trconfirm('Enter "yes" to confirm freezing funds: ') - opres = freezetrx(tronnet, pk, amount) + opres = freezetrx(tronnet, pk, amount, offline_mode) elif op == 'unf' or op == 'unfreeze': # unfreeze TRX if trans_confirm == True: trconfirm('Enter "yes" to confirm unfreezing funds: ') - opres = unfreezetrx(tronnet, pk, amount) + opres = unfreezetrx(tronnet, pk, amount, offline_mode) + elif op == 'bro' or op == 'broadcast': # broadcast signed JSON transaction + if offline_mode == True: + print('Broadcast operation is not available in offline mode!') + sys.exit(1) + transjson = input('Input transaction JSON: ') + if trans_confirm == True: + trconfirm('Enter "yes" to confirm sending the transaction: ') + opres = broadcastjson(tronnet, transjson) else: # unknown operation print('Unknown operation!') print('Run the script with -h parameter to see available options')