Browse Source
See: https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki Adds a new module jmclient.payjoin which implements the full sender workflow from a BIP21 uri to a payjoin broadcast, state is managed in JMPayjoinManager, includes all checks as per documentation of btcpayserver (and later, BIP78). Added simple client and server implementations in test/payjoinclient.py and test/payjoinserver.py which allow a full end to end test on regtest. Add TLS support to payjoin tests: Note: the jmclient.payjoin module already supports TLS by default (Agent object), but here we add the ability to test without certificate verification. Both test/payjoinclient.py and test/payjoinserver.py now support TLS, but the server needs a key and certificate in its directory to run. Adds BIP78 payjoin option to sendpayment.py Users can use a bip21 uri with the "pj" field to send a payment to a remote server. Removes require_path_templates setting from KeyStore call in PSBTWalletMixin.sign_psbtmaster
10 changed files with 816 additions and 10 deletions
@ -0,0 +1,22 @@ |
|||||||
|
""" See https://twistedmatrix.com/documents/current/web/howto/client.html |
||||||
|
""" |
||||||
|
from zope.interface import implementer |
||||||
|
|
||||||
|
from twisted.internet.defer import succeed |
||||||
|
from twisted.web.iweb import IBodyProducer |
||||||
|
|
||||||
|
@implementer(IBodyProducer) |
||||||
|
class BytesProducer(object): |
||||||
|
def __init__(self, body): |
||||||
|
self.body = body |
||||||
|
self.length = len(body) |
||||||
|
|
||||||
|
def startProducing(self, consumer): |
||||||
|
consumer.write(self.body) |
||||||
|
return succeed(None) |
||||||
|
|
||||||
|
def pauseProducing(self): |
||||||
|
pass |
||||||
|
|
||||||
|
def stopProducing(self): |
||||||
|
pass |
||||||
@ -0,0 +1,503 @@ |
|||||||
|
from zope.interface import implementer |
||||||
|
from twisted.internet import reactor |
||||||
|
from twisted.web.client import (Agent, readBody, ResponseFailed, |
||||||
|
BrowserLikePolicyForHTTPS) |
||||||
|
from twisted.web.iweb import IPolicyForHTTPS |
||||||
|
from twisted.internet.ssl import CertificateOptions |
||||||
|
from twisted.web.http_headers import Headers |
||||||
|
|
||||||
|
import json |
||||||
|
from pprint import pformat |
||||||
|
from jmbase import BytesProducer |
||||||
|
from .configure import get_log, jm_single |
||||||
|
import jmbitcoin as btc |
||||||
|
from .wallet import PSBTWalletMixin, SegwitLegacyWallet, SegwitWallet |
||||||
|
from .wallet_service import WalletService |
||||||
|
from .taker_utils import direct_send |
||||||
|
|
||||||
|
""" |
||||||
|
For some documentation see: |
||||||
|
https://github.com/btcpayserver/btcpayserver-doc/blob/master/Payjoin-spec.md |
||||||
|
which is a delta to: |
||||||
|
https://github.com/bitcoin/bips/blob/master/bip-0079.mediawiki |
||||||
|
""" |
||||||
|
log = get_log() |
||||||
|
|
||||||
|
""" 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): |
||||||
|
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 |
||||||
|
# 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 |
||||||
|
self.server = server |
||||||
|
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 |
||||||
|
|
||||||
|
def set_payment_tx_and_psbt(self, in_psbt): |
||||||
|
assert isinstance(in_psbt, btc.PartiallySignedTransaction) |
||||||
|
self.initial_psbt = in_psbt |
||||||
|
# any failure here is a coding error, as it is fully |
||||||
|
# under our control. |
||||||
|
assert self.sanity_check_initial_payment() |
||||||
|
self.pj_state = self.JM_PJ_PAYMENT_CREATED |
||||||
|
|
||||||
|
def sanity_check_initial_payment(self): |
||||||
|
""" These checks are motivated by the checks specified |
||||||
|
for the *receiver* in the btcpayserver implementation doc. |
||||||
|
We want to make sure our payment isn't rejected. |
||||||
|
We also sanity check that the payment details match |
||||||
|
the initialization of this Manager object. |
||||||
|
""" |
||||||
|
# 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 inp.utxo and isinstance(inp.utxo, btc.CTxOut): |
||||||
|
return False |
||||||
|
|
||||||
|
# check that there is no xpub or derivation info |
||||||
|
if self.initial_psbt.xpubs: |
||||||
|
return False |
||||||
|
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 |
||||||
|
for out in self.initial_psbt.outputs: |
||||||
|
if out.derivation_map: |
||||||
|
return False |
||||||
|
|
||||||
|
# TODO we can replicate the mempool check here for |
||||||
|
# Core versions sufficiently high, also encapsulate |
||||||
|
# it in bc_interface. |
||||||
|
|
||||||
|
# our logic requires no more than one change output |
||||||
|
# for now: |
||||||
|
found_payment = 0 |
||||||
|
assert len(self.payment_tx.vout) in [1, 2] |
||||||
|
for out in self.payment_tx.vout: |
||||||
|
if out.nValue == self.amount and \ |
||||||
|
btc.CCoinAddress.from_scriptPubKey( |
||||||
|
out.scriptPubKey) == self.destination: |
||||||
|
found_payment += 1 |
||||||
|
else: |
||||||
|
# store this for our balance check |
||||||
|
# for receiver proposal |
||||||
|
self.change_out = out |
||||||
|
if not found_payment == 1: |
||||||
|
return False |
||||||
|
|
||||||
|
return True |
||||||
|
|
||||||
|
def check_receiver_proposal(self, in_pbst, 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. |
||||||
|
We perform the following checks of the receiver proposal: |
||||||
|
1. Check that there are more inputs (i.e. some were contributed). |
||||||
|
2. Does it contain our inputs, unchanged? |
||||||
|
3. Does it contain our payment output, with amount increased? |
||||||
|
4. Are the other inputs finalized, and of the correct type? |
||||||
|
5. Is the feerate unchanged within tolerance? |
||||||
|
6. Does it contain no xpub information or derivation information? |
||||||
|
7. Are the sequence numbers unchanged (and all the same) for the inputs? |
||||||
|
8. Is the nLockTime and version unchanged? |
||||||
|
9. Is the extra fee we pay in reduced change output less than a doubling? |
||||||
|
|
||||||
|
If all the above checks pass we will consider this valid, and cosign. |
||||||
|
Returns: |
||||||
|
(False, "reason for failure") |
||||||
|
(True, None) |
||||||
|
""" |
||||||
|
assert isinstance(in_pbst, btc.PartiallySignedTransaction) |
||||||
|
orig_psbt = self.initial_psbt |
||||||
|
assert isinstance(orig_psbt, btc.PartiallySignedTransaction) |
||||||
|
# 1 |
||||||
|
if len(in_pbst.inputs) <= len(orig_psbt.inputs): |
||||||
|
return (False, "Receiver did not contribute inputs to payjoin.") |
||||||
|
# 2 |
||||||
|
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_pbst.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([found[i] != 1 for i in range(len(found))]): |
||||||
|
return (False, "Receiver proposed PSBT does not contain our inputs.") |
||||||
|
# 3 |
||||||
|
found = 0 |
||||||
|
for out in in_pbst.unsigned_tx.vout: |
||||||
|
if btc.CCoinAddress.from_scriptPubKey(out.scriptPubKey) == \ |
||||||
|
self.destination and out.nValue >= self.amount: |
||||||
|
found += 1 |
||||||
|
if found != 1: |
||||||
|
return (False, "Our payment output not found exactly once or " |
||||||
|
"with wrong amount.") |
||||||
|
# 4 |
||||||
|
for ind in receiver_input_indices: |
||||||
|
# check the input is finalized |
||||||
|
if not self.wallet_service.is_input_finalized(in_pbst.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. |
||||||
|
spk = in_pbst.inputs[ind].utxo.scriptPubKey |
||||||
|
if isinstance(self.wallet_service.wallet, SegwitLegacyWallet): |
||||||
|
try: |
||||||
|
btc.P2SHCoinAddress.from_scriptPubKey(spk) |
||||||
|
except btc.P2SHCoinAddressError: |
||||||
|
return (False, |
||||||
|
"Receiver input type does not match ours.") |
||||||
|
elif isinstance(self.wallet_service.wallet, SegwitWallet): |
||||||
|
try: |
||||||
|
btc.P2WPKHCoinAddress.from_scriptPubKey(spk) |
||||||
|
except btc.P2WPKHCoinAddressError: |
||||||
|
return (False, |
||||||
|
"Receiver input type does not match ours.") |
||||||
|
else: |
||||||
|
assert False |
||||||
|
# 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. |
||||||
|
gffp = PSBTWalletMixin.get_fee_from_psbt |
||||||
|
proposed_tx_fee = gffp(signed_psbt_for_fees) |
||||||
|
nonpayjoin_tx_fee = gffp(self.initial_psbt) |
||||||
|
proposed_tx_size = signed_psbt_for_fees.extract_transaction( |
||||||
|
).get_virtual_size() |
||||||
|
nonpayjoin_tx_size = self.initial_psbt.extract_transaction( |
||||||
|
).get_virtual_size() |
||||||
|
proposed_fee_rate = proposed_tx_fee / float(proposed_tx_size) |
||||||
|
log.debug("proposed fee rate: " + str(proposed_fee_rate)) |
||||||
|
nonpayjoin_fee_rate = nonpayjoin_tx_fee / float(nonpayjoin_tx_size) |
||||||
|
log.debug("nonpayjoin fee rate: " + str(nonpayjoin_fee_rate)) |
||||||
|
diff_rate = abs(proposed_fee_rate - nonpayjoin_fee_rate)/nonpayjoin_fee_rate |
||||||
|
if diff_rate > 0.2: |
||||||
|
log.error("Bad fee rate differential: " + str(diff_rate)) |
||||||
|
return (False, "fee rate of payjoin tx is more than 20% different " |
||||||
|
"from inital fee rate, rejecting.") |
||||||
|
# 6 |
||||||
|
if in_pbst.xpubs: |
||||||
|
return (False, "Receiver proposal contains xpub information.") |
||||||
|
# 7 |
||||||
|
# we created all inputs with one sequence number, make sure everything |
||||||
|
# agrees |
||||||
|
# TODO - discussion with btcpayserver devs, docs will be updated, |
||||||
|
# server will agree with client in future. For now disabling check |
||||||
|
# (it's a very complicated issue, surprisingly!) |
||||||
|
#seqno = self.initial_psbt.unsigned_tx.vin[0].nSequence |
||||||
|
#for inp in in_pbst.unsigned_tx.vin: |
||||||
|
# if inp.nSequence != seqno: |
||||||
|
# return (False, "all sequence numbers are not the same.") |
||||||
|
# 8 |
||||||
|
if in_pbst.unsigned_tx.nLockTime != \ |
||||||
|
self.initial_psbt.unsigned_tx.nLockTime: |
||||||
|
return (False, "receiver proposal has altered nLockTime.") |
||||||
|
if in_pbst.unsigned_tx.nVersion != \ |
||||||
|
self.initial_psbt.unsigned_tx.nVersion: |
||||||
|
return (False, "receiver proposal has altered nVersion.") |
||||||
|
# 9 |
||||||
|
if proposed_tx_fee >= 2 * nonpayjoin_tx_fee: |
||||||
|
return (False, "receiver's tx fee is too large (possibly " |
||||||
|
"too many extra inputs.") |
||||||
|
# as well as the overall fee, check our pay-out specifically: |
||||||
|
for out in in_pbst.unsigned_tx.vout: |
||||||
|
if out.scriptPubKey == self.change_out.scriptPubKey: |
||||||
|
found += 1 |
||||||
|
if self.change_out.nValue - out.nValue > nonpayjoin_tx_fee: |
||||||
|
return (False, "Our change output was reduced too much.") |
||||||
|
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 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.hrt(self.payment_tx) |
||||||
|
if verbose: |
||||||
|
txdata = txdata["hex"] |
||||||
|
reportdict["payment-tx"] = txdata |
||||||
|
if self.payjoin_psbt: |
||||||
|
psbtdata = PSBTWalletMixin.hr_psbt( |
||||||
|
self.payjoin_psbt) if verbose else self.payjoin_psbt.to_base64() |
||||||
|
reportdict["payjoin-proposed"] = psbtdata |
||||||
|
if self.final_psbt: |
||||||
|
finaldata = PSBTWalletMixin.hr_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): |
||||||
|
""" 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"] |
||||||
|
|
||||||
|
return JMPayjoinManager(wallet_service, mixdepth, destaddr, amount, server) |
||||||
|
|
||||||
|
def send_payjoin(manager, accept_callback=None, |
||||||
|
info_callback=None, tls_whitelist=None): |
||||||
|
""" 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`. |
||||||
|
|
||||||
|
If `tls_whitelist` is a list of bytestrings, they are treated as hostnames |
||||||
|
for which tls certificate verification is ignored. Obviously this is ONLY for |
||||||
|
testing. |
||||||
|
|
||||||
|
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) |
||||||
|
if not payment_psbt: |
||||||
|
return (False, "could not create non-payjoin payment") |
||||||
|
|
||||||
|
manager.set_payment_tx_and_psbt(payment_psbt) |
||||||
|
# TODO add delayed call to broadcast this after 1 minute |
||||||
|
|
||||||
|
# Now we send the request to the server, with the encoded |
||||||
|
# payment PSBT |
||||||
|
if not tls_whitelist: |
||||||
|
agent = Agent(reactor) |
||||||
|
else: |
||||||
|
agent = Agent(reactor, |
||||||
|
contextFactory=WhitelistContextFactory(tls_whitelist)) |
||||||
|
|
||||||
|
body = BytesProducer(payment_psbt.to_base64().encode("utf-8")) |
||||||
|
# TODO what to use as user agent? |
||||||
|
d = agent.request(b"POST", manager.server.encode("utf-8"), |
||||||
|
Headers({"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) |
||||||
|
log.error(failure.value.reasons[0].getTraceback()) |
||||||
|
reactor.stop() |
||||||
|
d.addErrback(noResponse) |
||||||
|
return (True, None) |
||||||
|
|
||||||
|
def fallback_nonpayjoin_broadcast(manager, err): |
||||||
|
assert isinstance(manager, JMPayjoinManager) |
||||||
|
log.warn("Payjoin did not succeed, falling back to non-payjoin payment.") |
||||||
|
log.warn("Error message was: " + str(err)) |
||||||
|
original_tx = manager.initial_psbt.extract_transaction() |
||||||
|
if not jm_single().bc_interface.pushtx(original_tx.serialize()): |
||||||
|
log.error("Unable to broadcast original payment. The payment is NOT made.") |
||||||
|
log.info("We paid without coinjoin. Transaction: ") |
||||||
|
log.info(btc.hrt(original_tx)) |
||||||
|
reactor.stop() |
||||||
|
|
||||||
|
def receive_payjoin_proposal_from_server(response, manager): |
||||||
|
assert isinstance(manager, JMPayjoinManager) |
||||||
|
|
||||||
|
# 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: |
||||||
|
fallback_nonpayjoin_broadcast(manager, err=response.phrase) |
||||||
|
return |
||||||
|
# for debugging; will be removed in future: |
||||||
|
log.debug("Response headers:") |
||||||
|
log.debug(pformat(list(response.headers.getAllRawHeaders()))) |
||||||
|
# no attempt at chunking or handling incrementally is needed |
||||||
|
# here. The body should be a byte string containing the |
||||||
|
# new PSBT. |
||||||
|
d = readBody(response) |
||||||
|
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(manager, err="Server sent invalid psbt") |
||||||
|
return |
||||||
|
|
||||||
|
log.debug("Receiver sent us this PSBT: ") |
||||||
|
log.debug(manager.wallet_service.hr_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.inputs[i].utxo = \ |
||||||
|
manager.initial_psbt.inputs[j].utxo |
||||||
|
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="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="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.hr_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.hrt(extracted_tx)) |
||||||
|
if not jm_single().bc_interface.pushtx(extracted_tx.serialize()): |
||||||
|
log.info("The above transaction failed to broadcast.") |
||||||
|
else: |
||||||
|
log.info("Payjoin transactoin broadcast successfully.") |
||||||
|
reactor.stop() |
||||||
@ -0,0 +1,49 @@ |
|||||||
|
#!/usr/bin/env python |
||||||
|
import sys |
||||||
|
from twisted.internet import reactor |
||||||
|
from jmclient.cli_options import check_regtest |
||||||
|
from jmclient import (get_wallet_path, WalletService, open_test_wallet_maybe, |
||||||
|
jm_single, load_test_config, |
||||||
|
SegwitLegacyWallet, SegwitWallet) |
||||||
|
from jmclient.payjoin import send_payjoin, parse_payjoin_setup |
||||||
|
|
||||||
|
if __name__ == "__main__": |
||||||
|
wallet_name = sys.argv[1] |
||||||
|
mixdepth = int(sys.argv[2]) |
||||||
|
usessl = int(sys.argv[3]) |
||||||
|
bip21uri = None |
||||||
|
if len(sys.argv) > 4: |
||||||
|
bip21uri = sys.argv[4] |
||||||
|
load_test_config() |
||||||
|
jm_single().datadir = "." |
||||||
|
check_regtest() |
||||||
|
if not bip21uri: |
||||||
|
if usessl == 0: |
||||||
|
pjurl = "http://127.0.0.1:8080" |
||||||
|
else: |
||||||
|
pjurl = "https://127.0.0.1:8080" |
||||||
|
bip21uri = "bitcoin:2N7CAdEUjJW9tUHiPhDkmL9ukPtcukJMoxK?amount=0.3&pj=" + pjurl |
||||||
|
wallet_path = get_wallet_path(wallet_name, None) |
||||||
|
if jm_single().config.get("POLICY", "native") == "true": |
||||||
|
walletclass = SegwitWallet |
||||||
|
else: |
||||||
|
walletclass = SegwitLegacyWallet |
||||||
|
wallet = open_test_wallet_maybe( |
||||||
|
wallet_path, wallet_name, 4, |
||||||
|
wallet_password_stdin=False, |
||||||
|
test_wallet_cls=walletclass, |
||||||
|
gap_limit=6) |
||||||
|
wallet_service = WalletService(wallet) |
||||||
|
# in this script, we need the wallet synced before |
||||||
|
# logic processing for some paths, so do it now: |
||||||
|
while not wallet_service.synced: |
||||||
|
wallet_service.sync_wallet(fast=True) |
||||||
|
# the sync call here will now be a no-op: |
||||||
|
wallet_service.startService() |
||||||
|
manager = parse_payjoin_setup(bip21uri, wallet_service, mixdepth) |
||||||
|
if usessl == 0: |
||||||
|
tlshostnames = None |
||||||
|
else: |
||||||
|
tlshostnames = [b"127.0.0.1"] |
||||||
|
reactor.callWhenRunning(send_payjoin, manager, tls_whitelist=tlshostnames) |
||||||
|
reactor.run() |
||||||
@ -0,0 +1,178 @@ |
|||||||
|
#! /usr/bin/env python |
||||||
|
""" Creates a very simple server for payjoin |
||||||
|
payment requests; uses regtest and a single |
||||||
|
JM wallet, provides a hex seed for the sender |
||||||
|
side of the test. |
||||||
|
Use the same command line setup as for ygrunner.py, |
||||||
|
except you needn't specify --nirc= |
||||||
|
NOTE: to run this test you will need a `key.pem` |
||||||
|
and a `cert.pem` in this (test/) directory, |
||||||
|
created in the standard way for ssl certificates. |
||||||
|
Note that (in test) the client will not verify |
||||||
|
them. |
||||||
|
""" |
||||||
|
import os |
||||||
|
from twisted.web.server import Site |
||||||
|
from twisted.web.resource import Resource |
||||||
|
from twisted.internet import ssl |
||||||
|
from twisted.internet import reactor, endpoints |
||||||
|
from io import BytesIO |
||||||
|
from common import make_wallets |
||||||
|
import pytest |
||||||
|
from jmbase import jmprint |
||||||
|
import jmbitcoin as btc |
||||||
|
from jmclient import load_test_config, jm_single,\ |
||||||
|
SegwitWallet, SegwitLegacyWallet, cryptoengine |
||||||
|
|
||||||
|
# TODO change test for arbitrary payment requests |
||||||
|
payment_amt = 30000000 |
||||||
|
|
||||||
|
dir_path = os.path.dirname(os.path.realpath(__file__)) |
||||||
|
|
||||||
|
def get_ssl_context(): |
||||||
|
"""Construct an SSL context factory from the user's privatekey/cert. |
||||||
|
Here just hardcoded for tests. |
||||||
|
Note this is off by default since the cert needs setting up. |
||||||
|
""" |
||||||
|
return ssl.DefaultOpenSSLContextFactory(os.path.join(dir_path, "key.pem"), |
||||||
|
os.path.join(dir_path, "cert.pem")) |
||||||
|
|
||||||
|
class PayjoinServer(Resource): |
||||||
|
def __init__(self, wallet_service): |
||||||
|
self.wallet_service = wallet_service |
||||||
|
super().__init__() |
||||||
|
isLeaf = True |
||||||
|
def render_GET(self, request): |
||||||
|
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. |
||||||
|
""" |
||||||
|
jmprint("The server got this POST request: ") |
||||||
|
print(request) |
||||||
|
print(request.method) |
||||||
|
print(request.uri) |
||||||
|
print(request.args) |
||||||
|
print(request.path) |
||||||
|
print(request.content) |
||||||
|
proposed_tx = request.content |
||||||
|
assert isinstance(proposed_tx, BytesIO) |
||||||
|
payment_psbt_base64 = proposed_tx.read() |
||||||
|
payment_psbt = btc.PartiallySignedTransaction.from_base64( |
||||||
|
payment_psbt_base64) |
||||||
|
all_receiver_utxos = self.wallet_service.get_all_utxos() |
||||||
|
# TODO is there a less verbose way to get any 2 utxos from the dict? |
||||||
|
receiver_utxos_keys = list(all_receiver_utxos.keys())[:2] |
||||||
|
receiver_utxos = {k: v for k, v in all_receiver_utxos.items( |
||||||
|
) if k in receiver_utxos_keys} |
||||||
|
|
||||||
|
# receiver will do other checks as discussed above, including payment |
||||||
|
# amount; as discussed above, this is out of the scope of this PSBT test. |
||||||
|
|
||||||
|
# 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()) |
||||||
|
# find payment output and change output |
||||||
|
pay_out = None |
||||||
|
change_out = None |
||||||
|
for o in payment_psbt.unsigned_tx.vout: |
||||||
|
jm_out_fmt = {"value": o.nValue, |
||||||
|
"address": str(btc.CCoinAddress.from_scriptPubKey( |
||||||
|
o.scriptPubKey))} |
||||||
|
if o.nValue == payment_amt: |
||||||
|
assert pay_out is None |
||||||
|
pay_out = jm_out_fmt |
||||||
|
else: |
||||||
|
assert change_out is None |
||||||
|
change_out = jm_out_fmt |
||||||
|
|
||||||
|
# we now know there were two outputs and know which is payment. |
||||||
|
# bump payment output with our input: |
||||||
|
outs = [pay_out, change_out] |
||||||
|
our_inputs_val = sum([v["value"] for _, v in receiver_utxos.items()]) |
||||||
|
pay_out["value"] += our_inputs_val |
||||||
|
print("we bumped the payment output value by: ", our_inputs_val) |
||||||
|
print("It is now: ", pay_out["value"]) |
||||||
|
unsigned_payjoin_tx = btc.make_shuffled_tx(payjoin_tx_inputs, outs, |
||||||
|
version=payment_psbt.unsigned_tx.nVersion, |
||||||
|
locktime=payment_psbt.unsigned_tx.nLockTime) |
||||||
|
print("we created this unsigned tx: ") |
||||||
|
print(btc.hrt(unsigned_payjoin_tx)) |
||||||
|
# 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: |
||||||
|
spent_outs.append(payment_psbt.inputs[j].utxo) |
||||||
|
input_found = True |
||||||
|
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 |
||||||
|
|
||||||
|
r_payjoin_psbt = self.wallet_service.create_psbt_from_tx(unsigned_payjoin_tx, |
||||||
|
spent_outs=spent_outs) |
||||||
|
print("Receiver created payjoin PSBT:\n{}".format( |
||||||
|
self.wallet_service.hr_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 |
||||||
|
|
||||||
|
print("Receiver signing successful. Payjoin PSBT is now:\n{}".format( |
||||||
|
self.wallet_service.hr_psbt(receiver_signed_psbt))) |
||||||
|
content = receiver_signed_psbt.to_base64() |
||||||
|
request.setHeader(b"content-length", ("%d" % len(content)).encode("ascii")) |
||||||
|
return content.encode("ascii") |
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_payjoin_server(setup_payjoin_server): |
||||||
|
# set up the wallet that the server owns, and the wallet for |
||||||
|
# the sender too (print the seed): |
||||||
|
if jm_single().config.get("POLICY", "native") == "true": |
||||||
|
walletclass = SegwitWallet |
||||||
|
else: |
||||||
|
walletclass = SegwitLegacyWallet |
||||||
|
|
||||||
|
wallet_services = make_wallets(2, |
||||||
|
wallet_structures=[[1, 3, 0, 0, 0]] * 2, |
||||||
|
mean_amt=2, |
||||||
|
walletclass=walletclass) |
||||||
|
#the server bot uses the first wallet, the sender the second |
||||||
|
server_wallet_service = wallet_services[0]['wallet'] |
||||||
|
jmprint("\n\nTaker wallet seed : " + wallet_services[1]['seed']) |
||||||
|
jmprint("\n") |
||||||
|
server_wallet_service.sync_wallet(fast=True) |
||||||
|
|
||||||
|
site = Site(PayjoinServer(server_wallet_service)) |
||||||
|
# TODO for now, just sticking with TLS test as non-encrypted |
||||||
|
# is unlikely to be used, but add that option. |
||||||
|
reactor.listenSSL(8080, site, contextFactory=get_ssl_context()) |
||||||
|
#endpoint = endpoints.TCP4ServerEndpoint(reactor, 8080) |
||||||
|
#endpoint.listen(site) |
||||||
|
reactor.run() |
||||||
|
|
||||||
|
@pytest.fixture(scope="module") |
||||||
|
def setup_payjoin_server(): |
||||||
|
load_test_config() |
||||||
|
jm_single().bc_interface.tick_forward_chain_interval = 10 |
||||||
|
jm_single().bc_interface.simulate_blocks() |
||||||
|
# handles the custom regtest hrp for bech32 |
||||||
|
cryptoengine.BTC_P2WPKH.VBYTE = 100 |
||||||
Loading…
Reference in new issue