You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
598 lines
26 KiB
598 lines
26 KiB
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.internet.error import ConnectionRefusedError |
|
from twisted.web.http_headers import Headers |
|
import urllib.parse as urlparse |
|
from urllib.parse import urlencode |
|
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/bitcoin/bips/blob/master/bip-0078.mediawiki |
|
and an earlier document: |
|
https://github.com/btcpayserver/btcpayserver-doc/blob/master/Payjoin-spec.md |
|
and even earlier: |
|
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, disable_output_substitution=False): |
|
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.disable_output_substitution = disable_output_substitution |
|
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 |
|
self.change_out_index = 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 isinstance(inp.witness_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 i, out in enumerate(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 |
|
self.change_out_index = i |
|
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. |
|
See https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki#senders-payjoin-proposal-checklist |
|
We perform the following checks of the receiver proposal: |
|
1. Does it contain our inputs, unchanged? |
|
2. if output substitution was disabled: |
|
check that the payment output (same scriptPubKey) has |
|
amount equal to or greater than original tx. |
|
if output substition is not disabled: |
|
no check here (all of index, sPK and amount may be altered) |
|
3. Are the other inputs (if they exist) finalized, and of the correct type? |
|
4. Is the absolute fee >= fee of original tx? |
|
5. Check that the feerate of the transaction is not less than our minfeerate |
|
(after signing - we have the signed version here). |
|
6. If we have a change output, check that: |
|
- the change output still exists, exactly once |
|
- amount subtracted from self.change_out is less than or equal to |
|
maxadditionalfeecontribution. |
|
- Check that the MAFC is only going to fee: check difference between |
|
new fee and old fee is >= MAFC |
|
We do not need to further check against number of new inputs, since |
|
we already insisted on only paying for one. |
|
7. Does it contain no xpub information or derivation information? |
|
8. Are the sequence numbers unchanged (and all the same) for the inputs? |
|
9. Is the nLockTime and version unchanged? |
|
|
|
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 |
|
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([f != 1 for f in found]): |
|
return (False, "Receiver proposed PSBT does not contain our inputs.") |
|
# 2 |
|
if self.disable_output_substitution: |
|
found_payment = 0 |
|
for out in in_pbst.unsigned_tx.vout: |
|
if btc.CCoinAddress.from_scriptPubKey(out.scriptPubKey) == \ |
|
self.destination and out.nValue >= self.amount: |
|
found_payment += 1 |
|
if found_payment != 1: |
|
return (False, "Our payment output not found exactly once or " |
|
"with wrong amount.") |
|
# 3 |
|
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. |
|
# TODO this can be genericized to arbitrary wallets in future. |
|
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 |
|
# 4, 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. |
|
try: |
|
proposed_tx_fee = signed_psbt_for_fees.get_fee() |
|
except ValueError: |
|
return (False, "receiver proposed tx has negative fee.") |
|
nonpayjoin_tx_fee = self.initial_psbt.get_fee() |
|
if proposed_tx_fee < nonpayjoin_tx_fee: |
|
return (False, "receiver proposed transaction has lower fee.") |
|
proposed_tx_size = signed_psbt_for_fees.extract_transaction( |
|
).get_virtual_size() |
|
proposed_fee_rate = proposed_tx_fee / float(proposed_tx_size) |
|
log.debug("proposed fee rate: " + str(proposed_fee_rate)) |
|
if proposed_fee_rate < float( |
|
jm_single().config.get("PAYJOIN", "min_fee_rate")): |
|
return (False, "receiver proposed transaction has too low " |
|
"feerate: " + str(proposed_fee_rate)) |
|
# 6 |
|
if self.change_out: |
|
found_change = 0 |
|
for out in in_pbst.unsigned_tx.vout: |
|
if out.scriptPubKey == self.change_out.scriptPubKey: |
|
found_change += 1 |
|
actual_contribution = self.change_out.nValue - out.nValue |
|
if actual_contribution > in_pbst.get_fee( |
|
) - self.initial_psbt.get_fee(): |
|
return (False, "Our change output is reduced more" |
|
" than the fee is bumped.") |
|
mafc = get_max_additional_fee_contribution(self) |
|
if actual_contribution > mafc: |
|
return (False, "Proposed transactions requires " |
|
"us to pay more additional fee that we " |
|
"agreed to: " + str(mafc) + " sats.") |
|
# note this check is only if the initial tx had change: |
|
if found_change != 1: |
|
return (False, "Our change output was not found " |
|
"exactly once.") |
|
# 7 |
|
if in_pbst.xpubs: |
|
return (False, "Receiver proposal contains xpub information.") |
|
# 8 |
|
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.") |
|
# 9 |
|
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.") |
|
# all checks passed |
|
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.human_readable_transaction(self.payment_tx) |
|
if verbose: |
|
txdata = txdata["hex"] |
|
reportdict["payment-tx"] = txdata |
|
if self.payjoin_psbt: |
|
psbtdata = PSBTWalletMixin.human_readable_psbt( |
|
self.payjoin_psbt) if verbose else self.payjoin_psbt.to_base64() |
|
reportdict["payjoin-proposed"] = psbtdata |
|
if self.final_psbt: |
|
finaldata = PSBTWalletMixin.human_readable_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"] |
|
disable_output_substitution = False |
|
if "pjos" in decoded and decoded["pjos"] == "0": |
|
disable_output_substitution = True |
|
return JMPayjoinManager(wallet_service, mixdepth, destaddr, amount, server, |
|
disable_output_substitution=disable_output_substitution) |
|
|
|
def get_max_additional_fee_contribution(manager): |
|
""" See definition of maxadditionalfeecontribution in BIP 78. |
|
""" |
|
max_additional_fee_contribution = jm_single( |
|
).config.get("PAYJOIN", "max_additional_fee_contribution") |
|
if max_additional_fee_contribution == "default": |
|
# calculate the fee bumping allowed according to policy: |
|
if isinstance(manager.wallet_service.wallet, SegwitLegacyWallet): |
|
vsize = 91 |
|
elif isinstance(manager.wallet_service.wallet, SegwitWallet): |
|
vsize = 68 |
|
else: |
|
raise Exception("Payjoin only supported for segwit wallets") |
|
original_fee_rate = manager.initial_psbt.get_fee()/float( |
|
manager.initial_psbt.extract_transaction().get_virtual_size()) |
|
log.debug("Initial nonpayjoin transaction feerate is: " + str(original_fee_rate)) |
|
max_additional_fee_contribution = int(original_fee_rate * 1.2 * vsize) |
|
log.debug("From which we calculated a max additional fee " |
|
"contribution of: " + str(max_additional_fee_contribution)) |
|
return max_additional_fee_contribution |
|
|
|
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) |
|
|
|
# add delayed call to broadcast this after 1 minute |
|
reactor.callLater(60, fallback_nonpayjoin_broadcast, manager, b"timeout") |
|
|
|
# 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")) |
|
|
|
#Set the query parameters for the request: |
|
|
|
# construct the URI from the given parameters |
|
pj_version = jm_single().config.getint("PAYJOIN", |
|
"payjoin_version") |
|
params = {"v": pj_version} |
|
|
|
disable_output_substitution = "false" |
|
if manager.disable_output_substitution: |
|
disable_output_substitution = "true" |
|
else: |
|
if jm_single().config.getint("PAYJOIN", |
|
"disable_output_substitution") == 1: |
|
disable_output_substitution = "true" |
|
params["disableoutputsubstitution"] = disable_output_substitution |
|
|
|
# to determine the additionalfeeoutputindex in cases where we have |
|
# change and we are allowing fee bump, we examine the initial tx: |
|
if manager.change_out: |
|
params["additionalfeeoutputindex"] = manager.change_out_index |
|
params["maxadditionalfeecontribution"] = \ |
|
get_max_additional_fee_contribution(manager) |
|
|
|
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) |
|
log.error(failure.value) |
|
fallback_nonpayjoin_broadcast(manager, b"connection refused") |
|
d.addErrback(noResponse) |
|
return (True, None) |
|
|
|
def fallback_nonpayjoin_broadcast(manager, err): |
|
""" Sends the non-coinjoin payment onto the network, |
|
assuming that the payjoin failed. The reason for failure is |
|
`err` and will usually be communicated by the server, and must |
|
be a bytestring. |
|
Note that the reactor is shutdown after sending the payment (one-shot |
|
processing). |
|
""" |
|
assert isinstance(manager, JMPayjoinManager) |
|
def quit(): |
|
for dc in reactor.getDelayedCalls(): |
|
dc.cancel() |
|
reactor.stop() |
|
log.warn("Payjoin did not succeed, falling back to non-payjoin payment.") |
|
log.warn("Error message was: " + err.decode("utf-8")) |
|
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.") |
|
quit() |
|
return |
|
log.info("We paid without coinjoin. Transaction: ") |
|
log.info(btc.human_readable_transaction(original_tx)) |
|
quit() |
|
|
|
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.human_readable_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.set_utxo( |
|
manager.initial_psbt.inputs[j].utxo, i) |
|
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.human_readable_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.human_readable_transaction(extracted_tx)) |
|
if not jm_single().bc_interface.pushtx(extracted_tx.serialize()): |
|
log.info("The above transaction failed to broadcast.") |
|
else: |
|
log.info("Payjoin transaction broadcast successfully.") |
|
reactor.stop()
|
|
|