Browse Source

BIP78 sender protocol via daemon

This PR creates a client-daemon protocol for
the BIP78 sender, using the base protocol
`HTTPPassThrough` which provides tor and non-tor
agents with POST and GET request functionality.
As for Joinmarket coinjoins, the use of an in-process
daemon is the default option, but it can be isolated
by changing the `[DAEMON]` section of the config.
The receiver side of BIP78 will be addressed in a
future PR.
master
Adam Gibson 5 years ago
parent
commit
b6e2576c3a
No known key found for this signature in database
GPG Key ID: 141001A1AF77F20B
  1. 35
      jmbase/jmbase/commands.py
  2. 3
      jmclient/jmclient/__init__.py
  3. 112
      jmclient/jmclient/client_protocol.py
  4. 185
      jmclient/jmclient/payjoin.py
  5. 44
      jmclient/test/test_payjoin.py
  6. 3
      jmdaemon/jmdaemon/__init__.py
  7. 69
      jmdaemon/jmdaemon/daemon_protocol.py
  8. 21
      scripts/joinmarket-qt.py
  9. 5
      scripts/joinmarketd.py
  10. 14
      scripts/sendpayment.py

35
jmbase/jmbase/commands.py

@ -281,3 +281,38 @@ class SNICKERRequestPowTarget(JMCommand):
class SNICKERReceivePowTarget(JMCommand): class SNICKERReceivePowTarget(JMCommand):
arguments = [(b'server', Unicode()), arguments = [(b'server', Unicode()),
(b'targetbits', Integer())] (b'targetbits', Integer())]
""" Payjoin-related commands
"""
class BIP78SenderInit(JMCommand):
""" Initialization data for a BIP78 service.
See documentation of `netconfig` in
jmdaemon.HTTPPassThrough.on_INIT
"""
arguments = [(b'netconfig', Unicode())]
class BIP78SenderUp(JMCommand):
arguments = []
class BIP78SenderOriginalPSBT(JMCommand):
""" Sends the payjoin url and the original
payment PSBT, base64 encoded,
from the client to the daemon,
to be sent as an http request to the receiver.
"""
arguments = [(b'body', BigUnicode()),
(b'params', Unicode())]
class BIP78SenderReceiveProposal(JMCommand):
""" Sends the payjoin proposal PSBT, received
from the BIP78 Receiver, from the daemon to the client.
"""
arguments = [(b'psbt', BigUnicode())]
class BIP78ReceiverError(JMCommand):
""" Sends a message from daemon to client
indicating that the BIP78 receiver did not
accept the request, or there was a network error.
"""
arguments = [(b'errormsg', Unicode()),
(b'errorcode', Integer())]

3
jmclient/jmclient/__init__.py

