Browse Source

Dynamic transaction fees

philemon 8 years ago
parent
commit
235cd4d8e2
9 changed files with 109 additions and 23 deletions
  1. 4 3
      mmgen/globalvars.py
  2. 64 12
      mmgen/main_txcreate.py
  3. 1 0
      mmgen/rpc.py
  4. 15 0
      mmgen/test.py
  5. 1 1
      mmgen/tool.py
  6. 2 2
      mmgen/tx.py
  7. 1 1
      setup.py
  8. 20 3
      test/test.py
  9. 1 1
      test/tooltest.py

+ 4 - 3
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')
 disable_hold_protect = os.getenv('MMGEN_DISABLE_HOLD_PROTECT')
 
 
 from decimal import Decimal
 from decimal import Decimal
-tx_fee        = Decimal('0.00005')
+tx_fee        = Decimal('0.0003')
 max_tx_fee    = Decimal('0.01')
 max_tx_fee    = Decimal('0.01')
+tx_confs      = 3
 
 
 seed_len     = 256
 seed_len     = 256
 http_timeout = 60
 http_timeout = 60
@@ -49,7 +50,7 @@ prog_name = os.path.basename(sys.argv[0])
 author    = 'Philemon'
 author    = 'Philemon'
 email     = '<mmgen-py@yandex.com>'
 email     = '<mmgen-py@yandex.com>'
 Cdates    = '2013-2016'
 Cdates    = '2013-2016'
-version   = '0.8.3'
+version   = '0.8.4'
 
 
 required_opts = [
 required_opts = [
 	'quiet','verbose','debug','outdir','echo_passphrase','passwd_file',
 	'quiet','verbose','debug','outdir','echo_passphrase','passwd_file',
@@ -87,7 +88,7 @@ default_wordlist    = 'electrum'
 #default_wordlist    = 'tirosh'
 #default_wordlist    = 'tirosh'
 
 
 # Global value sets user opt
 # 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
 seed_lens = 128,192,256
 mn_lens = [i / 32 * 3 for i in seed_lens]
 mn_lens = [i / 32 * 3 for i in seed_lens]

+ 64 - 12
mmgen/main_txcreate.py

@@ -35,9 +35,10 @@ opts_data = {
 	'options': """
 	'options': """
 -h, --help            Print this help message
 -h, --help            Print this help message
 -c, --comment-file= f Source the transaction's comment from file 'f'
 -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
 -d, --outdir=       d Specify an alternate directory 'd' for output
 -e, --echo-passphrase Print passphrase to screen when typing it
 -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
 -i, --info            Display unspent outputs and exit
 -q, --quiet           Suppress warnings; overwrite files without
 -q, --quiet           Suppress warnings; overwrite files without
                       prompting
                       prompting
@@ -48,6 +49,11 @@ opts_data = {
 Transaction inputs are chosen from a list of the user's unpent outputs
 Transaction inputs are chosen from a list of the user's unpent outputs
 via an interactive menu.
 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
 Ages of transactions are approximate based on an average block creation
 interval of {g.mins_per_block} minutes.
 interval of {g.mins_per_block} minutes.
 
 
@@ -104,9 +110,9 @@ was specified.
 
 
 def format_unspent_outputs_for_printing(out,sort_info,total):
 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):
 	for n,i in enumerate(out):
 		addr = '=' if i['skip'] == 'addr' and 'grouped' in sort_info else i['address']
 		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'])
 			if i['skip'] == 'txid' and 'grouped' in sort_info else str(i['txid'])
 
 
 		s = pfs % (str(n+1)+')', tx+','+str(i['vout']),addr,
 		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())
 		pout.append(s.rstrip())
 
 
 	return \
 	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()))
 	d.update(ail_f.make_reverse_dict(tx_out.keys()))
 	return d
 	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)
 cmd_args = opts.init(opts_data)
 
 
 if opt.comment_file:
 if opt.comment_file:
@@ -367,10 +417,11 @@ if not opt.info:
 	if not tx_out:
 	if not tx_out:
 		die(2,'At least one output must be specified on the command line')
 		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
 if g.bogus_wallet_data:  # for debugging purposes only
 	us = eval(get_data_from_file(g.bogus_wallet_data))
 	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]
 	sel_unspent = [unspent[i-1] for i in sel_nums]
 
 
 	mmaddrs = set([i['mmid'] for i in sel_unspent])
 	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)))
 		msg(wmsg['mixed_inputs'] % ', '.join(sorted(mmaddrs)))
 		if not keypress_confirm('Accept?'):
 		if not keypress_confirm('Accept?'):
 			continue
 			continue
 
 
 	total_in = trim_exponent(sum([i['amount'] for i in sel_unspent]))
 	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:
 	if change >= 0:
 		prompt = 'Transaction produces %s BTC in change.  OK?' % change
 		prompt = 'Transaction produces %s BTC in change.  OK?' % change

+ 1 - 0
mmgen/rpc.py

