From 235cd4d8e2569519062532fa0a5ac74018eda406 Mon Sep 17 00:00:00 2001 From: philemon Date: Sat, 25 Jun 2016 18:27:45 +0300 Subject: [PATCH] Dynamic transaction fees --- mmgen/globalvars.py | 7 ++-- mmgen/main_txcreate.py | 76 +++++++++++++++++++++++++++++++++++------- mmgen/rpc.py | 1 + mmgen/test.py | 15 +++++++++ mmgen/tool.py | 2 +- mmgen/tx.py | 4 +-- setup.py | 2 +- test/test.py | 23 +++++++++++-- test/tooltest.py | 2 +- 9 files changed, 109 insertions(+), 23 deletions(-) diff --git a/mmgen/globalvars.py b/mmgen/globalvars.py index 26e87c86..d41a90ab 100755 --- a/mmgen/globalvars.py +++ b/mmgen/globalvars.py @@ -36,8 +36,9 @@ bogus_wallet_data = os.getenv('MMGEN_BOGUS_WALLET_DATA') disable_hold_protect = os.getenv('MMGEN_DISABLE_HOLD_PROTECT') from decimal import Decimal -tx_fee = Decimal('0.00005') +tx_fee = Decimal('0.0003') max_tx_fee = Decimal('0.01') +tx_confs = 3 seed_len = 256 http_timeout = 60 @@ -49,7 +50,7 @@ prog_name = os.path.basename(sys.argv[0]) author = 'Philemon' email = '' Cdates = '2013-2016' -version = '0.8.3' +version = '0.8.4' required_opts = [ 'quiet','verbose','debug','outdir','echo_passphrase','passwd_file', @@ -87,7 +88,7 @@ default_wordlist = 'electrum' #default_wordlist = 'tirosh' # Global value sets user opt -dfl_vars = 'seed_len','hash_preset','usr_randchars','debug' +dfl_vars = 'seed_len','hash_preset','usr_randchars','debug','tx_fee','tx_confs' seed_lens = 128,192,256 mn_lens = [i / 32 * 3 for i in seed_lens] diff --git a/mmgen/main_txcreate.py b/mmgen/main_txcreate.py index 35eca510..99b84c3c 100755 --- a/mmgen/main_txcreate.py +++ b/mmgen/main_txcreate.py @@ -35,9 +35,10 @@ opts_data = { 'options': """ -h, --help Print this help message -c, --comment-file= f Source the transaction's comment from file 'f' +-C, --tx-confs= c Estimated confirmations (default: {g.tx_confs}) -d, --outdir= d Specify an alternate directory 'd' for output -e, --echo-passphrase Print passphrase to screen when typing it --f, --tx-fee= f Transaction fee (default: {g.tx_fee} BTC) +-f, --tx-fee= f Transaction fee (default: {g.tx_fee} BTC (but see below)) -i, --info Display unspent outputs and exit -q, --quiet Suppress warnings; overwrite files without prompting @@ -48,6 +49,11 @@ opts_data = { Transaction inputs are chosen from a list of the user's unpent outputs via an interactive menu. +If not specified by the user, transaction fees are calculated using +bitcoind's "estimatefee" function for the default (or user-specified) +number of confirmations. Only if "estimatefee" fails is the default fee +of {g.tx_fee} BTC used. + Ages of transactions are approximate based on an average block creation interval of {g.mins_per_block} minutes. @@ -104,9 +110,9 @@ was specified. def format_unspent_outputs_for_printing(out,sort_info,total): - pfs = ' %-4s %-67s %-34s %-12s %-13s %-8s %-10s %s' - pout = [pfs % ('Num','TX id,Vout','Address','{pnm} ID'.format(pnm=pnm), - 'Amount (BTC)','Conf.','Age (days)', 'Comment')] + pfs = ' %-4s %-67s %-34s %-14s %-12s %-8s %-6s %s' + pout = [pfs % ('Num','Tx ID,Vout','Address','{pnm} ID'.format(pnm=pnm), + 'Amount(BTC)','Conf.','Age(d)', 'Comment')] for n,i in enumerate(out): addr = '=' if i['skip'] == 'addr' and 'grouped' in sort_info else i['address'] @@ -114,7 +120,7 @@ def format_unspent_outputs_for_printing(out,sort_info,total): if i['skip'] == 'txid' and 'grouped' in sort_info else str(i['txid']) s = pfs % (str(n+1)+')', tx+','+str(i['vout']),addr, - i['mmid'],i['amt'],i['confirmations'],i['days'],i['comment']) + i['mmid'],i['amt'].strip(),i['confirmations'],i['days'],i['comment']) pout.append(s.rstrip()) return \ @@ -317,6 +323,50 @@ def make_b2m_map(inputs_data,tx_out,ail_w,ail_f): d.update(ail_f.make_reverse_dict(tx_out.keys())) return d +def get_fee_estimate(): + if 'tx_fee' in opt.set_by_user: + return None + else: + ret = c.estimatefee(opt.tx_confs) + if ret != -1: + return ret + else: + m = """ +Fee estimation failed! +Your possible courses of action (from best to worst): + 1) Re-run script with a different '--tx-confs' parameter (now '{c}') + 2) Re-run script with the '--tx-fee' option (specify fee manually) + 3) Accept the global default fee of {f} BTC +Accept the global default fee of {f} BTC? +""".format(c=opt.tx_confs,f=opt.tx_fee).strip() + if keypress_confirm(m): + return None + else: + die(1,'Exiting at user request') + +# see: https://bitcoin.stackexchange.com/questions/1195/how-to-calculate-transaction-size-before-sending +def get_tx_size_and_fee(inputs,outputs): + tx_size = len(inputs)*180 + len(outputs)*34 + 10 + if fee_estimate: + ftype,fee = 'Calculated','{:.8f}'.format(fee_estimate * tx_size / 1024) + else: + ftype,fee = 'User-selected',opt.tx_fee + if not keypress_confirm('{} TX fee: {} BTC. OK?'.format(ftype,fee),default_yes=True): + while True: + ufee = my_raw_input('Enter transaction fee: ') + if normalize_btc_amt(ufee): + if Decimal(ufee) > g.max_tx_fee: + msg('{} BTC: fee too large (maximum fee: {} BTC)'.format(ufee,g.max_tx_fee)) + else: + fee = ufee + break + vmsg('Inputs:{} Outputs:{} TX size:{}'.format(len(sel_unspent),len(tx_out),tx_size)) + vmsg('Fee estimate: {} (1024 bytes, {} confs)'.format(fee_estimate,opt.tx_confs)) + vmsg('TX fee: {}'.format(fee)) + return tx_size,normalize_btc_amt(fee) + +# main(): execution begins here + cmd_args = opts.init(opts_data) if opt.comment_file: @@ -367,10 +417,11 @@ if not opt.info: if not tx_out: die(2,'At least one output must be specified on the command line') - tx_fee = (g.tx_fee,opt.tx_fee)[bool(opt.tx_fee)] - tx_fee = normalize_btc_amt(tx_fee) - if tx_fee > g.max_tx_fee: - die(2,'Transaction fee too large: %s > %s' % (tx_fee,g.max_tx_fee)) + if opt.tx_fee > g.max_tx_fee: + die(2,'Transaction fee too large: %s > %s' % (opt.tx_fee,g.max_tx_fee)) + + fee_estimate = get_fee_estimate() + if g.bogus_wallet_data: # for debugging purposes only us = eval(get_data_from_file(g.bogus_wallet_data)) @@ -404,15 +455,16 @@ while True: sel_unspent = [unspent[i-1] for i in sel_nums] mmaddrs = set([i['mmid'] for i in sel_unspent]) - mmaddrs.discard('') - if mmaddrs and len(mmaddrs) < len(sel_unspent): + if '' in mmaddrs and len(mmaddrs) > 1: + mmaddrs.discard('') msg(wmsg['mixed_inputs'] % ', '.join(sorted(mmaddrs))) if not keypress_confirm('Accept?'): continue total_in = trim_exponent(sum([i['amount'] for i in sel_unspent])) - change = trim_exponent(total_in - (send_amt + tx_fee)) + tx_size,tx_fee = get_tx_size_and_fee(sel_unspent,tx_out) + change = trim_exponent(total_in - (send_amt + tx_fee)) if change >= 0: prompt = 'Transaction produces %s BTC in change. OK?' % change diff --git a/mmgen/rpc.py b/mmgen/rpc.py index 6105b7ae..6d7f7311 100755 --- a/mmgen/rpc.py +++ b/mmgen/rpc.py @@ -110,6 +110,7 @@ class BitcoinRPCConnection(object): rpcmethods = ( + 'estimatefee', 'getinfo', 'getbalance', 'getaddressesbyaccount', diff --git a/mmgen/test.py b/mmgen/test.py index 0d9a9cfb..3787c0b3 100755 --- a/mmgen/test.py +++ b/mmgen/test.py @@ -46,6 +46,21 @@ def mk_tmpdir(cfg): if e.errno != 17: raise else: msg("Created directory '%s'" % cfg['tmpdir']) +def mk_tmpdir_path(path,cfg): + try: + name = os.path.split(cfg['tmpdir'])[-1] + src = os.path.join(path,name) + try: + os.unlink(cfg['tmpdir']) + except OSError as e: + if e.errno != 2: raise + finally: + os.mkdir(src) + os.symlink(src,cfg['tmpdir']) + except OSError as e: + if e.errno != 17: raise + else: msg("Created directory '%s'" % cfg['tmpdir']) + def get_tmpfile_fn(cfg,fn): return os.path.join(cfg['tmpdir'],fn) diff --git a/mmgen/tool.py b/mmgen/tool.py index 6ab2c4a3..adb753a1 100755 --- a/mmgen/tool.py +++ b/mmgen/tool.py @@ -417,7 +417,7 @@ def listaddresses(addrs='',minconf=1,showempty=False,pager=False,showbtcaddrs=Fa addrs[key][2] = addr[0] if not addrs: - die(1,('No addresses with balances!','No tracked addresses!')[showempty]) + die(0,('No addresses with balances!','No tracked addresses!')[showempty]) fs = '%-{}s %-{}s %-{}s %s'.format( max(len(k) for k in addrs), diff --git a/mmgen/tx.py b/mmgen/tx.py index 5f61a356..d3627861 100755 --- a/mmgen/tx.py +++ b/mmgen/tx.py @@ -42,7 +42,7 @@ def normalize_btc_amt(amt): try: ret = Decimal(amt) except: - msg('%s: Invalid amount' % amt) + msg('%s: Invalid BTC amount' % amt) return False dmsg('Decimal(amt): %s\nAs tuple: %s' % (amt,repr(ret.as_tuple()))) @@ -146,7 +146,7 @@ def view_tx_data(c,inputs_data,tx_hex,b2m_map,comment,metadata,pager=False,pause fs = ( 'TRANSACTION DATA\n\nHeader: [Tx ID: {}] [Amount: {} BTC] [Time: {}]\n\n', - 'Transaction {} - {} BTC - {} GMT\n' + 'Transaction {} - {} BTC - {} UTC\n' )[bool(terse)] out = fs.format(*metadata) diff --git a/setup.py b/setup.py index 50af1b0a..a46f5fc2 100755 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ from distutils.core import setup setup( name = 'mmgen', description = 'A complete Bitcoin cold-storage solution for the command line', - version = '0.8.3', + version = '0.8.4', author = 'Philemon', author_email = 'mmgen-py@yandex.com', url = 'https://github.com/mmgen/mmgen', diff --git a/test/test.py b/test/test.py index 0f33388b..9c319568 100755 --- a/test/test.py +++ b/test/test.py @@ -559,7 +559,7 @@ def my_expect(p,s,t='',delay=send_delay,regex=False,nonl=False): if s == '': ret = 0 else: f = (p.expect_exact,p.expect)[bool(regex)] - ret = f(s,timeout=3) + ret = f(s,timeout=60) except pexpect.TIMEOUT: errmsg(red('\nERROR. Expect %s%s%s timed out. Exiting' % (quo,s,quo))) sys.exit(1) @@ -1141,7 +1141,8 @@ class MMGenTestSuite(object): t.expect('Enter a range or space-separated list of outputs to spend: ', ' '.join([str(i) for i in outputs_list])+'\n') if non_mmgen_input: t.expect('Accept? (y/N): ','y') - t.expect('OK? (Y/n): ','y') + t.expect('OK? (Y/n): ','y') # fee OK? + t.expect('OK? (Y/n): ','y') # change OK? t.expect('Add a comment to transaction? (y/N): ','\n') t.tx_view() t.expect('Save transaction? (y/N): ','y') @@ -1757,7 +1758,23 @@ if opt.pause: start_time = int(time.time()) ts = MMGenTestSuite() -for cfg in sorted(cfgs): mk_tmpdir(cfgs[cfg]) +# Laggy flash media cause pexpect to crash, so read and write all temporary +# files to volatile memory in '/dev/shm' +if sys.platform[:3] == 'win': + for cfg in sorted(cfgs): mk_tmpdir(cfgs[cfg]) +else: + d,pfx = '/dev/shm','mmgen-test-' + try: + import subprocess + subprocess.call('rm -rf %s/%s*'%(d,pfx),shell=True) + except Exception as e: + die(2,'Unable to delete directory tree %s/%s* (%s)'%(d,pfx,e)) + try: + import tempfile + shm_dir = tempfile.mkdtemp('',pfx,d) + except Exception as e: + die(2,'Unable to create temporary directory in %s (%s)'%(d,e)) + for cfg in sorted(cfgs): mk_tmpdir_path(shm_dir,cfgs[cfg]) try: if cmd_args: diff --git a/test/tooltest.py b/test/tooltest.py index c80aef4a..392d780d 100755 --- a/test/tooltest.py +++ b/test/tooltest.py @@ -232,7 +232,7 @@ class MMGenToolTestSuite(object): ret = self.run_cmd(name,([],[carg])[bool(carg)],kwargs=kwargs,extra_msg=extra_msg) if carg: vmsg('In: ' + repr(carg)) vmsg('Out: ' + (repr(ret),ret)[literal]) - if ret: + if ret or ret == '': write_to_tmpfile(cfg,'%s%s.out' % (name,fn_idx),ret+'\n') if chkdata: cmp_or_die(ret,chkdata)