@ -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 60 s 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 + = " \n Check 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 " )