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.
 
 
 
 

1204 lines
54 KiB

from zope.interface import implementer
from twisted.internet import reactor, task
from twisted.web.http import UNAUTHORIZED, BAD_REQUEST, NOT_FOUND
from twisted.web.client import (Agent, readBody, ResponseFailed,
BrowserLikePolicyForHTTPS)
from twisted.web.server import Site
from twisted.web.resource import Resource, ErrorPage
from twisted.web.iweb import IPolicyForHTTPS
from twisted.internet.ssl import CertificateOptions
from twisted.internet.error import ConnectionRefusedError, ConnectionLost
from twisted.internet.endpoints import TCP4ClientEndpoint, UNIXClientEndpoint
from twisted.web.http_headers import Headers
import txtorcon
from txtorcon.web import tor_agent
from txtorcon.socks import HostUnreachableError
import urllib.parse as urlparse
from urllib.parse import urlencode
import json
import random
from io import BytesIO
from pprint import pformat
from jmbase import BytesProducer, bintohex, jmprint
from .configure import get_log, jm_single
import jmbitcoin as btc
from .wallet import PSBTWalletMixin, SegwitLegacyWallet, SegwitWallet, estimate_tx_fee
from .wallet_service import WalletService
from .taker_utils import direct_send
from jmclient import RegtestBitcoinCoreInterface, select_one_utxo, process_shutdown
"""
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
# txtorcon outputs erroneous warnings about hiddenservice directory strings,
# annoyingly, so we suppress it here:
import warnings
warnings.filterwarnings("ignore")
""" This whitelister allows us to accept any cert for a specific
domain, and is to be used for testing only; the default Agent
behaviour of twisted.web.client.Agent for https URIs is
the correct one in production (i.e. uses local trust store).
"""
@implementer(IPolicyForHTTPS)
class WhitelistContextFactory(object):
def __init__(self, good_domains=None):
"""
:param good_domains: List of domains. The URLs must be in bytes
"""
if not good_domains:
self.good_domains = []
else:
self.good_domains = good_domains
# by default, handle requests like a browser would
self.default_policy = BrowserLikePolicyForHTTPS()
def creatorForNetloc(self, hostname, port):
# check if the hostname is in the the whitelist,
# otherwise return the default policy
if hostname in self.good_domains:
return CertificateOptions(verify=False)
return self.default_policy.creatorForNetloc(hostname, port)
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
else:
receiver_input_indices.append(i)
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)
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 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 `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.
"""
# wallet should already be synced before calling here;
# 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")
# TLS whitelist is for regtest testing, it is treated as hostnames for
# which tls certificate verification is ignored.
tls_whitelist = None
if isinstance(jm_single().bc_interface, RegtestBitcoinCoreInterface):
tls_whitelist = [b"127.0.0.1"]
manager.set_payment_tx_and_psbt(payment_psbt)
# add delayed call to broadcast this after 1 minute
manager.timeout_fallback_dc = reactor.callLater(60,
fallback_nonpayjoin_broadcast,
b"timeout", manager)
# Now we send the request to the server, with the encoded
# payment PSBT
# First we create a twisted web Agent object:
# TODO genericize/move out/use library function:
def is_hs_uri(s):
x = urlparse.urlparse(s)
if x.hostname.endswith(".onion"):
return (x.scheme, x.hostname, x.port)
return False
tor_url_data = is_hs_uri(manager.server)
if tor_url_data:
# note the return value is currently unused here
socks5_host = jm_single().config.get("PAYJOIN", "onion_socks5_host")
socks5_port = int(jm_single().config.get("PAYJOIN", "onion_socks5_port"))
# note: SSL not supported at the moment:
torEndpoint = TCP4ClientEndpoint(reactor, socks5_host, socks5_port)
agent = tor_agent(reactor, torEndpoint)
else:
if not tls_whitelist:
agent = Agent(reactor)
else:
agent = Agent(reactor,
contextFactory=WhitelistContextFactory(tls_whitelist))
body = BytesProducer(payment_psbt.to_base64().encode("utf-8"))
#Set the query parameters for the request:
# 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
destination_url = manager.server.encode("utf-8")
url_parts = list(urlparse.urlparse(destination_url))
url_parts[4] = urlencode(params).encode("utf-8")
destination_url = urlparse.urlunparse(url_parts)
# TODO what to use as user agent?
d = agent.request(b"POST", destination_url,
Headers({"User-Agent": ["Twisted Web Client Example"],
"Content-Type": ["text/plain"]}),
bodyProducer=body)
d.addCallback(receive_payjoin_proposal_from_server, manager)
# note that the errback (here "noResponse") is *not* triggered
# by a server rejection (which is accompanied by a non-200
# status code returned), but by failure to communicate.
def noResponse(failure):
failure.trap(ResponseFailed, ConnectionRefusedError,
HostUnreachableError, ConnectionLost)
log.error(failure.value)
fallback_nonpayjoin_broadcast(b"connection failed", manager)
d.addErrback(noResponse)
if return_deferred:
return d
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.active():
manager.timeout_fallback_dc.cancel()
quit()
def receive_payjoin_proposal_from_server(response, manager):
assert isinstance(manager, JMPayjoinManager)
# no attempt at chunking or handling incrementally is needed
# here. The body should be a byte string containing the
# new PSBT, or a jsonified error page.
d = readBody(response)
# if the response code is not 200 OK, we must assume payjoin
# attempt has failed, and revert to standard payment.
if int(response.code) != 200:
log.warn("Receiver returned error code: " + str(response.code))
d.addCallback(fallback_nonpayjoin_broadcast, manager)
return
d.addCallback(process_payjoin_proposal_from_server, manager)
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.active():
manager.timeout_fallback_dc.cancel()
manager.set_broadcast(True)
if manager.mode == "command-line" and reactor.running:
process_shutdown()
""" Receiver-specific code
"""
class PayjoinServer(Resource):
def __init__(self, wallet_service, mixdepth, destination, amount,
shutdown_callback, info_callback , mode="command-line",
pj_version = 1):
self.pj_version = pj_version
self.wallet_service = 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
self.manager = JMPayjoinManager(self.wallet_service, mixdepth,
destination, amount, mode=mode,
user_info_callback=self.info_callback)
super().__init__()
isLeaf = True
def bip78_error(self, request, error_meaning,
error_code="unavailable", http_code=400):
"""
See https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki#receivers-well-known-errors
We return, to the sender, stringified json in the body as per the above.
In case the error code is "original-psbt-rejected", we do not have
any valid payment to broadcast, so we shut down with "not paid".
for other cases, we schedule the fallback for 60s from now.
"""
request.setResponseCode(http_code)
request.setHeader(b"content-type", b"text/html; charset=utf-8")
log.debug("Returning an error: " + str(
error_code) + ": " + str(error_meaning))
if error_code in ["original-psbt-rejected", "version-unsupported"]:
# if there is a negotiation failure in the first step, we cannot
# know whether the sender client sent a valid non-payjoin or not,
# hence the warning below is somewhat ambiguous:
log.warn("Negotiation failure. Payment has not yet been made,"
" check wallet.")
# shutdown now but wait until response is sent.
task.deferLater(reactor, 2.0, self.end_failure)
else:
reactor.callLater(60.0, fallback_nonpayjoin_broadcast,
error_meaning.encode("utf-8"), self.manager)
return json.dumps({"errorCode": error_code,
"message": error_meaning}).encode("utf-8")
def render_GET(self, request):
# can be used e.g. to check if an ephemeral HS is up
# on Tor Browser:
return "<html>Only for testing.</html>".encode("utf-8")
def render_POST(self, request):
""" The sender will use POST to send the initial
payment transaction.
"""
log.debug("The server got this POST request: ")
# unfortunately the twisted Request object is not
# easily serialized:
log.debug(request)
log.debug(request.method)
log.debug(request.uri)
log.debug(request.args)
sender_parameters = request.args
log.debug(request.path)
# defer logging of raw request content:
proposed_tx = request.content
# we only support version 1; reject others:
if not self.pj_version == int(sender_parameters[b'v'][0]):
return self.bip78_error(request,
"This version of payjoin is not supported. ",
"version-unsupported")
if not isinstance(proposed_tx, BytesIO):
return self.bip78_error(request, "invalid psbt format",
"original-psbt-rejected")
payment_psbt_base64 = proposed_tx.read()
log.debug("request content: " + bintohex(payment_psbt_base64))
try:
payment_psbt = btc.PartiallySignedTransaction.from_base64(
payment_psbt_base64)
except:
return self.bip78_error(request,
"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 self.bip78_error(request,
"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 self.bip78_error(request, "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 self.bip78_error(request, "Invalid request parameters.",
"original-psbt-rejected")
if afoi and not (self.manager.change_out_index == afoi):
return self.bip78_error(request, "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.rpc('testmempoolaccept', [[bintohex(
self.manager.payment_tx.serialize())]])
if not res[0]["allowed"]:
return self.bip78_error(request, "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 self.bip78_error(request,
"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]
payjoin_tx_inputs.extend(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 self.bip78_error(request, "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:
# 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 self.bip78_error(request, "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
# TODO this only works for 2 input transactions, otherwise
# reversal [::-1] will not be valid as per BIP78 ordering requirement.
# (For outputs, we do nothing since we aren't batching in other payments).
if random.random() < 0.5:
payjoin_tx_inputs = payjoin_tx_inputs[::-1]
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)
# TODO consider RBF if we implement it in Joinmarket payments.
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 succcessful, 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.
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")
content = receiver_signed_psbt.to_base64()
request.setHeader(b"content-length", ("%d" % len(content)).encode("ascii"))
return content.encode("ascii")
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)
# do end processing of calling object (e.g. Tor disconnect)
self.shutdown_callback()
# informs the wallet service transaction monitor
# that the transaction has been processed:
return True
def end_failure(self):
shutdown_msg = "Shutting down, payjoin negotiation failed."
if self.manager.mode == "gui":
shutdown_msg += "\nCheck wallet tab for payment."
shutdown_msg += ":FINAL"
self.info_callback(shutdown_msg)
self.shutdown_callback()
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 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
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 start_pj_server_and_tor(self):
""" Packages the startup of the receiver side.
"""
self.get_receiving_address()
self.pj_server = PayjoinServer(self.wallet_service, self.mixdepth,
self.receiving_address, self.amount,
self.shutdown, self.info_callback, mode=self.mode)
self.site = Site(self.pj_server)
self.site.displayTracebacks = False
self.info_callback("Attempting to start onion service on port: " + str(
self.port) + " ...")
self.start_tor()
def setup_failed(self, arg):
errmsg = "Setup failed: " + str(arg)
log.error(errmsg)
self.info_callback(errmsg)
process_shutdown()
def create_onion_ep(self, t):
self.tor_connection = t
return t.create_onion_endpoint(self.port)
def onion_listen(self, onion_ep):
return onion_ep.listen(self.site)
def print_host(self, ep):
""" Callback fired once the HS is available;
receiver user needs a BIP21 URI to pass to
the sender:
"""
self.info_callback("Your hidden service is available. Please\n"
"now pass this URI string to the sender to\n"
"effect the payjoin payment:")
# Note that ep,getHost().onion_port must return the same
# port as we chose in self.port; if not there is an error.
assert ep.getHost().onion_port == self.port
self.uri_created_callback(self.bip21_uri_from_onion_hostname(
str(ep.getHost().onion_uri)))
if self.mode == "command-line":
self.info_callback("Keep this process running until the payment "
"is received.")
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:
return btc.encode_bip21_uri(str(self.receiving_address),
{"amount": bip78_btc_amount,
"pj": full_pj_string.encode("utf-8")},
safe=":/")
def start_tor(self):
""" This function executes the workflow
of starting the hidden service and returning/
printing the BIP21 URI:
"""
control_host = jm_single().config.get("PAYJOIN", "tor_control_host")
control_port = int(jm_single().config.get("PAYJOIN", "tor_control_port"))
if str(control_host).startswith('unix:'):
control_endpoint = UNIXClientEndpoint(reactor, control_host[5:])
else:
control_endpoint = TCP4ClientEndpoint(reactor, control_host, control_port)
d = txtorcon.connect(reactor, control_endpoint)
d.addCallback(self.create_onion_ep)
d.addErrback(self.setup_failed)
# TODO: add errbacks to the next two calls in
# the chain:
d.addCallback(self.onion_listen)
d.addCallback(self.print_host)
def shutdown(self):
self.tor_connection.protocol.transport.loseConnection()
process_shutdown(self.mode)
self.info_callback("Hidden service shutdown complete")
if self.shutdown_callback:
self.shutdown_callback()