From 93c99755a77606e5af7005a87fb40ae431992845 Mon Sep 17 00:00:00 2001 From: philemon Date: Wed, 17 May 2017 15:37:30 +0300 Subject: [PATCH] [tx]: BIP 125 replace-by-fee (RBF) support: * create replaceable transactions with 'mmgen-txcreate' and 'mmgen-txdo' using the '--rbf' option * create replacement transactions from existing MMGen transactions using the new, optionally scriptable 'mmgen-txbump' command * Bitcoind must be started with the '-walletrbf' option to enable RBF functionality [tx]: Command scriptability: * New '--yes' option makes 'txbump' and 'txsign' fully non-interactive and 'txcreate' and 'txsend' mostly non-interactive [tx]: Satoshis-per-byte format: * Tx fees, both on the command line and at the interactive prompt, may be specified either as absolute BTC amounts or in satoshis-per-byte format (an integer followed by the letter 's') [tx]: Fees * Completely reworked fee-handling code with better fee checking * default tx fee eliminated, max_tx_fee configurable in mmgen.cfg Bugfixes and usability improvements: * 'mmgen-tool listaddresses' now list addresses from multiple seeds correctly * Improved user interaction with all 'mmgen-tx*' commands --- data_files/mmgen.cfg | 4 +- mmgen-txbump | 25 +++++ mmgen/globalvars.py | 19 ++-- mmgen/main_txbump.py | 133 ++++++++++++++++++++++ mmgen/main_txcreate.py | 34 +++--- mmgen/main_txdo.py | 14 ++- mmgen/main_txsend.py | 25 +++-- mmgen/main_txsign.py | 16 ++- mmgen/obj.py | 2 +- mmgen/opts.py | 17 +++ mmgen/rpc.py | 6 +- mmgen/tx.py | 247 ++++++++++++++++++++++++++++++++--------- mmgen/txcreate.py | 119 +++++++++++--------- mmgen/util.py | 2 +- setup.py | 2 + test/test.py | 43 ++++++- 16 files changed, 555 insertions(+), 153 deletions(-) create mode 100755 mmgen-txbump create mode 100755 mmgen/main_txbump.py diff --git a/data_files/mmgen.cfg b/data_files/mmgen.cfg index 106f0602..171a80f5 100644 --- a/data_files/mmgen.cfg +++ b/data_files/mmgen.cfg @@ -43,8 +43,8 @@ # A value of 0 disables user entropy, but this is not recommended: # usr_randchars 30 -# Set the default transaction fee in BTC: -# tx_fee 0.0003 +# Set the maximum transaction fee in BTC: +# max_tx_fee 0.01 # Set the transaction fee adjustment factor. Auto-calculated fees are # multiplied by this value: diff --git a/mmgen-txbump b/mmgen-txbump new file mode 100755 index 00000000..c03c8333 --- /dev/null +++ b/mmgen-txbump @@ -0,0 +1,25 @@ +#!/usr/bin/env python + +# mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution +# Copyright (C)2013-2016 Philemon +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program. If not, see . + +""" +mmgen-txbump: Increase the fee on a replaceable (RBF) MMGen transaction, and +optionally sign and send it. +""" + +from mmgen.main import launch +launch("txbump") diff --git a/mmgen/globalvars.py b/mmgen/globalvars.py index b2977a7f..19824125 100755 --- a/mmgen/globalvars.py +++ b/mmgen/globalvars.py @@ -36,8 +36,8 @@ class g(object): sys.exit(ev) # Variables - these might be altered at runtime: - version = '0.9.0' - release_date = 'December 2016' + version = '0.9.O+' + release_date = 'May 2017' proj_name = 'MMGen' proj_url = 'https://github.com/mmgen/mmgen' @@ -51,13 +51,14 @@ class g(object): hash_preset = '3' usr_randchars = 30 - tx_fee = BTCAmt('0.0003') - tx_fee_adj = 1.0 - tx_confs = 3 - + max_tx_fee = BTCAmt('0.01') + tx_fee_adj = 1.0 + tx_confs = 3 + satoshi = BTCAmt('0.00000001') # One bitcoin equals 100,000,000 satoshis seed_len = 256 http_timeout = 60 + max_int = 0xffffffff # Constants - some of these might be overriden, but they don't change thereafter @@ -111,8 +112,8 @@ class g(object): ) cfg_file_opts = ( 'color','debug','hash_preset','http_timeout','no_license','rpc_host','rpc_port', - 'quiet','tx_fee','tx_fee_adj','usr_randchars','testnet','rpc_user','rpc_password', - 'bitcoin_data_dir','force_256_color' + 'quiet','tx_fee_adj','usr_randchars','testnet','rpc_user','rpc_password', + 'bitcoin_data_dir','force_256_color','max_tx_fee' ) env_opts = ( 'MMGEN_BOGUS_WALLET_DATA', @@ -132,7 +133,7 @@ class g(object): # Global var sets user opt: global_sets_opt = ['minconf','seed_len','hash_preset','usr_randchars','debug', - 'quiet','tx_confs','tx_fee_adj','tx_fee','key_generator'] + 'quiet','tx_confs','tx_fee_adj','key_generator'] keyconv_exec = 'keyconv' diff --git a/mmgen/main_txbump.py b/mmgen/main_txbump.py new file mode 100755 index 00000000..87fb8e4a --- /dev/null +++ b/mmgen/main_txbump.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python +# +# mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution +# Copyright (C)2013-2016 Philemon +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +mmgen-txbump: Increase the fee on a replaceable (replace-by-fee) MMGen + transaction, and optionally sign and send it +""" + +from mmgen.txcreate import * +from mmgen.txsign import * + +opts_data = { + 'desc': 'Increase the fee on a replaceable (RBF) {g.proj_name} transaction, creating a new transaction, and optionally sign and send the new transaction'.format(g=g), + 'usage': '[opts] <{g.proj_name} TX file> [seed source] ...'.format(g=g), + 'sets': ( ('yes', True, 'quiet', True), ), + 'options': """ +-h, --help Print this help message +--, --longhelp Print help message for long options (common options) +-b, --brain-params=l,p Use seed length 'l' and hash preset 'p' for + brainwallet input +-c, --comment-file= f Source the transaction's comment from file 'f' +-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, as a decimal BTC amount or in + satoshis per byte (an integer followed by 's') +-H, --hidden-incog-input-params=f,o Read hidden incognito data from file + 'f' at offset 'o' (comma-separated) +-i, --in-fmt= f Input is from wallet format 'f' (see FMT CODES below) +-l, --seed-len= l Specify wallet seed length of 'l' bits. This option + is required only for brainwallet and incognito inputs + with non-standard (< {g.seed_len}-bit) seed lengths. +-k, --keys-from-file=f Provide additional keys for non-{pnm} addresses +-K, --key-generator= m Use method 'm' for public key generation + Options: {kgs} + (default: {kg}) +-M, --mmgen-keys-from-file=f Provide keys for {pnm} addresses in a key- + address file (output of '{pnl}-keygen'). Permits + online signing without an {pnm} seed source. The + key-address file is also used to verify {pnm}-to-BTC + mappings, so the user should record its checksum. +-o, --output-to-reduce=o Deduct the fee from output 'o' (an integer, or 'c' + for the transaction's change output, if present) +-O, --old-incog-fmt Specify old-format incognito input +-p, --hash-preset= p Use the scrypt hash parameters defined by preset 'p' + for password hashing (default: '{g.hash_preset}') +-P, --passwd-file= f Get {pnm} wallet or bitcoind passphrase from file 'f' +-q, --quiet Suppress warnings; overwrite files without prompting +-s, --send Sign and send the transaction (the default if seed + data is provided) +-v, --verbose Produce more verbose output +-y, --yes Answer 'yes' to prompts, suppress non-essential output +-z, --show-hash-presets Show information on available hash presets +""".format(g=g,pnm=pnm,pnl=pnm.lower(), + kgs=' '.join(['{}:{}'.format(n,k) for n,k in enumerate(g.key_generators,1)]), + kg=g.key_generator), + 'notes': '\n' + fee_notes + txsign_notes +} + +cmd_args = opts.init(opts_data) + +c = bitcoin_connection() + +tx_file = cmd_args.pop(0) +check_infile(tx_file) + +seed_files = get_seed_files(opt,cmd_args) if (cmd_args or opt.send) else None +kal = get_keyaddrlist(opt) +kl = get_keylist(opt) + +tx = MMGenBumpTX(filename=tx_file,send=(seed_files or kl or kal)) + +do_license_msg() + +silent = opt.yes and opt.tx_fee != None and opt.output_to_reduce != None + +if not silent: + msg(green('ORIGINAL TRANSACTION')) + msg(tx.format_view(terse=True)) + +tx.set_min_fee() + +if not [o.amt for o in tx.outputs if o.amt >= tx.min_fee]: + die(1,'Transaction cannot be bumped.' + + '\nAll outputs have less than the minimum fee ({} BTC)'.format(tx.min_fee)) + +msg('Creating new transaction') + +op_idx = tx.choose_output() + +if not silent: + msg('Minimum fee for new transaction: {} BTC'.format(tx.min_fee)) + +fee = tx.get_usr_fee_interactive(tx_fee=opt.tx_fee,desc='User-selected') + +tx.update_output_amt(op_idx,tx.sum_inputs()-tx.sum_outputs(exclude=op_idx)-fee) + +d = tx.get_fee() +assert d == fee and d <= g.max_tx_fee + +if not opt.yes: + tx.add_comment() # edits an existing comment +tx.create_raw(c) # creates tx.hex, tx.txid +tx.add_timestamp() +tx.add_blockcount(c) + +qmsg('Fee successfully increased') + +if not silent: + msg(green('\nREPLACEMENT TRANSACTION:')) + msg_r(tx.format_view(terse=True)) + +if seed_files or kl or kal: + txsign(opt,c,tx,seed_files,kl,kal) + tx.write_to_file(ask_write=False) + if tx.send(c): + tx.write_to_file(ask_write=False) +else: + tx.write_to_file(ask_write=not opt.yes,ask_write_default_yes=False,ask_overwrite=not opt.yes) diff --git a/mmgen/main_txcreate.py b/mmgen/main_txcreate.py index 45adebdc..d4a457c0 100755 --- a/mmgen/main_txcreate.py +++ b/mmgen/main_txcreate.py @@ -26,24 +26,30 @@ from mmgen.txcreate import * opts_data = { 'desc': 'Create a transaction with outputs to specified Bitcoin or {g.proj_name} addresses'.format(g=g), 'usage': '[opts] ... [change addr] [addr file] ...', + 'sets': ( ('yes', True, 'quiet', True), ), 'options': """ --h, --help Print this help message ---, --longhelp Print help message for long options (common options) --a, --tx-fee-adj= f Adjust transaction fee by factor 'f' (see below) --B, --no-blank Don't blank screen before displaying unspent outputs --c, --comment-file= f Source the transaction's comment from file 'f' --C, --tx-confs= c Desired number of confirmations (default: {g.tx_confs}) --d, --outdir= d Specify an alternate directory 'd' for output --f, --tx-fee= f Transaction fee (default: {g.tx_fee} BTC (but see below)) --i, --info Display unspent outputs and exit --m, --minconf= n Minimum number of confirmations required to spend outputs (default: 1) --q, --quiet Suppress warnings; overwrite files without prompting --v, --verbose Produce more verbose output +-h, --help Print this help message +--, --longhelp Print help message for long options (common options) +-a, --tx-fee-adj= f Adjust transaction fee by factor 'f' (see below) +-B, --no-blank Don't blank screen before displaying unspent outputs +-c, --comment-file=f Source the transaction's comment from file 'f' +-C, --tx-confs= c Desired number of confirmations (default: {g.tx_confs}) +-d, --outdir= d Specify an alternate directory 'd' for output +-f, --tx-fee= f Transaction fee, as a decimal BTC amount or in satoshis + per byte (an integer followed by 's'). If omitted, fee + will be calculated using bitcoind's 'estimatefee' call +-i, --info Display unspent outputs and exit +-m, --minconf= n Minimum number of confirmations required to spend + outputs (default: 1) +-q, --quiet Suppress warnings; overwrite files without prompting +-r, --rbf Make transaction BIP 125 replaceable (replace-by-fee) +-v, --verbose Produce more verbose output +-y, --yes Answer 'yes' to prompts, suppress non-essential output """.format(g=g), - 'notes': '\n' + txcreate_notes + 'notes': '\n' + txcreate_notes + fee_notes } cmd_args = opts.init(opts_data) do_license_msg() tx = txcreate(opt,cmd_args,do_info=opt.info) -tx.write_to_file(ask_write_default_yes=False) +tx.write_to_file(ask_write=not opt.yes,ask_overwrite=not opt.yes,ask_write_default_yes=False) diff --git a/mmgen/main_txdo.py b/mmgen/main_txdo.py index fcfd5378..3379c402 100755 --- a/mmgen/main_txdo.py +++ b/mmgen/main_txdo.py @@ -26,6 +26,7 @@ from mmgen.txsign import * opts_data = { 'desc': 'Create, sign and send an {g.proj_name} transaction'.format(g=g), 'usage': '[opts] ... [change addr] [addr file] ... [seed source] ...', + 'sets': ( ('yes', True, 'quiet', True), ), 'options': """ -h, --help Print this help message --, --longhelp Print help message for long options (common options) @@ -37,7 +38,10 @@ opts_data = { -C, --tx-confs= c Desired number of 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 (but see below)) +-f, --tx-fee= f Transaction fee, as a decimal BTC amount or in + satoshis per byte (an integer followed by 's'). + If omitted, bitcoind's 'estimatefee' will be used + to calculate the fee. -H, --hidden-incog-input-params=f,o Read hidden incognito data from file 'f' at offset 'o' (comma-separated) -i, --in-fmt= f Input is from wallet format 'f' (see FMT CODES below) @@ -59,13 +63,15 @@ opts_data = { -p, --hash-preset= p Use the scrypt hash parameters defined by preset 'p' for password hashing (default: '{g.hash_preset}') -P, --passwd-file= f Get {pnm} wallet passphrase from file 'f' +-r, --rbf Make transaction BIP 125 (replace-by-fee) replaceable -q, --quiet Suppress warnings; overwrite files without prompting -v, --verbose Produce more verbose output +-y, --yes Answer 'yes' to prompts, suppress non-essential output -z, --show-hash-presets Show information on available hash presets """.format(g=g,pnm=pnm,pnl=pnm.lower(), kgs=' '.join(['{}:{}'.format(n,k) for n,k in enumerate(g.key_generators,1)]), kg=g.key_generator), - 'notes': '\n' + txcreate_notes + txsign_notes + 'notes': '\n' + txcreate_notes + fee_notes + txsign_notes } cmd_args = opts.init(opts_data) @@ -81,5 +87,5 @@ tx = txcreate(opt,cmd_args,caller='txdo') txsign(opt,c,tx,seed_files,kl,kal) tx.write_to_file(ask_write=False) -if tx.send(opt,c): - tx.write_to_file(ask_write=False) +if tx.send(c): + tx.write_to_file(ask_overwrite=False,ask_write=False) diff --git a/mmgen/main_txsend.py b/mmgen/main_txsend.py index b9927c7c..ba039465 100755 --- a/mmgen/main_txsend.py +++ b/mmgen/main_txsend.py @@ -27,11 +27,13 @@ opts_data = { 'desc': 'Send a Bitcoin transaction signed by {pnm}-txsign'.format( pnm=g.proj_name.lower()), 'usage': '[opts] ', + 'sets': ( ('yes', True, 'quiet', True), ), 'options': """ -h, --help Print this help message --, --longhelp Print help message for long options (common options) -d, --outdir= d Specify an alternate directory 'd' for output -q, --quiet Suppress warnings; overwrite files without prompting +-y, --yes Answer 'yes' to prompts, suppress non-essential output """ } @@ -44,12 +46,19 @@ else: opts.usage() do_license_msg() tx = MMGenTX(infile) c = bitcoin_connection() -if not tx.check_signed(c): - die(1,'Transaction has no signature!') -qmsg("Signed transaction file '%s' is valid" % infile) -tx.view_with_prompt('View transaction data?') -if tx.add_comment(): # edits an existing comment, returns true if changed - tx.write_to_file(ask_write_default_yes=True) -if tx.send(opt,c): - tx.write_to_file(ask_write=False) +if not tx.check_signed(c): + die(1,'Transaction is not signed!') + +if tx.btc_txid: + msg('Warning: transaction has already been sent!') + +qmsg("Signed transaction file '%s' is valid" % infile) + +if not opt.yes: + tx.view_with_prompt('View transaction data?') + if tx.add_comment(): # edits an existing comment, returns true if changed + tx.write_to_file(ask_write_default_yes=True) + +if tx.send(c): + tx.write_to_file(ask_overwrite=False,ask_write=False) diff --git a/mmgen/main_txsign.py b/mmgen/main_txsign.py index 81e91020..a783fb53 100755 --- a/mmgen/main_txsign.py +++ b/mmgen/main_txsign.py @@ -26,11 +26,12 @@ from mmgen.txsign import * opts_data = { 'desc': 'Sign Bitcoin transactions generated by {pnl}-txcreate'.format(pnl=pnm.lower()), 'usage': '[opts] ... [seed source]...', + 'sets': ( ('yes', True, 'quiet', True), ), 'options': """ -h, --help Print this help message --, --longhelp Print help message for long options (common options) --b, --brain-params=l,p Use seed length 'l' and hash preset 'p' for brainwallet - input +-b, --brain-params=l,p Use seed length 'l' and hash preset 'p' for + brainwallet input -d, --outdir= d Specify an alternate directory 'd' for output -D, --tx-id Display transaction ID and exit -e, --echo-passphrase Print passphrase to screen when typing it @@ -57,6 +58,7 @@ opts_data = { -I, --info Display information about the transaction and exit -t, --terse-info Like '--info', but produce more concise output -v, --verbose Produce more verbose output +-y, --yes Answer 'yes' to prompts, suppress non-essential output """.format( g=g,pnm=pnm,pnl=pnm.lower(), kgs=' '.join(['{}:{}'.format(n,k) for n,k in enumerate(g.key_generators,1)]), @@ -98,8 +100,12 @@ for tx_num,tx_file in enumerate(tx_files,1): if opt.info or opt.terse_info: tx.view(pause=False,terse=opt.terse_info); continue - tx.view_with_prompt('View data for transaction%s?' % tx_num_str) + if not opt.yes: + tx.view_with_prompt('View data for transaction%s?' % tx_num_str) txsign(opt,c,tx,seed_files,kl,kal,tx_num_str) - tx.add_comment() # edits an existing comment - tx.write_to_file(ask_write_default_yes=True,add_desc=tx_num_str) + + if not opt.yes: + tx.add_comment() # edits an existing comment + + tx.write_to_file(ask_write=not opt.yes,ask_write_default_yes=True,add_desc=tx_num_str) diff --git a/mmgen/obj.py b/mmgen/obj.py index 252b6eb2..aa48c041 100755 --- a/mmgen/obj.py +++ b/mmgen/obj.py @@ -256,7 +256,7 @@ class Hilite(object): if trunc_ok and len(s) > width: s = s[:width] if app: return cls.colorize(a+s+b,color=color) + \ - cls.colorize(app.ljust(width-len(a+s+b)),color=appcolor) + cls.colorize(app.ljust(width-len(a+s+b)),color=appcolor) else: return cls.colorize((a+s+b).ljust(width),color=color) diff --git a/mmgen/opts.py b/mmgen/opts.py index 624dfd85..cbaac7c5 100755 --- a/mmgen/opts.py +++ b/mmgen/opts.py @@ -278,6 +278,18 @@ def check_opts(usr_opts): # Returns false if any check fails return False return True + def opt_is_tx_fee(val,desc): + from mmgen.tx import MMGenTX + ret = MMGenTX().convert_fee_spec(val,224,on_fail='return') + if ret == False: + msg("'{}': invalid {} (not a BTC amount or satoshis-per-byte specification)".format( + val,desc)) + elif ret != None and ret > g.max_tx_fee: + msg("'{}': invalid {} (greater than max_tx_fee ({} BTC))".format(val,desc,g.max_tx_fee)) + else: + return True + return False + def opt_is_in_list(val,lst,desc): if val not in lst: q,sep = (('',','),("'","','"))[type(lst[0]) == str] @@ -375,6 +387,11 @@ def check_opts(usr_opts): # Returns false if any check fails if not opt_is_int(val,desc): return False if not opt_compares(val,'>=',g.min_urandchars,desc): return False if not opt_compares(val,'<=',g.max_urandchars,desc): return False + elif key == 'tx_fee': + if not opt_is_tx_fee(val,desc): return False + elif key == 'tx_confs': + if not opt_is_int(val,desc): return False + if not opt_compares(val,'>=',1,desc): return False elif key == 'key_generator': if not opt_compares(val,'<=',len(g.key_generators),desc): return False if not opt_compares(val,'>',0,desc): return False diff --git a/mmgen/rpc.py b/mmgen/rpc.py index e0db0002..f56f2f6a 100755 --- a/mmgen/rpc.py +++ b/mmgen/rpc.py @@ -111,10 +111,12 @@ class BitcoinRPCConnection(object): dmsg(' RPC GETRESPONSE data ==> %s\n' % r.__dict__) if r.status != 200: - msgred('RPC Error: {} {}'.format(r.status,r.reason)) + msg_r(yellow('Bitcoind RPC Error: ')) + msg(red('{} {}'.format(r.status,r.reason))) e1 = r.read() try: - e2 = json.loads(e1)['error']['message'] + e3 = json.loads(e1)['error'] + e2 = '{} (code {})'.format(e3['message'],e3['code']) except: e2 = str(e1) return die_maybe(r,1,e2) diff --git a/mmgen/tx.py b/mmgen/tx.py index 358ac672..051b675a 100755 --- a/mmgen/tx.py +++ b/mmgen/tx.py @@ -46,7 +46,7 @@ class MMGenTxInputOldFmt(MMGenListItem): # for converting old tx files only attrs_priv = 'tr', class MMGenTxInput(MMGenListItem): - attrs = 'txid','vout','amt','label','mmid','addr','confs','scriptPubKey','have_wif' + attrs = 'txid','vout','amt','label','mmid','addr','confs','scriptPubKey','have_wif','sequence' label = MMGenListItemAttr('label','MMGenAddrLabel') class MMGenTxOutput(MMGenListItem): @@ -60,16 +60,11 @@ class MMGenTX(MMGenObject): txid_ext = 'txid' desc = 'transaction' - max_fee = BTCAmt('0.01') - def __init__(self,filename=None): self.inputs = [] self.inputs_enc = [] self.outputs = [] self.outputs_enc = [] - self.change_addr = '' - self.size = 0 # size of raw serialized tx - self.fee = BTCAmt('0') self.send_amt = BTCAmt('0') # total amt minus change self.hex = '' # raw serialized hex transaction self.label = MMGenTXLabel('') @@ -84,17 +79,27 @@ class MMGenTX(MMGenObject): self.mark_signed() self.parse_tx_file(filename) - def add_output(self,btcaddr,amt): # 'txid','vout','amount','label','mmid','address' - self.outputs.append(MMGenTxOutput(addr=btcaddr,amt=amt)) + def add_output(self,btcaddr,amt,is_chg=None): + self.outputs.append(MMGenTxOutput(addr=btcaddr,amt=amt,is_chg=is_chg)) - def del_output(self,btcaddr): + def get_chg_output_idx(self): for i in range(len(self.outputs)): - if self.outputs[i].addr == btcaddr: - self.outputs.pop(i); return - raise ValueError + if self.outputs[i].is_chg == True: + return i + return None - def sum_outputs(self): - return BTCAmt(sum([e.amt for e in self.outputs])) + def update_output_amt(self,idx,amt): + o = self.outputs[idx].__dict__ + o['amt'] = amt + self.outputs[idx] = MMGenTxOutput(**o) + + def del_output(self,idx): + self.outputs.pop(idx) + + def sum_outputs(self,exclude=None): + olist = self.outputs if exclude == None else \ + self.outputs[:exclude] + self.outputs[exclude+1:] + return BTCAmt(sum([e.amt for e in olist])) def add_mmaddrs_to_outputs(self,ad_w,ad_f): a = [e.addr for e in self.outputs] @@ -113,6 +118,8 @@ class MMGenTX(MMGenObject): # def create_raw(self,c): i = [{'txid':e.txid,'vout':e.vout} for e in self.inputs] + if self.inputs[0].sequence: + i[0]['sequence'] = self.inputs[0].sequence o = dict([(e.addr,e.amt) for e in self.outputs]) self.hex = c.createrawtransaction(i,o) self.txid = MMGenTxID(make_chksum_6(unhexlify(self.hex)).upper()) @@ -137,31 +144,70 @@ class MMGenTX(MMGenObject): def edit_comment(self): return self.add_comment(self) - # https://bitcoin.stackexchange.com/questions/1195/how-to-calculate-transaction-size-before-sending - def calculate_size_and_fee(self,fee_estimate): - self.size = len(self.inputs)*180 + len(self.outputs)*34 + 10 - if fee_estimate: - ftype,fee = 'Calculated',fee_estimate*opt.tx_fee_adj*self.size / 1024 - else: - ftype,fee = 'User-selected',opt.tx_fee + # https://bitcoin.stackexchange.com/questions/1195/ + # how-to-calculate-transaction-size-before-sending + # 180: uncompressed, 148: compressed + def get_size(self): + if not self.inputs or not self.outputs: return None + return len(self.inputs)*180 + len(self.outputs)*34 + 10 - ufee = None - if not keypress_confirm('{} TX fee is {} BTC. OK?'.format(ftype,fee.hl()),default_yes=True): - while True: - ufee = my_raw_input('Enter transaction fee: ') - if BTCAmt(ufee,on_fail='return'): - ufee = BTCAmt(ufee) - if ufee > self.max_fee: - msg('{} BTC: fee too large (maximum fee: {} BTC)'.format(ufee,self.max_fee)) - else: - fee = ufee - break - self.fee = fee - vmsg('Inputs:{} Outputs:{} TX size:{}'.format( - len(self.inputs),len(self.outputs),self.size)) - vmsg('Fee estimate: {} (1024 bytes, {} confs)'.format(fee_estimate,opt.tx_confs)) - m = ('',' (after %sx adjustment)' % opt.tx_fee_adj)[opt.tx_fee_adj != 1 and not ufee] - vmsg('TX fee: {}{}'.format(self.fee,m)) + def get_fee(self): + return self.sum_inputs() - self.sum_outputs() + + def btc2spb(self,btc_fee): + return int(btc_fee/g.satoshi/self.get_size()) + + def get_relay_fee(self): + assert self.get_size() + kb_fee = BTCAmt(bitcoin_connection().getinfo()['relayfee']) + vmsg('Relay fee: {} BTC/kB'.format(kb_fee)) + return kb_fee * self.get_size() / 1024 + + def convert_fee_spec(self,tx_fee,tx_size,on_fail='throw'): + if BTCAmt(tx_fee,on_fail='silent'): + return BTCAmt(tx_fee) + elif len(tx_fee) >= 2 and tx_fee[-1] == 's' and is_int(tx_fee[:-1]) and int(tx_fee[:-1]) >= 1: + if tx_size: + return BTCAmt(int(tx_fee[:-1]) * tx_size * g.satoshi) + else: + return None + else: + if on_fail == 'return': + return False + elif on_fail == 'throw': + assert False, "'{}': invalid tx-fee argument".format(tx_fee) + + def get_usr_fee(self,tx_fee,desc='Missing description'): + btc_fee = self.convert_fee_spec(tx_fee,self.get_size(),on_fail='return') + if btc_fee == None: + msg("'{}': cannot convert satoshis-per-byte to BTC because transaction size is unknown".format(tx_fee)) + assert False # because we shouldn't be calling this if tx size is unknown + elif btc_fee == False: + msg("'{}': invalid TX fee (not a BTC amount or satoshis-per-byte specification)".format(tx_fee)) + return False + elif btc_fee > g.max_tx_fee: + msg('{} BTC: {} fee too large (maximum fee: {} BTC)'.format(btc_fee,desc,g.max_tx_fee)) + return False + elif btc_fee < self.get_relay_fee(): + msg('{} BTC: {} fee too small (below relay fee of {} BTC)'.format(str(btc_fee),desc,str(self.get_relay_fee()))) + return False + else: + return btc_fee + + def get_usr_fee_interactive(self,tx_fee=None,desc='Starting'): + btc_fee = None + while True: + if tx_fee: + btc_fee = self.get_usr_fee(tx_fee,desc) + if btc_fee: + m = ('',' (after {}x adjustment)'.format(opt.tx_fee_adj))[opt.tx_fee_adj != 1] + p = '{} TX fee{}: {} BTC ({} satoshis per byte)'.format(desc,m, + btc_fee.hl(),pink(str(self.btc2spb(btc_fee)))) + if opt.yes or keypress_confirm(p+'. OK?',default_yes=True): + if opt.yes: msg(p) + return btc_fee + tx_fee = my_raw_input('Enter transaction fee: ') + desc = 'User-selected' # inputs methods def list_wifs(self,desc,mmaddrs_only=False): @@ -248,27 +294,36 @@ class MMGenTX(MMGenObject): msg('OK') self.hex = sig_tx['hex'] self.mark_signed() + vmsg('Signed transaction size: {}'.format(len(self.hex)/2)) return True else: msg('failed\nBitcoind returned the following errors:') pp_msg(sig_tx['errors']) return False + def mark_raw(self): + self.desc = 'transaction' + self.ext = self.raw_ext + def mark_signed(self): self.desc = 'signed transaction' self.ext = self.sig_ext + def is_signed(self,color=False): + ret = self.desc == 'signed transaction' + return (red,green)[ret](str(ret)) if color else ret + def check_signed(self,c): d = c.decoderawtransaction(self.hex) ret = bool(d['vin'][0]['scriptSig']['hex']) if ret: self.mark_signed() return ret - def send(self,opt,c,prompt_user=True): + def send(self,c,prompt_user=True): if prompt_user: m1 = ("Once this transaction is sent, there's no taking it back!",'')[bool(opt.quiet)] m2 = 'broadcast this transaction to the network' - m3 = ('YES, I REALLY WANT TO DO THIS','YES')[bool(opt.quiet)] + m3 = ('YES, I REALLY WANT TO DO THIS','YES')[bool(opt.quiet or opt.yes)] confirm_or_exit(m1,m2,m3) msg('Sending transaction') @@ -276,7 +331,7 @@ class MMGenTX(MMGenObject): ret = 'deadbeef' * 8 m = 'BOGUS transaction NOT sent: %s' else: - ret = c.sendrawtransaction(self.hex) # exits on failure? + ret = c.sendrawtransaction(self.hex) # exits on failure m = 'Transaction sent: %s' if ret: @@ -288,6 +343,7 @@ class MMGenTX(MMGenObject): self.add_blockcount(c) return True + # rpc implementation exits on failure, so we won't get here msg('Sending of transaction {} failed'.format(self.txid)) return False @@ -297,12 +353,14 @@ class MMGenTX(MMGenObject): ask_write=ask_write, ask_write_default_yes=ask_write_default_yes) - def write_to_file(self,add_desc='',ask_write=True,ask_write_default_yes=False,ask_tty=True): + def write_to_file(self,add_desc='',ask_write=True,ask_write_default_yes=False,ask_tty=True,ask_overwrite=True): if ask_write == False: ask_write_default_yes=True self.format() - fn = '%s[%s].%s' % (self.txid,self.send_amt,self.ext) + spbs = ('',',{}'.format(self.btc2spb(self.get_fee())))[self.is_rbf()] + fn = '{}[{}{}].{}'.format(self.txid,self.send_amt,spbs,self.ext) write_data_to_file(fn,self.fmt_data,self.desc+add_desc, + ask_overwrite=ask_overwrite, ask_write=ask_write, ask_tty=ask_tty, ask_write_default_yes=ask_write_default_yes) @@ -314,7 +372,7 @@ class MMGenTX(MMGenObject): self.view(pager=reply in 'Vv',terse=reply in 'Tt') def view(self,pager=False,pause=True,terse=False): - o = self.format_view(terse=terse).encode('utf8') + o = self.format_view(terse=terse) if pager: do_pager(o) else: sys.stdout.write(o) @@ -323,6 +381,21 @@ class MMGenTX(MMGenObject): get_char('Press any key to continue: ') msg('') +# def is_rbf_fromhex(self,color=False): +# try: +# dec_tx = bitcoin_connection().decoderawtransaction(self.hex) +# except: +# return yellow('Unknown') if color else None +# rbf = bool(dec_tx['vin'][0]['sequence'] == g.max_int - 2) +# return (red,green)[rbf](str(rbf)) if color else rbf + + def is_rbf(self,color=False): + ret = None < self.inputs[0].sequence <= g.max_int - 2 + return (red,green)[ret](str(ret)) if color else ret + + def signal_for_rbf(self): + self.inputs[0].sequence = g.max_int - 2 + def format_view(self,terse=False): try: blockcount = bitcoin_connection().getblockcount() @@ -330,11 +403,12 @@ class MMGenTX(MMGenObject): blockcount = None hdr_fs = ( - 'TRANSACTION DATA\n\nHeader: [Tx ID: {}] [Amount: {} BTC] [Time: {}]\n', - 'Transaction {} - {} BTC - {} UTC\n' + 'TRANSACTION DATA\n\nHeader: [ID:{}] [{} BTC] [{} UTC] [RBF:{}] [Signed:{}]\n', + 'Transaction {} {} BTC ({} UTC) RBF={} Signed={}\n' )[bool(terse)] - out = hdr_fs.format(self.txid.hl(),self.send_amt.hl(),self.timestamp) + out = hdr_fs.format(self.txid.hl(),self.send_amt.hl(),self.timestamp, + self.is_rbf(color=True),self.is_signed(color=True)) enl = ('\n','')[bool(terse)] if self.btc_txid: out += 'Bitcoin TxID: {}\n'.format(self.btc_txid.hl()) @@ -386,8 +460,8 @@ class MMGenTX(MMGenObject): out += '\n' fs = ( - 'Total input: %s BTC\nTotal output: %s BTC\nTX fee: %s BTC\n', - 'In %s BTC - Out %s BTC - Fee %s BTC\n' + 'Total input: %s BTC\nTotal output: %s BTC\nTX fee: %s BTC (%s satoshis per byte)\n', + 'In %s BTC - Out %s BTC - Fee %s BTC (%s satoshis/byte)\n' )[bool(terse)] total_in = self.sum_inputs() @@ -395,10 +469,16 @@ class MMGenTX(MMGenObject): out += fs % ( total_in.hl(), total_out.hl(), - (total_in-total_out).hl() + (total_in-total_out).hl(), + pink(str(self.btc2spb(total_in-total_out))), ) + if opt.verbose: + ts = len(self.hex)/2 if self.hex else 'unknown' + out += 'Transaction size: estimated - {}, actual - {}\n'.format(self.get_size(),ts) - return out + # only tx label may contain non-ascii chars + # encode() is necessary for test suite with PopenSpawn + return out.encode('utf8') def parse_tx_file(self,infile): @@ -453,3 +533,68 @@ class MMGenTX(MMGenObject): try: self.outputs = self.decode_io('outputs',eval(outputs_data)) except: do_err('btc-to-mmgen address map data') + +class MMGenBumpTX(MMGenTX): + + min_fee = None + bump_output_idx = None + + def __init__(self,filename,send=False): + + super(type(self),self).__init__(filename) + + if not self.is_rbf(): + die(1,"Transaction '{}' is not replaceable (RBF)".format(self.txid)) + + # If sending, require tx to have been signed and broadcast + if send: + if not self.is_signed(): + die(1,"File '{}' is not a signed {} transaction file".format(filename,g.proj_name)) + if not self.btc_txid: + die(1,"Transaction '{}' was not broadcast to the network".format(self.txid,g.proj_name)) + + self.btc_txid = '' + self.mark_raw() + + def choose_output(self): + chg_idx = self.get_chg_output_idx() + init_reply = opt.output_to_reduce + while True: + if init_reply == None: + reply = my_raw_input('Which output do you wish to deduct the fee from? ') + else: + reply,init_reply = init_reply,None + if chg_idx == None and not is_int(reply): + msg("Output must be an integer") + elif chg_idx != None and not is_int(reply) and reply != 'c': + msg("Output must be an integer, or 'c' for the change output") + else: + idx = chg_idx if reply == 'c' else (int(reply) - 1) + if idx < 0 or idx >= len(self.outputs): + msg('Output must be in the range 1-{}'.format(len(self.outputs))) + else: + o_amt = self.outputs[idx].amt + cs = ('',' (change output)')[chg_idx == idx] + p = 'Fee will be deducted from output {}{} ({} BTC)'.format(idx+1,cs,o_amt) + if o_amt < self.min_fee: + msg('Minimum fee ({} BTC) is greater than output amount ({} BTC)'.format( + self.min_fee,o_amt)) + elif opt.yes or keypress_confirm(p+'. OK?',default_yes=True): + if opt.yes: msg(p) + self.bump_output_idx = idx + return idx + + def set_min_fee(self): + self.min_fee = self.sum_inputs() - self.sum_outputs() + self.get_relay_fee() + + def get_usr_fee(self,tx_fee,desc): + ret = super(type(self),self).get_usr_fee(tx_fee,desc) + if ret < self.min_fee: + msg('{} BTC: {} fee too small. Minimum fee: {} BTC ({} satoshis per byte)'.format( + ret,desc,self.min_fee,self.btc2spb(self.min_fee))) + return False + output_amt = self.outputs[self.bump_output_idx].amt + if ret >= output_amt: + msg('{} BTC: {} fee too large. Maximum fee: <{} BTC'.format(ret,desc,output_amt)) + return False + return ret diff --git a/mmgen/txcreate.py b/mmgen/txcreate.py index be0c6fa6..ad3b6b9d 100755 --- a/mmgen/txcreate.py +++ b/mmgen/txcreate.py @@ -31,12 +31,13 @@ txcreate_notes = """ The transaction's outputs are specified on the command line, while its inputs are chosen from a list of the user's unpent outputs via an interactive menu. -If the transaction fee is not specified by the user, it will be calculated -using bitcoind's "estimatefee" function for the default (or user-specified) -number of confirmations. If "estimatefee" fails, the global default fee of -{g.tx_fee} BTC will be used. +If the transaction fee is not specified on the command line (see FEE +SPECIFICATION below), it will be calculated dynamically using bitcoind's +"estimatefee" function for the default (or user-specified) number of +confirmations. If "estimatefee" fails, the user will be prompted for a fee. -Dynamic fees will be multiplied by the value of '--tx-fee-adj', if specified. +Dynamic ("estimatefee") fees will be multiplied by the value of '--tx-fee-adj', +if specified. Ages of transactions are approximate based on an average block discovery interval of {g.mins_per_block} minutes. @@ -48,6 +49,13 @@ To send the value of all inputs (minus TX fee) to a single output, specify one address with no amount on the command line. """.format(g=g,pnm=pnm) +fee_notes = """ +FEE SPECIFICATION: Transaction fees, both on the command line and at the +interactive prompt, may be specified as either absolute BTC amounts, using a +plain decimal number, or as satoshis per byte, using an integer followed by +the letter 's'. +""" + wmsg = { 'addr_in_addrfile_only': """ Warning: output address {mmgenaddr} is not in the tracking wallet, which means @@ -72,11 +80,15 @@ option. Selected non-{pnm} inputs: %s """.strip().format(pnm=pnm,pnl=pnm.lower()), 'not_enough_btc': """ -Not enough BTC in the inputs for this transaction (%s BTC) +Selected outputs insufficient to fund this transaction (%s BTC needed) """.strip(), 'throwaway_change': """ ERROR: This transaction produces change (%s BTC); however, no change address was specified. +""".strip(), + 'no_change_output': """ +ERROR: No change address specified. If you wish to create a transaction with +only one output, specify a single output address with no BTC amount """.strip(), } @@ -109,26 +121,25 @@ def mmaddr2baddr(c,mmaddr,ad_w,ad_f): return BTCAddr(btc_addr) -def get_fee_estimate(c): - if 'tx_fee' in opt.set_by_user: # TODO - return None +def get_fee_from_estimate_or_usr(tx,c,estimate_fail_msg_shown=[]): + if opt.tx_fee: + desc = 'User-selected' + start_fee = opt.tx_fee else: + desc = 'Network-estimated' ret = c.estimatefee(opt.tx_confs) - if ret != -1: - return BTCAmt(ret) + if ret == -1: + if not estimate_fail_msg_shown: + msg('Network fee estimation for {} confirmations failed'.format(opt.tx_confs)) + estimate_fail_msg_shown.append(True) + start_fee = None 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') + start_fee = BTCAmt(ret) * opt.tx_fee_adj * tx.get_size() / 1024 + if opt.verbose: + msg('{} fee ({} confs): {} BTC/kB'.format(desc,opt.tx_confs,ret)) + msg('TX size (estimated): {}'.format(tx.get_size())) + + return tx.get_usr_fee_interactive(start_fee,desc=desc) def txcreate(opt,cmd_args,do_info=False,caller='txcreate'): @@ -153,29 +164,25 @@ def txcreate(opt,cmd_args,do_info=False,caller='txcreate'): for a in cmd_args: if ',' in a: a1,a2 = a.split(',',1) - if is_btc_addr(a1): - btc_addr = BTCAddr(a1) - elif is_mmgen_id(a1): - btc_addr = mmaddr2baddr(c,a1,ad_w,ad_f) + if is_mmgen_id(a1) or is_btc_addr(a1): + btc_addr = mmaddr2baddr(c,a1,ad_w,ad_f) if is_mmgen_id(a1) else BTCAddr(a1) + tx.add_output(btc_addr,BTCAmt(a2)) else: die(2,"%s: unrecognized subargument in argument '%s'" % (a1,a)) - tx.add_output(btc_addr,BTCAmt(a2)) elif is_mmgen_id(a) or is_btc_addr(a): - if tx.change_addr: - die(2,'ERROR: More than one change address specified: %s, %s' % - (change_addr, a)) - tx.change_addr = mmaddr2baddr(c,a,ad_w,ad_f) if is_mmgen_id(a) else BTCAddr(a) - tx.add_output(tx.change_addr,BTCAmt('0')) + if tx.get_chg_output_idx() != None: + die(2,'ERROR: More than one change address listed on command line') + btc_addr = mmaddr2baddr(c,a,ad_w,ad_f) if is_mmgen_id(a) else BTCAddr(a) + tx.add_output(btc_addr,BTCAmt('0'),is_chg=True) else: die(2,'%s: unrecognized argument' % a) if not tx.outputs: die(2,'At least one output must be specified on the command line') - if opt.tx_fee > tx.max_fee: - die(2,'Transaction fee too large: %s > %s' % (opt.tx_fee,tx.max_fee)) + if tx.get_chg_output_idx() == None: + die(2,('ERROR: No change output specified',wmsg['no_change_output'])[len(tx.outputs) == 1]) - fee_estimate = get_fee_estimate(c) tw = MMGenTrackingWallet(minconf=opt.minconf) tw.view_and_sort() @@ -194,8 +201,14 @@ def txcreate(opt,cmd_args,do_info=False,caller='txcreate'): ('s','')[len(sel_nums)==1], ' '.join(str(i) for i in sel_nums) )) + sel_unspent = [tw.unspent[i-1] for i in sel_nums] + t_inputs = sum(s.amt for s in sel_unspent) + if t_inputs < tx.send_amt: + msg(wmsg['not_enough_btc'] % (tx.send_amt - t_inputs)) + continue + non_mmaddrs = [i for i in sel_unspent if i.mmid == None] if non_mmaddrs and caller != 'txdo': msg(wmsg['non_mmgen_inputs'] % ', '.join(set(sorted([a.addr.hl() for a in non_mmaddrs])))) @@ -204,42 +217,44 @@ def txcreate(opt,cmd_args,do_info=False,caller='txcreate'): tx.copy_inputs_from_tw(sel_unspent) # makes tx.inputs - tx.calculate_size_and_fee(fee_estimate) # sets tx.size, tx.fee + if opt.rbf: tx.signal_for_rbf() # only after we have inputs - change_amt = tx.sum_inputs() - tx.send_amt - tx.fee + change_amt = tx.sum_inputs() - tx.send_amt - get_fee_from_estimate_or_usr(tx,c) if change_amt >= 0: - prompt = 'Transaction produces %s BTC in change. OK?' % change_amt.hl() - if keypress_confirm(prompt,default_yes=True): + p = 'Transaction produces %s BTC in change' % change_amt.hl() + if opt.yes or keypress_confirm(p+'. OK?',default_yes=True): + if opt.yes: msg(p) break else: - msg(wmsg['not_enough_btc'] % change_amt) + msg(wmsg['not_enough_btc'] % abs(change_amt)) + chg_idx = tx.get_chg_output_idx() if change_amt > 0: - change_amt = BTCAmt(change_amt) - if not tx.change_addr: - die(2,wmsg['throwaway_change'] % change_amt) - tx.del_output(tx.change_addr) - tx.add_output(BTCAddr(tx.change_addr),change_amt) - elif tx.change_addr: - msg('Warning: Change address will be unused as transaction produces no change') - tx.del_output(tx.change_addr) + tx.update_output_amt(chg_idx,BTCAmt(change_amt)) + else: + msg('Warning: Change address will be deleted as transaction produces no change') + tx.del_output(chg_idx) if not tx.send_amt: tx.send_amt = change_amt dmsg('tx: %s' % tx) - tx.add_comment() # edits an existing comment - tx.create_raw(c) # creates tx.hex, tx.txid + if not opt.yes: + tx.add_comment() # edits an existing comment + tx.create_raw(c) # creates tx.hex, tx.txid tx.add_mmaddrs_to_outputs(ad_w,ad_f) tx.add_timestamp() tx.add_blockcount(c) + assert tx.get_fee() <= g.max_tx_fee + qmsg('Transaction successfully created') dmsg('TX (final): %s' % tx) - tx.view_with_prompt('View decoded transaction?') + if not opt.yes: + tx.view_with_prompt('View decoded transaction?') return tx diff --git a/mmgen/util.py b/mmgen/util.py index 32ddc066..46f98342 100755 --- a/mmgen/util.py +++ b/mmgen/util.py @@ -657,7 +657,7 @@ def do_pager(text): def do_license_msg(immed=False): - if opt.quiet or g.no_license: return + if opt.quiet or g.no_license or opt.yes: return import mmgen.license as gpl diff --git a/setup.py b/setup.py index fe148227..6b90ea2c 100755 --- a/setup.py +++ b/setup.py @@ -100,6 +100,7 @@ setup( 'mmgen.main_addrgen', 'mmgen.main_addrimport', 'mmgen.main_txcreate', + 'mmgen.main_txbump', 'mmgen.main_txsign', 'mmgen.main_txsend', 'mmgen.main_txdo', @@ -119,6 +120,7 @@ setup( 'mmgen-walletconv', 'mmgen-walletgen', 'mmgen-txcreate', + 'mmgen-txbump', 'mmgen-txsign', 'mmgen-txsend', 'mmgen-txdo', diff --git a/test/test.py b/test/test.py index c4777ef9..ff0c5fad 100755 --- a/test/test.py +++ b/test/test.py @@ -200,6 +200,7 @@ cfgs = { 'mmdat': 'walletgen', 'addrs': 'addrgen', 'rawtx': 'txcreate', + 'txbump': 'txbump', 'sigtx': 'txsign', 'mmwords': 'export_mnemonic', 'mmseed': 'export_seed', @@ -246,6 +247,7 @@ cfgs = { 'addrs': 'addrgen4', 'rawtx': 'txcreate4', 'sigtx': 'txsign4', + 'txdo': 'txdo4', }, 'bw_filename': 'brainwallet.mmbrain', 'bw_params': '192,1', @@ -403,7 +405,8 @@ cmd_group['main'] = OrderedDict([ ['addrgen', (1,'address generation', [[['mmdat',pwfile],1]],1)], ['addrimport', (1,'address import', [[['addrs'],1]],1)], ['txcreate', (1,'transaction creation', [[['addrs'],1]],1)], - ['txsign', (1,'transaction signing', [[['mmdat','rawtx',pwfile],1]],1)], + ['txbump', (1,'transaction fee bumping (no send)',[[['rawtx'],1]],1)], + ['txsign', (1,'transaction signing', [[['mmdat','rawtx',pwfile,'txbump'],1]],1)], ['txsend', (1,'transaction sending', [[['sigtx'],1]])], # txdo must go after txsign ['txdo', (1,'online transaction', [[['sigtx','mmdat'],1]])], @@ -444,6 +447,7 @@ cmd_group['main'] = OrderedDict([ ['txcreate4', (4,'tx creation with inputs and outputs from four seed sources, key-address file and non-MMGen inputs and outputs', [[['addrs'],1],[['addrs'],2],[['addrs'],3],[['addrs'],4],[['addrs','akeys.mmenc'],14]])], ['txsign4', (4,'tx signing with inputs and outputs from incog file, mnemonic file, wallet, brainwallet, key-address file and non-MMGen inputs and outputs', [[['mmincog'],1],[['mmwords'],2],[['mmdat'],3],[['mmbrain','rawtx'],4],[['akeys.mmenc'],14]])], ['txdo4', (4,'tx creation,signing and sending with inputs and outputs from four seed sources, key-address file and non-MMGen inputs and outputs', [[['addrs'],1],[['addrs'],2],[['addrs'],3],[['addrs'],4],[['addrs','akeys.mmenc'],14],[['mmincog'],1],[['mmwords'],2],[['mmdat'],3],[['mmbrain','rawtx'],4],[['akeys.mmenc'],14]])], # must go after txsign4 + ['txbump4', (4,'tx fee bump + send with inputs and outputs from four seed sources, key-address file and non-MMGen inputs and outputs', [[['akeys.mmenc'],14],[['mmincog'],1],[['mmwords'],2],[['mmdat'],3],[['akeys.mmenc'],14],[['mmbrain','sigtx','mmdat','txdo'],4]])], # must go after txsign4 ]) cmd_group['tool'] = OrderedDict([ @@ -1415,7 +1419,7 @@ class MMGenTestSuite(object): bwd_msg = 'MMGEN_BOGUS_WALLET_DATA=%s' % unspent_data_file if opt.print_cmdline: msg(bwd_msg) if opt.log: log_fd.write(bwd_msg + ' ') - t = MMGenExpect(name,'mmgen-'+('txcreate','txdo')[bool(txdo_args)],['-f','0.0001'] + add_args + cmd_args + txdo_args) + t = MMGenExpect(name,'mmgen-'+('txcreate','txdo')[bool(txdo_args)],['--rbf','-f',tx_fee] + add_args + cmd_args + txdo_args) if ia: return t.license() @@ -1462,6 +1466,31 @@ class MMGenTestSuite(object): def txcreate(self,name,addrfile): self.txcreate_common(name,sources=['1']) + def txbump(self,name,txfile,prepend_args=[],seed_args=[]): + args = prepend_args + ['-q','-d',cfg['tmpdir'],txfile] + seed_args + t = MMGenExpect(name,'mmgen-txbump',args) + if seed_args: + t.hash_preset('key-address data','1') + t.passphrase('key-address data',cfgs['14']['kapasswd']) + t.expect('Check key-to-address validity? (y/N): ','y') + t.expect('Which output do you wish to deduct the fee from? ','1\n') + # Fee must be > tx_fee + network relay fee (currently 0.00001) + t.expect('OK? (Y/n): ','\n') + t.expect('Enter transaction fee: ','124s\n') + t.expect('OK? (Y/n): ','\n') + if seed_args: # sign and send + t.expect('Edit transaction comment? (y/N): ','\n') + for cnum,desc in ('1','incognito data'),('3','MMGen wallet'),('4','MMGen wallet'): + t.passphrase(('%s' % desc),cfgs[cnum]['wpasswd']) + t.expect("Type uppercase 'YES' to confirm: ",'YES\n') + else: + t.expect('Add a comment to transaction? (y/N): ','\n') + t.expect('Save transaction? (y/N): ','y') + t.written_to_file('Transaction') + os.unlink(txfile) # our tx file replaces the original + os.system('touch ' + os.path.join(cfg['tmpdir'],'txbump')) + ok() + def txdo(self,name,addrfile,wallet): t = self.txcreate_common(name,sources=['1'],txdo_args=[wallet]) self.txsign(name,'','',pf='',save=True,has_label=False,txdo_handle=t) @@ -1478,7 +1507,7 @@ class MMGenTestSuite(object): add = ' #' + tnum if tnum else '' t.written_to_file('Signed transaction' + add, oo=True) - def txsign(self,name,txfile,wf,pf='',save=True,has_label=False,txdo_handle=None): + def txsign(self,name,txfile,wf,pf='',bumpf='',save=True,has_label=False,txdo_handle=None): add_args = ([],['-q','-P',pf])[ia] if ia: m = '\nAnswer the interactive prompts as follows:\n ENTER, ENTER, ENTER' @@ -1516,7 +1545,7 @@ class MMGenTestSuite(object): m = 'YES, I REALLY WANT TO DO THIS' t.expect("'%s' to confirm: " % m,m+'\n') t.expect('BOGUS transaction NOT sent') - t.written_to_file('Transaction ID') + t.written_to_file('Sent transaction') ok() def walletconv_export(self,name,wf,desc,uargs=[],out_fmt='w',pf=None,out_pw=False): @@ -1719,8 +1748,14 @@ class MMGenTestSuite(object): non_mm_fn = os.path.join(cfg['tmpdir'],non_mmgen_fn) add_args = ['-d',cfg['tmpdir'],'-i','brain','-b'+cfg['bw_params'],'-p1','-k',non_mm_fn,'-M',f12] t = self.txcreate_common(name,sources=['1','2','3','4','14'],non_mmgen_input='4',do_label=1,txdo_args=[f7,f8,f9,f10],add_args=add_args) + os.system('rm -f %s/*.sigtx' % cfg['tmpdir']) self.txsign4(name,f7,f8,f9,f10,f11,f12,txdo_handle=t) self.txsend(name,'',txdo_handle=t) + os.system('touch ' + os.path.join(cfg['tmpdir'],'txdo')) + + def txbump4(self,name,f1,f2,f3,f4,f5,f6,f7,f8,f9): # f7:txfile,f9:'txdo' + non_mm_fn = os.path.join(cfg['tmpdir'],non_mmgen_fn) + self.txbump(name,f7,prepend_args=['-p1','-k',non_mm_fn,'-M',f1],seed_args=[f2,f3,f4,f5,f6,f8]) def txsign4(self,name,f1,f2,f3,f4,f5,f6,txdo_handle=None): if txdo_handle: