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":