|
|
|
|
@ -1,11 +1,10 @@
|
|
|
|
|
from twisted.internet import reactor, task |
|
|
|
|
from twisted.web.server import Site |
|
|
|
|
from twisted.web.resource import Resource |
|
|
|
|
from twisted.internet.endpoints import TCP4ClientEndpoint, UNIXClientEndpoint |
|
|
|
|
import txtorcon |
|
|
|
|
from twisted.internet import reactor |
|
|
|
|
try: |
|
|
|
|
from twisted.internet.ssl import ClientContextFactory |
|
|
|
|
except ImportError: |
|
|
|
|
pass |
|
|
|
|
import json |
|
|
|
|
import random |
|
|
|
|
from io import BytesIO |
|
|
|
|
from jmbase import bintohex, jmprint |
|
|
|
|
from .configure import get_log, jm_single |
|
|
|
|
import jmbitcoin as btc |
|
|
|
|
@ -393,7 +392,6 @@ class JMPayjoinManager(object):
|
|
|
|
|
|
|
|
|
|
self.user_info_callback("Choosing one coin at random") |
|
|
|
|
try: |
|
|
|
|
print('attempting to select from mixdepth: ', str(self.mixdepth)) |
|
|
|
|
my_utxos = self.wallet_service.select_utxos( |
|
|
|
|
self.mixdepth, jm_single().DUST_THRESHOLD, |
|
|
|
|
select_fn=select_one_utxo) |
|
|
|
|
@ -558,10 +556,12 @@ def send_payjoin(manager, accept_callback=None,
|
|
|
|
|
params = make_payjoin_request_params(manager) |
|
|
|
|
factory = BIP78ClientProtocolFactory(manager, params, |
|
|
|
|
process_payjoin_proposal_from_server, process_error_from_server) |
|
|
|
|
# TODO add SSL option as for other protocol instances: |
|
|
|
|
reactor.connectTCP(jm_single().config.get("DAEMON", "daemon_host"), |
|
|
|
|
jm_single().config.getint("DAEMON", "daemon_port")-2000, |
|
|
|
|
factory) |
|
|
|
|
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): |
|
|
|
|
@ -662,99 +662,49 @@ def process_payjoin_proposal_from_server(response_body, manager):
|
|
|
|
|
""" Receiver-specific code |
|
|
|
|
""" |
|
|
|
|
|
|
|
|
|
class PayjoinServer(Resource): |
|
|
|
|
def __init__(self, wallet_service, mixdepth, destination, amount, |
|
|
|
|
shutdown_callback, info_callback , mode="command-line", |
|
|
|
|
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 = wallet_service |
|
|
|
|
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 |
|
|
|
|
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. |
|
|
|
|
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. |
|
|
|
|
""" |
|
|
|
|
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. ", |
|
|
|
|
return (False, "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") |
|
|
|
|
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 self.bip78_error(request, |
|
|
|
|
"Proposed initial PSBT does not pass sanity checks.", |
|
|
|
|
return (False, "Proposed initial PSBT does not pass sanity checks.", |
|
|
|
|
"original-psbt-rejected") |
|
|
|
|
|
|
|
|
|
# if the sender set the additionalfeeoutputindex and maxadditionalfeecontribution |
|
|
|
|
@ -773,7 +723,7 @@ class PayjoinServer(Resource):
|
|
|
|
|
else: |
|
|
|
|
minfeerate = None |
|
|
|
|
except Exception as e: |
|
|
|
|
return self.bip78_error(request, "Invalid request parameters.", |
|
|
|
|
return (False, "Invalid request parameters.", |
|
|
|
|
"original-psbt-rejected") |
|
|
|
|
|
|
|
|
|
# if sender chose a fee output it must be the change output, |
|
|
|
|
@ -783,11 +733,11 @@ class PayjoinServer(Resource):
|
|
|
|
|
# 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.", |
|
|
|
|
return (False, "Invalid request parameters.", |
|
|
|
|
"original-psbt-rejected") |
|
|
|
|
|
|
|
|
|
if afoi and not (self.manager.change_out_index == afoi): |
|
|
|
|
return self.bip78_error(request, "additionalfeeoutputindex is " |
|
|
|
|
return (False, "additionalfeeoutputindex is " |
|
|
|
|
"not the change output. Joinmarket does " |
|
|
|
|
"not currently support this.", |
|
|
|
|
"original-psbt-rejected") |
|
|
|
|
@ -799,7 +749,7 @@ class PayjoinServer(Resource):
|
|
|
|
|
res = jm_single().bc_interface.testmempoolaccept(bintohex( |
|
|
|
|
self.manager.payment_tx.serialize())) |
|
|
|
|
if not res[0]["allowed"]: |
|
|
|
|
return self.bip78_error(request, "Proposed transaction was " |
|
|
|
|
return (False, "Proposed transaction was " |
|
|
|
|
"rejected from mempool.", |
|
|
|
|
"original-psbt-rejected") |
|
|
|
|
|
|
|
|
|
@ -811,9 +761,8 @@ class PayjoinServer(Resource):
|
|
|
|
|
|
|
|
|
|
receiver_utxos = self.manager.select_receiver_utxos() |
|
|
|
|
if not receiver_utxos: |
|
|
|
|
return self.bip78_error(request, |
|
|
|
|
"Could not select coins for payjoin", |
|
|
|
|
"unavailable") |
|
|
|
|
return (False, "Could not select coins for payjoin", |
|
|
|
|
"unavailable") |
|
|
|
|
|
|
|
|
|
# construct unsigned tx for payjoin-psbt: |
|
|
|
|
payjoin_tx_inputs = [(x.prevout.hash[::-1], |
|
|
|
|
@ -861,7 +810,7 @@ class PayjoinServer(Resource):
|
|
|
|
|
# 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 " |
|
|
|
|
return (False, "Bad request: minfeerate " |
|
|
|
|
"bigger than original psbt feerate.", |
|
|
|
|
"original-psbt-rejected") |
|
|
|
|
# set the intended virtual size of our input: |
|
|
|
|
@ -889,7 +838,7 @@ class PayjoinServer(Resource):
|
|
|
|
|
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 " |
|
|
|
|
return (False, "Bad request: we cannot " |
|
|
|
|
"achieve minfeerate requested.", |
|
|
|
|
"original-psbt-rejected") |
|
|
|
|
|
|
|
|
|
@ -965,15 +914,14 @@ class PayjoinServer(Resource):
|
|
|
|
|
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. |
|
|
|
|
# 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") |
|
|
|
|
content = receiver_signed_psbt.to_base64() |
|
|
|
|
request.setHeader(b"content-length", ("%d" % len(content)).encode("ascii")) |
|
|
|
|
return content.encode("ascii") |
|
|
|
|
return (True, receiver_signed_psbt.to_base64(), None) |
|
|
|
|
|
|
|
|
|
def end_receipt(self, txd, txid): |
|
|
|
|
if self.manager.mode == "gui": |
|
|
|
|
@ -981,20 +929,12 @@ class PayjoinServer(Resource):
|
|
|
|
|
"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) |
|
|
|
|
# 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 |
|
|
|
|
|
|
|
|
|
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 |
|
|
|
|
""" |
|
|
|
|
@ -1030,6 +970,29 @@ class JMBIP78ReceiverManager(object):
|
|
|
|
|
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) |
|
|
|
|
@ -1042,48 +1005,17 @@ class JMBIP78ReceiverManager(object):
|
|
|
|
|
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: |
|
|
|
|
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.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.") |
|
|
|
|
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, |
|
|
|
|
@ -1096,38 +1028,28 @@ class JMBIP78ReceiverManager(object):
|
|
|
|
|
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), |
|
|
|
|
bip21_uri = 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) |
|
|
|
|
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): |
|
|
|
|
self.tor_connection.protocol.transport.loseConnection() |
|
|
|
|
""" 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.pj_server.manager.timeout_fallback_dc |
|
|
|
|
tfdc = self.manager.timeout_fallback_dc |
|
|
|
|
if tfdc and tfdc.active(): |
|
|
|
|
tfdc.cancel() |
|
|
|
|
self.info_callback("Hidden service shutdown complete") |
|
|
|
|
|