|
|
|
|
@ -19,13 +19,14 @@ import json
|
|
|
|
|
import random |
|
|
|
|
from io import BytesIO |
|
|
|
|
from pprint import pformat |
|
|
|
|
from jmbase import BytesProducer, bintohex, jmprint |
|
|
|
|
from jmbase import 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 |
|
|
|
|
from jmclient import (RegtestBitcoinCoreInterface, select_one_utxo, |
|
|
|
|
process_shutdown, BIP78ClientProtocolFactory) |
|
|
|
|
|
|
|
|
|
""" |
|
|
|
|
For some documentation see: |
|
|
|
|
@ -43,36 +44,6 @@ 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 |
|
|
|
|
@ -435,6 +406,7 @@ 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) |
|
|
|
|
@ -519,76 +491,32 @@ def get_max_additional_fee_contribution(manager):
|
|
|
|
|
"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. |
|
|
|
|
def make_payment_psbt(manager, accept_callback=None, info_callback=None): |
|
|
|
|
""" Creates a valid payment transaction and PSBT for it, |
|
|
|
|
and adds it to the JMPayjoinManager instance passed as argument. |
|
|
|
|
Wallet should already be synced before calling here. |
|
|
|
|
Returns True, None if successful or False, errormsg if not. |
|
|
|
|
""" |
|
|
|
|
|
|
|
|
|
# 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) |
|
|
|
|
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")) |
|
|
|
|
return (True, None) |
|
|
|
|
|
|
|
|
|
#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 |
|
|
|
|
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")) |
|
|
|
|
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 params |
|
|
|
|
|
|
|
|
|
def send_payjoin(manager, accept_callback=None, |
|
|
|
|
info_callback=None, return_deferred=False): |
|
|
|
|
""" Given a JMPayjoinManager object `manager`, initialised with the |
|
|
|
|
payment request data from the server, use its wallet_service to construct |
|
|
|
|
a payment transaction, with coins sourced from mixdepth `manager.mixdepth`, |
|
|
|
|
then wait for the server response, parse the PSBT, perform checks and complete sign. |
|
|
|
|
The info and accept callbacks are to ask the user to confirm the creation of |
|
|
|
|
the original payment transaction (None defaults to terminal/CLI processing), |
|
|
|
|
and are as defined in `taker_utils.direct_send`. |
|
|
|
|
|
|
|
|
|
Returns: |
|
|
|
|
(True, None) in case of payment setup successful (response will be delivered |
|
|
|
|
asynchronously) - the `manager` object can be inspected for more detail. |
|
|
|
|
(False, errormsg) in case of failure. |
|
|
|
|
""" |
|
|
|
|
success, errmsg = make_payment_psbt(manager, accept_callback, info_callback) |
|
|
|
|
if not success: |
|
|
|
|
return (False, errmsg) |
|
|
|
|
|
|
|
|
|
# add delayed call to broadcast this after 1 minute |
|
|
|
|
manager.timeout_fallback_dc = reactor.callLater(60, |
|
|
|
|
fallback_nonpayjoin_broadcast, |
|
|
|
|
b"timeout", manager) |
|
|
|
|
|
|
|
|
|
params = make_payjoin_request_params(manager) |
|
|
|
|
factory = BIP78ClientProtocolFactory(manager, params, |
|
|
|
|
process_payjoin_proposal_from_server, process_error_from_server) |
|
|
|
|
# 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) |
|
|
|
|
|
|
|
|
|
def fallback_nonpayjoin_broadcast(err, manager): |
|
|
|
|
@ -667,19 +607,14 @@ def fallback_nonpayjoin_broadcast(err, manager):
|
|
|
|
|
manager.timeout_fallback_dc.cancel() |
|
|
|
|
quit() |
|
|
|
|
|
|
|
|
|
def receive_payjoin_proposal_from_server(response, manager): |
|
|
|
|
def process_error_from_server(errormsg, errorcode, 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) |
|
|
|
|
# payjoin attempt has failed, we revert to standard payment. |
|
|
|
|
assert int(errorcode) != 200 |
|
|
|
|
log.warn("Receiver returned error code: {}, message: {}".format( |
|
|
|
|
errorcode, errormsg)) |
|
|
|
|
fallback_nonpayjoin_broadcast(errormsg.encode("utf-8"), manager) |
|
|
|
|
return |
|
|
|
|
|
|
|
|
|
def process_payjoin_proposal_from_server(response_body, manager): |
|
|
|
|
assert isinstance(manager, JMPayjoinManager) |
|
|
|
|
|