From 9cc03336b28aa279c137cac844d218ec4ee73c71 Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Sat, 25 Apr 2020 11:59:12 +0100 Subject: [PATCH] Adds full payjoin workflow test --- jmbitcoin/jmbitcoin/__init__.py | 5 +- jmclient/jmclient/wallet.py | 2 +- jmclient/test/test_psbt_wallet.py | 163 +++++++++++++++++++++++++++++- 3 files changed, 165 insertions(+), 5 deletions(-) diff --git a/jmbitcoin/jmbitcoin/__init__.py b/jmbitcoin/jmbitcoin/__init__.py index de68a54bd..12f4f934c 100644 --- a/jmbitcoin/jmbitcoin/__init__.py +++ b/jmbitcoin/jmbitcoin/__init__.py @@ -6,8 +6,9 @@ from jmbitcoin.amount import * from bitcointx import select_chain_params from bitcointx.core import (x, b2x, b2lx, lx, COutPoint, CTxOut, CTxIn, - CTxInWitness, CTxWitness, CMutableTransaction, - Hash160, coins_to_satoshi, satoshi_to_coins) + CTxInWitness, CTxWitness, CTransaction, + CMutableTransaction, Hash160, + coins_to_satoshi, satoshi_to_coins) from bitcointx.core.key import KeyStore from bitcointx.core.script import (CScript, OP_0, SignatureHash, SIGHASH_ALL, SIGVERSION_WITNESS_V0, CScriptWitness) diff --git a/jmclient/jmclient/wallet.py b/jmclient/jmclient/wallet.py index 741dc970d..941138d8b 100644 --- a/jmclient/jmclient/wallet.py +++ b/jmclient/jmclient/wallet.py @@ -1035,7 +1035,7 @@ def create_psbt_from_tx(self, tx, spent_outs=None): if spent_outs[i] is None: # as above, will not be signable in this case continue - if isinstance(spent_outs[i], (btc.CMutableTransaction, btc.CMutableTxOut)): + if isinstance(spent_outs[i], (btc.CTransaction, btc.CTxOut)): # note that we trust the caller to choose Tx vs TxOut as according # to non-witness/witness: txinput.utxo = spent_outs[i] diff --git a/jmclient/test/test_psbt_wallet.py b/jmclient/test/test_psbt_wallet.py index 93ac3353e..154bd8d81 100644 --- a/jmclient/test/test_psbt_wallet.py +++ b/jmclient/test/test_psbt_wallet.py @@ -164,8 +164,167 @@ def test_create_psbt_and_sign(setup_psbt_wallet, unowned_utxo, wallet_cls): # transaction extraction must fail for not-fully-signed psbts: with pytest.raises(ValueError) as e: extracted_tx = signed_psbt.extract_transaction() - - + +@pytest.mark.parametrize('payment_amt, wallet_cls_sender, wallet_cls_receiver', [ + (0.05, SegwitLegacyWallet, SegwitLegacyWallet), + (0.95, SegwitLegacyWallet, SegwitWallet), + (0.05, SegwitWallet, SegwitLegacyWallet), + (0.95, SegwitWallet, SegwitWallet), +]) +def test_payjoin_workflow(setup_psbt_wallet, payment_amt, wallet_cls_sender, + wallet_cls_receiver): + """ Workflow step 1: + Create a payment from a wallet, and create a finalized PSBT. + This step is fairly trivial as the functionality is built-in to + PSBTWalletMixin. + Note that only Segwit* wallets are supported for PayJoin. + + Workflow step 2: + Receiver creates a new partially signed PSBT with the same amount + and at least one more utxo. + + Workflow step 3: + Given a partially signed PSBT created by a receiver, here the sender + completes (co-signs) the PSBT they are given. Note this code is a PSBT + functionality check, and does NOT include the detailed checks that + the sender should perform before agreeing to sign (see: + https://github.com/btcpayserver/btcpayserver-doc/blob/eaac676866a4d871eda5fd7752b91b88fdf849ff/Payjoin-spec.md#receiver-side + ). + """ + + wallet_r = make_wallets(1, [[3,0,0,0,0]], 1, + wallet_cls=wallet_cls_receiver)[0]["wallet"] + wallet_s = make_wallets(1, [[3,0,0,0,0]], 1, + wallet_cls=wallet_cls_sender)[0]["wallet"] + for w in [wallet_r, wallet_s]: + w.sync_wallet(fast=True) + + # destination address for payment: + destaddr = str(bitcoin.CCoinAddress.from_scriptPubKey( + bitcoin.pubkey_to_p2wpkh_script(bitcoin.privkey_to_pubkey(b"\x01"*33)))) + + payment_amt = bitcoin.coins_to_satoshi(payment_amt) + + # *** STEP 1 *** + # ************** + + # create a normal tx from the sender wallet: + payment_psbt = direct_send(wallet_s, payment_amt, 0, destaddr, + accept_callback=dummy_accept_callback, + info_callback=dummy_info_callback, + with_final_psbt=True) + + # ensure that the payemnt amount is what was intended: + out_amts = [x.nValue for x in payment_psbt.unsigned_tx.vout] + # NOTE this would have to change for more than 2 outputs: + assert any([out_amts[i] == payment_amt for i in [0, 1]]) + + # ensure that we can actually broadcast the created tx: + # (note that 'extract_transaction' represents an implicit + # PSBT finality check). + extracted_tx = payment_psbt.extract_transaction().serialize() + # don't want to push the tx right now, because of test structure + # (in production code this isn't really needed, we will not + # produce invalid payment transactions). + res = jm_single().bc_interface.rpc('testmempoolaccept', + [[bintohex(extracted_tx)]]) + assert res[0]["allowed"], "Payment transaction was rejected from mempool." + + # *** STEP 2 *** + # ************** + + # This step will not be in Joinmarket code for the first cut, + # it will be done by the merchant, but included here for the data flow. + # receiver grabs a random utxo here (as per previous sentence, this is + # the merchant's responsibility, not ours, but see earlier code in + # jmclient.maker.P2EPMaker for possibe heuristics). + # for more generality we test with two receiver-utxos, not one. + all_receiver_utxos = wallet_r.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(bitcoin.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 = bitcoin.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(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( + wallet_r.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 = wallet_r.create_psbt_from_tx(unsigned_payjoin_tx, + spent_outs=spent_outs) + signresultandpsbt, err = wallet_r.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 + + # *** STEP 3 *** + # ************** + + # take the half-signed PSBT, validate and co-sign: + + signresultandpsbt, err = wallet_s.sign_psbt( + receiver_signed_psbt.serialize(), with_sign_result=True) + assert not err, err + signresult, sender_signed_psbt = signresultandpsbt + assert signresult.is_final + # broadcast the tx + extracted_tx = sender_signed_psbt.extract_transaction().serialize() + assert jm_single().bc_interface.pushtx(extracted_tx) + @pytest.fixture(scope="module") def setup_psbt_wallet(): load_test_config()