from twisted.internet import reactor try: from twisted.internet.ssl import ClientContextFactory except ImportError: pass import json from jmbase import bintohex, jmprint, random_insert from .configure import get_log, jm_single import jmbitcoin as btc from .wallet import PSBTWalletMixin, SegwitLegacyWallet, SegwitWallet from .wallet_service import WalletService from .taker_utils import direct_send from jmclient import (select_one_utxo, process_shutdown, BIP78ClientProtocolFactory) """ For some documentation see: https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki and an earlier document: https://github.com/btcpayserver/btcpayserver-doc/blob/master/Payjoin-spec.md and even earlier: https://github.com/bitcoin/bips/blob/master/bip-0079.mediawiki """ log = get_log() # Recommended sizes for input vsize as per BIP78 # (stored here since BIP78 specific; could be moved to jmbitcoin) INPUT_VSIZE_LEGACY = 148 INPUT_VSIZE_SEGWIT_LEGACY = 91 INPUT_VSIZE_SEGWIT_NATIVE = 68 class JMPayjoinManager(object): """ An encapsulation of state for an ongoing Payjoin payment. Allows reporting details of the outcome of a Payjoin attempt. """ # enum such that progress can be # reported JM_PJ_NONE = 0 JM_PJ_INIT = 1 JM_PJ_PAYMENT_CREATED = 2 JM_PJ_PAYMENT_SENT = 3 JM_PJ_PARTIAL_RECEIVED = 4 JM_PJ_PARTIAL_REJECTED = 5 JM_PJ_PAYJOIN_COSIGNED = 6 JM_PJ_PAYJOIN_BROADCAST = 7 JM_PJ_PAYJOIN_BROADCAST_FAILED = 8 pj_state = JM_PJ_NONE def __init__(self, wallet_service, mixdepth, destination, amount, server=None, disable_output_substitution=False, mode="command-line", user_info_callback=None): assert isinstance(wallet_service, WalletService) # payjoin is not supported for non-segwit wallets: assert isinstance(wallet_service.wallet, (SegwitWallet, SegwitLegacyWallet)) # our payjoin implementation requires PSBT assert isinstance(wallet_service.wallet, PSBTWalletMixin) self.wallet_service = wallet_service # for convenience define wallet type here: if isinstance(self.wallet_service.wallet, SegwitLegacyWallet): self.wallet_type = "sw-legacy" elif isinstance(self.wallet_service.wallet, SegwitWallet): self.wallet_type = "sw" else: assert False # mixdepth from which payment is sourced assert isinstance(mixdepth, int) self.mixdepth = mixdepth assert isinstance(destination, btc.CCoinAddress) self.destination = destination assert isinstance(amount, int) assert amount > 0 self.amount = amount if server is None: self.server = None self.role = "receiver" else: self.role = "sender" self.server = server self.disable_output_substitution = disable_output_substitution self.pj_state = self.JM_PJ_INIT self.payment_tx = None self.initial_psbt = None self.payjoin_psbt = None self.final_psbt = None # change is initialized as None # in case there is no change: self.change_out = None self.change_out_index = None # payment mode is "command-line" for one-shot # processing, shutting down on completion. self.mode = mode # fix the sequence number if the sender uses only one # (otherwise the receiver is free to do anything): self.fixed_sequence_number = None # to be able to cancel the timeout fallback broadcast # in case of success: self.timeout_fallback_dc = None # set callback for conveying info to user (takes one string arg): if not user_info_callback: self.user_info_callback = self.default_user_info_callback else: self.user_info_callback = user_info_callback def default_user_info_callback(self, msg): """ Info level message print to command line. """ jmprint(msg) def set_payment_tx_and_psbt(self, in_psbt): assert isinstance(in_psbt, btc.PartiallySignedTransaction), "invalid PSBT input to JMPayjoinManager." self.initial_psbt = in_psbt success, msg = self.sanity_check_initial_payment() if not success: log.error(msg) assert False, msg self.pj_state = self.JM_PJ_PAYMENT_CREATED def get_payment_psbt_feerate(self): return self.initial_psbt.get_fee()/float( self.initial_psbt.extract_transaction().get_virtual_size()) def get_vsize_for_input(self): if isinstance(self.wallet_service.wallet, SegwitLegacyWallet): vsize = INPUT_VSIZE_SEGWIT_LEGACY elif isinstance(self.wallet_service.wallet, SegwitWallet): vsize = INPUT_VSIZE_SEGWIT_NATIVE else: raise Exception("Payjoin only supported for segwit wallets") return vsize def sanity_check_initial_payment(self): """ These checks are those specified for the *receiver* in BIP78. However, for the sender, we want to make sure our payment isn't rejected. So this is not receiver-only. We also sanity check that the payment details match the initialization of this Manager object. Returns: (False, reason) or (True, None) """ # failure to extract tx should throw an error; # this PSBT must be finalized and sane. self.payment_tx = self.initial_psbt.extract_transaction() # inputs must all have witness utxo populated for inp in self.initial_psbt.inputs: if not isinstance(inp.witness_utxo, btc.CTxOut): return (False, "Input utxo was not witness type.") # see third bullet point of: # https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki#receivers-original-psbt-checklist # # Check that all inputs have same scriptPubKey type, # and that it is the same as our wallet (for sender # code in JM this is a no-op, for receiver, we can # only support payjoins fitting our wallet type, since # we do not use multi-wallet or output substitution: input_type = self.wallet_service.check_finalized_input_type(inp) if input_type != self.wallet_type: return (False, "an input was not of the same script type.") # check that there is no xpub or derivation info if self.initial_psbt.xpubs: return (False, "Unexpected xpubs found in PSBT.") for inp in self.initial_psbt.inputs: # derivation_map is an OrderedDict, if empty # it will be counted as false: if inp.derivation_map: return (False, "Unexpected derivation found in PSBT.") for out in self.initial_psbt.outputs: if out.derivation_map: return (False, "Unexpected derivation found in PSBT.") # our logic requires no more than one change output # for now: found_payment = 0 assert len(self.payment_tx.vout) in [1, 2] for i, out in enumerate(self.payment_tx.vout): if out.nValue == self.amount and \ btc.CCoinAddress.from_scriptPubKey( out.scriptPubKey) == self.destination: found_payment += 1 self.pay_out = out self.pay_out_index = i else: # store this for our balance check # for receiver proposal self.change_out = out self.change_out_index = i if not found_payment == 1: return (False, "The payment output was not found.") # if the sequence numbers chosen are uniform, record this: seqnums = [x.nSequence for x in self.payment_tx.vin] if seqnums.count(seqnums[0]) == len(seqnums): self.fixed_sequence_number = seqnums[0] return (True, None) def check_receiver_proposal(self, in_psbt, signed_psbt_for_fees): """ This is the most security critical part of the business logic of the payjoin. We must check in detail that what the server proposes does not unfairly take money from us, and also conforms to acceptable structure. See https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki#senders-payjoin-proposal-checklist We perform the following checks of the receiver proposal: 1. Does it contain our inputs, unchanged? 2. if output substitution was disabled: check that the payment output (same scriptPubKey) has amount equal to or greater than original tx. if output substition is not disabled: no check here (all of index, sPK and amount may be altered) 3. Are the other inputs (if they exist) finalized, and of the correct type? 4. Is the absolute fee >= fee of original tx? 5. Check that the feerate of the transaction is not less than our minfeerate (after signing - we have the signed version here). 6. If we have a change output, check that: - the change output still exists, exactly once - amount subtracted from self.change_out is less than or equal to maxadditionalfeecontribution. - Check that the MAFC is only going to fee: check difference between new fee and old fee is >= MAFC We do not need to further check against number of new inputs, since we already insisted on only paying for one. 7. Does it contain no xpub information or derivation information? 8. Are the sequence numbers unchanged (and all the same) for the inputs? 9. Is the nLockTime and version unchanged? If all the above checks pass we will consider this valid, and cosign. Returns: (False, "reason for failure") (True, None) """ assert isinstance(in_psbt, btc.PartiallySignedTransaction) orig_psbt = self.initial_psbt assert isinstance(orig_psbt, btc.PartiallySignedTransaction) # 1 ourins = [(i.prevout.hash, i.prevout.n) for i in orig_psbt.unsigned_tx.vin] found = [0] * len(ourins) receiver_input_indices = [] for i, inp in enumerate(in_psbt.unsigned_tx.vin): for j, inp2 in enumerate(ourins): if (inp.prevout.hash, inp.prevout.n) == inp2: found[j] += 1 break else: receiver_input_indices.append(i) assert len(receiver_input_indices) + len(ourins) == len(in_psbt.unsigned_tx.vin) if any([f != 1 for f in found]): return (False, "Receiver proposed PSBT does not contain our inputs.") # 2 if self.disable_output_substitution: found_payment = 0 for out in in_psbt.unsigned_tx.vout: if btc.CCoinAddress.from_scriptPubKey(out.scriptPubKey) == \ self.destination and out.nValue >= self.amount: found_payment += 1 if found_payment != 1: return (False, "Our payment output not found exactly once or " "with wrong amount.") # 3 for ind in receiver_input_indices: # check the input is finalized if not self.wallet_service.is_input_finalized(in_psbt.inputs[ind]): return (False, "receiver input is not finalized.") # check the utxo field of the input and see if the # scriptPubKey is of the right type. # TODO this can be genericized to arbitrary wallets in future. input_type = self.wallet_service.check_finalized_input_type( in_psbt.inputs[ind]) if input_type != self.wallet_type: return (False, "receiver input does not match our script type.") # 4, 5 # To get the feerate of the psbt proposed, we use the already-signed # version (so all witnesses filled in) to calculate its size, # then compare that with the fee, and do the same for the # pre-existing non-payjoin. try: proposed_tx_fee = signed_psbt_for_fees.get_fee() except ValueError: return (False, "receiver proposed tx has negative fee.") nonpayjoin_tx_fee = self.initial_psbt.get_fee() if proposed_tx_fee < nonpayjoin_tx_fee: return (False, "receiver proposed transaction has lower fee.") proposed_tx_size = signed_psbt_for_fees.extract_transaction( ).get_virtual_size() proposed_fee_rate = proposed_tx_fee / float(proposed_tx_size) log.debug("proposed fee rate: " + str(proposed_fee_rate)) if proposed_fee_rate < float( jm_single().config.get("PAYJOIN", "min_fee_rate")): return (False, "receiver proposed transaction has too low " "feerate: " + str(proposed_fee_rate)) # 6 if self.change_out: found_change = 0 for out in in_psbt.unsigned_tx.vout: if out.scriptPubKey == self.change_out.scriptPubKey: found_change += 1 actual_contribution = self.change_out.nValue - out.nValue if actual_contribution > in_psbt.get_fee( ) - self.initial_psbt.get_fee(): return (False, "Our change output is reduced more" " than the fee is bumped.") mafc = get_max_additional_fee_contribution(self) if actual_contribution > mafc: return (False, "Proposed transactions requires " "us to pay more additional fee that we " "agreed to: " + str(mafc) + " sats.") # note this check is only if the initial tx had change: if found_change != 1: return (False, "Our change output was not found " "exactly once.") # 7 if in_psbt.xpubs: return (False, "Receiver proposal contains xpub information.") # 8 seqno = self.initial_psbt.unsigned_tx.vin[0].nSequence for inp in in_psbt.unsigned_tx.vin: if inp.nSequence != seqno: return (False, "all sequence numbers are not the same.") # 9 if in_psbt.unsigned_tx.nLockTime != \ self.initial_psbt.unsigned_tx.nLockTime: return (False, "receiver proposal has altered nLockTime.") if in_psbt.unsigned_tx.nVersion != \ self.initial_psbt.unsigned_tx.nVersion: return (False, "receiver proposal has altered nVersion.") # all checks passed return (True, None) def set_payjoin_psbt(self, in_psbt, signed_psbt_for_fees): """ This is the PSBT as initially proposed by the receiver, so we keep a copy of it in that state. This must be a copy as the sig_psbt function will update the mutable psbt it is given. This must not be called until the psbt has passed all sanity and validation checks. """ assert isinstance(in_psbt, btc.PartiallySignedTransaction) assert isinstance(signed_psbt_for_fees, btc.PartiallySignedTransaction) success, msg = self.check_receiver_proposal(in_psbt, signed_psbt_for_fees) if not success: return (success, msg) self.payjoin_psbt = in_psbt self.pj_state = self.JM_PJ_PARTIAL_RECEIVED return (True, None) def set_final_payjoin_psbt(self, in_psbt): """ This is the PSBT after we have co-signed it. If it is in a sane state, we update our state. """ assert isinstance(in_psbt, btc.PartiallySignedTransaction) # note that this is the simplest way to check # for finality and validity of PSBT: assert in_psbt.extract_transaction() self.final_psbt = in_psbt self.pj_state = self.JM_PJ_PAYJOIN_COSIGNED def set_broadcast(self, success): if success: self.pj_state = self.JM_PJ_PAYJOIN_BROADCAST else: self.pj_state = self.JM_PJ_PAYJOIN_BROADCAST_FAILED def select_receiver_utxos(self): # Receiver chooses own inputs: # For earlier ideas about more complex algorithms, see the gist comment here: # https://gist.github.com/AdamISZ/4551b947789d3216bacfcb7af25e029e#gistcomment-2799709 # and also see the code in P2EPMaker in earlier versions of Joinmarket. # # For now, it is considered too complex to accurately judge the implications # of the UIH1/2 heuristic violations, in particular because selecting more than # one input has impact on fees which is undesirable and tricky to deal with. # So here we ONLY choose one utxo at random. # Returns: # list of utxos (currently always of length 1) # or # False if coins cannot be selected self.user_info_callback("Choosing one coin at random") try: my_utxos = self.wallet_service.select_utxos( self.mixdepth, jm_single().DUST_THRESHOLD, select_fn=select_one_utxo, minconfs=1) except Exception as e: log.error("Failed to select coins, exception: " + repr(e)) return False my_total_in = sum([va['value'] for va in my_utxos.values()]) self.user_info_callback("We selected inputs worth: " + str(my_total_in)) return my_utxos def report(self, jsonified=False, verbose=False): """ Returns a dict (optionally jsonified) containing the following information (if they are available): * current status of Payjoin * payment transaction (original, non payjoin) * payjoin partial (PSBT) sent by receiver * final payjoin transaction * whether or not the payjoin transaction is broadcast and/or confirmed. If verbose is True, we include the full deserialization of transactions and PSBTs, which is too verbose for GUI display. """ reportdict = {"name:", "PAYJOIN STATUS REPORT"} reportdict["status"] = self.pj_state # TODO: string if self.payment_tx: txdata = btc.human_readable_transaction(self.payment_tx) if verbose: txdata = txdata["hex"] reportdict["payment-tx"] = txdata if self.payjoin_psbt: psbtdata = PSBTWalletMixin.human_readable_psbt( self.payjoin_psbt) if verbose else self.payjoin_psbt.to_base64() reportdict["payjoin-proposed"] = psbtdata if self.final_psbt: finaldata = PSBTWalletMixin.human_readable_psbt( self.final_psbt) if verbose else self.final_psbt.to_base64() reportdict["payjoin-final"] = finaldata if jsonified: return json.dumps(reportdict, indent=4) else: return reportdict def parse_payjoin_setup(bip21_uri, wallet_service, mixdepth, mode="command-line"): """ Takes the payment request data from the uri and returns a JMPayjoinManager object initialised for that payment. """ assert btc.is_bip21_uri(bip21_uri), "invalid bip21 uri: " + bip21_uri decoded = btc.decode_bip21_uri(bip21_uri) assert "amount" in decoded assert "address" in decoded assert "pj" in decoded amount = decoded["amount"] destaddr = decoded["address"] # this will throw for any invalid address: destaddr = btc.CCoinAddress(destaddr) server = decoded["pj"] disable_output_substitution = False if "pjos" in decoded and decoded["pjos"] == "0": disable_output_substitution = True return JMPayjoinManager(wallet_service, mixdepth, destaddr, amount, server=server, disable_output_substitution=disable_output_substitution, mode=mode) def get_max_additional_fee_contribution(manager): """ See definition of maxadditionalfeecontribution in BIP 78. """ max_additional_fee_contribution = jm_single( ).config.get("PAYJOIN", "max_additional_fee_contribution") if max_additional_fee_contribution == "default": vsize = manager.get_vsize_for_input() original_fee_rate = manager.get_payment_psbt_feerate() log.debug("Initial nonpayjoin transaction feerate is: " + str(original_fee_rate)) # Factor slightly higher than 1 is to allow some breathing room for # receiver. NB: This may not be appropriate for sender wallets that # use rounded fee rates, but Joinmarket does not. max_additional_fee_contribution = int(original_fee_rate * 1.2 * vsize) log.debug("From which we calculated a max additional fee " "contribution of: " + str(max_additional_fee_contribution)) return max_additional_fee_contribution def make_payment_psbt(manager, accept_callback=None, info_callback=None): """ Creates a valid payment transaction and PSBT for it, and adds it to the JMPayjoinManager instance passed as argument. Wallet should already be synced before calling here. Returns True, None if successful or False, errormsg if not. """ # we can create a standard payment, but have it returned as a PSBT. assert isinstance(manager, JMPayjoinManager) assert manager.wallet_service.synced payment_psbt = direct_send(manager.wallet_service, manager.amount, manager.mixdepth, str(manager.destination), accept_callback=accept_callback, info_callback=info_callback, with_final_psbt=True, optin_rbf=True) if not payment_psbt: return (False, "could not create non-payjoin payment") manager.set_payment_tx_and_psbt(payment_psbt) return (True, None) def make_payjoin_request_params(manager): """ Returns the query parameters for the request to the payjoin receiver, based on the configuration of the given JMPayjoinManager instance. """ # construct the URI from the given parameters pj_version = jm_single().config.getint("PAYJOIN", "payjoin_version") params = {"v": pj_version} disable_output_substitution = "false" if manager.disable_output_substitution: disable_output_substitution = "true" else: if jm_single().config.getint("PAYJOIN", "disable_output_substitution") == 1: disable_output_substitution = "true" params["disableoutputsubstitution"] = disable_output_substitution # to determine the additionalfeeoutputindex in cases where we have # change and we are allowing fee bump, we examine the initial tx: if manager.change_out: params["additionalfeeoutputindex"] = manager.change_out_index params["maxadditionalfeecontribution"] = \ get_max_additional_fee_contribution(manager) min_fee_rate = float(jm_single().config.get("PAYJOIN", "min_fee_rate")) params["minfeerate"] = min_fee_rate return params def send_payjoin(manager, accept_callback=None, info_callback=None, return_deferred=False): """ Given a JMPayjoinManager object `manager`, initialised with the payment request data from the server, use its wallet_service to construct a payment transaction, with coins sourced from mixdepth `manager.mixdepth`, then wait for the server response, parse the PSBT, perform checks and complete sign. The info and accept callbacks are to ask the user to confirm the creation of the original payment transaction (None defaults to terminal/CLI processing), and are as defined in `taker_utils.direct_send`. Returns: (True, None) in case of payment setup successful (response will be delivered asynchronously) - the `manager` object can be inspected for more detail. (False, errormsg) in case of failure. """ success, errmsg = make_payment_psbt(manager, accept_callback, info_callback) if not success: return (False, errmsg) # add delayed call to broadcast this after 1 minute manager.timeout_fallback_dc = reactor.callLater(60, fallback_nonpayjoin_broadcast, b"timeout", manager) params = make_payjoin_request_params(manager) factory = BIP78ClientProtocolFactory(manager, params, process_payjoin_proposal_from_server, process_error_from_server) h = jm_single().config.get("DAEMON", "daemon_host") p = jm_single().config.getint("DAEMON", "daemon_port")-2000 if jm_single().config.get("DAEMON", "use_ssl") != 'false': reactor.connectSSL(h, p, factory, ClientContextFactory()) else: reactor.connectTCP(h, p, factory) return (True, None) def fallback_nonpayjoin_broadcast(err, manager): """ Sends the non-coinjoin payment onto the network, assuming that the payjoin failed. The reason for failure is `err` and will usually be communicated by the server, and must be a bytestring. Note that the reactor is shutdown after sending the payment (one-shot processing) if this is called on the command line. """ assert isinstance(manager, JMPayjoinManager) def quit(): if manager.mode == "command-line" and reactor.running: process_shutdown() log.warn("Payjoin did not succeed, falling back to non-payjoin payment.") log.warn("Error message was: " + err.decode("utf-8")) original_tx = manager.initial_psbt.extract_transaction() if not jm_single().bc_interface.pushtx(original_tx.serialize()): errormsg = ("Unable to broadcast original payment. Check your wallet\n" "to see whether original payment was made.") log.error(errormsg) # ensure any GUI as well as command line sees the message: manager.user_info_callback(errormsg) quit() return log.info("Payment made without coinjoin. Transaction: ") log.info(btc.human_readable_transaction(original_tx)) manager.set_broadcast(False) if manager.timeout_fallback_dc and manager.timeout_fallback_dc.active(): manager.timeout_fallback_dc.cancel() quit() def process_error_from_server(errormsg, errorcode, manager): assert isinstance(manager, JMPayjoinManager) # payjoin attempt has failed, we revert to standard payment. assert int(errorcode) != 200 log.warn("Receiver returned error code: {}, message: {}".format( errorcode, errormsg)) fallback_nonpayjoin_broadcast(errormsg.encode("utf-8"), manager) return def process_payjoin_proposal_from_server(response_body, manager): assert isinstance(manager, JMPayjoinManager) try: payjoin_proposal_psbt = \ btc.PartiallySignedTransaction.from_base64(response_body) except Exception as e: log.error("Payjoin tx from server could not be parsed: " + repr(e)) fallback_nonpayjoin_broadcast(b"Server sent invalid psbt", manager) return log.debug("Receiver sent us this PSBT: ") log.debug(manager.wallet_service.human_readable_psbt(payjoin_proposal_psbt)) # we need to add back in our utxo information to the received psbt, # since the servers remove it (not sure why?) for i, inp in enumerate(payjoin_proposal_psbt.unsigned_tx.vin): for j, inp2 in enumerate(manager.initial_psbt.unsigned_tx.vin): if (inp.prevout.hash, inp.prevout.n) == ( inp2.prevout.hash, inp2.prevout.n): payjoin_proposal_psbt.set_utxo( manager.initial_psbt.inputs[j].utxo, i, force_witness_utxo=True) signresultandpsbt, err = manager.wallet_service.sign_psbt( payjoin_proposal_psbt.serialize(), with_sign_result=True) if err: log.error("Failed to sign PSBT from the receiver, error: " + err) fallback_nonpayjoin_broadcast(manager, err=b"Failed to sign receiver PSBT") return signresult, sender_signed_psbt = signresultandpsbt assert signresult.is_final success, msg = manager.set_payjoin_psbt(payjoin_proposal_psbt, sender_signed_psbt) if not success: log.error(msg) fallback_nonpayjoin_broadcast(manager, err=b"Receiver PSBT checks failed.") return # All checks have passed. We can use the already signed transaction in # sender_signed_psbt. log.info("Our final signed PSBT is:\n{}".format( manager.wallet_service.human_readable_psbt(sender_signed_psbt))) manager.set_final_payjoin_psbt(sender_signed_psbt) # broadcast the tx extracted_tx = sender_signed_psbt.extract_transaction() log.info("Here is the final payjoin transaction:") log.info(btc.human_readable_transaction(extracted_tx)) if not jm_single().bc_interface.pushtx(extracted_tx.serialize()): log.info("The above transaction failed to broadcast.") else: log.info("Payjoin transaction broadcast successfully.") # if transaction is succesfully broadcast, remove the # timeout fallback to avoid confusing error messages: if manager.timeout_fallback_dc and manager.timeout_fallback_dc.active(): manager.timeout_fallback_dc.cancel() manager.set_broadcast(True) if manager.mode == "command-line" and reactor.running: process_shutdown() """ Receiver-specific code """ class PayjoinConverter(object): """ This class is used to encapsulate the objects and operations needed to convert a given payment psbt from a sender, to a payjoin psbt proposal. """ def __init__(self, manager, shutdown_callback, info_callback, pj_version = 1): assert isinstance(manager, JMPayjoinManager) self.manager = manager self.pj_version = pj_version self.wallet_service = manager.wallet_service # a callback with no arguments and no return value, # to take whatever actions are needed when the payment has # been received: self.shutdown_callback = shutdown_callback self.info_callback = info_callback super().__init__() def request_to_psbt(self, payment_psbt_base64, sender_parameters): """ Takes a payment psbt from a sender and their url parameters, and returns a new payment PSBT proposal, assuming all conditions are met. Returns: (False, errormsg, errortype) in case of failure. or: (True, base64_payjoin_psbt) in case of success. """ # we only support version 1; reject others: if not self.pj_version == int(sender_parameters[b'v'][0]): return (False, "This version of payjoin is not supported. ", "version-unsupported") try: payment_psbt = btc.PartiallySignedTransaction.from_base64( payment_psbt_base64) except: return (False, "invalid psbt format", "original-psbt-rejected") try: self.manager.set_payment_tx_and_psbt(payment_psbt) except Exception: # note that Assert errors, Value errors and CheckTransaction errors # are all possible, so we catch all exceptions to avoid a crash. return (False, "Proposed initial PSBT does not pass sanity checks.", "original-psbt-rejected") # if the sender set the additionalfeeoutputindex and maxadditionalfeecontribution # settings, pass them to the PayJoin manager: try: if b"additionalfeeoutputindex" in sender_parameters: afoi = int(sender_parameters[b"additionalfeeoutputindex"][0]) else: afoi = None if b"maxadditionalfeecontribution" in sender_parameters: mafc = int(sender_parameters[b"maxadditionalfeecontribution"][0]) else: mafc = None if b"minfeerate" in sender_parameters: minfeerate = float(sender_parameters[b"minfeerate"][0]) else: minfeerate = None except Exception as e: return (False, "Invalid request parameters.", "original-psbt-rejected") # if sender chose a fee output it must be the change output, # and the mafc will be applied to that. Any more complex transaction # structure is not supported. # If they did not choose a fee output index, we must rely on the feerate # reduction being not too much, which is checked against minfeerate; if # it is too big a reduction, again we fail payjoin. if (afoi is not None and mafc is None) or (mafc is not None and afoi is None): return (False, "Invalid request parameters.", "original-psbt-rejected") if afoi is not None and not (self.manager.change_out_index == afoi): return (False, "additionalfeeoutputindex is " "not the change output. Joinmarket does " "not currently support this.", "original-psbt-rejected") # while we do not need to defend against probing attacks, # it is still safer to at least verify the validity of the signatures # at this stage, to ensure no misbehaviour with using inputs # that are not signed correctly: res = jm_single().bc_interface.testmempoolaccept(bintohex( self.manager.payment_tx.serialize())) if not res[0]["allowed"]: return (False, "Proposed transaction was " "rejected from mempool.", "original-psbt-rejected") # Now that the PSBT is accepted, we schedule fallback in case anything # fails later on in negotiation (as specified in BIP78): self.manager.timeout_fallback_dc = reactor.callLater(60, fallback_nonpayjoin_broadcast, b"timeout", self.manager) receiver_utxos = self.manager.select_receiver_utxos() if not receiver_utxos: return (False, "Could not select coins for payjoin", "unavailable") # construct unsigned tx for payjoin-psbt: payjoin_tx_inputs = [(x.prevout.hash[::-1], x.prevout.n) for x in payment_psbt.unsigned_tx.vin] # See https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki#Protocol random_insert(payjoin_tx_inputs, receiver_utxos.keys()) pay_out = {"value": self.manager.pay_out.nValue, "address": str(btc.CCoinAddress.from_scriptPubKey( self.manager.pay_out.scriptPubKey))} if self.manager.change_out: change_out = {"value": self.manager.change_out.nValue, "address": str(btc.CCoinAddress.from_scriptPubKey( self.manager.change_out.scriptPubKey))} # we now know there were one/two outputs and know which is payment. # set the ordering of the outputs correctly. if change_out: # indices of original payment were set in JMPayjoinManager # sanity check: if self.manager.change_out_index == 0 and \ self.manager.pay_out_index == 1: outs = [change_out, pay_out] elif self.manager.change_out_index == 1 and \ self.manager.pay_out_index == 0: outs = [pay_out, change_out] else: assert False, "More than 2 outputs is not supported." else: outs = [pay_out] # bump payment output with our input: our_inputs_val = sum([v["value"] for _, v in receiver_utxos.items()]) pay_out["value"] += our_inputs_val log.debug("We bumped the payment output value by: " + str( our_inputs_val) + " sats.") log.debug("It is now: " + str(pay_out["value"]) + " sats.") # if the sender allowed a fee bump, we can apply it to the change output # now (we already checked it's the right index). # A note about checking `minfeerate`: it is impossible for the receiver # to be 100% certain on the size of the final transaction, since he does # not see in advance the (slightly) variable sizes of the sender's final # signatures; hence we do not attempt more than an estimate of the final # signed transaction's size and hence feerate. Very small inaccuracies # (< 1% typically) are possible, therefore. # # First, let's check that the user's requested minfeerate is not higher # than the feerate they already chose: if minfeerate and minfeerate > self.manager.get_payment_psbt_feerate(): return (False, "Bad request: minfeerate " "bigger than original psbt feerate.", "original-psbt-rejected") # set the intended virtual size of our input: vsize = self.manager.get_vsize_for_input() our_fee_bump = 0 if afoi is not None: # We plan to reduce the change_out by a fee contribution. # Calculate the additional fee we think we need for our input, # to keep the same feerate as the original transaction (this also # accounts for rounding as per the BIP). # If it is more than mafc, then bump by mafc, else bump by the # calculated amount. # This should not meaningfully change the feerate. our_fee_bump = int( self.manager.get_payment_psbt_feerate() * vsize) if our_fee_bump > mafc: our_fee_bump = mafc elif minfeerate: # In this case the change_out will remain unchanged. # the user has not allowed a fee bump; calculate the new fee # rate; if it is lower than the limit, give up. expected_new_tx_size = self.manager.initial_psbt.extract_transaction( ).get_virtual_size() + vsize expected_new_fee_rate = self.manager.initial_psbt.get_fee()/( expected_new_tx_size + vsize) if expected_new_fee_rate < minfeerate: return (False, "Bad request: we cannot " "achieve minfeerate requested.", "original-psbt-rejected") # Having checked the sender's conditions, we can apply the fee bump # intended: outs[self.manager.change_out_index]["value"] -= our_fee_bump unsigned_payjoin_tx = btc.mktx(payjoin_tx_inputs, outs, version=payment_psbt.unsigned_tx.nVersion, locktime=payment_psbt.unsigned_tx.nLockTime) # to create the PSBT we need the spent_outs for each input, # in the right order: spent_outs = [] for i, inp in enumerate(unsigned_payjoin_tx.vin): input_found = False for j, inp2 in enumerate(payment_psbt.unsigned_tx.vin): if inp.prevout == inp2.prevout: # this belongs to sender. # respect sender's sequence number choice, even # if they were not uniform: inp.nSequence = inp2.nSequence spent_outs.append(payment_psbt.inputs[j].utxo) input_found = True sender_index = i break if input_found: continue # if we got here this input is ours, we must find # it from our original utxo choice list: for ru in receiver_utxos.keys(): if (inp.prevout.hash[::-1], inp.prevout.n) == ru: spent_outs.append( self.wallet_service.witness_utxos_to_psbt_utxos( {ru: receiver_utxos[ru]})[0]) input_found = True break # there should be no other inputs: assert input_found # respect the sender's fixed sequence number, if it was used (we checked # in the initial sanity check) if self.manager.fixed_sequence_number: for inp in unsigned_payjoin_tx.vin: inp.nSequence = self.manager.fixed_sequence_number log.debug("We created this unsigned tx: ") log.debug(btc.human_readable_transaction(unsigned_payjoin_tx)) r_payjoin_psbt = self.wallet_service.create_psbt_from_tx(unsigned_payjoin_tx, spent_outs=spent_outs) log.debug("Receiver created payjoin PSBT:\n{}".format( self.wallet_service.human_readable_psbt(r_payjoin_psbt))) signresultandpsbt, err = self.wallet_service.sign_psbt(r_payjoin_psbt.serialize(), with_sign_result=True) assert not err, err signresult, receiver_signed_psbt = signresultandpsbt assert signresult.num_inputs_final == len(receiver_utxos) assert not signresult.is_final # with signing successful, remove the utxo field from the # counterparty's input (this is required by BIP78). Note we don't # do this on PSBT creation as the psbt signing code throws ValueError # unless utxos are present. receiver_signed_psbt.inputs[sender_index] = btc.PSBT_Input(index=sender_index) log.debug("Receiver signing successful. Payjoin PSBT is now:\n{}".format( self.wallet_service.human_readable_psbt(receiver_signed_psbt))) # construct txoutset for the wallet service callback; we cannot use # txid as we don't have all signatures (TODO: ? but segwit only? even so, # works anyway). txinfo = tuple(( x.scriptPubKey, x.nValue) for x in receiver_signed_psbt.unsigned_tx.vout) self.wallet_service.register_callbacks([self.end_receipt], txinfo =txinfo, cb_type="unconfirmed") return (True, receiver_signed_psbt.to_base64(), None) def end_receipt(self, txd, txid): if self.manager.mode == "gui": self.info_callback("Transaction seen on network, " "view wallet tab for update.:FINAL") else: self.info_callback("Transaction seen on network: " + txid) # in some cases (GUI) a notification of HS end is needed: self.shutdown_callback() # informs the wallet service transaction monitor # that the transaction has been processed: return True class JMBIP78ReceiverManager(object): """ A class to encapsulate receiver construction """ def __init__(self, wallet_service, mixdepth, amount, port, info_callback=None, uri_created_callback=None, shutdown_callback=None, mode="command-line"): assert isinstance(wallet_service, WalletService) assert isinstance(mixdepth, int) assert isinstance(amount, int) assert isinstance(port, int) assert amount > 0 assert port in range(65535) self.wallet_service = wallet_service self.mixdepth = mixdepth self.amount = amount self.port = port # info_callback has signature (str) and returns None if info_callback is None: self.info_callback = self.default_info_callback else: self.info_callback = info_callback # uri_created_callback is specifically to signal the # created BIP21 uri for transfer to sender; made distinct # from information messages in case it needs to be # handled differently, but defaults to info_callback. if uri_created_callback is None: self.uri_created_callback = self.info_callback else: self.uri_created_callback = uri_created_callback # This callback is used by GUI as a signal that it can # signal the user that the dialog is close-able: self.shutdown_callback = shutdown_callback self.receiving_address = None self.mode = mode self.get_receiving_address() self.manager = JMPayjoinManager(wallet_service, mixdepth, self.receiving_address, amount, mode=mode, user_info_callback=self.info_callback) def initiate(self): """ Called at reactor start to start up hidden service and provide uri string to sender. """ # Note that we don't pass a "failure_callback" to the BIP78 # Protocol; because the only failure is that the payment # HTTP request simply doesn't arrive. Note also that the # "params" argument is None as this is only learnt from request. factory = BIP78ClientProtocolFactory(self, None, self.receive_proposal_from_sender, None, mode="receiver") h = jm_single().config.get("DAEMON", "daemon_host") p = jm_single().config.getint("DAEMON", "daemon_port")-2000 if jm_single().config.get("DAEMON", "use_ssl") != 'false': reactor.connectSSL(h, p, factory, ClientContextFactory()) else: reactor.connectTCP(h, p, factory) def default_info_callback(self, msg): jmprint(msg) def get_receiving_address(self): # the receiving address is sourced from the 'next' mixdepth # to avoid clustering of input and output: next_mixdepth = (self.mixdepth + 1) % ( self.wallet_service.wallet.mixdepth + 1) self.receiving_address = btc.CCoinAddress( self.wallet_service.get_internal_addr(next_mixdepth)) def receive_proposal_from_sender(self, body, params): """ Accepts the contents of the HTTP request from the sender and returns a payjoin proposal, or an error. """ self.pj_converter = PayjoinConverter(self.manager, self.shutdown, self.info_callback) success, a, b = self.pj_converter.request_to_psbt(body, params) if not success: return (False, a, b) else: return (True, a) def bip21_uri_from_onion_hostname(self, host): """ Encoding the BIP21 URI according to BIP78 specifications, and specifically only supporting a hidden service endpoint. Note: we hardcode http; no support for TLS over HS. Second, note we convert the amount-in-sats self.amount to BTC denomination as expected by BIP21. """ port_str = ":" + str(self.port) if self.port != 80 else "" full_pj_string = "http://" + host + port_str bip78_btc_amount = btc.amount_to_btc(btc.amount_to_sat(self.amount)) # "safe" option is required to encode url in url unmolested: bip21_uri = btc.encode_bip21_uri(str(self.receiving_address), {"amount": bip78_btc_amount, "pj": full_pj_string.encode("utf-8")}, safe=":/") self.info_callback("Your hidden service is available. Please\n" "now pass this URI string to the sender to\n" "effect the payjoin payment:") self.uri_created_callback(bip21_uri) if self.mode == "command-line": self.info_callback("Keep this process running until the payment " "is received.") def shutdown(self): """ Triggered when processing has completed successfully or failed, receiver side. """ process_shutdown(self.mode) # on receiver side, if we are part of a long running # process (meaning above process_shutdown is a no-op), # we need to abandon the delayed call (this is the normal # success case): tfdc = self.manager.timeout_fallback_dc if tfdc and tfdc.active(): tfdc.cancel() self.info_callback("Hidden service shutdown complete") if self.shutdown_callback: self.shutdown_callback()