From ad8cd74ee96587d838e726ddf191e83646e550fe Mon Sep 17 00:00:00 2001 From: csH7KmCC9 Date: Mon, 19 Apr 2021 18:04:01 +0000 Subject: [PATCH] Enable external/custom change addresses. Fixes #797. Adds `custom_change_addr` argument to `direct_send()` joinmarket-qt: Adds input field for optional external change address joinmarket-qt: Better handle PayJoin/CoinJoin state changes for changeInput widget Adds `custom_change_address` argument to Taker constructor and use it in joinmarket-qt Custom change also allowed in sendpayment CLI with `-u` flag (not supported in tumbler). Explicitly disallows using custom change with BIP78 Payjoin, though that could change later. Both sendpayment and CLI provide detailed warnings to avoid misuse. In particular, they have an extra warning for using a nonstandard or non-wallet scriptpubkey type. Setting custom change to the recipient address is explicitly forbidden. Tests: Adds custom_change usage test in test_taker. --- jmclient/jmclient/__init__.py | 5 +- jmclient/jmclient/cli_options.py | 7 ++ jmclient/jmclient/output.py | 31 +++++++++ jmclient/jmclient/taker.py | 15 +++-- jmclient/jmclient/taker_utils.py | 24 +++++-- jmclient/test/commontest.py | 3 +- jmclient/test/test_taker.py | 45 +++++++++++-- scripts/joinmarket-qt.py | 112 +++++++++++++++++++++++++++---- scripts/sendpayment.py | 46 ++++++++++++- 9 files changed, 252 insertions(+), 36 deletions(-) diff --git a/jmclient/jmclient/__init__.py b/jmclient/jmclient/__init__.py index 35ff958..1e1d6a3 100644 --- a/jmclient/jmclient/__init__.py +++ b/jmclient/jmclient/__init__.py @@ -19,7 +19,7 @@ from .wallet import (Mnemonic, estimate_tx_fee, WalletError, BaseWallet, ImportW from .storage import (Argon2Hash, Storage, StorageError, RetryableStorageError, StoragePasswordError, VolatileStorage) from .cryptoengine import (BTCEngine, BTC_P2PKH, BTC_P2SH_P2WPKH, BTC_P2WPKH, EngineError, - TYPE_P2PKH, TYPE_P2SH_P2WPKH, TYPE_P2WPKH) + TYPE_P2PKH, TYPE_P2SH_P2WPKH, TYPE_P2WPKH, detect_script_type) from .configure import (load_test_config, process_shutdown, load_program_config, jm_single, get_network, update_persist_config, validate_address, is_burn_destination, get_irc_mchannels, @@ -36,7 +36,8 @@ from .podle import (set_commitment_file, get_commitment_file, PoDLE, generate_podle, get_podle_commitments, update_commitments) from .output import generate_podle_error_string, fmt_utxos, fmt_utxo,\ - fmt_tx_data + fmt_tx_data, general_custom_change_warning, nonwallet_custom_change_warning,\ + sweep_custom_change_warning from .schedule import (get_schedule, get_tumble_schedule, schedule_to_text, tweak_tumble_schedule, human_readable_schedule_entry, schedule_to_text, NO_ROUNDING) diff --git a/jmclient/jmclient/cli_options.py b/jmclient/jmclient/cli_options.py index 0926493..5840cd8 100644 --- a/jmclient/jmclient/cli_options.py +++ b/jmclient/jmclient/cli_options.py @@ -519,6 +519,13 @@ def get_sendpayment_parser(): 'broadcasting the transaction. ' 'Currently only works with direct ' 'send (-N 0).') + parser.add_option('-u', + '--custom-change', + type="str", + dest='customchange', + default='', + help='specify address to receive change ' + ', by default use in-wallet address.') add_common_options(parser) return parser diff --git a/jmclient/jmclient/output.py b/jmclient/jmclient/output.py index e76a790..7477bde 100644 --- a/jmclient/jmclient/output.py +++ b/jmclient/jmclient/output.py @@ -1,5 +1,36 @@ from jmbase import utxo_to_utxostr +general_custom_change_warning = """You are attempting to send change to a custom change +address. Change outputs are usually directly linkable to +your CoinJoin inputs, and incautious combination of +custom change UTXOs can catastrophically compromise +your CoinJoin privacy, especially if those UTXOs are from +different mixdepths. + +Are you sure you know what you're doing?""" + +nonwallet_custom_change_warning =""" +The custom change address type is different from your wallet +address type. + +Be extremely careful here: It will be obvious to any blockchain +observer that this output was disposed of by the taker (i.e. +you) and is directly linkable to your CoinJoin inputs. + +Sending change in a one-off transaction to a party with a +different address type than this wallet is otherwise probably +OK. + +HOWEVER, if you regularly send your change to unusual +address types, especially multisig P2(W)SH addresses, you +seriously risk linking ALL of those CoinJoins to you, +REGARDLESS of how carefully you spend those custom change +UTXOs. + +Are you sure you want to continue?""" + +sweep_custom_change_warning = \ + "Custom change cannot be set while doing a sweep (zero amount)." def fmt_utxos(utxos, wallet_service, prefix=''): output = [] diff --git a/jmclient/jmclient/taker.py b/jmclient/jmclient/taker.py index e27fa04..839e27d 100644 --- a/jmclient/jmclient/taker.py +++ b/jmclient/jmclient/taker.py @@ -33,6 +33,7 @@ class Taker(object): order_chooser=weighted_order_choose, callbacks=None, tdestaddrs=None, + custom_change_address=None, ignored_makers=None): """`schedule`` must be a list of tuples: (see sample_schedule_for_testnet for explanation of syntax, also schedule.py module in this directory), @@ -84,6 +85,7 @@ class Taker(object): self.schedule = schedule self.order_chooser = order_chooser self.max_cj_fee = max_cj_fee + self.my_change_addr = custom_change_address #List (which persists between transactions) of makers #who have not responded or behaved maliciously at any @@ -288,13 +290,13 @@ class Taker(object): if not self.my_cj_addr: #previously used for donations; TODO reimplement? raise NotImplementedError - self.my_change_addr = None if self.cjamount != 0: - try: - self.my_change_addr = self.wallet_service.get_internal_addr(self.mixdepth) - except: - self.taker_info_callback("ABORT", "Failed to get a change address") - return False + if self.my_change_addr is None: + try: + self.my_change_addr = self.wallet_service.get_internal_addr(self.mixdepth) + except: + self.taker_info_callback("ABORT", "Failed to get a change address") + return False #adjust the required amount upwards to anticipate an increase in #transaction fees after re-estimation; this is sufficiently conservative #to make failures unlikely while keeping the occurence of failure to @@ -314,6 +316,7 @@ class Taker(object): else: #sweep self.input_utxos = self.wallet_service.get_utxos_by_mixdepth()[self.mixdepth] + self.my_change_addr = None #do our best to estimate the fee based on the number of #our own utxos; this estimate may be significantly higher #than the default set in option.txfee * makercount, where diff --git a/jmclient/jmclient/taker_utils.py b/jmclient/jmclient/taker_utils.py index a01ae89..158e737 100644 --- a/jmclient/jmclient/taker_utils.py +++ b/jmclient/jmclient/taker_utils.py @@ -24,7 +24,7 @@ Currently re-used by CLI script tumbler.py and joinmarket-qt def direct_send(wallet_service, amount, mixdepth, destination, answeryes=False, accept_callback=None, info_callback=None, error_callback=None, return_transaction=False, with_final_psbt=False, - optin_rbf=False): + optin_rbf=False, custom_change_addr=None): """Send coins directly from one mixdepth to one destination address; does not need IRC. Sweep as for normal sendpayment (set amount=0). If answeryes is True, callback/command line query is not performed. @@ -34,7 +34,9 @@ def direct_send(wallet_service, amount, mixdepth, destination, answeryes=False, accept_callback: ==== args: - deserialized tx, destination address, amount in satoshis, fee in satoshis + deserialized tx, destination address, amount in satoshis, + fee in satoshis, custom change address + returns: True if accepted, False if not ==== @@ -52,6 +54,8 @@ def direct_send(wallet_service, amount, mixdepth, destination, answeryes=False, """ #Sanity checks assert validate_address(destination)[0] or is_burn_destination(destination) + assert custom_change_addr is None or validate_address(custom_change_addr)[0] + assert amount > 0 or custom_change_addr is None assert isinstance(mixdepth, numbers.Integral) assert mixdepth >= 0 assert isinstance(amount, numbers.Integral) @@ -77,6 +81,7 @@ def direct_send(wallet_service, amount, mixdepth, destination, answeryes=False, txtype = wallet_service.get_txtype() if amount == 0: + #doing a sweep utxos = wallet_service.get_utxos_by_mixdepth()[mixdepth] if utxos == {}: log.error( @@ -105,10 +110,11 @@ def direct_send(wallet_service, amount, mixdepth, destination, answeryes=False, + wallet_service.wallet.get_path_repr(path) \ + "\n\nWARNING: This transaction if broadcasted will PERMANENTLY DESTROY your bitcoins\n" else: - #regular send (non-burn) + #regular sweep (non-burn) fee_est = estimate_tx_fee(len(utxos), 1, txtype=txtype) outs = [{"address": destination, "value": total_inputs_val - fee_est}] else: + #not doing a sweep; we will have change #8 inputs to be conservative initial_fee_est = estimate_tx_fee(8,2, txtype=txtype) utxos = wallet_service.select_utxos(mixdepth, amount + initial_fee_est) @@ -119,7 +125,8 @@ def direct_send(wallet_service, amount, mixdepth, destination, answeryes=False, total_inputs_val = sum([va['value'] for u, va in utxos.items()]) changeval = total_inputs_val - fee_est - amount outs = [{"value": amount, "address": destination}] - change_addr = wallet_service.get_internal_addr(mixdepth) + change_addr = wallet_service.get_internal_addr(mixdepth) if custom_change_addr is None \ + else custom_change_addr outs.append({"value": changeval, "address": change_addr}) #compute transaction locktime, has special case for spending timelocked coins @@ -175,7 +182,11 @@ def direct_send(wallet_service, amount, mixdepth, destination, answeryes=False, log.info("Got signed transaction:\n") log.info(human_readable_transaction(tx)) actual_amount = amount if amount != 0 else total_inputs_val - fee_est - log.info("Sends: " + amount_to_str(actual_amount) + " to destination: " + destination) + sending_info = "Sends: " + amount_to_str(actual_amount) + \ + " to destination: " + destination + if custom_change_addr: + sending_info += ", custom change to: " + custom_change_addr + log.info(sending_info) if not answeryes: if not accept_callback: if input('Would you like to push to the network? (y/n):')[0] != 'y': @@ -183,7 +194,8 @@ def direct_send(wallet_service, amount, mixdepth, destination, answeryes=False, return False else: accepted = accept_callback(human_readable_transaction(tx), - destination, actual_amount, fee_est) + destination, actual_amount, fee_est, + custom_change_addr) if not accepted: return False if jm_single().bc_interface.pushtx(tx.serialize()): diff --git a/jmclient/test/commontest.py b/jmclient/test/commontest.py index 0ae4249..b06b618 100644 --- a/jmclient/test/commontest.py +++ b/jmclient/test/commontest.py @@ -26,7 +26,8 @@ PINL = '\r\n' if OS == 'Windows' else '\n' default_max_cj_fee = (1, float('inf')) # callbacks for making transfers in-script with direct_send: -def dummy_accept_callback(tx, destaddr, actual_amount, fee_est): +def dummy_accept_callback(tx, destaddr, actual_amount, fee_est, + custom_change_addr): return True def dummy_info_callback(msg): pass diff --git a/jmclient/test/test_taker.py b/jmclient/test/test_taker.py index 1d3e4a2..31cd525 100644 --- a/jmclient/test/test_taker.py +++ b/jmclient/test/test_taker.py @@ -13,8 +13,8 @@ from base64 import b64encode from jmbase import utxostr_to_utxo, hextobin from jmclient import load_test_config, jm_single, set_commitment_file,\ get_commitment_file, SegwitWallet, Taker, VolatileStorage,\ - get_network, WalletService, NO_ROUNDING, BTC_P2PKH,\ - NotEnoughFundsException + get_network, WalletService, NO_ROUNDING, NotEnoughFundsException,\ + BTC_P2SH_P2WPKH, BTC_P2PKH, BTC_P2WPKH from taker_test_data import t_utxos_by_mixdepth, t_orderbook,\ t_maker_response, t_chosen_orders, t_dummy_ext from commontest import default_max_cj_fee @@ -146,7 +146,7 @@ def dummy_filter_orderbook(orders_fees, cjamount): return True def get_taker(schedule=None, schedule_len=0, on_finished=None, - filter_orders=None): + filter_orders=None, custom_change=None): if not schedule: #note, for taker.initalize() this will result in junk schedule = [['a', 'b', 'c', 'd', 'e', 'f']]*schedule_len @@ -154,7 +154,8 @@ def get_taker(schedule=None, schedule_len=0, on_finished=None, on_finished_callback = on_finished if on_finished else taker_finished filter_orders_callback = filter_orders if filter_orders else dummy_filter_orderbook taker = Taker(WalletService(DummyWallet()), schedule, default_max_cj_fee, - callbacks=[filter_orders_callback, None, on_finished_callback]) + callbacks=[filter_orders_callback, None, on_finished_callback], + custom_change_address=custom_change) taker.wallet_service.current_blockheight = 10**6 return taker @@ -438,10 +439,46 @@ def test_taker_init(setup_taker, schedule, highfee, toomuchcoins, minmakers, a = taker.coinjoin_address() taker.wallet_service.wallet.inject_addr_get_failure = True taker.my_cj_addr = "dummy" + taker.my_change_addr = None assert not taker.prepare_my_bitcoin_data() #clean up return clean_up() +def test_custom_change(setup_taker): + # create three random custom change addresses, one of each + # known type in Joinmarket. + privs = [x*32 + b"\x01" for x in [struct.pack(b'B', y) for y in range(1,4)]] + scripts = [a.key_to_script(i) for a, i in zip([BTC_P2PKH, BTC_P2SH_P2WPKH, BTC_P2WPKH], privs)] + addrs = [a.privkey_to_address(i) for a, i in zip([BTC_P2PKH, BTC_P2SH_P2WPKH, BTC_P2WPKH], privs)] + schedule = [(0, 20000000, 3, "mnsquzxrHXpFsZeL42qwbKdCP2y1esN3qw", 0, NO_ROUNDING)] + for script, addr in zip(scripts, addrs): + taker = get_taker(schedule, custom_change=addr) + orderbook = copy.deepcopy(t_orderbook) + res = taker.initialize(orderbook) + taker.orderbook = copy.deepcopy(t_chosen_orders) + maker_response = copy.deepcopy(t_maker_response) + res = taker.receive_utxos(maker_response) + assert res[0] + # ensure that the transaction created for signing has + # the address we intended with the right amount: + custom_change_found = False + for out in taker.latest_tx.vout: + # input utxo is 20M; amount is 2M; as per logs: + # totalin=200000000 + # my_txfee=12930 + # makers_txfee=3000 + # cjfee_total=12000 => changevalue=179975070 + # note that there is a small variation in the size of + # the transaction (a few bytes) for the different scriptPubKey + # type, but this is currently ignored by the Taker, who makes + # fee estimate purely based on the number of ins and outs; + # this will never be too far off anyway. + if out.scriptPubKey == script and out.nValue == 179975070: + # must be only one + assert not custom_change_found + custom_change_found = True + assert custom_change_found + @pytest.mark.parametrize( "schedule_len", [ diff --git a/scripts/joinmarket-qt.py b/scripts/joinmarket-qt.py index 1929247..0c415ff 100755 --- a/scripts/joinmarket-qt.py +++ b/scripts/joinmarket-qt.py @@ -70,7 +70,10 @@ from jmclient import load_program_config, get_network, update_persist_config,\ NO_ROUNDING, get_max_cj_fee_values, get_default_max_absolute_fee, \ get_default_max_relative_fee, RetryableStorageError, add_base_options, \ BTCEngine, FidelityBondMixin, wallet_change_passphrase, \ - parse_payjoin_setup, send_payjoin, JMBIP78ReceiverManager + parse_payjoin_setup, send_payjoin, JMBIP78ReceiverManager, \ + detect_script_type, general_custom_change_warning, \ + nonwallet_custom_change_warning, sweep_custom_change_warning, EngineError + from qtsupport import ScheduleWizard, TumbleRestartWizard, config_tips,\ config_types, QtHandler, XStream, Buttons, OkButton, CancelButton,\ PasswordDialog, MyTreeWidget, JMQtMessageBox, BLUE_FG,\ @@ -298,9 +301,11 @@ class SpendTab(QWidget): self.pjEndpointLabel.setVisible(True) self.pjEndpointInput.setVisible(True) - # while user is attempting a payjoin, address + # while user is attempting a payjoin, address/change # cannot be edited; to back out, they hit Abort. self.addressInput.setEnabled(False) + self.changeInput.setEnabled(False) + self.changeInput.clear() self.abortButton.setEnabled(True) def switchToJoinmarket(self): @@ -314,14 +319,24 @@ class SpendTab(QWidget): self.switchToJoinmarket() self.addressInput.setText('') self.amountInput.setText('') + self.changeInput.setText('') self.addressInput.setEnabled(True) self.pjEndpointInput.setEnabled(True) self.mixdepthInput.setEnabled(True) self.amountInput.setEnabled(True) + self.changeInput.setEnabled(True) self.startButton.setEnabled(True) self.abortButton.setEnabled(False) def checkAddress(self, addr): + valid, errmsg = validate_address(str(addr)) + if not valid and len(addr) > 0: + JMQtMessageBox(self, + "Bitcoin address not valid.\n" + errmsg, + mbtype='warn', + title="Error") + + def parseURIAndValidateAddress(self, addr): addr = addr.strip() if btc.is_bip21_uri(addr): try: @@ -344,12 +359,7 @@ class SpendTab(QWidget): self.bip21_uri = None self.addressInput.setText(addr) - valid, errmsg = validate_address(str(addr)) - if not valid and len(addr) > 0: - JMQtMessageBox(self, - "Bitcoin address not valid.\n" + errmsg, - mbtype='warn', - title="Error") + self.checkAddress(addr) def checkAmount(self, amount_str): if not amount_str: @@ -519,6 +529,7 @@ class SpendTab(QWidget): sch_buttons_box.setLayout(sch_buttons_layout) sch_layout.addWidget(sch_buttons_box, 0, 1, 1, 1) + #construct layout for single joins innerTopLayout = QGridLayout() innerTopLayout.setSpacing(4) self.single_join_tab.setLayout(innerTopLayout) @@ -531,7 +542,7 @@ class SpendTab(QWidget): 'The address or bitcoin: URI you want to send the payment to') self.addressInput = QLineEdit() self.addressInput.editingFinished.connect( - lambda: self.checkAddress(self.addressInput.text())) + lambda: self.parseURIAndValidateAddress(self.addressInput.text())) innerTopLayout.addWidget(recipientLabel, 1, 0) innerTopLayout.addWidget(self.addressInput, 1, 1, 1, 2) @@ -570,6 +581,17 @@ class SpendTab(QWidget): innerTopLayout.addWidget(amountLabel, 4, 0) innerTopLayout.addWidget(self.amountInput, 4, 1, 1, 2) + changeLabel = QLabel('Custom change address') + changeLabel.setToolTip( + 'Specify an address to receive change, rather ' + + 'than sending it to the internal wallet.') + self.changeInput = QLineEdit() + self.changeInput.editingFinished.connect( + lambda: self.checkAddress(self.changeInput.text().strip())) + self.changeInput.setPlaceholderText("(optional)") + innerTopLayout.addWidget(changeLabel, 5, 0) + innerTopLayout.addWidget(self.changeInput, 5, 1, 1, 2) + self.startButton = QPushButton('Start') self.startButton.setToolTip( 'If "checktx" is selected in the Settings, you will be \n' @@ -585,7 +607,7 @@ class SpendTab(QWidget): buttons.addWidget(self.startButton) buttons.addWidget(self.abortButton) self.abortButton.clicked.connect(self.abortTransactions) - innerTopLayout.addLayout(buttons, 5, 0, 1, 2) + innerTopLayout.addLayout(buttons, 6, 0, 1, 2) splitter1 = QSplitter(QtCore.Qt.Vertical) self.textedit = QTextEdit() self.textedit.verticalScrollBar().rangeChanged.connect( @@ -676,14 +698,18 @@ class SpendTab(QWidget): self.updateSchedView() self.startJoin() - def checkDirectSend(self, dtx, destaddr, amount, fee): + def checkDirectSend(self, dtx, destaddr, amount, fee, custom_change_addr): """Give user info to decide whether to accept a direct send; note the callback includes the full prettified transaction, but currently not printing it for space reasons. """ mbinfo = ["Sending " + btc.amount_to_str(amount) + ",", - "to: " + destaddr + ",", - "Fee: " + btc.amount_to_str(fee) + ".", + "to: " + destaddr + ","] + + if custom_change_addr: + mbinfo.append("change to: " + custom_change_addr + ",") + + mbinfo += ["fee: " + btc.amount_to_str(fee) + ".", "Accept?"] reply = JMQtMessageBox(self, '\n'.join([m + '