@ -29,7 +29,8 @@ from .blockchaininterface import (BlockchainInterface,
RegtestBitcoinCoreInterface, BitcoinCoreInterface) RegtestBitcoinCoreInterface, BitcoinCoreInterface)
from .snicker_receiver import SNICKERError, SNICKERReceiver from .snicker_receiver import SNICKERError, SNICKERReceiver
from .client_protocol import (JMTakerClientProtocol, JMClientProtocolFactory, from .client_protocol import (JMTakerClientProtocol, JMClientProtocolFactory,
start_reactor, SNICKERClientProtocolFactory) start_reactor, SNICKERClientProtocolFactory,
BIP78ClientProtocolFactory)
from .podle import (set_commitment_file, get_commitment_file, from .podle import (set_commitment_file, get_commitment_file,
add_external_commitments, add_external_commitments,
PoDLE, generate_podle, get_podle_commitments, PoDLE, generate_podle, get_podle_commitments,

112
jmclient/jmclient/client_protocol.py

@ -44,6 +44,48 @@ class BaseClientProtocol(amp.AMP):
class JMProtocolError(Exception): class JMProtocolError(Exception):
pass pass
class BIP78ClientProtocol(BaseClientProtocol):
def __init__(self, manager, params,
success_callback, failure_callback,
tls_whitelist=[]):
self.manager = manager
self.success_callback = success_callback
self.failure_callback = failure_callback
self.params = params
if len(tls_whitelist) == 0:
if isinstance(jm_single().bc_interface,
RegtestBitcoinCoreInterface):
tls_whitelist = ["127.0.0.1"]
self.tls_whitelist = tls_whitelist
def connectionMade(self):
netconfig = {"socks5_host": jm_single().config.get("PAYJOIN", "onion_socks5_host"),
"socks5_port": jm_single().config.get("PAYJOIN", "onion_socks5_port"),
"tls_whitelist": ",".join(self.tls_whitelist),
"servers": [self.manager.server]}
d = self.callRemote(commands.BIP78SenderInit,
netconfig=json.dumps(netconfig))
self.defaultCallbacks(d)
@commands.BIP78SenderUp.responder
def on_BIP78_SENDER_UP(self):
d = self.callRemote(commands.BIP78SenderOriginalPSBT,
body=self.manager.initial_psbt.to_base64(),
params=json.dumps(self.params))
self.defaultCallbacks(d)
return {"accepted": True}
@commands.BIP78SenderReceiveProposal.responder
def on_BIP78_SENDER_RECEIVE_PROPOSAL(self, psbt):
self.success_callback(psbt, self.manager)
return {"accepted": True}
@commands.BIP78ReceiverError.responder
def on_BIP78_RECEIVER_ERROR(self, errormsg, errorcode):
self.failure_callback(errormsg, errorcode, self.manager)
return {"accepted": True}
class SNICKERClientProtocol(BaseClientProtocol): class SNICKERClientProtocol(BaseClientProtocol):
def __init__(self, client, servers, tls_whitelist=[], oneshot=False): def __init__(self, client, servers, tls_whitelist=[], oneshot=False):
@ -635,6 +677,19 @@ class SNICKERClientProtocolFactory(protocol.ClientFactory):
self.servers = servers self.servers = servers
self.oneshot = oneshot self.oneshot = oneshot
class BIP78ClientProtocolFactory(protocol.ClientFactory):
protocol = BIP78ClientProtocol
def buildProtocol(self, addr):
return self.protocol(self.manager, self.params,
self.success_callback,
self.failure_callback)
def __init__(self, manager, params, success_callback,
failure_callback):
self.manager = manager
self.params = params
self.success_callback = success_callback
self.failure_callback = failure_callback
class JMClientProtocolFactory(protocol.ClientFactory): class JMClientProtocolFactory(protocol.ClientFactory):
protocol = JMTakerClientProtocol protocol = JMTakerClientProtocol
@ -653,7 +708,8 @@ class JMClientProtocolFactory(protocol.ClientFactory):
def buildProtocol(self, addr): def buildProtocol(self, addr):
return self.protocol(self, self.client) return self.protocol(self, self.client)
def start_reactor(host, port, factory=None, snickerfactory=None, ish=True, def start_reactor(host, port, factory=None, snickerfactory=None,
bip78=False, jm_coinjoin=True, ish=True,
daemon=False, rs=True, gui=False): #pragma: no cover daemon=False, rs=True, gui=False): #pragma: no cover
#(Cannot start the reactor in tests) #(Cannot start the reactor in tests)
#Not used in prod (twisted logging): #Not used in prod (twisted logging):
@ -663,33 +719,51 @@ def start_reactor(host, port, factory=None, snickerfactory=None, ish=True,
if daemon: if daemon:
try: try:
from jmdaemon import JMDaemonServerProtocolFactory, start_daemon, \ from jmdaemon import JMDaemonServerProtocolFactory, start_daemon, \
SNICKERDaemonServerProtocolFactory SNICKERDaemonServerProtocolFactory, BIP78ServerProtocolFactory
except ImportError: except ImportError:
jlog.error("Cannot start daemon without jmdaemon package; " jlog.error("Cannot start daemon without jmdaemon package; "
"either install it, and restart, or, if you want " "either install it, and restart, or, if you want "
"to run the daemon separately, edit the DAEMON " "to run the daemon separately, edit the DAEMON "
"section of the config. Quitting.") "section of the config. Quitting.")
return return
dfactory = JMDaemonServerProtocolFactory() if jm_coinjoin:
dfactory = JMDaemonServerProtocolFactory()
if snickerfactory: if snickerfactory:
sdfactory = SNICKERDaemonServerProtocolFactory() sdfactory = SNICKERDaemonServerProtocolFactory()
orgport = port if bip78:
while True: bip78factory = BIP78ServerProtocolFactory()
try: # ints are immutable in python, to pass by ref we use
start_daemon(host, port, dfactory, usessl, # an array object:
'./ssl/key.pem', './ssl/cert.pem') port_a = [port]
jlog.info("Listening on port " + str(port)) def start_daemon_on_port(p, f, name, port_offset):
break orgp = p[0]
except Exception: while True:
jlog.warn("Cannot listen on port " + str(port) + ", trying next port") try:
if port >= (orgport + 100): start_daemon(host, p[0] - port_offset, f, usessl,
jlog.error("Tried 100 ports but cannot listen on any of them. Quitting.") './ssl/key.pem', './ssl/cert.pem')
sys.exit(EXIT_FAILURE) jlog.info("{} daemon listening on port {}".format(
port += 1 name, str(p[0] - port_offset)))
break
except Exception:
jlog.warn("Cannot listen on port " + str(
p[0] - port_offset) + ", trying next port")
if p[0] >= (orgp + 100):
jlog.error("Tried 100 ports but cannot "
"listen on any of them. Quitting.")
sys.exit(EXIT_FAILURE)
p[0] += 1
if jm_coinjoin:
# TODO either re-apply this port incrementing logic
# to other protocols, or re-work how the ports work entirely.
start_daemon_on_port(port_a, dfactory, "Joinmarket", 0)
# (See above) For now these other two are just on ports that are 1K offsets.
if snickerfactory: if snickerfactory:
start_daemon(host, port-1000, sdfactory, usessl, start_daemon_on_port(port_a, sdfactory, "SNICKER", 1000)
'./ssl/key.pem', './ssl/cert.pem') if bip78:
jlog.info("(SNICKER) Listening on port " + str(port-1000)) start_daemon_on_port(port_a, bip78factory, "BIP78", 2000)
# Note the reactor.connect*** entries do not include BIP78 which
# starts in jmclient.payjoin:
if usessl: if usessl:
if factory: if factory:
reactor.connectSSL(host, port, factory, ClientContextFactory()) reactor.connectSSL(host, port, factory, ClientContextFactory())

185
jmclient/jmclient/payjoin.py

@ -19,13 +19,14 @@ import json
import random import random
from io import BytesIO from io import BytesIO
from pprint import pformat from pprint import pformat
from jmbase import BytesProducer, bintohex, jmprint from jmbase import bintohex, jmprint
from .configure import get_log, jm_single from .configure import get_log, jm_single
import jmbitcoin as btc import jmbitcoin as btc
from .wallet import PSBTWalletMixin, SegwitLegacyWallet, SegwitWallet, estimate_tx_fee from .wallet import PSBTWalletMixin, SegwitLegacyWallet, SegwitWallet, estimate_tx_fee
from .wallet_service import WalletService from .wallet_service import WalletService
from .taker_utils import direct_send from .taker_utils import direct_send
from jmclient import RegtestBitcoinCoreInterface, select_one_utxo, process_shutdown from jmclient import (RegtestBitcoinCoreInterface, select_one_utxo,
process_shutdown, BIP78ClientProtocolFactory)
""" """
For some documentation see: For some documentation see:
@ -43,36 +44,6 @@ INPUT_VSIZE_LEGACY = 148
INPUT_VSIZE_SEGWIT_LEGACY = 91 INPUT_VSIZE_SEGWIT_LEGACY = 91
INPUT_VSIZE_SEGWIT_NATIVE = 68 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): class JMPayjoinManager(object):
""" An encapsulation of state for an """ An encapsulation of state for an
ongoing Payjoin payment. Allows reporting ongoing Payjoin payment. Allows reporting
@ -435,6 +406,7 @@ class JMPayjoinManager(object):
self.user_info_callback("Choosing one coin at random") self.user_info_callback("Choosing one coin at random")
try: try:
print('attempting to select from mixdepth: ', str(self.mixdepth))
my_utxos = self.wallet_service.select_utxos( my_utxos = self.wallet_service.select_utxos(
self.mixdepth, jm_single().DUST_THRESHOLD, self.mixdepth, jm_single().DUST_THRESHOLD,
select_fn=select_one_utxo) select_fn=select_one_utxo)
@ -519,76 +491,32 @@ def get_max_additional_fee_contribution(manager):
"contribution of: " + str(max_additional_fee_contribution)) "contribution of: " + str(max_additional_fee_contribution))
return max_additional_fee_contribution return max_additional_fee_contribution
def send_payjoin(manager, accept_callback=None, def make_payment_psbt(manager, accept_callback=None, info_callback=None):
info_callback=None, return_deferred=False): """ Creates a valid payment transaction and PSBT for it,
""" Given a JMPayjoinManager object `manager`, initialised with the and adds it to the JMPayjoinManager instance passed as argument.
payment request data from the server, use its wallet_service to construct Wallet should already be synced before calling here.
a payment transaction, with coins sourced from mixdepth `mixdepth`, Returns True, None if successful or False, errormsg if not.
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. # we can create a standard payment, but have it returned as a PSBT.
assert isinstance(manager, JMPayjoinManager) assert isinstance(manager, JMPayjoinManager)
assert manager.wallet_service.synced assert manager.wallet_service.synced
payment_psbt = direct_send(manager.wallet_service, manager.amount, manager.mixdepth, payment_psbt = direct_send(manager.wallet_service, manager.amount,
str(manager.destination), accept_callback=accept_callback, manager.mixdepth, str(manager.destination),
info_callback=info_callback, accept_callback=accept_callback,
with_final_psbt=True, optin_rbf=True) info_callback=info_callback,
with_final_psbt=True, optin_rbf=True)
if not payment_psbt: if not payment_psbt:
return (False, "could not create non-payjoin payment") 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) manager.set_payment_tx_and_psbt(payment_psbt)
# add delayed call to broadcast this after 1 minute return (True, None)
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: 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 # construct the URI from the given parameters
pj_version = jm_single().config.getint("PAYJOIN", pj_version = jm_single().config.getint("PAYJOIN",
@ -614,27 +542,39 @@ def send_payjoin(manager, accept_callback=None,
min_fee_rate = float(jm_single().config.get("PAYJOIN", "min_fee_rate")) min_fee_rate = float(jm_single().config.get("PAYJOIN", "min_fee_rate"))
params["minfeerate"] = min_fee_rate params["minfeerate"] = min_fee_rate
destination_url = manager.server.encode("utf-8") return params
url_parts = list(urlparse.urlparse(destination_url))
url_parts[4] = urlencode(params).encode("utf-8") def send_payjoin(manager, accept_callback=None,
destination_url = urlparse.urlunparse(url_parts) info_callback=None, return_deferred=False):
# TODO what to use as user agent? """ Given a JMPayjoinManager object `manager`, initialised with the
d = agent.request(b"POST", destination_url, payment request data from the server, use its wallet_service to construct
Headers({"User-Agent": ["Twisted Web Client Example"], a payment transaction, with coins sourced from mixdepth `manager.mixdepth`,
"Content-Type": ["text/plain"]}), then wait for the server response, parse the PSBT, perform checks and complete sign.
bodyProducer=body) The info and accept callbacks are to ask the user to confirm the creation of
d.addCallback(receive_payjoin_proposal_from_server, manager) the original payment transaction (None defaults to terminal/CLI processing),
# note that the errback (here "noResponse") is *not* triggered and are as defined in `taker_utils.direct_send`.
# by a server rejection (which is accompanied by a non-200
# status code returned), but by failure to communicate. Returns:
def noResponse(failure): (True, None) in case of payment setup successful (response will be delivered
failure.trap(ResponseFailed, ConnectionRefusedError, asynchronously) - the `manager` object can be inspected for more detail.
HostUnreachableError, ConnectionLost) (False, errormsg) in case of failure.
log.error(failure.value) """
fallback_nonpayjoin_broadcast(b"connection failed", manager) success, errmsg = make_payment_psbt(manager, accept_callback, info_callback)
d.addErrback(noResponse) if not success:
if return_deferred: return (False, errmsg)
return d
# 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)
# 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)
return (True, None) return (True, None)
def fallback_nonpayjoin_broadcast(err, manager): def fallback_nonpayjoin_broadcast(err, manager):
@ -667,19 +607,14 @@ def fallback_nonpayjoin_broadcast(err, manager):
manager.timeout_fallback_dc.cancel() manager.timeout_fallback_dc.cancel()
quit() quit()
def receive_payjoin_proposal_from_server(response, manager): def process_error_from_server(errormsg, errorcode, manager):
assert isinstance(manager, JMPayjoinManager) assert isinstance(manager, JMPayjoinManager)
# no attempt at chunking or handling incrementally is needed # payjoin attempt has failed, we revert to standard payment.
# here. The body should be a byte string containing the assert int(errorcode) != 200
# new PSBT, or a jsonified error page. log.warn("Receiver returned error code: {}, message: {}".format(
d = readBody(response) errorcode, errormsg))
# if the response code is not 200 OK, we must assume payjoin fallback_nonpayjoin_broadcast(errormsg.encode("utf-8"), manager)
# attempt has failed, and revert to standard payment. return
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): def process_payjoin_proposal_from_server(response_body, manager):
assert isinstance(manager, JMPayjoinManager) assert isinstance(manager, JMPayjoinManager)

44
jmclient/test/test_payjoin.py

@ -8,15 +8,22 @@ import sys
import pytest import pytest
from twisted.internet import reactor from twisted.internet import reactor
from twisted.web.server import Site from twisted.web.server import Site
from twisted.web.client import readBody
from twisted.web.http_headers import Headers
from twisted.trial import unittest from twisted.trial import unittest
import urllib.parse as urlparse
from urllib.parse import urlencode
from jmbase import get_log, jmprint from jmbase import get_log, jmprint, BytesProducer
from jmbitcoin import (CCoinAddress, encode_bip21_uri, from jmbitcoin import (CCoinAddress, encode_bip21_uri,
amount_to_btc, amount_to_sat) amount_to_btc, amount_to_sat)
from jmclient import cryptoengine from jmclient import cryptoengine
from jmclient import (load_test_config, jm_single, from jmclient import (load_test_config, jm_single,
SegwitLegacyWallet, SegwitWallet, SegwitLegacyWallet, SegwitWallet,
PayjoinServer, parse_payjoin_setup, send_payjoin) PayjoinServer, parse_payjoin_setup,
send_payjoin)
from jmclient.payjoin import make_payjoin_request_params, make_payment_psbt
from jmclient.payjoin import process_payjoin_proposal_from_server
from commontest import make_wallets from commontest import make_wallets
from test_coinjoin import make_wallets_to_list, create_orderbook, sync_wallets from test_coinjoin import make_wallets_to_list, create_orderbook, sync_wallets
@ -75,7 +82,23 @@ class TrialTestPayjoinServer(unittest.TestCase):
self.manager = parse_payjoin_setup(bip78_uri, self.wallet_services[1], 0) self.manager = parse_payjoin_setup(bip78_uri, self.wallet_services[1], 0)
self.manager.mode = "testing" self.manager.mode = "testing"
self.site = site self.site = site
return send_payjoin(self.manager, return_deferred=True) success, msg = make_payment_psbt(self.manager)
assert success, msg
params = make_payjoin_request_params(self.manager)
# avoiding backend daemon (testing only jmclient code here),
# we send the http request manually:
from jmbase import get_nontor_agent, wrapped_urlparse
serv = b"http://127.0.0.1:47083"
agent = get_nontor_agent()
body = BytesProducer(self.manager.initial_psbt.to_base64().encode("utf-8"))
url_parts = list(wrapped_urlparse(serv))
url_parts[4] = urlencode(params).encode("utf-8")
destination_url = urlparse.urlunparse(url_parts)
d = agent.request(b"POST", destination_url,
Headers({"Content-Type": ["text/plain"]}),
bodyProducer=body)
d.addCallback(bip78_receiver_response, self.manager)
return d
def tearDown(self): def tearDown(self):
for dc in reactor.getDelayedCalls(): for dc in reactor.getDelayedCalls():
@ -85,6 +108,21 @@ class TrialTestPayjoinServer(unittest.TestCase):
self.ssb, self.rsb) self.ssb, self.rsb)
assert res, "final checks failed" assert res, "final checks failed"
def bip78_receiver_response(response, manager):
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:
d.addCallback(process_receiver_errormsg, response.code)
return
d.addCallback(process_receiver_psbt, manager)
def process_receiver_errormsg(r, c):
print("Failed: r, c: ", r, c)
def process_receiver_psbt(response, manager):
process_payjoin_proposal_from_server(response.decode("utf-8"), manager)
def getbals(wallet_service, mixdepth): def getbals(wallet_service, mixdepth):
""" Retrieves balances for a mixdepth and the 'next' """ Retrieves balances for a mixdepth and the 'next'
""" """

3
jmdaemon/jmdaemon/__init__.py

@ -9,7 +9,8 @@ from .message_channel import MessageChannel, MessageChannelCollection
from .orderbookwatch import OrderbookWatch from .orderbookwatch import OrderbookWatch
from jmbase import commands from jmbase import commands
from .daemon_protocol import (JMDaemonServerProtocolFactory, JMDaemonServerProtocol, from .daemon_protocol import (JMDaemonServerProtocolFactory, JMDaemonServerProtocol,
start_daemon, SNICKERDaemonServerProtocolFactory) start_daemon, SNICKERDaemonServerProtocolFactory,
BIP78ServerProtocolFactory, BIP78ServerProtocol)
from .protocol import (COMMAND_PREFIX, ORDER_KEYS, NICK_HASH_LENGTH, from .protocol import (COMMAND_PREFIX, ORDER_KEYS, NICK_HASH_LENGTH,
NICK_MAX_ENCODED, JM_VERSION, JOINMARKET_NICK_HEADER) NICK_MAX_ENCODED, JM_VERSION, JOINMARKET_NICK_HEADER)
from .message_channel import MessageChannelCollection from .message_channel import MessageChannelCollection

69
jmdaemon/jmdaemon/daemon_protocol.py

@ -16,7 +16,8 @@ from twisted.protocols import amp
from twisted.internet import reactor, ssl from twisted.internet import reactor, ssl
from twisted.internet.protocol import ServerFactory from twisted.internet.protocol import ServerFactory
from twisted.internet.error import (ConnectionLost, ConnectionAborted, from twisted.internet.error import (ConnectionLost, ConnectionAborted,
ConnectionClosed, ConnectionDone) ConnectionClosed, ConnectionDone,
ConnectionRefusedError)
from twisted.web.http_headers import Headers from twisted.web.http_headers import Headers
from twisted.web.client import ResponseFailed, readBody from twisted.web.client import ResponseFailed, readBody
from txtorcon.socks import HostUnreachableError from txtorcon.socks import HostUnreachableError
@ -131,6 +132,14 @@ class HTTPPassThrough(amp.AMP):
destination_url = urlparse.urlunparse(url_parts) destination_url = urlparse.urlunparse(url_parts)
return (agent, destination_url) return (agent, destination_url)
def getDefaultHeaders(self):
# Deliberately sending NO headers other than
# Content-Type by default;
# this could be a tricky point for anonymity of users,
# as much boilerplate code will not create
# requests that look like this.
return Headers({"Content-Type": ["text/plain"]})
def getRequest(self, server, success_callback, url=None, headers=None): def getRequest(self, server, success_callback, url=None, headers=None):
""" Make GET request to server server, if response received OK, """ Make GET request to server server, if response received OK,
passed to success_callback, which must have function signature passed to success_callback, which must have function signature
@ -142,7 +151,7 @@ class HTTPPassThrough(amp.AMP):
# Deliberately sending NO headers; this could be a tricky point # Deliberately sending NO headers; this could be a tricky point
# for anonymity of users, as much boilerplate code will not create # for anonymity of users, as much boilerplate code will not create
# requests that look like this. # requests that look like this.
headers = Headers({}) if not headers else headers headers = self.getDefaultHeaders() if not headers else headers
d = agent.request(b"GET", destination_url, headers) d = agent.request(b"GET", destination_url, headers)
d.addCallback(success_callback, server) d.addCallback(success_callback, server)
# note that the errback (here "noResponse") is *not* triggered # note that the errback (here "noResponse") is *not* triggered
@ -154,13 +163,18 @@ class HTTPPassThrough(amp.AMP):
log.msg(failure.value) log.msg(failure.value)
d.addErrback(noResponse) d.addErrback(noResponse)
def postRequest(self, body, server, success_callback, headers=None): def postRequest(self, body, server, success_callback,
url=None, params=None, headers=None):
""" Pass body of post request as string, will be encoded here. """ Pass body of post request as string, will be encoded here.
""" """
agent, destination_url = self.getAgentDestination(server) agent, destination_url = self.getAgentDestination(server,
params=params)
if url:
destination_url = destination_url + url
body = BytesProducer(body.encode("utf-8")) body = BytesProducer(body.encode("utf-8"))
headers = self.getDefaultHeaders() if not headers else headers
d = agent.request(b"POST", destination_url, d = agent.request(b"POST", destination_url,
Headers({}), bodyProducer=body) headers, bodyProducer=body)
d.addCallback(success_callback, server) d.addCallback(success_callback, server)
# note that the errback (here "noResponse") is *not* triggered # note that the errback (here "noResponse") is *not* triggered
# by a server rejection (which is accompanied by a non-200 # by a server rejection (which is accompanied by a non-200
@ -169,9 +183,9 @@ class HTTPPassThrough(amp.AMP):
failure.trap(ResponseFailed, ConnectionRefusedError, failure.trap(ResponseFailed, ConnectionRefusedError,
HostUnreachableError, ConnectionLost) HostUnreachableError, ConnectionLost)
log.msg(failure.value) log.msg(failure.value)
self.callRemote(SNICKERProposalsServerResponse, self.callRemote(BIP78ReceiverError,
response="failure to connect", errormsg="failure to connect",
server=server) errorcode=10000)
d.addErrback(noResponse) d.addErrback(noResponse)
def checkClientResponse(self, response): def checkClientResponse(self, response):
@ -191,6 +205,42 @@ class HTTPPassThrough(amp.AMP):
d.addCallback(self.checkClientResponse) d.addCallback(self.checkClientResponse)
d.addErrback(self.defaultErrback) d.addErrback(self.defaultErrback)
class BIP78ServerProtocol(HTTPPassThrough):
@BIP78SenderInit.responder
def on_BIP78_SENDER_INIT(self, netconfig):
self.on_INIT(netconfig)
d = self.callRemote(BIP78SenderUp)
self.defaultCallbacks(d)
return {"accepted": True}
@BIP78SenderOriginalPSBT.responder
def on_BIP78_SENDER_ORIGINAL_PSBT(self, body, params):
self.postRequest(body, self.servers[0],
self.bip78_receiver_response,
params=json.loads(params),
headers=Headers({"Content-Type": ["text/plain"]}))
return {"accepted": True}
def bip78_receiver_response(self, response, server):
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:
d.addCallback(self.process_receiver_errormsg, response.code)
return
d.addCallback(self.process_receiver_psbt)
def process_receiver_errormsg(self, response, errorcode):
d = self.callRemote(BIP78ReceiverError,
errormsg=response.decode("utf-8"),
errorcode=errorcode)
self.defaultCallbacks(d)
def process_receiver_psbt(self, response):
d = self.callRemote(BIP78SenderReceiveProposal,
psbt=response.decode("utf-8"))
self.defaultCallbacks(d)
class SNICKERDaemonServerProtocol(HTTPPassThrough): class SNICKERDaemonServerProtocol(HTTPPassThrough):
@SNICKERProposerPostProposals.responder @SNICKERProposerPostProposals.responder
@ -836,6 +886,9 @@ class JMDaemonServerProtocolFactory(ServerFactory):
class SNICKERDaemonServerProtocolFactory(ServerFactory): class SNICKERDaemonServerProtocolFactory(ServerFactory):
protocol = SNICKERDaemonServerProtocol protocol = SNICKERDaemonServerProtocol
class BIP78ServerProtocolFactory(ServerFactory):
protocol = BIP78ServerProtocol
def start_daemon(host, port, factory, usessl=False, sslkey=None, sslcert=None): def start_daemon(host, port, factory, usessl=False, sslkey=None, sslcert=None):
if usessl: if usessl:
assert sslkey assert sslkey

21
scripts/joinmarket-qt.py

@ -37,8 +37,6 @@ elif platform.system() == 'Darwin':
else: else:
MONOSPACE_FONT = 'monospace' MONOSPACE_FONT = 'monospace'
import jmbitcoin as btc
# This is required to change the decimal separator # This is required to change the decimal separator
# to '.' regardless of the locale; TODO don't require # to '.' regardless of the locale; TODO don't require
# this, but will require other edits for parsing amounts. # this, but will require other edits for parsing amounts.
@ -63,6 +61,7 @@ JM_GUI_VERSION = '21dev'
from jmbase import get_log, stop_reactor from jmbase import get_log, stop_reactor
from jmbase.support import DUST_THRESHOLD, EXIT_FAILURE, utxo_to_utxostr,\ from jmbase.support import DUST_THRESHOLD, EXIT_FAILURE, utxo_to_utxostr,\
bintohex, hextobin, JM_CORE_VERSION bintohex, hextobin, JM_CORE_VERSION
import jmbitcoin as btc
from jmclient import load_program_config, get_network, update_persist_config,\ from jmclient import load_program_config, get_network, update_persist_config,\
open_test_wallet_maybe, get_wallet_path,\ open_test_wallet_maybe, get_wallet_path,\
jm_single, validate_address, weighted_order_choose, Taker,\ jm_single, validate_address, weighted_order_choose, Taker,\
@ -74,7 +73,8 @@ from jmclient import load_program_config, get_network, update_persist_config,\
NO_ROUNDING, get_max_cj_fee_values, get_default_max_absolute_fee, \ NO_ROUNDING, get_max_cj_fee_values, get_default_max_absolute_fee, \
get_default_max_relative_fee, RetryableStorageError, add_base_options, \ get_default_max_relative_fee, RetryableStorageError, add_base_options, \
BTCEngine, BTC_P2SH_P2WPKH, FidelityBondMixin, wallet_change_passphrase, \ BTCEngine, BTC_P2SH_P2WPKH, FidelityBondMixin, wallet_change_passphrase, \
parse_payjoin_setup, send_payjoin, JMBIP78ReceiverManager parse_payjoin_setup, send_payjoin, JMBIP78ReceiverManager, \
BIP78ClientProtocolFactory
from qtsupport import ScheduleWizard, TumbleRestartWizard, config_tips,\ from qtsupport import ScheduleWizard, TumbleRestartWizard, config_tips,\
config_types, QtHandler, XStream, Buttons, OkButton, CancelButton,\ config_types, QtHandler, XStream, Buttons, OkButton, CancelButton,\
PasswordDialog, MyTreeWidget, JMQtMessageBox, BLUE_FG,\ PasswordDialog, MyTreeWidget, JMQtMessageBox, BLUE_FG,\
@ -292,6 +292,8 @@ class SpendTab(QWidget):
self.spendstate.reset() #trigger callback to 'ready' state self.spendstate.reset() #trigger callback to 'ready' state
# needed to be saved for parse_payjoin_setup() # needed to be saved for parse_payjoin_setup()
self.bip21_uri = None self.bip21_uri = None
# avoid re-starting BIP78 daemon unnecessarily:
self.bip78_daemon_started = False
def switchToBIP78Payjoin(self, endpoint_url): def switchToBIP78Payjoin(self, endpoint_url):
self.numCPLabel.setVisible(False) self.numCPLabel.setVisible(False)
@ -746,7 +748,18 @@ class SpendTab(QWidget):
if bip78url: if bip78url:
manager = parse_payjoin_setup(self.bip21_uri, manager = parse_payjoin_setup(self.bip21_uri,
mainWindow.wallet_service, mixdepth, "joinmarket-qt") mainWindow.wallet_service, mixdepth, "joinmarket-qt")
# start BIP78 AMP protocol if not yet up:
if not self.bip78_daemon_started:
daemon = jm_single().config.getint("DAEMON", "no_daemon")
daemon = True if daemon == 1 else False
start_reactor(jm_single().config.get("DAEMON", "daemon_host"),
jm_single().config.getint("DAEMON", "daemon_port"),
bip78=True, jm_coinjoin=False,
ish=False,
daemon=daemon,
gui=True)
self.bip78_daemon_started = True
# disable form fields until payment is done # disable form fields until payment is done
self.addressInput.setEnabled(False) self.addressInput.setEnabled(False)
self.pjEndpointInput.setEnabled(False) self.pjEndpointInput.setEnabled(False)

5
scripts/joinmarketd.py

@ -15,11 +15,12 @@ def startup_joinmarketd(host, port, usessl, factories=None,
startLogging(sys.stdout) startLogging(sys.stdout)
if not factories: if not factories:
factories = [jmdaemon.JMDaemonServerProtocolFactory(), factories = [jmdaemon.JMDaemonServerProtocolFactory(),
jmdaemon.SNICKERDaemonServerProtocolFactory()] jmdaemon.SNICKERDaemonServerProtocolFactory(),
jmdaemon.BIP78ServerProtocolFactory()]
for factory in factories: for factory in factories:
jmdaemon.start_daemon(host, port, factory, usessl, jmdaemon.start_daemon(host, port, factory, usessl,
'./ssl/key.pem', './ssl/cert.pem') './ssl/key.pem', './ssl/cert.pem')
port += 1 port -= 1000
if finalizer: if finalizer:
reactor.addSystemEventTrigger("after", "shutdown", finalizer, reactor.addSystemEventTrigger("after", "shutdown", finalizer,
finalizer_args) finalizer_args)

14
scripts/sendpayment.py

@ -298,11 +298,16 @@ def main():
log.info("All transactions completed correctly") log.info("All transactions completed correctly")
reactor.stop() reactor.stop()
nodaemon = jm_single().config.getint("DAEMON", "no_daemon")
daemon = True if nodaemon == 1 else False
dhost = jm_single().config.get("DAEMON", "daemon_host")
dport = jm_single().config.getint("DAEMON", "daemon_port")
if bip78url: if bip78url:
# TODO sanity check wallet type is segwit # TODO sanity check wallet type is segwit
manager = parse_payjoin_setup(args[1], wallet_service, options.mixdepth) manager = parse_payjoin_setup(args[1], wallet_service, options.mixdepth)
reactor.callWhenRunning(send_payjoin, manager) reactor.callWhenRunning(send_payjoin, manager)
reactor.run() # JM is default, so must be switched off explicitly in this call:
start_reactor(dhost, dport, bip78=True, jm_coinjoin=False, daemon=daemon)
return return
else: else:
@ -312,13 +317,10 @@ def main():
max_cj_fee=maxcjfee, max_cj_fee=maxcjfee,
callbacks=(filter_orders_callback, None, taker_finished)) callbacks=(filter_orders_callback, None, taker_finished))
clientfactory = JMClientProtocolFactory(taker) clientfactory = JMClientProtocolFactory(taker)
nodaemon = jm_single().config.getint("DAEMON", "no_daemon")
daemon = True if nodaemon == 1 else False
if jm_single().config.get("BLOCKCHAIN", "network") == "regtest": if jm_single().config.get("BLOCKCHAIN", "network") == "regtest":
startLogging(sys.stdout) startLogging(sys.stdout)
start_reactor(jm_single().config.get("DAEMON", "daemon_host"), start_reactor(dhost, dport, clientfactory, daemon=daemon)
jm_single().config.getint("DAEMON", "daemon_port"),
clientfactory, daemon=daemon)
if __name__ == "__main__": if __name__ == "__main__":
main() main()

Loading…
Cancel
Save