@@ -110,6 +110,7 @@ class BitcoinRPCConnection(object):
 
 
 
 
 	rpcmethods = (
 	rpcmethods = (
+		'estimatefee',
 		'getinfo',
 		'getinfo',
 		'getbalance',
 		'getbalance',
 		'getaddressesbyaccount',
 		'getaddressesbyaccount',

+ 15 - 0
mmgen/test.py

@@ -46,6 +46,21 @@ def mk_tmpdir(cfg):
 		if e.errno != 17: raise
 		if e.errno != 17: raise
 	else: msg("Created directory '%s'" % cfg['tmpdir'])
 	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):
 def get_tmpfile_fn(cfg,fn):
 	return os.path.join(cfg['tmpdir'],fn)
 	return os.path.join(cfg['tmpdir'],fn)
 
 

+ 1 - 1
mmgen/tool.py

@@ -417,7 +417,7 @@ def listaddresses(addrs='',minconf=1,showempty=False,pager=False,showbtcaddrs=Fa
 			addrs[key][2] = addr[0]
 			addrs[key][2] = addr[0]
 
 
 	if not addrs:
 	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(
 	fs = '%-{}s %-{}s %-{}s %s'.format(
 		max(len(k) for k in addrs),
 		max(len(k) for k in addrs),

+ 2 - 2
mmgen/tx.py

@@ -42,7 +42,7 @@ def normalize_btc_amt(amt):
 	try:
 	try:
 		ret = Decimal(amt)
 		ret = Decimal(amt)
 	except:
 	except:
-		msg('%s: Invalid amount' % amt)
+		msg('%s: Invalid BTC amount' % amt)
 		return False
 		return False
 
 
 	dmsg('Decimal(amt): %s\nAs tuple: %s' % (amt,repr(ret.as_tuple())))
 	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 = (
 	fs = (
 		'TRANSACTION DATA\n\nHeader: [Tx ID: {}] [Amount: {} BTC] [Time: {}]\n\n',
 		'TRANSACTION DATA\n\nHeader: [Tx ID: {}] [Amount: {} BTC] [Time: {}]\n\n',
-		'Transaction {} - {} BTC - {} GMT\n'
+		'Transaction {} - {} BTC - {} UTC\n'
 	)[bool(terse)]
 	)[bool(terse)]
 
 
 	out = fs.format(*metadata)
 	out = fs.format(*metadata)

+ 1 - 1
setup.py

@@ -21,7 +21,7 @@ from distutils.core import setup
 setup(
 setup(
 		name         = 'mmgen',
 		name         = 'mmgen',
 		description  = 'A complete Bitcoin cold-storage solution for the command line',
 		description  = 'A complete Bitcoin cold-storage solution for the command line',
-		version      = '0.8.3',
+		version      = '0.8.4',
 		author       = 'Philemon',
 		author       = 'Philemon',
 		author_email = 'mmgen-py@yandex.com',
 		author_email = 'mmgen-py@yandex.com',
 		url          = 'https://github.com/mmgen/mmgen',
 		url          = 'https://github.com/mmgen/mmgen',

+ 20 - 3
test/test.py

@@ -559,7 +559,7 @@ def my_expect(p,s,t='',delay=send_delay,regex=False,nonl=False):
 		if s == '': ret = 0
 		if s == '': ret = 0
 		else:
 		else:
 			f = (p.expect_exact,p.expect)[bool(regex)]
 			f = (p.expect_exact,p.expect)[bool(regex)]
-			ret = f(s,timeout=3)
+			ret = f(s,timeout=60)
 	except pexpect.TIMEOUT:
 	except pexpect.TIMEOUT:
 		errmsg(red('\nERROR.  Expect %s%s%s timed out.  Exiting' % (quo,s,quo)))
 		errmsg(red('\nERROR.  Expect %s%s%s timed out.  Exiting' % (quo,s,quo)))
 		sys.exit(1)
 		sys.exit(1)
@@ -1141,7 +1141,8 @@ class MMGenTestSuite(object):
 		t.expect('Enter a range or space-separated list of outputs to spend: ',
 		t.expect('Enter a range or space-separated list of outputs to spend: ',
 				' '.join([str(i) for i in outputs_list])+'\n')
 				' '.join([str(i) for i in outputs_list])+'\n')
 		if non_mmgen_input: t.expect('Accept? (y/N): ','y')
 		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.expect('Add a comment to transaction? (y/N): ','\n')
 		t.tx_view()
 		t.tx_view()
 		t.expect('Save transaction? (y/N): ','y')
 		t.expect('Save transaction? (y/N): ','y')
@@ -1757,7 +1758,23 @@ if opt.pause:
 start_time = int(time.time())
 start_time = int(time.time())
 ts = MMGenTestSuite()
 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:
 try:
 	if cmd_args:
 	if cmd_args:

+ 1 - 1
test/tooltest.py

@@ -232,7 +232,7 @@ class MMGenToolTestSuite(object):
 		ret = self.run_cmd(name,([],[carg])[bool(carg)],kwargs=kwargs,extra_msg=extra_msg)
 		ret = self.run_cmd(name,([],[carg])[bool(carg)],kwargs=kwargs,extra_msg=extra_msg)
 		if carg: vmsg('In:   ' + repr(carg))
 		if carg: vmsg('In:   ' + repr(carg))
 		vmsg('Out:  ' + (repr(ret),ret)[literal])
 		vmsg('Out:  ' + (repr(ret),ret)[literal])
-		if ret:
+		if ret or ret == '':
 			write_to_tmpfile(cfg,'%s%s.out' % (name,fn_idx),ret+'\n')
 			write_to_tmpfile(cfg,'%s%s.out' % (name,fn_idx),ret+'\n')
 			if chkdata:
 			if chkdata:
 				cmp_or_die(ret,chkdata)
 				cmp_or_die(ret,chkdata)