' for m in mbinfo]), mbtype='question', title="Direct send") @@ -716,11 +742,15 @@ class SpendTab(QWidget): bip78url = self.pjEndpointInput.text() if makercount == 0 and not bip78url: + custom_change = None + if len(self.changeInput.text().strip()) > 0: + custom_change = str(self.changeInput.text().strip()) try: txid = direct_send(mainWindow.wallet_service, amount, mixdepth, destaddr, accept_callback=self.checkDirectSend, info_callback=self.infoDirectSend, - error_callback=self.errorDirectSend) + error_callback=self.errorDirectSend, + custom_change_addr=custom_change) except Exception as e: JMQtMessageBox(self, e.args[0], title="Error", mbtype="warn") return @@ -761,6 +791,7 @@ class SpendTab(QWidget): self.pjEndpointInput.setEnabled(False) self.mixdepthInput.setEnabled(False) self.amountInput.setEnabled(False) + self.changeInput.setEnabled(False) self.startButton.setEnabled(False) d = task.deferLater(reactor, 0.0, send_payjoin, manager, accept_callback=self.checkDirectSend, @@ -826,6 +857,9 @@ class SpendTab(QWidget): check_offers_callback = None destaddrs = self.tumbler_destaddrs if self.tumbler_options else [] + custom_change = None + if len(self.changeInput.text().strip()) > 0: + custom_change = str(self.changeInput.text().strip()) maxcjfee = get_max_cj_fee_values(jm_single().config, None, user_callback=self.getMaxCJFees) log.info("Using maximum coinjoin fee limits per maker of {:.4%}, {} " @@ -838,6 +872,7 @@ class SpendTab(QWidget): self.takerInfo, self.takerFinished], tdestaddrs=destaddrs, + custom_change_address=custom_change, ignored_makers=ignored_makers) if not self.clientfactory: #First run means we need to start: create clientfactory @@ -1117,6 +1152,55 @@ class SpendTab(QWidget): "Amount, in bitcoins, must be provided.", mbtype='warn', title="Error") return False + if len(self.changeInput.text().strip()) != 0: + dest_addr = str(self.addressInput.text().strip()) + change_addr = str(self.changeInput.text().strip()) + makercount = int(self.numCPInput.text()) + try: + amount = btc.amount_to_sat(self.amountInput.text()) + except ValueError as e: + JMQtMessageBox(self, e.args[0], title="Error", mbtype="warn") + return False + valid, errmsg = validate_address(change_addr) + if not valid: + JMQtMessageBox(self, + "Custom change address is invalid: \"%s\"" % errmsg, + mbtype='warn', title="Error") + return False + + if change_addr == dest_addr: + msg = ''.join(["Custom change address cannot be the ", + "same as the recipient address."]) + JMQtMessageBox(self, + msg, + mbtype='warn', title="Error") + return False + if amount == 0: + JMQtMessageBox(self, sweep_custom_change_warning, + mbtype='warn', title="Error") + return False + if makercount > 0: + reply = JMQtMessageBox(self, general_custom_change_warning, + mbtype='question', title="Warning") + if reply == QMessageBox.No: + return False + + change_spk = mainWindow.wallet_service.addr_to_script(change_addr) + engine_recognized = True + try: + change_addr_type = detect_script_type(change_spk) + except EngineError: + engine_recognized = False + wallet_type = mainWindow.wallet_service.TYPE + if (not engine_recognized) or ( + change_addr_type != wallet_type and makercount > 0): + reply = JMQtMessageBox(self, + nonwallet_custom_change_warning, + mbtype='question', + title="Warning") + if reply == QMessageBox.No: + return False + return True class TxHistoryTab(QWidget): diff --git a/scripts/sendpayment.py b/scripts/sendpayment.py index b812e2a..c152a90 100755 --- a/scripts/sendpayment.py +++ b/scripts/sendpayment.py @@ -16,7 +16,9 @@ from jmclient import Taker, load_program_config, get_schedule,\ jm_single, estimate_tx_fee, direct_send, WalletService,\ open_test_wallet_maybe, get_wallet_path, NO_ROUNDING, \ get_sendpayment_parser, get_max_cj_fee_values, check_regtest, \ - parse_payjoin_setup, send_payjoin + parse_payjoin_setup, send_payjoin, general_custom_change_warning, \ + nonwallet_custom_change_warning, sweep_custom_change_warning, \ + detect_script_type, EngineError from twisted.python.log import startLogging from jmbase.support import get_log, jmprint, \ EXIT_FAILURE, EXIT_ARGERROR, DUST_THRESHOLD @@ -202,9 +204,46 @@ def main(): log.info("Estimated miner/tx fees for this coinjoin amount: {:.1%}" .format(exp_tx_fees_ratio)) + custom_change = None + if options.customchange != '': + addr_valid, errormsg = validate_address(options.customchange) + if not addr_valid: + parser.error( + "The custom change address provided is not valid\n{}".format( + errormsg)) + sys.exit(EXIT_ARGERROR) + custom_change = options.customchange + if destaddr and custom_change == destaddr: + parser.error("The custom change address cannot be the same as the " + "destination address.") + sys.exit(EXIT_ARGERROR) + if sweeping: + parser.error(sweep_custom_change_warning) + sys.exit(EXIT_ARGERROR) + if bip78url: + parser.error("Custom change is not currently supported " + "with Payjoin. Please retry without a custom change address.") + sys.exit(EXIT_ARGERROR) + if options.makercount > 0: + if not options.answeryes and input( + general_custom_change_warning + " (y/n):")[0] != "y": + sys.exit(EXIT_ARGERROR) + change_spk = wallet_service.addr_to_script(custom_change) + engine_recognized = True + try: + change_addr_type = detect_script_type(change_spk) + except EngineError: + engine_recognized = False + if (not engine_recognized) or ( + change_addr_type != wallet_service.TYPE): + if not options.answeryes and input( + nonwallet_custom_change_warning + " (y/n):")[0] != "y": + sys.exit(EXIT_ARGERROR) + if options.makercount == 0 and not bip78url: tx = direct_send(wallet_service, amount, mixdepth, destaddr, - options.answeryes, with_final_psbt=options.with_psbt) + options.answeryes, with_final_psbt=options.with_psbt, + custom_change_addr=custom_change) if options.with_psbt: log.info("This PSBT is fully signed and can be sent externally for " "broadcasting:") @@ -315,7 +354,8 @@ def main(): schedule, order_chooser=chooseOrdersFunc, max_cj_fee=maxcjfee, - callbacks=(filter_orders_callback, None, taker_finished)) + callbacks=(filter_orders_callback, None, taker_finished), + custom_change_address=custom_change) clientfactory = JMClientProtocolFactory(taker) if jm_single().config.get("BLOCKCHAIN", "network") == "regtest":