You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

1052 lines
48 KiB

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):
# Rceiver 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()