From 23d0b8f39c40588a6f076c30a6c278d965de4e32 Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Sun, 6 Sep 2020 12:20:32 +0100 Subject: [PATCH] BIP78 receiver over a Tor hidden service. This commit implements a command line script and a GUI dialog to receive a payment using the BIP78 protocol, by setting up an ephemeral hidden service. It also deprecates the pre-existing inter-Joinmarket protocol for payjoin payments, since we now have both sending and receiving support for BIP78. Thus, much code in Maker, Taker and client-daemon protocol is removed, as is some documentation in docs/PAYJOIN.md. Also the script `sendpayment.py` is altered to support only the BIP78 variant. The test in jmclient/test/test_payjoin now implements BIP78 over a TCP connection, while the custom tests in test/payjoinserver.py can support hidden service based tests, but the latter is not included in the test suite and may not always work (it is only for manual investigations). The following features of BIP78 are supported: minfeerate additionalfeeoutputindex - but *only* for single change output transactions maxadditionalfeecontribution The receiver does not have nor request payment output substitution. Utxo selection is no longer sophisticated, instead we only choose a single utxo to keep the size increase of the transaction minimal. Thus UIH is not addressed at the moment. Errors returned are in line with BIP78. Sequence numbers are checked by receiver, and kept identical if uniform, otherwise respected. Receiver uses transaction monitor to shut down when the payment is seen. The workflow is almost entirely implemented in jmclient/payjoin.py and the command line script is in scripts/receive-payjoin.py. The setup, including configuration changes for Tor, are documented in docs/PAYJOIN.md, including a user guide video linked. --- docs/PAYJOIN.md | 345 +++++-------- jmbitcoin/jmbitcoin/bip21.py | 4 +- jmclient/jmclient/__init__.py | 9 +- jmclient/jmclient/cli_options.py | 9 - jmclient/jmclient/client_protocol.py | 59 +-- jmclient/jmclient/configure.py | 11 + jmclient/jmclient/maker.py | 357 -------------- jmclient/jmclient/payjoin.py | 695 +++++++++++++++++++++++---- jmclient/jmclient/taker.py | 331 ------------- jmclient/jmclient/wallet.py | 21 + jmclient/test/test_payjoin.py | 207 ++++---- jmclient/test/test_psbt_wallet.py | 8 +- jmdaemon/jmdaemon/__init__.py | 2 +- jmdaemon/jmdaemon/daemon_protocol.py | 154 ------ scripts/joinmarket-qt.py | 59 ++- scripts/qtsupport.py | 150 ++++++ scripts/receive-payjoin.py | 49 +- scripts/sendpayment.py | 49 +- test/payjoinserver.py | 112 +---- 19 files changed, 1108 insertions(+), 1523 deletions(-) diff --git a/docs/PAYJOIN.md b/docs/PAYJOIN.md index aa76f11..5e891ab 100644 --- a/docs/PAYJOIN.md +++ b/docs/PAYJOIN.md @@ -1,6 +1,6 @@ ### PayJoin (aka P2EP) user guide. -(You've installed using the `install.sh` as per instructions in the README before +(You've installed using the `install.sh` or similar as per instructions in the README before reading this). This document does **not** discuss why PayJoin is interesting or the general concept. @@ -20,9 +20,17 @@ For that, see [this](https://joinmarket.me/blog/blog/payjoin/) post. b. [Using Joinmarket-wallet-to-Joinmarket-wallet payjoins](#jmtojm) + c. [About fees](#fees) + 5. [What if I wanted bech32 native segwit addresses?](#native) -6. [Sample testnet wallet display output](#sample) +6. [Receiving a BIP78 Payjoin payment](#receiving) + +7. [Configuring Tor to setup a hidden service](#torconfig) + +8. [Using JoinmarketQt to send and receive Payjoins](#using-qt) + +7. [Sample testnet wallet display output](#sample) Some instructions here will be redundant with the introductory [usage guide](USAGE.md); sections 1-3 are aimed at users who have not/ will not use Joinmarket for ordinary coinjoins. So just skip those sections if you already know it. @@ -124,7 +132,7 @@ python wallet-tool.py wallet-name-you-chose.dat [display] ("display" is optional because default; use `python wallet-tool.py -h` to see all possible methods). -Below is [an example](#sample-testnet-wallet-display-output) of what the wallet looks like (although +Below is [an example](#sample) of what the wallet looks like (although yours will be mainnet, so the addresses will start with '3' not '2'). Joinmarket by default uses *5* accounts, not only 1 as some other wallets (e.g. Electrum), this is to help @@ -142,6 +150,8 @@ including the 12 word seed, although consider privacy concerns when sending addr ### Doing a PayJoin payment. +This section gives details on how to make payments with Payjoin. You might prefer to start with the video linked [here](#using-qt) to see how this works if you are using JoinmarketQt rather than the command line. + #### Using BIP78 payjoins to pay a merchant. @@ -153,8 +163,8 @@ The process here is to use the syntax of sendpayment.py: ``` Notes on this: -* Payjoins BIP78 style are done using the `sendpayment` script (there is no Qt support yet, but it will come later). -* They are done using BIP21 URIs. These can be copy/pasted from a website (e.g. a btcpayserver invoice page), note that double quotes are required because the string contains special characters. Note also that you must see `pj=` in the URI, otherwise payjoin is not supported by that server. +* Payjoins BIP78 style are done using the `sendpayment` script, or by entering the BIP21 URI into the "Recipient" field in JoinmarketQt. +* They are done using BIP21 URIs. These can be copy/pasted from a website (e.g. a btcpayserver invoice page), note that double quotes are required (on the command line) because the string contains special characters. Note also that you must see `pj=` in the URI, otherwise payjoin is not supported by that server. * If the url in `pj=` is `****.onion` it means you must be using Tor, remember to have Tor running on your system and change the configuration (see below) for sock5 port if necessary. If you are running the Tor browser the port is 9150 instead of 9050. * Don't forget to specify the mixdepth you are spending from with `-m 0`. The payment amount is of course in the URI, along with the address. * Pay attention to address type; this point is complicated, but: some servers will not be able to match the address type of the sender, and so won't be able to construct sensible Payjoin transactions. In that case they may fallback to the non-Payjoin payment (which is not a disaster). If you want to do a Payjoin with a server that only supports bech32, you will have to create a new Joinmarket wallet, specifying `native=true` in the `POLICY` section of `joinmarket.cfg` before you generate the wallet. @@ -213,144 +223,163 @@ bump the fee enough to add one input to the transaction, and this should be fine #### Using Joinmarket-wallet-to-Joinmarket-wallet payjoins -(At the end of this file are full terminal outputs from a regtest run of the process, -you can read it after this to see that it makes sense; there's also a video -[here](https://joinmarket.me/blog/blog/payjoin-basic-demo) of the process running live with mainnet coins). +This is now deprecated; if you still want to use it, use Joinmarket(-clientserver) version 0.7.0 or lower, and see the corresponding older version of this document. -* Receiver needs to start: run (still in scripts/ directory): + -``` -(jmvenv)a$ python receive-payjoin.py -m 1 receiver-wallet-name.jmdat amount -``` +#### About fees -Note : `-m 1` is choosing the *mixdepth* (see above) to *spend* coins from: in a payjoin, -the receiver also spends some coins, he just gets that amount back, as well as his payment. +In the joinmarket.cfg file, under `[POLICY]` section you should see a setting called `tx_fees`. +You can set this to any integer; if you set it to 1000 or less then it's treated as a "confirmation in N blocks target", +i.e. if you set it to 3 (the default), the fee is chosen from Bitcoin Core's estimator to target confirmation in 3 blocks. +So if you change it to e.g. 10, it will be cheaper but projected to get its first confirmation in 10 blocks on average. -If you funded into mixdepth 0 at the start, and you only have coins there, you must choose 0 -here (which is the default). How much do you need? The code is fairly lenient and it doesn't -matter too much. But it needs to be more than zero! +If you set it to a number > 1000, though, it's a number of satoshis per kilobyte (technically, kilo-vbyte) that you want +to use. **Don't use less than about 1200 if you do this** - a typical figure might be 5000 or 10000, corresponding to +about 5-10 sats/byte, which nowadays is a reasonable fee. The exact amount is randomised by ~20% to avoid you inadvertently +watermarking all your transactions. So don't use < 1200 because then you might be using less than 1 sat/byte which is +difficult to relay on the Bitcoin network. + +BIP78 itself has various controls around fees - essentially it tries to let the receiver bump the fee but *only slightly* to account for the fact that the receiver is adding at least one more input and so increasing the size of the transaction, and also ensure that low fees do not accidentally fall too low (even, below the relay limit). Joinmarket's receiver will only add one input and never more, for now, and it looks like this is the tradeoff that most wallets will make. If you want to learn more investigate the `maxadditionalfeecontribution`, `additionalfeeoutputindex` and `minfeerate` parameters described in the BIP. + +As a spender in the BIP78 protocol, you will usually see the following: a small reduction in the size of your change output as a result of the extra 1 input. Unless the payment is very small, this probably won't be significant. + + + +#### What if I wanted bech32 native segwit addresses? + +You can do this, but bear in mind: PayJoin only gives its full effect if you and your receiver are using the same kind of addresses; so do this only if you and your receiver(s)/sender(s) agree on it - most BIP78 receivers at least for now will only engage in the protocol if they can provide inputs of the same type as yours. + +As was noted in the BIP78 section, it may be therefore that you *need* to do this (albeit that the worst that can happen is a fallback to non-payjoin payment, which isn't a disaster). -The receiver will be prompted to read/note the receiving address, amount and "ephemeral nick", and then -the script will just wait (indefinitely). It'll look something like this: +Also note: you *cannot* do Joinmarket coinjoins if you choose a bech32 wallet (this may change in future, see [this PR](https://github.com/JoinMarket-Org/joinmarket-clientserver/pull/656)). + +In the configuration file `joinmarket.cfg` (which was created in the preparatory step above), go to the +POLICY section and set: ``` -2019-01-16 16:11:40,018 [INFO] Your receiving address is: 2NA65YN6eXf3LiciBb1dEdS6ovaZ8HVBcHS -2019-01-16 16:11:40,018 [INFO] You will receive amount: 27000000 satoshis. -2019-01-16 16:11:40,018 [INFO] The sender also needs to know your ephemeral nickname: J5AFezpsuV95CBCH -2019-01-16 16:11:40,018 [INFO] This information has been stored in a file payjoin.txt; send it to your counterparty when you are ready. +[POLICY] +native = true ``` -The "ephemeral nick" starts with J, version (5 currently), then a short base58 string. It's how the sender will find you in the -"joinmarket trading pit" (multiple IRC servers are used for this currently). +Note that this must be done *before* generating the wallet, as +the wallet internally, of course, stores which type of addresses it manages, and it can only be of two +types currently (ignoring legacy upgrades): bech32 or p2sh-segwit (starting with '3'), the latter being +the default (and the one used in Joinmarket itself). -* Receiver sends data to sender (amount, address, ephemeral nick). +Note that the bech32 style wallet is written to conform to [BIP84](https://github.com/bitcoin/bips/blob/master/bip-0084.mediawiki), +analogous to the BIP49 case for p2sh. -This data is stored in the file payjoin.txt but not currently using any encoding (that's a TODO). + -* Sender starts up the sendpayment script: +#### Receiving payments using BIP78 Payjoin +Joinmarket allows you to receive payments from any wallet that supports spending via the BIP78 Protocol, using a Tor hidden service. + +This hidden service is "ephemeral" meaning it is set up specifically for the payment and discarded after you shut down the receiving process. The setup takes some few seconds but it isn't too slow. + +To make this work, you will need to do some minor configuring Tor, the first time. This is explained in detail [below](#torconfig). If you fail to do this step, you will see some rather unintelligible errors about connection failures when trying to run the script described next. + +Once that is ready, you can run the `receive-payjoin.py` script something like this: + +```python3 +(jmvenv)a$ python receive-payjoin.py -m1 walletname.jmdat 0.32 ``` -(jmvenv)a$ python sendpayment.py -m 1 sender-wallet.jmdat 27000000 2NA65YN6eXf3LiciBb1dEdS6ovaZ8HVBcHS -T J5AFezpsuV95CBCH -``` -Notice that the user has specified the three pieces of data given; using the `-T` flags this as a PayJoin; if you don't do this you will be -doing a Joinmarket coinjoin accidentally! (which wouldn't be the end of the world, the receiver would still get the money!). +The arguments provided are the wallet name and the exact amount you intend to receive (here it's 0.32 BTC (flagged as BTC because decimal), but you could also write `32000000` which will be interpreted as satoshis). + +After a delay of 5-50 seconds (usually; Tor setup varies unpredictably), you will see a message like this: -As before -m 1 tells the wallet which mixdepth to source coins to spend from; it obviously needs to have at least the given amount -(in this case, 27 million satoshis or 0.27 btc). +``` +Your hidden service is available. Please now pass this URI string to the sender to effect the payjoin payment: +bitcoin:bc1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx?amount=0.32000000&pj=http://p53lf57qovyuvwsc6xnrppyply3vtqm7l6pcobkmyqsiofyeznfu5uqd.onion:7083 +Keep this process running until the payment is received. +``` -* The two sides communicate +... which should be self explanatory. The sender may be using Joinmarket, or a different wallet that supports Payjoin, like Wasabi. As previously noted, Payjoins will not work if the address type in use by the two wallets is different, so for Wasabi it would be necessary to use a bech32 Joinmarket wallet (as was discussed [here](#native)). -This takes generally only a couple of seconds, not including a relatively slow startup (30s-60s or so depending on configuration), -during which time the sender acts similarly to any other Joinmarket participant (and so does the receiver, likewise), to have -a slightly better "blend in with the crowd" effect. +When the payment goes through you will see a chunk of logging text (most of which is serialized PSBTs being exchanged). If Payjoin was effected as intended, you will see: -Once the transaction is broadcast, you'll see a message to that effect and the script will shutdown. You can check the txid -on your favourite block explorer. +``` +date string [INFO] Removed utxos= ... +date string [INFO] Added utxos= ... +date string [INFO] transaction seen on network: hex-txid +done +``` -* What if something goes wrong and the payment fails to go through? +where hex-txid is of course the transaction id of the payjoin transaction which paid you. -First, if the sender succeeds in doing the first step, the receiver will have a non-coinjoin ordinary payment transaction -to fall back on. It'll look like this: +If you see: ``` -2019-01-16 16:34:55,168 [INFO] Network transaction fee is: 1672 satoshis. -2019-01-16 16:34:55,175 [INFO] We'll use this serialized transaction to broadcast if your counterparty fails to broadcast the payjoin version: -2019-01-16 16:34:55,177 [INFO] 0200000000010152da645b5a2ec3a166ad8a933b1442fa38fc119faf7659d72033ba863fdd8d470000000017160014297b55001daa905a3552137ef19755cf4eae7babfeffffff02b8be4f0a0000000017a914e3cb2bf72ccd4412035f9668e07e17b6c9ebd58d87c0fc9b010000000017a914b8bf57f3bae00d23f9a60c9a6e4d4c7182ec87cc8702483045022100e3605548f5e07ebd14a0700dc3e54e7c8ae90ce0057a3fdc80b5fd37636b44a002202fd7942104fb344b80b8fd91faf0394a0f274f7a68fa462084b8ecf2fa245a65012102f823d62891d8bc8544d4369bb98c6fb8235a372d2c36196d40c448690b42754f42030000 -2019-01-16 16:34:55,186 [INFO] The proposed tx does not trigger UIH2, which means it is indistinguishable from a normal payment. This is the ideal case. Continuing.. -.... +date string [INFO] Payment made without coinjoin. Transaction: ``` -If the final step fails and we don't get a PayJoin, the receiver can just use his favorite broadcast method (e.g. -`sendrawtransaction` in Bitcoin Core) to send the signed transaction above (02000...). +followed by a detailed transaction output, it means that some incompatibility or error between the two wallets resulted in the normal non-payjoin (non-coinjoin) payment being sent; you still received your money, so DON'T ask to be paid again just because Payjoin failed! This is part of BIP78 - we recognize that things can go slightly wrong in the arrangement (for example, the wrong address type, or a fee requirement that cannot be met), so allowing normal payments instead is very much *intended behaviour*. + +On the other hand, if you see at the end: + +``` +2020-09-12 13:01:15,887 [WARNING] Payment is not valid. Payment has NOT been made. +``` -* Privacy and security controls +it means of course the other case. Double check with your counterparty, something more fundamental has gone wrong because they did not send you a valid non-coinjoin payment, as they were supposed to right at the start. -Just like Joinmarket, do note, that here the private information is communicated with **end-to-end encryption** - it's not like -the people/operators on the IRC servers are going to learn any information about your transaction. + -Second, it's very recommended to use Tor connections to the messaging servers via their hidden services. See the -`[MESSAGING:serverN]` sections in the `joinmarket.cfg`. This keeps anyone from seeing your IP address origin. +#### Configuring Tor to setup a hidden service -This altogether means it's possible to have anonymity from your sender/receiver counterparty, if that's an issue. -You *do* still have to send them the payment information out of band, though. We could perhaps fold this in, although -it seems a bit tricky to do so without introducing a security issue. +(These steps were prepared using Ubuntu; you may have to adjust for your distro). -An additional minor feature is that the receiver "fakes" being a Joinmarket maker, so his bot looks the same as the other -ones in the Joinmarket trading pit (at least, to a crude extent). And the sender fakes being a Joinmarket taker, too. +First, ensure you have Tor installed: -Also we try to make the PayJoin look as much as possible as an ordinary payment. For example: - - we make the transaction version, locktime and sequence numbers look similar to those created by Core, Electrum. - - we try to avoid "UIH2", meaning we avoid a situation where one input is larger than any output, since that would - mean no other inputs are needed; wallet coin selection algorithms *usually* don't do that. - -Security - since the receiver passively waits, what happens if a bad actor tries to connect to him? Nothing; an attacker -would fail to even start the process without knowing the payment amount and address, which the receiver is not broadcasting -around everywhere (especially not the amount and ephemeral nickname), and even if they knew that, the worst -they can do is learn at least 1 utxo of the receiver. The receiver won't pay attention to non-PayJoin messages, either. +``` +sudo apt install tor +``` -##### Controlling fees +Don't start the tor daemon yet though, since we need to do some setup. Edit Tor's config file with sudo: -**The fees are paid by the sender of funds; note that the fees are going to be a bit higher than a normal payment** (typically -about 2-3x higher); this may be changed to share the fee, in a future version. There are controls to make sure the fee -isn't *too* high. +``` +sudo vim /etc/tor/torrc +``` -In the joinmarket.cfg file, under `[POLICY]` section you should see a setting called `tx_fees`. -You can set this to any integer; if you set it to 1000 or less then it's treated as a "confirmation in N blocks target", -i.e. if you set it to 3 (the default), the fee is chosen from Bitcoin Core's estimator to target confirmation in 3 blocks. -So if you change it to e.g. 10, it will be cheaper but projected to get its first confirmation in 10 blocks on average. +and uncomment these two lines to enable hidden service startup: -If you set it to a number > 1000, though, it's a number of satoshis per kilobyte (technically, kilo-vbyte) that you want -to use. **Don't use less than about 1200 if you do this** - a typical figure might be 5000 or 10000, corresponding to -about 5-10 sats/byte, which nowadays is a reasonable fee. The exact amount is randomised by ~20% to avoid you inadvertently -watermarking all your transactions. So don't use < 1200 because then you might be using less than 1 sat/byte which is -difficult to relay on the Bitcoin network. +``` +ControlPort 9051 +CookieAuthentication 1 +``` - +However if you proceed at this point to try to run `receive-payjoin.py` as outlined above, you will almost certainly get an error like this: -#### What if I wanted bech32 native segwit addresses? +``` +Permission denied: '/var/run/tor/control.authcookie' +``` -You can do this, but bear in mind: PayJoin only gives its full effect if you and your receiver are using the same kind of addresses; so do this only if you and your receiver(s)/sender(s) agree on it. +... because reading this file requires being a member of the group `debian-tor`. So add your user to this group: -As was noted in the BIP78 section, it may be therefore that you *need* to do this (albeit that the worst that can happen is a fallback to non-payjoin payment, which isn't a disaster). +``` +sudo usermod -a -G debian-tor yourusername +``` -Also note: you *cannot* do Joinmarket coinjoins if you choose a bech32 wallet (this may change in future). +... and then you must *restart the computer/server* for that change to take effect (check it with `groups yourusername`). -In the configuration file `joinmarket.cfg` (which was created in the preparatory step above), go to the -POLICY section and set: +Finally, after system restart, ensure Tor is started (it may be automatically, but anyway): ``` -[POLICY] -native = true +sudo service tor start ``` -Note that this must be done *before* generating the wallet, as -the wallet internally, of course, stores which type of addresses it manages, and it can only be of two -types currently (ignoring legacy upgrades): bech32 or p2sh-segwit (starting with '3'), the latter being -the default (and the one used in Joinmarket itself). + Once this is done, you should be able to run the BIP 78 receiver script, or [JoinmarketQt](#using-qt) and a hidden service will be automatically created for you from now on. -Note that the bech32 style wallet is written to conform to [BIP84](https://github.com/bitcoin/bips/blob/master/bip-0084.mediawiki), -analogous to the BIP49 case for p2sh. + + +### Using JoinmarketQt to send and receive Payjoins + +All of the configuration details above apply to this scenario (for example, setting up Tor if you want to act as receiver. +But for the workflow on the GUI application, this video explains what to do: + +https://video.autizmo.xyz/videos/watch/7081ae10-dce0-491e-9717-389ccc3aad0d @@ -424,125 +453,3 @@ Balance: 0.00000000 Balance for mixdepth 4: 0.00000000 Total balance: 8.00000000 ``` - -#### Full sender-side log of a regtest run of PayJoin - -(Note that the "Received offers from joinmarket pit" message is a privacy feature, we don't actually respond to any offers). - -``` -(jmvenv) me@here:~/jm/scripts$ python sendpayment.py -m1 b80d142a466dbf56f518a3a8c017ab85 27000000 2NA65YN6eXf3LiciBb1dEdS6ovaZ8HVBcHS -T J5AFezpsuV95CBCH -2019-01-16 16:34:44,877 [WARNING] Cannot listen on port 27183, trying next port -2019-01-16 16:34:44,877 [WARNING] Cannot listen on port 27184, trying next port -2019-01-16 16:34:44,877 [WARNING] Cannot listen on port 27185, trying next port -2019-01-16 16:34:44,878 [WARNING] Cannot listen on port 27186, trying next port -2019-01-16 16:34:44,878 [INFO] Listening on port 27187 -2019-01-16 16:34:47,495 [INFO] Could not connect to *ALL* servers yet, waiting up to 60 more seconds. -2019-01-16 16:34:47,496 [INFO] All IRC servers connected, starting execution. -2019-01-16 16:34:47,501 [INFO] JM daemon setup complete -2019-01-16 16:34:52,508 [INFO] INFO:Received offers from joinmarket pit -2019-01-16 16:34:52,531 [INFO] total estimated amount spent = 27020000 -2019-01-16 16:34:52,594 [INFO] Makers responded with: ["J5AFezpsuV95CBCH"] -2019-01-16 16:34:52,600 [INFO] Obtained proposed payjoin tx -{'ins': [{'outpoint': {'hash': '478ddd3f86ba3320d75976af9f11fc38fa42143b938aad66a1c32e5a5b64da52', - 'index': 0}, - 'script': '', - 'sequence': 4294967294}], - 'locktime': 834, - 'outs': [{'script': 'a914e3cb2bf72ccd4412035f9668e07e17b6c9ebd58d87', - 'value': 172998328}, - {'script': 'a914b8bf57f3bae00d23f9a60c9a6e4d4c7182ec87cc87', - 'value': 27000000}], - 'version': 2} -2019-01-16 16:34:52,602 [INFO] INFO:Built tx proposal, sending to receiver. -2019-01-16 16:34:56,622 [INFO] Obtained tx from receiver: -{'ins': [{'outpoint': {'hash': '478ddd3f86ba3320d75976af9f11fc38fa42143b938aad66a1c32e5a5b64da52', - 'index': 0}, - 'script': '', - 'sequence': 4294967294, - 'txinwitness': []}, - {'outpoint': {'hash': '5e19edacc10298e7761be7231db5fa44ad73903c3690c4b71b443355267d346a', - 'index': 0}, - 'script': '160014a5b07a64bff72b442c71761599e0b627c687661f', - 'sequence': 4294967294, - 'txinwitness': ['3044022077a811939910a935934f68ac8a70439bab665ce9ad4cd02e42289c3046d606e1022054ef241980733caa7c7c5b6bb1babf96caba83a9fb0a8e091d8358b1cb35fcdb01', - '02c7ceecf0783ecc7530e018397db434af63eda63765c79816f023699a22663926']}], - 'locktime': 834, - 'outs': [{'script': 'a914b8bf57f3bae00d23f9a60c9a6e4d4c7182ec87cc87', - 'value': 227000000}, - {'script': 'a914e3cb2bf72ccd4412035f9668e07e17b6c9ebd58d87', - 'value': 172997415}], - 'version': 2} -2019-01-16 16:34:56,630 [INFO] INFO:Network transaction fee is: 2585 satoshis. -2019-01-16 16:34:56,641 [INFO] txid = 1031780b31bd3b1cfdec44296fdb82e467284e93f871eb48d7d3e72df059f0ae -2019-01-16 16:34:56,661 [INFO] Transaction broadcast OK. -2019-01-16 16:35:01,651 [INFO] Transaction seen on network, shutting down. -2019-01-16 16:35:01,652 [INFO] Txid was: 1031780b31bd3b1cfdec44296fdb82e467284e93f871eb48d7d3e72df059f0ae -``` - -#### Full receiver-side log of a regtest run of PayJoin - -``` -(jmvenv) me@here:~/jm/scripts$ python receive-payjoin.py -m1 84735f364c2cf4c8ddaa614315aeae14 27000000 -2019-01-16 16:11:39,952 [INFO] offerlist=[{'maxsize': 112412265, 'ordertype': 'swreloffer', 'cjfee': '0.00035792', 'oid': 0, 'minsize': 1095839, 'txfee': 493}] -2019-01-16 16:11:39,952 [INFO] starting receive-payjoin -2019-01-16 16:11:40,011 [WARNING] Cannot listen on port 27183, trying next port -2019-01-16 16:11:40,011 [WARNING] Cannot listen on port 27184, trying next port -2019-01-16 16:11:40,011 [WARNING] Cannot listen on port 27185, trying next port -2019-01-16 16:11:40,012 [INFO] Listening on port 27186 -2019-01-16 16:11:40,018 [INFO] Your receiving address is: 2NA65YN6eXf3LiciBb1dEdS6ovaZ8HVBcHS -2019-01-16 16:11:40,018 [INFO] You will receive amount: 27000000 satoshis. -2019-01-16 16:11:40,018 [INFO] The sender also needs to know your ephemeral nickname: J5AFezpsuV95CBCH -2019-01-16 16:11:40,018 [INFO] This information has been stored in a file payjoin.txt; send it to your counterparty when you are ready. -2019-01-16 16:35:00+0100 [-] Enter 'y' to wait for the payment:y -2019-01-16 16:34:32,449 [INFO] Could not connect to *ALL* servers yet, waiting up to 60 more seconds. -2019-01-16 16:34:32,450 [INFO] All IRC servers connected, starting execution. -2019-01-16 16:34:32,453 [INFO] JM daemon setup complete - -(note time gap here; just waiting) - -2019-01-16 16:34:55,163 [INFO] obtained tx proposal from sender: -{'ins': [{'outpoint': {'hash': '478ddd3f86ba3320d75976af9f11fc38fa42143b938aad66a1c32e5a5b64da52', - 'index': 0}, - 'script': '160014297b55001daa905a3552137ef19755cf4eae7bab', - 'sequence': 4294967294, - 'txinwitness': ['3045022100e3605548f5e07ebd14a0700dc3e54e7c8ae90ce0057a3fdc80b5fd37636b44a002202fd7942104fb344b80b8fd91faf0394a0f274f7a68fa462084b8ecf2fa245a6501', - '02f823d62891d8bc8544d4369bb98c6fb8235a372d2c36196d40c448690b42754f']}], - 'locktime': 834, - 'outs': [{'script': 'a914e3cb2bf72ccd4412035f9668e07e17b6c9ebd58d87', - 'value': 172998328}, - {'script': 'a914b8bf57f3bae00d23f9a60c9a6e4d4c7182ec87cc87', - 'value': 27000000}], - 'version': 2} -2019-01-16 16:34:55,165 [WARNING] Connection had broken pipe, attempting reconnect. -2019-01-16 16:34:55,168 [INFO] Network transaction fee is: 1672 satoshis. -2019-01-16 16:34:55,175 [INFO] We'll use this serialized transaction to broadcast if your counterparty fails to broadcast the payjoin version: -2019-01-16 16:34:55,177 [INFO] 0200000000010152da645b5a2ec3a166ad8a933b1442fa38fc119faf7659d72033ba863fdd8d470000000017160014297b55001daa905a3552137ef19755cf4eae7babfeffffff02b8be4f0a0000000017a914e3cb2bf72ccd4412035f9668e07e17b6c9ebd58d87c0fc9b010000000017a914b8bf57f3bae00d23f9a60c9a6e4d4c7182ec87cc8702483045022100e3605548f5e07ebd14a0700dc3e54e7c8ae90ce0057a3fdc80b5fd37636b44a002202fd7942104fb344b80b8fd91faf0394a0f274f7a68fa462084b8ecf2fa245a65012102f823d62891d8bc8544d4369bb98c6fb8235a372d2c36196d40c448690b42754f42030000 -2019-01-16 16:34:55,186 [INFO] The proposed tx does not trigger UIH2, which means it is indistinguishable from a normal payment. This is the ideal case. Continuing.. -2019-01-16 16:34:55,187 [INFO] We selected inputs worth: 200000000 -2019-01-16 16:34:55,195 [INFO] We estimated a fee of: 2585 -2019-01-16 16:34:55,196 [INFO] We calculated a new change amount of: 172997415 -2019-01-16 16:34:55,197 [INFO] We calculated a new destination amount of: 227000000 -2019-01-16 16:35:00,232 [INFO] The transaction has been broadcast. -2019-01-16 16:35:00,232 [INFO] Txid is: 1031780b31bd3b1cfdec44296fdb82e467284e93f871eb48d7d3e72df059f0ae -2019-01-16 16:35:00,233 [INFO] Transaction in detail: {'ins': [{'outpoint': {'hash': '478ddd3f86ba3320d75976af9f11fc38fa42143b938aad66a1c32e5a5b64da52', - 'index': 0}, - 'script': '160014297b55001daa905a3552137ef19755cf4eae7bab', - 'sequence': 4294967294, - 'txinwitness': ['3045022100c1d579fb19f5b8710b407e3995657313d0b1c9fe1180b852834b2210ba19b5e30220464f034eef8d8975b4fe10db7edbf14fcfdc6d71ada3d2063d34fa5c04a38cb801', - '02f823d62891d8bc8544d4369bb98c6fb8235a372d2c36196d40c448690b42754f']}, - {'outpoint': {'hash': '5e19edacc10298e7761be7231db5fa44ad73903c3690c4b71b443355267d346a', - 'index': 0}, - 'script': '160014a5b07a64bff72b442c71761599e0b627c687661f', - 'sequence': 4294967294, - 'txinwitness': ['3044022077a811939910a935934f68ac8a70439bab665ce9ad4cd02e42289c3046d606e1022054ef241980733caa7c7c5b6bb1babf96caba83a9fb0a8e091d8358b1cb35fcdb01', - '02c7ceecf0783ecc7530e018397db434af63eda63765c79816f023699a22663926']}], - 'locktime': 834, - 'outs': [{'script': 'a914b8bf57f3bae00d23f9a60c9a6e4d4c7182ec87cc87', - 'value': 227000000}, - {'script': 'a914e3cb2bf72ccd4412035f9668e07e17b6c9ebd58d87', - 'value': 172997415}], - 'version': 2} -2019-01-16 16:35:00,234 [INFO] shutting down. -``` - - diff --git a/jmbitcoin/jmbitcoin/bip21.py b/jmbitcoin/jmbitcoin/bip21.py index ce53ec8..8d410ac 100644 --- a/jmbitcoin/jmbitcoin/bip21.py +++ b/jmbitcoin/jmbitcoin/bip21.py @@ -44,10 +44,10 @@ def decode_bip21_uri(uri): return result -def encode_bip21_uri(address, params): +def encode_bip21_uri(address, params, safe=""): uri = 'bitcoin:' + address if len(params) > 0: if 'amount' in params: validate_bip21_amount(params['amount']) - uri += '?' + urlencode(params, quote_via=quote) + uri += '?' + urlencode(params, safe=safe, quote_via=quote) return uri diff --git a/jmclient/jmclient/__init__.py b/jmclient/jmclient/__init__.py index 8edcc29..56579f4 100644 --- a/jmclient/jmclient/__init__.py +++ b/jmclient/jmclient/__init__.py @@ -10,7 +10,7 @@ from .support import (calc_cj_fee, choose_sweep_orders, choose_orders, select_one_utxo) from .jsonrpc import JsonRpcError, JsonRpcConnectionError, JsonRpc from .old_mnemonic import mn_decode, mn_encode -from .taker import Taker, P2EPTaker +from .taker import Taker from .wallet import (Mnemonic, estimate_tx_fee, WalletError, BaseWallet, ImportWalletMixin, BIP39WalletMixin, BIP32Wallet, BIP49Wallet, LegacyWallet, SegwitWallet, SegwitLegacyWallet, FidelityBondMixin, @@ -20,7 +20,7 @@ from .storage import (Argon2Hash, Storage, StorageError, RetryableStorageError, StoragePasswordError, VolatileStorage) from .cryptoengine import (BTCEngine, BTC_P2PKH, BTC_P2SH_P2WPKH, EngineError, TYPE_P2PKH, TYPE_P2SH_P2WPKH, TYPE_P2WPKH) -from .configure import (load_test_config, +from .configure import (load_test_config, process_shutdown, load_program_config, jm_single, get_network, update_persist_config, validate_address, is_burn_destination, get_irc_mchannels, get_blockchain_interface_instance, set_config, is_segwit_mode, @@ -53,10 +53,11 @@ from .wallet_utils import ( wallet_display, get_utxos_enabled_disabled, wallet_gettimelockaddress, wallet_change_passphrase) from .wallet_service import WalletService -from .maker import Maker, P2EPMaker +from .maker import Maker from .yieldgenerator import YieldGenerator, YieldGeneratorBasic, ygmain from .snicker_receiver import SNICKERError, SNICKERReceiver -from .payjoin import parse_payjoin_setup, send_payjoin +from .payjoin import (parse_payjoin_setup, send_payjoin, PayjoinServer, + JMBIP78ReceiverManager) # Set default logging handler to avoid "No handler found" warnings. try: diff --git a/jmclient/jmclient/cli_options.py b/jmclient/jmclient/cli_options.py index bbd0ccc..901417c 100644 --- a/jmclient/jmclient/cli_options.py +++ b/jmclient/jmclient/cli_options.py @@ -510,15 +510,6 @@ def get_sendpayment_parser(): dest='answeryes', default=False, help='answer yes to everything') - parser.add_option('--payjoin', - '-T', - type='str', - action='store', - dest='p2ep', - default='', - help='specify recipient IRC nick for a ' - 'p2ep style payment, for example:\n' - 'J5Ehn3EieVZFtm4q ') parser.add_option('--psbt', action='store_true', dest='with_psbt', diff --git a/jmclient/jmclient/client_protocol.py b/jmclient/jmclient/client_protocol.py index b580c88..df1f885 100644 --- a/jmclient/jmclient/client_protocol.py +++ b/jmclient/jmclient/client_protocol.py @@ -144,35 +144,6 @@ class JMClientProtocol(amp.AMP): txhex=txhex) self.defaultCallbacks(d) - def on_p2ep_tx_received(self, nick, txhex): - """ This is called by both "maker" and "taker" - in the p2ep protocol; taker sends signed fallback - non-coinjoin transaction to maker, then maker sends - partially signed payjoin to taker. - Processing at client protocol level is the - same, business logic deferred to the P2EPMaker,Taker - classes. - """ - retval = self.client.on_tx_received(nick, txhex) - if not retval[0]: - if retval[1] == "OK": - jlog.info("Transaction broadcast OK.") - else: - jlog.info("We refused to continue on receiving tx") - jlog.info("Reason: " + retval[1]) - else: - # This will only be called on the "maker"/receiver - # side, who will pass back the new partially signed - # version; on Taker side, we stop above, since we - # just sign and push within the P2EPTaker. - nick, txhex = retval[1:] - # make_tx is slightly misnamed; it just transfers - # the transaction to the given counterparties (here - # only one) over the message channel, via the - # AMP command JMMakeTx. - self.make_tx([nick], txhex) - return {"accepted": True} - class JMMakerClientProtocol(JMClientProtocol): def __init__(self, factory, maker, nick_priv=None): self.factory = factory @@ -264,11 +235,6 @@ class JMMakerClientProtocol(JMClientProtocol): @commands.JMTXReceived.responder def on_JM_TX_RECEIVED(self, nick, txhex, offer): - # "none" flags p2ep protocol; pass through to the generic - # on_tx handler for that: - if offer == "none": - return self.on_p2ep_tx_received(nick, txhex) - offer = json.loads(offer) retval = self.client.on_tx_received(nick, txhex, offer) if not retval[0]: @@ -504,16 +470,6 @@ class JMTakerClientProtocol(JMClientProtocol): self.push_tx(nick_to_use, txhex) return {'accepted': True} - @commands.JMTXReceived.responder - def on_JM_TX_RECEIVED(self, nick, txhex, offer): - """ Only used in the P2EP protocol. - """ - if not offer == "none": - # Protocol error; this message should only ever - # be received, Taker side, in the P2EP protocol. - raise JMProtocolError("Taker received unexpected JMTXReceived") - return self.on_p2ep_tx_received(nick, txhex) - def get_offers(self): d = self.callRemote(commands.JMRequestOffers) self.defaultCallbacks(d) @@ -542,25 +498,21 @@ class JMClientProtocolFactory(protocol.ClientFactory): return self.protocol(self, self.client) def start_reactor(host, port, factory, ish=True, daemon=False, rs=True, - gui=False, p2ep=False): #pragma: no cover + gui=False): #pragma: no cover #(Cannot start the reactor in tests) #Not used in prod (twisted logging): #startLogging(stdout) usessl = True if jm_single().config.get("DAEMON", "use_ssl") != 'false' else False if daemon: try: - from jmdaemon import JMDaemonServerProtocolFactory, start_daemon,\ - P2EPDaemonServerProtocolFactory + from jmdaemon import JMDaemonServerProtocolFactory, start_daemon except ImportError: jlog.error("Cannot start daemon without jmdaemon package; " "either install it, and restart, or, if you want " "to run the daemon separately, edit the DAEMON " "section of the config. Quitting.") return - if not p2ep: - dfactory = JMDaemonServerProtocolFactory() - else: - dfactory = P2EPDaemonServerProtocolFactory() + dfactory = JMDaemonServerProtocolFactory() orgport = port while True: try: @@ -574,11 +526,6 @@ def start_reactor(host, port, factory, ish=True, daemon=False, rs=True, jlog.error("Tried 100 ports but cannot listen on any of them. Quitting.") sys.exit(EXIT_FAILURE) port += 1 - else: - # if daemon run separately, and we do p2ep, we are using - # the protocol server at port+1 - if p2ep: - port += 1 if usessl: ctx = ClientContextFactory() reactor.connectSSL(host, port, factory, ctx) diff --git a/jmclient/jmclient/configure.py b/jmclient/jmclient/configure.py index d8e927a..0c7c8df 100644 --- a/jmclient/jmclient/configure.py +++ b/jmclient/jmclient/configure.py @@ -674,3 +674,14 @@ def is_native_segwit_mode(): if not is_segwit_mode(): return False return jm_single().config.get('POLICY', 'native') != 'false' + +def process_shutdown(mode="command-line"): + if mode=="command-line": + from twisted.internet import reactor + for dc in reactor.getDelayedCalls(): + dc.cancel() + reactor.stop() + +def process_startup(): + from twisted.internet import reactor + reactor.run() \ No newline at end of file diff --git a/jmclient/jmclient/maker.py b/jmclient/jmclient/maker.py index 5637f2e..7d7677f 100644 --- a/jmclient/jmclient/maker.py +++ b/jmclient/jmclient/maker.py @@ -287,360 +287,3 @@ class Maker(object): """Performs actions on receipt of 1st confirmation of a transaction into a block (e.g. announce orders) """ - -class P2EPMaker(Maker): - """ The P2EP Maker object is instantiated for a specific payment, - with a specific address and expected payment amount. It inherits - normal Maker behaviour on startup and makes fake offers, which - it does not follow up in direct peer interaction (to be specific: - `!fill` requests in privmsg are simply ignored). Under the hood, - the daemon protocol will allow pubkey exchange with any counterparty, - but only after the Taker makes a !tx proposal matching our intended - address and payment amount, which were agreed out of band with the - sender(Taker) counterparty, do we pass over our intended inputs - and partially signed transaction, thus information leak to snoopers - is not possible. - """ - def __init__(self, wallet_service, mixdepth, amount): - super().__init__(wallet_service) - self.receiving_amount = amount - self.mixdepth = mixdepth - # destination mixdepth must be different from that - # which we source coins from; use the standard "next" - dest_mixdepth = (self.mixdepth + 1) % self.wallet_service.max_mixdepth - # Select an unused destination in the external branch - self.destination_addr = self.wallet_service.get_external_addr( - dest_mixdepth) - # Callback to request user permission (for e.g. GUI) - # args: (1) message, as string - # returns: True or False - self.user_check = self.default_user_check - self.user_info = self.default_user_info_callback - - def default_user_check(self, message): - if input(message) == 'y': - return True - return False - - def default_user_info_callback(self, message): - """ TODO this is basically the same function - as taker_info_callback (currently used for GUI); - fold this and some other convenience functions together - and use a root CJPeer class in jmbase to avoid code - duplication. - """ - jlog.info(message) - - def inform_user_details(self): - self.user_info("Your receiving address is: " + self.destination_addr) - self.user_info("You will receive amount: " + str( - self.receiving_amount) + " satoshis.") - self.user_info("The sender also needs to know your ephemeral " - "nickname: " + jm_single().nickname) - receive_uri = btc.encode_bip21_uri(self.destination_addr, { - 'amount': btc.sat_to_btc(self.receiving_amount), - 'jmnick': jm_single().nickname - }) - self.user_info("Receive URI: " + receive_uri) - self.user_info("This information has also been stored in a file payjoin.txt;" - " send it to your counterparty when you are ready.") - with open("payjoin.txt", "w") as f: - f.write("Payjoin transfer details:\n\n") - f.write("Receive URI: " + receive_uri + "\n") - f.write("Address: " + self.destination_addr + "\n") - f.write("Amount (in sats): " + str(self.receiving_amount) + "\n") - f.write("Receiver nick: " + jm_single().nickname + "\n") - if not self.user_check("Enter 'y' to wait for the payment:"): - sys.exit(EXIT_SUCCESS) - - def create_my_orders(self): - """ Fake offer for public consumption. - Requests to fill will be ignored. - """ - ordertype = random.choice(("swreloffer", "swabsoffer")) - minsize = random.randint(100000, 10000000) - maxsize = random.randint(100000, 1000000000) + minsize - txfee = random.randint(0, 1000) - if ordertype == "swreloffer": - cjfee = str(random.randint(0, 100000)/100000000.0) - else: - cjfee = random.randint(0, 10000) - order = {'oid': 0, - 'ordertype': ordertype, - 'minsize': minsize, - 'maxsize': maxsize, - 'txfee': txfee, - 'cjfee': cjfee} - - # sanity check - assert order['minsize'] >= 0 - assert order['maxsize'] > 0 - if order['minsize'] > order['maxsize']: - jlog.info('minsize (' + str(order['minsize']) + ') > maxsize (' + str( - order['maxsize']) + ')') - return [] - - return [order] - - def oid_to_order(self, offer, amount): - # unreachable; only here to satisy abc. - pass - - def on_tx_unconfirmed(self, txd, txid): - """ For P2EP Maker there is no "offer", so - the second argument is repurposed as the deserialized - transaction. - """ - self.user_info("The transaction has been broadcast.") - self.user_info("Txid is: " + txid) - self.user_info("Transaction in detail: " + pprint.pformat(txd)) - self.user_info("shutting down.") - reactor.stop() - - def on_tx_confirmed(self, txd, txid, confirmations): - # will not be reached except in testing - self.on_tx_unconfirmed(txd, txid) - - @hexbin - def on_tx_received(self, nick, txser): - """ Called when the sender-counterparty has sent a transaction proposal. - 1. First we check for the expected destination and amount (this is - sufficient to identify our cp, as this info was presumably passed - out of band, as for any normal payment). - 2. Then we verify the validity of the proposed non-coinjoin - transaction; if not, reject, otherwise store this as a - fallback transaction in case the protocol doesn't complete. - 3. Next, we select utxos from our wallet, to add into the - payment transaction as input. Try to select so as to not - trigger the UIH2 condition, but continue (and inform user) - even if we can't (if we can't select any coins, broadcast the - non-coinjoin payment, if the user agrees). - Proceeding with payjoin: - 4. We update the output amount at the destination address. - 5. We modify the change amount in the original proposal (which - will be the only other output other than the destination), - reducing it to account for the increased transaction fee - caused by our additional proposed input(s). - 6. Finally we sign our own input utxo(s) and re-serialize the - tx, allowing it to be sent back to the counterparty. - 7. If the transaction is not fully signed and broadcast within - the time unconfirm_timeout_sec as specified in the joinmarket.cfg, - we broadcast the non-coinjoin fallback tx instead. - """ - try: - tx = btc.CMutableTransaction.deserialize(txser) - except Exception as e: - return (False, 'malformed txhex. ' + repr(e)) - self.user_info('obtained proposed fallback (non-coinjoin) ' +\ - 'transaction from sender:\n' + str(tx)) - - if len(tx.vout) != 2: - return (False, "Transaction has more than 2 outputs; not supported.") - dest_found = False - destination_index = -1 - change_index = -1 - proposed_change_value = 0 - for index, out in enumerate(tx.vout): - if out.scriptPubKey == btc.CCoinAddress( - self.destination_addr).to_scriptPubKey(): - # we found the expected destination; is the amount correct? - if not out.nValue == self.receiving_amount: - return (False, "Wrong payout value in proposal from sender.") - dest_found = True - destination_index = index - else: - change_found = True - proposed_change_out = out.scriptPubKey - proposed_change_value = out.nValue - change_index = index - - if not dest_found: - return (False, "Our expected destination address was not found.") - - # Verify valid input utxos provided and check their value. - # batch retrieval of utxo data - utxo = {} - ctr = 0 - for index, ins in enumerate(tx.vin): - utxo_for_checking = (ins.prevout.hash[::-1], ins.prevout.n) - utxo[ctr] = [index, utxo_for_checking] - ctr += 1 - - utxo_data = jm_single().bc_interface.query_utxo_set( - [x[1] for x in utxo.values()]) - - total_sender_input = 0 - for i, u in utxo.items(): - if utxo_data[i] is None: - return (False, "Proposed transaction contains invalid utxos") - total_sender_input += utxo_data[i]["value"] - - # Check that the transaction *as proposed* balances; check that the - # included fee is within 0.3-3x our own current estimates, if not user - # must decide. - btc_fee = total_sender_input - self.receiving_amount - proposed_change_value - self.user_info("Network transaction fee of fallback tx is: " + str( - btc_fee) + " satoshis.") - fee_est = estimate_tx_fee(len(tx.vin), len(tx.vout), - txtype=self.wallet_service.get_txtype()) - fee_ok = False - if btc_fee > 0.3 * fee_est and btc_fee < 3 * fee_est: - fee_ok = True - else: - if self.user_check("Is this transaction fee acceptable? (y/n):"): - fee_ok = True - if not fee_ok: - return (False, - "Proposed transaction fee not accepted due to tx fee: " + str( - btc_fee)) - - # This direct rpc call currently assumes Core 0.17, so not using now. - # It has the advantage of (a) being simpler and (b) allowing for any - # non standard coins. - # - #res = jm_single().bc_interface.rpc('testmempoolaccept', [txser]) - #print("Got this result from rpc call: ", res) - #if not res["accepted"]: - # return (False, "Proposed transaction was rejected from mempool.") - - # Manual verification of the transaction signatures. - # TODO handle native segwit properly - for i, u in utxo.items(): - if not btc.verify_tx_input(tx, i, - tx.vin[i].scriptSig, - btc.CScript(utxo_data[i]["script"]), - amount=utxo_data[i]["value"], - witness=tx.wit.vtxinwit[i].scriptWitness): - return (False, "Proposed transaction is not correctly signed.") - - # At this point we are satisfied with the proposal. Record the fallback - # in case the sender disappears and the payjoin tx doesn't happen: - self.user_info("We'll use this serialized transaction to broadcast if your" - " counterparty fails to broadcast the payjoin version:") - self.user_info(bintohex(txser)) - # Keep a local copy for broadcast fallback: - self.fallback_tx = tx - - # Now we add our own inputs: - # See the gist comment here: - # https://gist.github.com/AdamISZ/4551b947789d3216bacfcb7af25e029e#gistcomment-2799709 - # which sets out the decision Bob must make. - # In cases where Bob can add any amount, he selects one utxo - # to keep it simple. - # In cases where he must choose at least X, he selects one utxo - # which provides X if possible, otherwise defaults to a normal - # selection algorithm. - # In those cases where he must choose X but X is unavailable, - # he selects all coins, and proceeds anyway with payjoin, since - # it has other advantages (CIOH and utxo defrag). - my_utxos = {} - largest_out = max(self.receiving_amount, proposed_change_value) - max_sender_amt = max([u['value'] for u in utxo_data]) - not_uih2 = False - if max_sender_amt < largest_out: - # just select one coin. - # have some reasonable lower limit but otherwise choose - # randomly; note that this is actually a great way of - # sweeping dust ... - self.user_info("Choosing one coin at random") - try: - my_utxos = self.wallet_service.select_utxos( - self.mixdepth, jm_single().DUST_THRESHOLD, - select_fn=select_one_utxo) - except: - return self.no_coins_fallback() - not_uih2 = True - else: - # get an approximate required amount assuming 4 inputs, which is - # fairly conservative (but guess by necessity). - fee_for_select = estimate_tx_fee(len(tx.vin) + 4, 2, - txtype=self.wallet_service.get_txtype()) - approx_sum = max_sender_amt - self.receiving_amount + fee_for_select - try: - my_utxos = self.wallet_service.select_utxos(self.mixdepth, approx_sum) - not_uih2 = True - except Exception: - # TODO probably not logical to always sweep here. - self.user_info("Sweeping all coins in this mixdepth.") - my_utxos = self.wallet_service.get_utxos_by_mixdepth()[self.mixdepth] - if my_utxos == {}: - return self.no_coins_fallback() - if not_uih2: - self.user_info("The proposed tx does not trigger UIH2, which " - "means it is indistinguishable from a normal " - "payment. This is the ideal case. Continuing..") - else: - self.user_info("The proposed tx does trigger UIH2, which it makes " - "it somewhat distinguishable from a normal payment," - " but proceeding with payjoin..") - - my_total_in = sum([va['value'] for va in my_utxos.values()]) - self.user_info("We selected inputs worth: " + str(my_total_in)) - # adjust the output amount at the destination based on our contribution - new_destination_amount = self.receiving_amount + my_total_in - # estimate the required fee for the new version of the transaction - total_ins = len(tx.vin) + len(my_utxos.keys()) - est_fee = estimate_tx_fee(total_ins, 2, txtype=self.wallet_service.get_txtype()) - self.user_info("We estimated a fee of: " + str(est_fee)) - new_change_amount = total_sender_input + my_total_in - \ - new_destination_amount - est_fee - self.user_info("We calculated a new change amount of: " + str(new_change_amount)) - self.user_info("We calculated a new destination amount of: " + str(new_destination_amount)) - # now reconstruct the transaction with the new inputs and the - # amount-changed outputs - new_outs = [{"address": self.destination_addr, - "value": new_destination_amount}] - if new_change_amount >= jm_single().BITCOIN_DUST_THRESHOLD: - new_outs.append({"address": str(btc.CCoinAddress.from_scriptPubKey( - proposed_change_out)), "value": new_change_amount}) - new_ins = [x[1] for x in utxo.values()] - new_ins.extend(my_utxos.keys()) - new_tx = btc.make_shuffled_tx(new_ins, new_outs, 2, compute_tx_locktime()) - - - # sign our inputs before transfer - our_inputs = {} - for index, ins in enumerate(new_tx.vin): - utxo = (ins.prevout.hash[::-1], ins.prevout.n) - if utxo not in my_utxos: - continue - script = my_utxos[utxo]["script"] - amount = my_utxos[utxo]["value"] - our_inputs[index] = (script, amount) - - success, msg = self.wallet_service.sign_tx(new_tx, our_inputs) - if not success: - return (False, "Failed to sign new transaction, error: " + msg) - txinfo = tuple((x.scriptPubKey, x.nValue) for x in new_tx.vout) - self.wallet_service.register_callbacks([self.on_tx_unconfirmed], txinfo, "unconfirmed") - self.wallet_service.register_callbacks([self.on_tx_confirmed], txinfo, "confirmed") - # The blockchain interface just abandons monitoring if the transaction - # is not broadcast before the configured timeout; we want to take - # action in this case, so we add an additional callback to the reactor: - reactor.callLater(jm_single().config.getint("TIMEOUT", - "unconfirm_timeout_sec"), self.broadcast_fallback) - return (True, nick, bintohex(new_tx.serialize())) - - def no_coins_fallback(self): - """ Broadcast, optionally, the fallback non-coinjoin transaction - because we were not able to select coins to contribute. - """ - self.user_info("Unable to select any coins; this mixdepth is empty.") - if self.user_check("Would you like to broadcast the non-coinjoin payment?"): - self.broadcast_fallback() - return (False, "Coinjoin unsuccessful, fallback attempted.") - else: - self.user_info("You chose not to broadcast; the payment has NOT been made.") - return (False, "No transaction made.") - - def broadcast_fallback(self): - self.user_info("Broadcasting non-coinjoin fallback transaction.") - txid = self.fallback_tx.GetTxid()[::-1] - success = jm_single().bc_interface.pushtx(self.fallback_tx.serialize()) - if not success: - self.user_info("ERROR: the fallback transaction did not broadcast. " - "The payment has NOT been made.") - else: - self.user_info("Payment received successfully, but it was NOT a coinjoin.") - self.user_info("Txid: " + txid) - reactor.stop() diff --git a/jmclient/jmclient/payjoin.py b/jmclient/jmclient/payjoin.py index b7c91a0..551c0b3 100644 --- a/jmclient/jmclient/payjoin.py +++ b/jmclient/jmclient/payjoin.py @@ -1,25 +1,30 @@ from zope.interface import implementer -from twisted.internet import reactor +from twisted.internet import reactor, task +from twisted.web.http import UNAUTHORIZED, BAD_REQUEST, NOT_FOUND from twisted.web.client import (Agent, readBody, ResponseFailed, BrowserLikePolicyForHTTPS) +from twisted.web.server import Site +from twisted.web.resource import Resource, ErrorPage from twisted.web.iweb import IPolicyForHTTPS from twisted.internet.ssl import CertificateOptions -from twisted.internet.error import ConnectionRefusedError +from twisted.internet.error import ConnectionRefusedError, ConnectionLost from twisted.internet.endpoints import TCP4ClientEndpoint from twisted.web.http_headers import Headers +import txtorcon from txtorcon.web import tor_agent from txtorcon.socks import HostUnreachableError import urllib.parse as urlparse from urllib.parse import urlencode import json +from io import BytesIO from pprint import pformat -from jmbase import BytesProducer +from jmbase import BytesProducer, bintohex, jmprint from .configure import get_log, jm_single import jmbitcoin as btc -from .wallet import PSBTWalletMixin, SegwitLegacyWallet, SegwitWallet +from .wallet import PSBTWalletMixin, SegwitLegacyWallet, SegwitWallet, estimate_tx_fee from .wallet_service import WalletService from .taker_utils import direct_send -from jmclient import RegtestBitcoinCoreInterface +from jmclient import RegtestBitcoinCoreInterface, select_one_utxo, process_shutdown """ For some documentation see: @@ -31,6 +36,17 @@ For some documentation see: """ log = get_log() +# Recommended sizes for input vsize as per BIP78 +# (stored here since BIP78 specific; could be moved to jmbitcoin) +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 @@ -77,8 +93,8 @@ class JMPayjoinManager(object): pj_state = JM_PJ_NONE def __init__(self, wallet_service, mixdepth, destination, - amount, server, disable_output_substitution=False, - mode="command-line"): + amount, server=None, disable_output_substitution=False, + mode="command-line", user_info_callback=None): assert isinstance(wallet_service, WalletService) # payjoin is not supported for non-segwit wallets: assert isinstance(wallet_service.wallet, @@ -86,6 +102,13 @@ class JMPayjoinManager(object): # our payjoin implementation requires PSBT assert isinstance(wallet_service.wallet, PSBTWalletMixin) self.wallet_service = wallet_service + # for convenience define wallet type here: + if isinstance(self.wallet_service.wallet, SegwitLegacyWallet): + self.wallet_type = "sw-legacy" + elif isinstance(self.wallet_service.wallet, SegwitWallet): + self.wallet_type = "sw" + else: + assert False # mixdepth from which payment is sourced assert isinstance(mixdepth, int) self.mixdepth = mixdepth @@ -94,7 +117,12 @@ class JMPayjoinManager(object): assert isinstance(amount, int) assert amount > 0 self.amount = amount - self.server = server + if server is None: + self.server = None + self.role = "receiver" + else: + self.role = "sender" + self.server = server self.disable_output_substitution = disable_output_substitution self.pj_state = self.JM_PJ_INIT self.payment_tx = None @@ -109,49 +137,91 @@ class JMPayjoinManager(object): # processing, shutting down on completion. self.mode = mode + # fix the sequence number if the sender uses only one + # (otherwise the receiver is free to do anything): + self.fixed_sequence_number = None + # to be able to cancel the timeout fallback broadcast # in case of success: self.timeout_fallback_dc = None + # set callback for conveying info to user (takes one string arg): + if not user_info_callback: + self.user_info_callback = self.default_user_info_callback + else: + self.user_info_callback = user_info_callback + + def default_user_info_callback(self, msg): + """ Info level message print to command line. + """ + jmprint(msg) + def set_payment_tx_and_psbt(self, in_psbt): - assert isinstance(in_psbt, btc.PartiallySignedTransaction) + assert isinstance(in_psbt, btc.PartiallySignedTransaction), "invalid PSBT input to JMPayjoinManager." 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() + + success, msg = self.sanity_check_initial_payment() + if not success: + log.error(msg) + assert False, msg self.pj_state = self.JM_PJ_PAYMENT_CREATED + def get_payment_psbt_feerate(self): + return self.initial_psbt.get_fee()/float( + self.initial_psbt.extract_transaction().get_virtual_size()) + + def get_vsize_for_input(self): + if isinstance(self.wallet_service.wallet, SegwitLegacyWallet): + vsize = INPUT_VSIZE_SEGWIT_LEGACY + elif isinstance(self.wallet_service.wallet, SegwitWallet): + vsize = INPUT_VSIZE_SEGWIT_NATIVE + else: + raise Exception("Payjoin only supported for segwit wallets") + return vsize + 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. + """ These checks are those specified + for the *receiver* in BIP78. + However, for the sender, we want to make sure our + payment isn't rejected. So this is not receiver-only. We also sanity check that the payment details match the initialization of this Manager object. + Returns: + (False, reason) + or + (True, None) """ + # 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 + return (False, "Input utxo was not witness type.") + # see third bullet point of: + # https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki#receivers-original-psbt-checklist + # + # Check that all inputs have same scriptPubKey type, + # and that it is the same as our wallet (for sender + # code in JM this is a no-op, for receiver, we can + # only support payjoins fitting our wallet type, since + # we do not use multi-wallet or output substitution: + input_type = self.wallet_service.check_finalized_input_type(inp) + if input_type != self.wallet_type: + return (False, "an input was not of the same script type.") # check that there is no xpub or derivation info if self.initial_psbt.xpubs: - return False + return (False, "Unexpected xpubs found in PSBT.") 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 + return (False, "Unexpected derivation found in PSBT.") 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. + return (False, "Unexpected derivation found in PSBT.") # our logic requires no more than one change output # for now: @@ -162,17 +232,24 @@ class JMPayjoinManager(object): btc.CCoinAddress.from_scriptPubKey( out.scriptPubKey) == self.destination: found_payment += 1 + self.pay_out = out + self.pay_out_index = i 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 (False, "The payment output was not found.") - return True + # if the sequence numbers chosen are uniform, record this: + seqnums = [x.nSequence for x in self.payment_tx.vin] + if seqnums.count(seqnums[0]) == len(seqnums): + self.fixed_sequence_number = seqnums[0] - def check_receiver_proposal(self, in_pbst, signed_psbt_for_fees): + return (True, None) + + def check_receiver_proposal(self, in_psbt, 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 @@ -206,14 +283,14 @@ class JMPayjoinManager(object): (False, "reason for failure") (True, None) """ - assert isinstance(in_pbst, btc.PartiallySignedTransaction) + assert isinstance(in_psbt, 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 i, inp in enumerate(in_psbt.unsigned_tx.vin): for j, inp2 in enumerate(ourins): if (inp.prevout.hash, inp.prevout.n) == inp2: found[j] += 1 @@ -225,7 +302,7 @@ class JMPayjoinManager(object): # 2 if self.disable_output_substitution: found_payment = 0 - for out in in_pbst.unsigned_tx.vout: + for out in in_psbt.unsigned_tx.vout: if btc.CCoinAddress.from_scriptPubKey(out.scriptPubKey) == \ self.destination and out.nValue >= self.amount: found_payment += 1 @@ -235,26 +312,15 @@ class JMPayjoinManager(object): # 3 for ind in receiver_input_indices: # check the input is finalized - if not self.wallet_service.is_input_finalized(in_pbst.inputs[ind]): + if not self.wallet_service.is_input_finalized(in_psbt.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 + input_type = self.wallet_service.check_finalized_input_type( + in_psbt.inputs[ind]) + if input_type != self.wallet_type: + return (False, "receiver input does not match our script type.") # 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, @@ -278,11 +344,11 @@ class JMPayjoinManager(object): # 6 if self.change_out: found_change = 0 - for out in in_pbst.unsigned_tx.vout: + for out in in_psbt.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( + if actual_contribution > in_psbt.get_fee( ) - self.initial_psbt.get_fee(): return (False, "Our change output is reduced more" " than the fee is bumped.") @@ -296,18 +362,18 @@ class JMPayjoinManager(object): return (False, "Our change output was not found " "exactly once.") # 7 - if in_pbst.xpubs: + if in_psbt.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: + for inp in in_psbt.unsigned_tx.vin: if inp.nSequence != seqno: return (False, "all sequence numbers are not the same.") # 9 - if in_pbst.unsigned_tx.nLockTime != \ + if in_psbt.unsigned_tx.nLockTime != \ self.initial_psbt.unsigned_tx.nLockTime: return (False, "receiver proposal has altered nLockTime.") - if in_pbst.unsigned_tx.nVersion != \ + if in_psbt.unsigned_tx.nVersion != \ self.initial_psbt.unsigned_tx.nVersion: return (False, "receiver proposal has altered nVersion.") # all checks passed @@ -348,6 +414,34 @@ class JMPayjoinManager(object): else: self.pj_state = self.JM_PJ_PAYJOIN_BROADCAST_FAILED + def select_receiver_utxos(self): + # Rceiver chooses own inputs: + # For earlier ideas about more complex algorithms, see the gist comment here: + # https://gist.github.com/AdamISZ/4551b947789d3216bacfcb7af25e029e#gistcomment-2799709 + # and also see the code in P2EPMaker in earlier versions of Joinmarket. + # + # For now, it is considered too complex to accurately judge the implications + # of the UIH1/2 heuristic violations, in particular because selecting more than + # one input has impact on fees which is undesirable and tricky to deal with. + # So here we ONLY choose one utxo at random. + + # Returns: + # list of utxos (currently always of length 1) + # or + # False if coins cannot be selected + + self.user_info_callback("Choosing one coin at random") + try: + my_utxos = self.wallet_service.select_utxos( + self.mixdepth, jm_single().DUST_THRESHOLD, + select_fn=select_one_utxo) + except Exception as e: + log.error("Failed to select coins, exception: " + repr(e)) + return False + my_total_in = sum([va['value'] for va in my_utxos.values()]) + self.user_info_callback("We selected inputs worth: " + str(my_total_in)) + return my_utxos + def report(self, jsonified=False, verbose=False): """ Returns a dict (optionally jsonified) containing the following information (if they are @@ -401,7 +495,7 @@ def parse_payjoin_setup(bip21_uri, wallet_service, mixdepth, mode="command-line" disable_output_substitution = False if "pjos" in decoded and decoded["pjos"] == "0": disable_output_substitution = True - return JMPayjoinManager(wallet_service, mixdepth, destaddr, amount, server, + return JMPayjoinManager(wallet_service, mixdepth, destaddr, amount, server=server, disable_output_substitution=disable_output_substitution, mode=mode) @@ -411,23 +505,19 @@ def get_max_additional_fee_contribution(manager): 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()) + vsize = manager.get_vsize_for_input() + original_fee_rate = manager.get_payment_psbt_feerate() log.debug("Initial nonpayjoin transaction feerate is: " + str(original_fee_rate)) + # Factor slightly higher than 1 is to allow some breathing room for + # receiver. NB: This may not be appropriate for sender wallets that + # use rounded fee rates, but Joinmarket does not. 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): + 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`, @@ -449,7 +539,7 @@ def send_payjoin(manager, accept_callback=None, 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) + with_final_psbt=True, optin_rbf=True) if not payment_psbt: return (False, "could not create non-payjoin payment") @@ -462,8 +552,9 @@ def send_payjoin(manager, accept_callback=None, 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, - manager, b"timeout") + 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 @@ -529,32 +620,32 @@ def send_payjoin(manager, accept_callback=None, 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) + failure.trap(ResponseFailed, ConnectionRefusedError, + HostUnreachableError, ConnectionLost) log.error(failure.value) - fallback_nonpayjoin_broadcast(manager, b"connection refused") + fallback_nonpayjoin_broadcast(b"connection failed", manager) d.addErrback(noResponse) + if return_deferred: + return d return (True, None) -def fallback_nonpayjoin_broadcast(manager, err): +def fallback_nonpayjoin_broadcast(err, manager): """ 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). + processing) if this is called on the command line. """ assert isinstance(manager, JMPayjoinManager) def quit(): if manager.mode == "command-line" and reactor.running: - for dc in reactor.getDelayedCalls(): - dc.cancel() - reactor.stop() + process_shutdown() 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() @@ -562,24 +653,25 @@ def fallback_nonpayjoin_broadcast(manager, err): log.error("Unable to broadcast original payment. The payment is NOT made.") quit() return - log.info("We paid without coinjoin. Transaction: ") + log.info("Payment made without coinjoin. Transaction: ") log.info(btc.human_readable_transaction(original_tx)) + manager.set_broadcast(False) + if manager.timeout_fallback_dc.active(): + manager.timeout_fallback_dc.cancel() quit() def receive_payjoin_proposal_from_server(response, 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: - fallback_nonpayjoin_broadcast(manager, err=response.phrase) + log.warn("Receiver returned error code: " + str(response.code)) + d.addCallback(fallback_nonpayjoin_broadcast, manager) 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): @@ -589,7 +681,7 @@ def process_payjoin_proposal_from_server(response_body, manager): 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=b"Server sent invalid psbt") + fallback_nonpayjoin_broadcast(b"Server sent invalid psbt", manager) return log.debug("Receiver sent us this PSBT: ") @@ -632,6 +724,437 @@ def process_payjoin_proposal_from_server(response_body, manager): log.info("Payjoin transaction broadcast successfully.") # if transaction is succesfully broadcast, remove the # timeout fallback to avoid confusing error messages: - manager.timeout_fallback_dc.cancel() + if manager.timeout_fallback_dc.active(): + manager.timeout_fallback_dc.cancel() + manager.set_broadcast(True) if manager.mode == "command-line" and reactor.running: - reactor.stop() + process_shutdown() + +""" Receiver-specific code +""" + +class PayjoinServer(Resource): + def __init__(self, wallet_service, mixdepth, destination, amount, + shutdown_callback, info_callback , mode="command-line", + pj_version = 1): + self.pj_version = pj_version + self.wallet_service = wallet_service + # a callback with no arguments and no return value, + # to take whatever actions are needed when the payment has + # been received: + self.shutdown_callback = shutdown_callback + self.info_callback = info_callback + self.manager = JMPayjoinManager(self.wallet_service, mixdepth, + destination, amount, mode=mode, + user_info_callback=self.info_callback) + super().__init__() + + isLeaf = True + + def bip78_error(self, request, error_meaning, + error_code="unavailable", http_code=400): + """ + See https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki#receivers-well-known-errors + + We return, to the sender, stringified json in the body as per the above. + In case the error code is "original-psbt-rejected", we do not have + any valid payment to broadcast, so we shut down with "not paid". + for other cases, we schedule the fallback for 60s from now. + """ + + request.setResponseCode(http_code) + request.setHeader(b"content-type", b"text/html; charset=utf-8") + log.debug("Returning an error: " + str( + error_code) + ": " + str(error_meaning)) + if error_code in ["original-psbt-rejected", "version-unsupported"]: + # if there is a negotiation failure in the first step, we cannot + # know whether the sender client sent a valid non-payjoin or not, + # hence the warning below is somewhat ambiguous: + log.warn("Negotiation failure. Payment has not yet been made," + " check wallet.") + # shutdown now but wait until response is sent. + task.deferLater(reactor, 2.0, self.end_failure) + else: + reactor.callLater(60.0, fallback_nonpayjoin_broadcast, + error_meaning.encode("utf-8"), self.manager) + return json.dumps({"errorCode": error_code, + "message": error_meaning}).encode("utf-8") + + def render_GET(self, request): + # can be used e.g. to check if an ephemeral HS is up + # on Tor Browser: + return "Only for testing.".encode("utf-8") + + def render_POST(self, request): + """ The sender will use POST to send the initial + payment transaction. + """ + log.debug("The server got this POST request: ") + # unfortunately the twisted Request object is not + # easily serialized: + log.debug(request) + log.debug(request.method) + log.debug(request.uri) + log.debug(request.args) + sender_parameters = request.args + log.debug(request.path) + # defer logging of raw request content: + proposed_tx = request.content + + # we only support version 1; reject others: + if not self.pj_version == int(sender_parameters[b'v'][0]): + return self.bip78_error(request, + "This version of payjoin is not supported. ", + "version-unsupported") + if not isinstance(proposed_tx, BytesIO): + return self.bip78_error(request, "invalid psbt format", + "original-psbt-rejected") + payment_psbt_base64 = proposed_tx.read() + log.debug("request content: " + bintohex(payment_psbt_base64)) + try: + payment_psbt = btc.PartiallySignedTransaction.from_base64( + payment_psbt_base64) + except: + return self.bip78_error(request, + "invalid psbt format", + "original-psbt-rejected") + + try: + self.manager.set_payment_tx_and_psbt(payment_psbt) + except Exception: + # note that Assert errors, Value errors and CheckTransaction errors + # are all possible, so we catch all exceptions to avoid a crash. + return self.bip78_error(request, + "Proposed initial PSBT does not pass sanity checks.", + "original-psbt-rejected") + + # if the sender set the additionalfeeoutputindex and maxadditionalfeecontribution + # settings, pass them to the PayJoin manager: + try: + if b"additionalfeeoutputindex" in sender_parameters: + afoi = int(sender_parameters[b"additionalfeeoutputindex"][0]) + else: + afoi = None + if b"maxadditionalfeecontribution" in sender_parameters: + mafc = int(sender_parameters[b"maxadditionalfeecontribution"][0]) + else: + mafc = None + if b"minfeerate" in sender_parameters: + minfeerate = float(sender_parameters[b"minfeerate"][0]) + else: + minfeerate = None + except Exception as e: + return self.bip78_error(request, "Invalid request parameters.", + "original-psbt-rejected") + + # if sender chose a fee output it must be the change output, + # and the mafc will be applied to that. Any more complex transaction + # structure is not supported. + # If they did not choose a fee output index, we must rely on the feerate + # reduction being not too much, which is checked against minfeerate; if + # it is too big a reduction, again we fail payjoin. + if (afoi is not None and mafc is None) or (mafc is not None and afoi is None): + return self.bip78_error(request, "Invalid request parameters.", + "original-psbt-rejected") + + if afoi and not (self.manager.change_out_index == afoi): + return self.bip78_error(request, "additionalfeeoutputindex is " + "not the change output. Joinmarket does " + "not currently support this.", + "original-psbt-rejected") + + # while we do not need to defend against probing attacks, + # it is still safer to at least verify the validity of the signatures + # at this stage, to ensure no misbehaviour with using inputs + # that are not signed correctly: + res = jm_single().bc_interface.rpc('testmempoolaccept', [[bintohex( + self.manager.payment_tx.serialize())]]) + if not res[0]["allowed"]: + return self.bip78_error(request, "Proposed transaction was " + "rejected from mempool.", + "original-psbt-rejected") + + receiver_utxos = self.manager.select_receiver_utxos() + if not receiver_utxos: + return self.bip78_error(request, + "Could not select coins for payjoin", + "unavailable") + + # 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()) + pay_out = {"value": self.manager.pay_out.nValue, + "address": str(btc.CCoinAddress.from_scriptPubKey( + self.manager.pay_out.scriptPubKey))} + if self.manager.change_out: + change_out = {"value": self.manager.change_out.nValue, + "address": str(btc.CCoinAddress.from_scriptPubKey( + self.manager.change_out.scriptPubKey))} + + # we now know there were one/two outputs and know which is payment. + # bump payment output with our input: + if change_out: + outs = [pay_out, change_out] + else: + outs = [pay_out] + our_inputs_val = sum([v["value"] for _, v in receiver_utxos.items()]) + pay_out["value"] += our_inputs_val + log.debug("We bumped the payment output value by: " + str( + our_inputs_val) + " sats.") + log.debug("It is now: " + str(pay_out["value"]) + " sats.") + + # if the sender allowed a fee bump, we can apply it to the change output + # now (we already checked it's the right index). + # A note about checking `minfeerate`: it is impossible for the receiver + # to be 100% certain on the size of the final transaction, since he does + # not see in advance the (slightly) variable sizes of the sender's final + # signatures; hence we do not attempt more than an estimate of the final + # signed transaction's size and hence feerate. Very small inaccuracies + # (< 1% typically) are possible, therefore. + # + # First, let's check that the user's requested minfeerate is not higher + # than the feerate they already chose: + if minfeerate and minfeerate > self.manager.get_payment_psbt_feerate(): + return self.bip78_error(request, "Bad request: minfeerate " + "bigger than original psbt feerate.", + "original-psbt-rejected") + # set the intended virtual size of our input: + vsize = self.manager.get_vsize_for_input() + our_fee_bump = 0 + if afoi: + # We plan to reduce the change_out by a fee contribution. + # Calculate the additional fee we think we need for our input, + # to keep the same feerate as the original transaction (this also + # accounts for rounding as per the BIP). + # If it is more than mafc, then bump by mafc, else bump by the + # calculated amount. + # This should not meaningfully change the feerate. + our_fee_bump = int( + self.manager.get_payment_psbt_feerate() * vsize) + if our_fee_bump > mafc: + our_fee_bump = mafc + + elif minfeerate: + # In this case the change_out will remain unchanged. + # the user has not allowed a fee bump; calculate the new fee + # rate; if it is lower than the limit, give up. + expected_new_tx_size = self.manager.initial_psbt.extract_transaction( + ).get_virtual_size() + vsize + expected_new_fee_rate = self.manager.initial_psbt.get_fee()/( + expected_new_tx_size + vsize) + if expected_new_fee_rate < minfeerate: + return self.bip78_error(request, "Bad request: we cannot " + "achieve minfeerate requested.", + "original-psbt-rejected") + + # Having checked the sender's conditions, we can apply the fee bump + # intended (note the outputs will be shuffled next!): + outs[1]["value"] -= our_fee_bump + + unsigned_payjoin_tx = btc.make_shuffled_tx(payjoin_tx_inputs, outs, + version=payment_psbt.unsigned_tx.nVersion, + locktime=payment_psbt.unsigned_tx.nLockTime) + + # 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: + # this belongs to sender. + # respect sender's sequence number choice, even + # if they were not uniform: + inp.nSequence = inp2.nSequence + 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 + + # respect the sender's fixed sequence number, if it was used (we checked + # in the initial sanity check) + # TODO consider RBF if we implement it in Joinmarket payments. + if self.manager.fixed_sequence_number: + for inp in unsigned_payjoin_tx.vin: + inp.nSequence = self.manager.fixed_sequence_number + + log.debug("We created this unsigned tx: ") + log.debug(btc.human_readable_transaction(unsigned_payjoin_tx)) + + r_payjoin_psbt = self.wallet_service.create_psbt_from_tx(unsigned_payjoin_tx, + spent_outs=spent_outs) + log.debug("Receiver created payjoin PSBT:\n{}".format( + self.wallet_service.human_readable_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 + + log.debug("Receiver signing successful. Payjoin PSBT is now:\n{}".format( + self.wallet_service.human_readable_psbt(receiver_signed_psbt))) + # construct txoutset for the wallet service callback; we cannot use + # txid as we don't have all signatures. + txinfo = tuple(( + x.scriptPubKey, x.nValue) for x in receiver_signed_psbt.unsigned_tx.vout) + self.wallet_service.register_callbacks([self.end_receipt], + txinfo =txinfo, + cb_type="unconfirmed") + content = receiver_signed_psbt.to_base64() + request.setHeader(b"content-length", ("%d" % len(content)).encode("ascii")) + return content.encode("ascii") + + def end_receipt(self, txd, txid): + if self.manager.mode == "gui": + self.info_callback("Transaction seen on network, " + "view wallet tab for update.:FINAL") + else: + self.info_callback("Transaction seen on network: " + txid) + # do end processing of calling object (e.g. Tor disconnect) + self.shutdown_callback() + # informs the wallet service transaction monitor + # that the transaction has been processed: + return True + + def end_failure(self): + shutdown_msg = "Shutting down, payjoin negotiation failed." + if self.manager.mode == "gui": + shutdown_msg += "\nCheck wallet tab for payment." + shutdown_msg += ":FINAL" + self.info_callback(shutdown_msg) + self.shutdown_callback() + +class JMBIP78ReceiverManager(object): + """ A class to encapsulate receiver construction + """ + def __init__(self, wallet_service, mixdepth, amount, port, + info_callback=None, uri_created_callback = None, + mode="command-line"): + assert isinstance(wallet_service, WalletService) + assert isinstance(mixdepth, int) + assert isinstance(amount, int) + assert isinstance(port, int) + assert amount > 0 + assert port in range(65535) + self.wallet_service = wallet_service + self.mixdepth = mixdepth + self.amount = amount + self.port = port + # info_callback has signature (str) and returns None + if info_callback is None: + self.info_callback = self.default_info_callback + else: + self.info_callback = info_callback + # uri_created_callback is specifically to signal the + # created BIP21 uri for transfer to sender; made distinct + # from information messages in case it needs to be + # handled differently, but defaults to info_callback. + if uri_created_callback is None: + self.uri_created_callback = self.info_callback + else: + self.uri_created_callback = uri_created_callback + self.receiving_address = None + self.mode = mode + + def default_info_callback(self, msg): + jmprint(msg) + + def get_receiving_address(self): + # the receiving address is sourced from the 'next' mixdepth + # to avoid clustering of input and output: + next_mixdepth = (self.mixdepth + 1) % ( + self.wallet_service.wallet.mixdepth + 1) + self.receiving_address = btc.CCoinAddress( + self.wallet_service.get_internal_addr(next_mixdepth)) + + def start_pj_server_and_tor(self): + """ Packages the startup of the receiver side. + """ + self.get_receiving_address() + self.pj_server = PayjoinServer(self.wallet_service, self.mixdepth, + self.receiving_address, self.amount, + self.shutdown, self.info_callback, mode=self.mode) + self.site = Site(self.pj_server) + self.site.displayTracebacks = False + self.info_callback("Attempting to start onion service on port: " + str( + self.port) + " ...") + self.start_tor() + + def setup_failed(self, arg): + errmsg = "Setup failed: " + str(arg) + log.error(errmsg) + self.info_callback(errmsg) + process_shutdown() + + def create_onion_ep(self, t): + self.tor_connection = t + return t.create_onion_endpoint(self.port) + + def onion_listen(self, onion_ep): + return onion_ep.listen(self.site) + + def print_host(self, ep): + """ Callback fired once the HS is available; + receiver user needs a BIP21 URI to pass to + the sender: + """ + self.info_callback("Your hidden service is available. Please\n" + "now pass this URI string to the sender to\n" + "effect the payjoin payment:") + # Note that ep,getHost().onion_port must return the same + # port as we chose in self.port; if not there is an error. + assert ep.getHost().onion_port == self.port + self.uri_created_callback(self.bip21_uri_from_onion_hostname( + str(ep.getHost().onion_uri))) + if self.mode == "command-line": + self.info_callback("Keep this process running until the payment " + "is received.") + + def bip21_uri_from_onion_hostname(self, host): + """ Encoding the BIP21 URI according to BIP78 specifications, + and specifically only supporting a hidden service endpoint. + Note: we hardcode http; no support for TLS over HS. + Second, note we convert the amount-in-sats self.amount + to BTC denomination as expected by BIP21. + """ + port_str = ":" + str(self.port) if self.port != 80 else "" + full_pj_string = "http://" + host + port_str + bip78_btc_amount = btc.amount_to_btc(btc.amount_to_sat(self.amount)) + # "safe" option is required to encode url in url unmolested: + return btc.encode_bip21_uri(str(self.receiving_address), + {"amount": bip78_btc_amount, + "pj": full_pj_string.encode("utf-8")}, + safe=":/") + + def start_tor(self): + """ This function executes the workflow + of starting the hidden service and returning/ + printing the BIP21 URI: + """ + d = txtorcon.connect(reactor) + d.addCallback(self.create_onion_ep) + d.addErrback(self.setup_failed) + # TODO: add errbacks to the next two calls in + # the chain: + d.addCallback(self.onion_listen) + d.addCallback(self.print_host) + + def shutdown(self): + self.tor_connection.protocol.transport.loseConnection() + process_shutdown(self.mode) + self.info_callback("Hidden service shutdown complete") \ No newline at end of file diff --git a/jmclient/jmclient/taker.py b/jmclient/jmclient/taker.py index cf06cf0..9147194 100644 --- a/jmclient/jmclient/taker.py +++ b/jmclient/jmclient/taker.py @@ -877,337 +877,6 @@ class Taker(object): txdetails=(txd, txid)) return True -class P2EPTaker(Taker): - """ The P2EP Taker will initialize its protocol directly - with the prescribed counterparty (see -T argument to - sendpayment). It inherits the normal behaviour of requesting - an orderbook on startup, but does nothing with it; this - improves the privacy of the operation. - """ - - def __init__(self, counterparty, wallet_service, schedule, callbacks): - super().__init__(wallet_service, schedule, (1, float('inf')), - callbacks=callbacks) - self.p2ep_receiver_nick = counterparty - # Callback to request user permission (for e.g. GUI) - # args: (1) message, as string - # returns: True or False - self.user_check = self.default_user_check - - def default_user_check(self, message): - if input(message) == 'y': - return True - return False - - def register_user_check_callback(self, user_check): - self.user_check = user_check - - def unconfirm_callback(self, txd, txid): - jlog.info("Transaction seen on network, shutting down.") - jlog.info("Txid was: " + txid) - # In P2EP we stop the protocol here. - reactor.stop() - - def confirm_callback(self, txd, txid, confirmations): - # Will never trigger except in testing - self.unconfirm_callback(txd, txid) - - def initialize(self, orderbook): - """ Note that the orderbook parameter is ignored. - Here the schedule data (the standard format for coinjoin - request specification) passes in the amount, destination - and source mixdepth information. We then select coins - using the inherited Taker method to do so. - """ - if self.aborted: - return (False,) - self.taker_info_callback("INFO", "Received offers from joinmarket pit") - # only one schedule item has been allowed; parse from it. - self.schedule_index += 1 - si = self.schedule[0] - self.mixdepth = si[0] - self.cjamount = si[1] - # For the p2ep taker, the variable 'my_cj_addr' is the destination: - self.my_cj_addr = si[3] - if isinstance(self.cjamount, float): - raise JMTakerError("Payjoin must use amount in satoshis") - if self.cjamount == 0: - # Note that we don't allow sweep, currently, since the coin - # choosing algo would not apply in that case (we'd have to rewrite - # prepare_my_bitcoin_data for that case). - raise JMTakerError("Payjoin does not currently support sweep") - - # Next we prepare our coins with the inherited method - # for this purpose; for this we must set the - # number of counterparties and fee per cpy, for fee estimation; - # the estimates will be rather rough, but that's fine. Here we - # will end up selecting 20k sats more than the destination amount, - # which will make the already ultra-rare edge case of not selecting - # enough for fees, even more rare. "Stuck" coins due to edge cases - # are not an issue since the wallet has direct-send sweep. - self.n_counterparties = 1 - self.total_cj_fee = 0 - # Preparing bitcoin data here includes choosing utxos/coins. - # We don't trust the user on the selection algo choice; we want it - # to be fairly greedy for technical reasons explained in the comment - # thread to this gist: - # https://gist.github.com/AdamISZ/4551b947789d3216bacfcb7af25e029e - jm_single().config.set("POLICY", "merge_algorithm", "greedy") - self.noncj_fee_est = 0 - if not self.prepare_my_bitcoin_data(): - return (False, ) - self.outputs = [] - self.cjfee_total = 0 - self.latest_tx = None - self.txid = None - - # we return dummy values for commitment and revelation, - # and the offer dict only signals the nick of the counterparty. - return (True, self.cjamount, "p2ep", "p2ep", {self.p2ep_receiver_nick:{}}) - - def receive_utxos(self, ioauth_data): - """ TODO this function is misnamed for the - purpose of code reuse; fix it(e.g. 'make_tx_proposal'). - - The ioauth_data field will be a list containing the single - nick which we intend to send to (which fact we should check), - and then construct a transaction with the intended amount - to the intended destination. This transaction will not, in - normal operation, be broadcast; because the Maker (receiver) - will add his own inputs and change the total to be received at - that address. Thus we are functionally only sending our own input - utxos - the signed tx may be used as a fallback by the counterparty - in case we disappear - however this also serves the purpose of - signalling that we are the right counterparty. - """ - if not ioauth_data[0] == self.p2ep_receiver_nick: - return (False, "Wrong counterparty IRC nick: " + ioauth_data[0]) - # Transaction construction: use inputs as per `prepare_my_bitcoin_data`, - # use output destination self.my_cj_addr and use amount self.amount - self.outputs.append({'address': self.my_cj_addr, - 'value': self.cjamount}) - my_total_in = sum([va['value'] for u, va in self.input_utxos.items()]) - # estimate the fee for the version of the transaction which is - # not coinjoined: - est_fee = estimate_tx_fee(len(self.input_utxos), 2, - txtype=self.wallet_service.get_txtype()) - my_change_value = my_total_in - self.cjamount - est_fee - if my_change_value <= 0: - # as discussed in initialize(), this should be an extreme edge case. - raise ValueError("Wallet utxo selection chose too few coins") - elif self.my_change_addr and my_change_value <= jm_single( - ).BITCOIN_DUST_THRESHOLD: - jlog.info("Dynamically calculated change lower than dust: " + str( - my_change_value) + "; dropping.") - self.my_change_addr = None - my_change_value = 0 - # Note that the sweep case (my_change_addr is None, but not due to dust) - # is not currently allowed here. - if self.my_change_addr is not None: - self.outputs.append({'address': self.my_change_addr, - 'value': my_change_value}) - # Oour own inputs to the transaction; this preparatory version - # contains only those. - tx = btc.make_shuffled_tx(self.input_utxos, self.outputs, - version=2, locktime=compute_tx_locktime()) - jlog.info('Created proposed fallback tx:\n' + \ - btc.human_readable_transaction(tx)) - # We now sign as a courtesy, because if we disappear the recipient - # can still claim his coins with this. - # sign our inputs before transfer - our_inputs = {} - for index, ins in enumerate(tx.vin): - utxo = (ins.prevout.hash[::-1], ins.prevout.n) - our_inputs[index] = (self.input_utxos[utxo]["script"], - self.input_utxos[utxo]['value']) - success, msg = self.wallet_service.sign_tx(tx, our_inputs) - if not success: - jlog.error("Failed to create backup transaction; error: " + msg) - self.taker_info_callback("INFO", "Built tx proposal, sending to receiver.") - return (True, [self.p2ep_receiver_nick], bintohex(tx.serialize())) - - @hexbin - def on_tx_received(self, nick, txser): - """ Here the taker (payer) retrieves a version of the - transaction from the maker (receiver) which should have - the following properties: - * Destination address as previously agreed. - * Our correct change output with amount corresponding to fee. - * Net of (destination amount) - (receiver input amount) - must be equal to the original amount self.cjamount. - * Our inputs must be unchanged from original proposal. - * Counterparty should not provide more than 5 utxos; this - is a crude avoidance of over-paying fees, but note that - the maker selection should mean this almost never happens. - * Counterparties' transaction signatures must be valid. - If all conditions are met, we sign each of our inputs - and then broadcast (TODO broadcast delay or don't broadcast). - """ - try: - tx = btc.CMutableTransaction.deserialize(txser) - except Exception as e: - return (False, "malformed txhex. " + repr(e)) - jlog.info("Obtained tx from receiver:\n" + pprint.pformat(str(tx))) - cjaddr_script = btc.CCoinAddress( - self.my_cj_addr).to_scriptPubKey() - changeaddr_script = btc.CCoinAddress( - self.my_change_addr).to_scriptPubKey() - - # We ensure that the coinjoin address and our expected change - # address are still in the outputs, once (with the caveat that - # the change address is allowed to be absent in a special case - # of dust change, which we assess after). - times_seen_cj_addr = 0 - times_seen_change_addr = 0 - for outs in tx.vout: - if outs.scriptPubKey == cjaddr_script: - times_seen_cj_addr += 1 - new_cj_amount = outs.nValue - if new_cj_amount < self.cjamount: - # This is a violation of protocol; - # receiver must be providing extra bitcoin - # as input, so his receiving amount should have increased. - return (False, - 'Wrong cj_amount. I expect at least' + str(self.cjamount)) - if outs.scriptPubKey == changeaddr_script: - times_seen_change_addr += 1 - new_change_amount = outs.nValue - if times_seen_cj_addr != 1: - fmt = ('cj addr not in tx outputs once, #cjaddr={}').format - return (False, (fmt(times_seen_cj_addr))) - if times_seen_change_addr != 1: - if times_seen_change_addr > 1: - return (False, "proposed tx has change address duplicated") - # Otherwise change has been ditched; will check this later. - new_change_amount = 0 - - # Check that our inputs are present. - tx_utxo_set = set((ins.prevout.hash[::-1], ins.prevout.n) for ins in tx.vin) - if not tx_utxo_set.issuperset(set(self.utxos[None])): - return (False, "my utxos are not contained") - # Check that the sequence numbers of all inputs are unaltered - # from the intended 0xffffffff - 1, and that the locktime - # is not zero (could go further and check exact block). - # Note that this is hacky and is most elegantly addressed by - # use of PSBT (although any object encapsulation of tx input - # would serve the same purpose). - if tx.nLockTime == 0: - return (False, "Invalid PayJoin v0 transaction: locktime 0") - for i in tx.vin: - if i.nSequence != 0xffffffff - 1: - return (False, "Invalid PayJoin v0 transaction: "+\ - "sequence is not 0xffffffff -1") - - # Before even starting fee calculations, reject > 5 - # inputs from counterparty as an abuse (accidental or - # not) of PayJoin to sweep utxos at no cost. - # (TODO This is very kludgy, more sophisticated approach - # should be used in future): - if len(tx.vin) - len (self.utxos[None]) > 5: - return (False, - "proposed tx has more than 5 inputs from " - "the recipient, which is too expensive.") - - # If we ignored fees, we would only need to check that - # the difference between our inputs and outputs was equal - # to the expected payment; but this difference will include - # the bitcoin transaction fee. - # Hence, we retrieve the counterparty's input amount, - # and find the overall bitcoin network fee, and decide - # from this whether the change value is as expected - # (our inputs - expected payment - network fee); - # and if that is dusty, agree to sign without change. - - # batch retrieval of utxo data; we collect the utxos - # in the inputs which do not belong to us, and put - # them into a dict (`retrieve_utxos`), keyed by their - # index in the inputs, so we can use the collected - # script and amount data when we do the next stages, - # checking input validity and transaction balance. - retrieve_utxos = {} - ctr = 0 - for index, ins in enumerate(tx.vin): - utxo_for_checking = (ins.prevout.hash[::-1], ins.prevout.n) - if utxo_for_checking in self.utxos[None]: - continue - retrieve_utxos[ctr] = [index, utxo_for_checking] - ctr += 1 - # we always accept unconf utxos from receiver; it's their payment: - utxo_data = jm_single().bc_interface.query_utxo_set( - [x[1] for x in retrieve_utxos.values()], includeunconf=True) - - # Next we'll verify each of the counterparty's inputs, - # while at the same time gathering the total they spent. - total_receiver_input = 0 - for i, u in retrieve_utxos.items(): - if utxo_data[i] is None: - return (False, "Proposed transaction contains invalid utxos") - total_receiver_input += utxo_data[i]["value"] - idx = retrieve_utxos[i][0] - if not btc.verify_tx_input(tx, idx, tx.vin[idx].scriptSig, - btc.CScript(utxo_data[i]['script']), - amount=utxo_data[i]["value"], - witness=tx.wit.vtxinwit[idx].scriptWitness): - return (False, - "Proposed transaction is not correctly signed.") - payment = new_cj_amount - total_receiver_input - if payment != self.cjamount: - return (False, "Proposed transaction has wrong payment amount: " +\ - str(payment) + ", should be: " + str(self.cjamount)) - # reminder: the keys of the input_utxos dict == self.utxos[None] - total_sender_input = sum([va['value'] for va in self.input_utxos.values()]) - # check full transaction balance - btc_fee = total_receiver_input + total_sender_input - new_cj_amount - new_change_amount - self.taker_info_callback("INFO", - "Network transaction fee is: " + str(btc_fee) + " satoshis.") - if btc_fee <= 0: - return (False, "Proposed transaction has no bitcoin fee") - # To validate the fee, we need to check the size, but this can only be estimated - # until it's fully signed; we now know the number of inputs, so we can use - # our fee estimator. Its return value will be governed by our own fee settings - # in joinmarket.cfg; allow either (a) automatic agreement for any value within - # a range of 0.3 to 3x this figure, or (b) user to agree on prompt. - fee_est = estimate_tx_fee(len(tx.vin), len(tx.vout), - txtype=self.wallet_service.get_txtype()) - fee_ok = False - if btc_fee > 0.3 * fee_est and btc_fee < 3 * fee_est: - fee_ok = True - else: - if self.user_check("Is this transaction fee acceptable? (y/n):"): - fee_ok = True - if not fee_ok: - return (False, - "Proposed transaction fee not accepted due to tx fee: " + str( - btc_fee)) - - self.total_txfee = btc_fee - # now that the fee is known and accepted, we can check our change - if new_change_amount == 0: - # calculate what the change would be, after subtracting the agreed fee; - # if it's dusty, then we continue with no change; otherwise we prompt/ - # reject. - hyp_change_amount = total_receiver_input - self.cjamount - btc_fee - if hyp_change_amount <= jm_single().BITCOIN_DUST_THRESHOLD: - jlog.info("Counterparty correctly removed dusty change value:"\ - + str(hyp_change_amount)) - else: - jlog.info(('WARNING CHANGE NOT BEING ' - 'USED\nCHANGEVALUE = {}').format( - hyp_change_amount)) - if not self.user_check("OK to broadcast with this change spent " - "to miner fee? (y/n):"): - return (False, "Proposed transaction not accepted due to " - "absent change.") - - # All checks have passed; we sign and broadcast - self.latest_tx = tx - # Note that self.self_sign will only sign the self.input_utxos specified at - # the start of the processing, which guards against the "unwittingly sign - # extra inputs" attack mentioned in BIP79. - self.self_sign_and_push() - # returning False here is not an error condition, only stops processing. - return (False, "OK") - def round_to_significant_figures(d, sf): '''Rounding number d to sf significant figures in base 10''' for p in range(-10, 15): diff --git a/jmclient/jmclient/wallet.py b/jmclient/jmclient/wallet.py index 36cb271..bc38224 100644 --- a/jmclient/jmclient/wallet.py +++ b/jmclient/jmclient/wallet.py @@ -1154,6 +1154,27 @@ class PSBTWalletMixin(object): return [btc.CMutableTxOut(v["value"], v["script"]) for _, v in utxos.items()] + @staticmethod + def check_finalized_input_type(psbt_input): + """ Given an input of a PSBT which is already finalized, + return its type as either "sw-legacy" or "sw" or False + if not one of these two types. + TODO: can be extented to other types. + """ + assert isinstance(psbt_input, btc.PSBT_Input) + # TODO: cleanly check that this PSBT Input is finalized. + if psbt_input.utxo.scriptPubKey.is_p2sh(): + # Note: p2sh does not prove the redeemscript; + # we check the finalscriptSig is p2wpkh: + fss = btc.CScript(next(btc.CScript( + psbt_input.final_script_sig).raw_iter())[1]) + if fss.is_witness_v0_keyhash(): + return "sw-legacy" + elif psbt_input.utxo.scriptPubKey.is_witness_v0_keyhash(): + return "sw" + else: + return False + def create_psbt_from_tx(self, tx, spent_outs=None): """ Given a CMutableTransaction object, which should not currently contain signatures, we create and return a new PSBT object of type diff --git a/jmclient/test/test_payjoin.py b/jmclient/test/test_payjoin.py index 634a9f4..aa10ded 100644 --- a/jmclient/test/test_payjoin.py +++ b/jmclient/test/test_payjoin.py @@ -1,33 +1,89 @@ """ -Test doing payjoin joins (with message channel layer mocked) +Test doing payjoins over tcp client/server """ import os import sys import pytest from twisted.internet import reactor -from jmbase import get_log +from twisted.web.server import Site +from twisted.trial import unittest + +from jmbase import get_log, jmprint +from jmbitcoin import (CCoinAddress, encode_bip21_uri, + amount_to_btc, amount_to_sat) from jmclient import cryptoengine from jmclient import (load_test_config, jm_single, - P2EPMaker, P2EPTaker, - SegwitLegacyWallet, SegwitWallet) + SegwitLegacyWallet, SegwitWallet, + PayjoinServer, parse_payjoin_setup, send_payjoin) from commontest import make_wallets from test_coinjoin import make_wallets_to_list, create_orderbook, sync_wallets testdir = os.path.dirname(os.path.realpath(__file__)) log = get_log() -def create_taker(wallet_service, schedule, monkeypatch): - taker = P2EPTaker("fakemaker", wallet_service, schedule, - callbacks=(None, None, None)) - return taker +class TrialTestPayjoinServer(unittest.TestCase): -def dummy_user_check(message): - # No user interaction in test; just print message - # and assume acceptance. - log.info(message) - return True + def setUp(self): + load_test_config() + jm_single().bc_interface.tick_forward_chain_interval = 5 + jm_single().bc_interface.simulate_blocks() + + def test_payment(self): + wallet_structures = [[1, 3, 0, 0, 0]] * 2 + mean_amt = 2.0 + wallet_cls = (SegwitLegacyWallet, SegwitLegacyWallet) + self.wallet_services = [] + self.wallet_services.append(make_wallets_to_list(make_wallets( + 1, wallet_structures=[wallet_structures[0]], + mean_amt=mean_amt, wallet_cls=wallet_cls[0]))[0]) + self.wallet_services.append(make_wallets_to_list(make_wallets( + 1, wallet_structures=[wallet_structures[1]], + mean_amt=mean_amt, wallet_cls=wallet_cls[1]))[0]) + jm_single().bc_interface.tickchain() + sync_wallets(self.wallet_services) + + # For accounting purposes, record the balances + # at the start. + self.rsb = getbals(self.wallet_services[0], 0) + self.ssb = getbals(self.wallet_services[1], 0) + + self.cj_amount = int(1.1 * 10**8) + # destination address is in 2nd mixdepth of receiver + # (note: not first because sourcing from first) + bip78_receiving_address = self.wallet_services[0].get_internal_addr(1) + def cbStopListening(): + return self.port.stopListening() + pjs = PayjoinServer(self.wallet_services[0], 0, + CCoinAddress(bip78_receiving_address), + self.cj_amount, cbStopListening, jmprint) + site = Site(pjs) + + # NB The connectivity aspects of the BIP78 tests are in + # test/payjoin[client/server].py as they are time heavy + # and require extra setup. This server is TCP only. + self.port = reactor.listenTCP(47083, site) + self.addCleanup(cbStopListening) + + # setup of spender + bip78_btc_amount = amount_to_btc(amount_to_sat(self.cj_amount)) + bip78_uri = encode_bip21_uri(bip78_receiving_address, + {"amount": bip78_btc_amount, + "pj": b"http://127.0.0.1:47083"}, + safe=":/") + self.manager = parse_payjoin_setup(bip78_uri, self.wallet_services[1], 0) + self.manager.mode = "testing" + self.site = site + return send_payjoin(self.manager, return_deferred=True) + + def tearDown(self): + for dc in reactor.getDelayedCalls(): + dc.cancel() + res = final_checks(self.wallet_services, self.cj_amount, + self.manager.final_psbt.get_fee(), + self.ssb, self.rsb) + assert res, "final checks failed" def getbals(wallet_service, mixdepth): """ Retrieves balances for a mixdepth and the 'next' @@ -35,122 +91,41 @@ def getbals(wallet_service, mixdepth): bbm = wallet_service.get_balance_by_mixdepth() return (bbm[mixdepth], bbm[(mixdepth + 1) % (wallet_service.mixdepth + 1)]) -def final_checks(wallet_services, amount, txfee, tsb, msb, source_mixdepth=0): +def final_checks(wallet_services, amount, txfee, ssb, rsb, source_mixdepth=0): """We use this to check that the wallet contents are as we've expected according to the test case. - amount is the payment amount going from taker to maker. - txfee is the bitcoin network transaction fee, paid by the - taker. - tsb, msb are taker and maker starting balances, each a tuple + amount is the payment amount going from spender to receiver. + txfee is the bitcoin network transaction fee, paid by the spender. + ssb, rsb are spender and receiver starting balances, each a tuple of two entries, source and destination mixdepth respectively. """ jm_single().bc_interface.tickchain() sync_wallets(wallet_services) - takerbals = getbals(wallet_services[1], source_mixdepth) - makerbals = getbals(wallet_services[0], source_mixdepth) + spenderbals = getbals(wallet_services[1], source_mixdepth) + receiverbals = getbals(wallet_services[0], source_mixdepth) # is the payment received? - maker_newcoin_amt = makerbals[1] - msb[1] - if not maker_newcoin_amt >= amount: - print("Maker expected to receive at least: ", amount, - " but got: ", maker_newcoin_amt) + receiver_newcoin_amt = receiverbals[1] - rsb[1] + if not receiver_newcoin_amt >= amount: + print("Receiver expected to receive at least: ", amount, + " but got: ", receiver_newcoin_amt) return False - # assert that the maker received net exactly the right amount - maker_spentcoin_amt = msb[0] - makerbals[0] - if not maker_spentcoin_amt >= 0: + # assert that the receiver received net exactly the right amount + receiver_spentcoin_amt = rsb[0] - receiverbals[0] + if not receiver_spentcoin_amt >= 0: # for now allow the non-cj fallback case - print("maker's spent coin should have been positive, was: ", maker_spentcoin_amt) + print("receiver's spent coin should have been positive, was: ", receiver_spentcoin_amt) return False - if not maker_newcoin_amt == amount + maker_spentcoin_amt: - print("maker's new coins should have been: ", amount + maker_spentcoin_amt, - " but was: ", maker_newcoin_amt) + if not receiver_newcoin_amt == amount + receiver_spentcoin_amt: + print("receiver's new coins should have been: ", amount + receiver_spentcoin_amt, + " but was: ", receiver_newcoin_amt) return False - # Taker-side check - # assert that the taker's total ending minus total starting + # Spender-side check + # assert that the spender's total ending minus total starting # balance is the amount plus the txfee given. - if not (sum(takerbals) - sum(tsb) + txfee + amount) == 0: + if not (sum(spenderbals) - sum(ssb) + txfee + amount) == 0: print("Taker should have spent: ", txfee + amount, - " but spent: ", sum(tsb) - sum(takerbals)) + " but spent: ", sum(ssb) - sum(spenderbals)) return False + print("Final checks were passed") return True - -@pytest.mark.parametrize('wallet_cls, wallet_structures, mean_amt', - [ # note we have removed LegacyWallet test cases. - ([SegwitLegacyWallet, SegwitLegacyWallet], - [[1, 3, 0, 0, 0]] * 2, 2.0), - ([SegwitWallet, SegwitWallet], - [[1, 0, 0, 0, 0]] * 2, 4.0), - ([SegwitLegacyWallet, SegwitWallet], - [[1, 3, 0, 0, 0]] * 2, 2.0), - ([SegwitWallet, SegwitLegacyWallet], - [[1, 0, 0, 0, 0]] * 2, 4.0), - ]) -def test_simple_payjoin(monkeypatch, tmpdir, setup_cj, wallet_cls, - wallet_structures, mean_amt): - def raise_exit(i): - raise Exception("sys.exit called") - monkeypatch.setattr(sys, 'exit', raise_exit) - wallet_services = [] - wallet_services.append(make_wallets_to_list(make_wallets( - 1, wallet_structures=[wallet_structures[0]], - mean_amt=mean_amt, wallet_cls=wallet_cls[0]))[0]) - wallet_services.append(make_wallets_to_list(make_wallets( - 1, wallet_structures=[wallet_structures[1]], - mean_amt=mean_amt, wallet_cls=wallet_cls[1]))[0]) - jm_single().bc_interface.tickchain() - sync_wallets(wallet_services) - - # For accounting purposes, record the balances - # at the start. - msb = getbals(wallet_services[0], 0) - tsb = getbals(wallet_services[1], 0) - - cj_amount = int(1.1 * 10**8) - maker = P2EPMaker(wallet_services[0], 0, cj_amount) - destaddr = maker.destination_addr - monkeypatch.setattr(maker, 'user_check', dummy_user_check) - # TODO use this to sanity check behaviour - # in presence of the rest of the joinmarket orderbook. - orderbook = create_orderbook([maker]) - assert len(orderbook) == 1 - # mixdepth, amount, counterparties, dest_addr, waittime; - # in payjoin we only pay attention to the first two entries. - schedule = [(0, cj_amount, 1, destaddr, 0)] - taker = create_taker(wallet_services[-1], schedule, monkeypatch) - monkeypatch.setattr(taker, 'user_check', dummy_user_check) - init_data = taker.initialize(orderbook) - # the P2EPTaker.initialize() returns: - # (True, self.cjamount, "p2ep", "p2ep", {self.p2ep_receiver_nick:{}}) - assert init_data[0], "taker.initialize error" - active_orders = init_data[4] - assert len(active_orders.keys()) == 1 - response = taker.receive_utxos(list(active_orders.keys())) - assert response[0], "taker receive_utxos error" - # test for validity of signed fallback transaction; requires 0.17; - # note that we count this as an implicit test of fallback mode. - res = jm_single().bc_interface.rpc('testmempoolaccept', [[response[2]]]) - assert res[0]["allowed"], "Proposed transaction was rejected from mempool." - maker_response = maker.on_tx_received("faketaker", response[2]) - if not maker_response[0]: - print("maker on_tx_received failed, reason: ", maker_response[1]) - assert False - taker_response = taker.on_tx_received("fakemaker", maker_response[2]) - if not taker_response[1] == "OK": - print("Failure in taker on_tx_received, reason: ", taker_response[1]) - assert False - # Although the above OK is proof that a transaction went through, - # it doesn't prove it was a good transaction! Here do balance checks: - assert final_checks(wallet_services, cj_amount, taker.total_txfee, tsb, msb) - -@pytest.fixture(scope='module') -def setup_cj(): - load_test_config() - jm_single().config.set('POLICY', 'tx_broadcast', 'self') - jm_single().bc_interface.tick_forward_chain_interval = 5 - jm_single().bc_interface.simulate_blocks() - #see note in cryptoengine.py: - cryptoengine.BTC_P2WPKH.VBYTE = 100 - yield None - # teardown - for dc in reactor.getDelayedCalls(): - dc.cancel() diff --git a/jmclient/test/test_psbt_wallet.py b/jmclient/test/test_psbt_wallet.py index 5c59e00..6f8390e 100644 --- a/jmclient/test/test_psbt_wallet.py +++ b/jmclient/test/test_psbt_wallet.py @@ -234,12 +234,8 @@ def test_payjoin_workflow(setup_psbt_wallet, payment_amt, wallet_cls_sender, # *** 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. + # Simple receiver utxo choice heuristic. + # 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] diff --git a/jmdaemon/jmdaemon/__init__.py b/jmdaemon/jmdaemon/__init__.py index bc7b08b..1b92812 100644 --- a/jmdaemon/jmdaemon/__init__.py +++ b/jmdaemon/jmdaemon/__init__.py @@ -9,7 +9,7 @@ from .message_channel import MessageChannel, MessageChannelCollection from .orderbookwatch import OrderbookWatch from jmbase import commands from .daemon_protocol import (JMDaemonServerProtocolFactory, JMDaemonServerProtocol, - start_daemon, P2EPDaemonServerProtocolFactory) + start_daemon) from .protocol import (COMMAND_PREFIX, ORDER_KEYS, NICK_HASH_LENGTH, NICK_MAX_ENCODED, JM_VERSION, JOINMARKET_NICK_HEADER) from .message_channel import MessageChannelCollection diff --git a/jmdaemon/jmdaemon/daemon_protocol.py b/jmdaemon/jmdaemon/daemon_protocol.py index f6e7544..74196a6 100644 --- a/jmdaemon/jmdaemon/daemon_protocol.py +++ b/jmdaemon/jmdaemon/daemon_protocol.py @@ -633,166 +633,12 @@ class JMDaemonServerProtocol(amp.AMP, OrderbookWatch): if self.mcc: self.mcc.shutdown() -class P2EPDaemonServerProtocol(JMDaemonServerProtocol): - @JMFill.responder - def on_JM_FILL(self, amount, commitment, revelation, filled_offers): - """ Takes the setup information for the transaction - from the Taker (p2ep sender) and starts the key negotiation, - which is handled entirely in the daemon. - """ - if not isinstance(amount, Integral) and amount >= 0: - return {'accepted': False} - self.amount = amount - # The counterparty was passed within a offers dict struct; - # we retain the variable `active_orders` for compatibility - # with inherited code, even though it's not really an - # "order" or "offer", and there's only one. - self.active_orders = json.loads(filled_offers) - if len(self.active_orders.keys()) != 1: - return {'accepted': False} - # Here the p2ep communication starts with sending an ephemeral - # pubkey. The version data is appended. - pubkeymsg = self.versioned_pubkey_msg(self.kp.hex_pk().decode('ascii')) - self.mcc.prepare_privmsg(list(self.active_orders.keys())[0], - "pubkey", pubkeymsg) - return {'accepted': True} - - def on_pubkey(self, nick, tmaker_pk): - """This is handled locally in the daemon; set up e2e - encrypted messaging with this counterparty. - Note that this message is used in both directions in p2ep - (as distinct from coinjoins where it's sent Maker->Taker - only). - The pubkey passed in hex and appended with two bytes; - for the proposer (sender) this is the minimum and maximum - of the p2ep version supported. For the receiver these two - bytes must be equal and are equal to the version chosen. - """ - if len(tmaker_pk) < 6: - self.mcc.send_error(nick, "Invalid pubkey message: " + tmaker_pk) - return - try: - version_low, version_high = [int(x, 16) for x in [tmaker_pk[-4:-2], - tmaker_pk[-2:]]] - except ValueError: - self.mcc.send_error(nick, "Invalid pubkey message: " + tmaker_pk) - return - # For both sender and receiver, the received version must be low 0 - if version_low != 0: - self.mcc.send_error(nick, - "Unsupported p2ep version: " + str(version_low)) - return - tmaker_pk = tmaker_pk[:-4] - if self.role == "TAKER": - # For sender, when he receives pubkey message from receiver, it - # must be a choice of version 00 i.e. low, high = 0,0 - if version_high != 0: - self.mcc.send_error(nick, - "Invalid p2ep version string, high: " + str(version_high)) - return - maker_pk = tmaker_pk - if nick not in self.active_orders.keys(): - log.msg("Counterparty not part of this transaction. Ignoring") - return - try: - self.crypto_boxes[nick] = [maker_pk, as_init_encryption( - self.kp, init_pubkey(maker_pk))] - except NaclError as e: - print("Unable to setup crypto box with " + nick + ": " + repr(e)) - self.mcc.send_error(nick, "invalid nacl pubkey: " + maker_pk) - return - d = self.callRemote(JMFillResponse, success=True, - ioauth_data= json.dumps(list(self.active_orders.keys()))) - self.defaultCallbacks(d) - elif self.role == "MAKER": - # Receiver only cares that version 0 (low) is accepted/in range, - # doesn't check high version value. - taker_pk = tmaker_pk - # Note that the Maker may receive this message from anyone, - # not only the sender he communicated with out of band; - # for this reason, the protocol only provides an ephemeral - # pubkey at this stage; the maker only sends private data, - # encrypted, when the Taker, in the next message (!tx), sends - # data demonstrating knowledge of the out-of-band info - - # specifically, the receiving address. - - #prepare a pubkey for this valid transaction - kp = init_keypair() - try: - crypto_box = as_init_encryption(kp, init_pubkey(taker_pk)) - except NaclError as e: - log.msg("Unable to set up cryptobox with counterparty: " + repr(e)) - self.mcc.send_error(nick, "Invalid nacl pubkey: " + taker_pk) - return - #Note this sets the *whole* dict, old entries (e.g. changeaddr) - #are removed, so we can't have a conflict between old and new - #versions of active_orders[nick] - self.active_orders[nick] = {"crypto_box": crypto_box, - "kp": kp, - "offer": None, - "amount": None, - "commit": None} - self.mcc.prepare_privmsg(nick, "pubkey", self.versioned_pubkey_msg( - kp.hex_pk().decode('ascii'))) - else: - raise JMProtocolError("Invalid role: " + self.role) - - # This (on_pubkey) method can be called repeatedly without - # issue (see note above about active_orders); the following - # state update means that after this is called at least once, - # the JMMakeTx client call can be answered successfully. - self.jm_state = 4 - - def on_seen_tx(self, nick, txhex): - """Passes the txhex to the Taker and Maker (two way sending); - the Maker receives an unsigned tx from the Taker, checks it, - modifies it and sends a partially signed updated tx back. - This functions as a passthrough with a sanity check on the - counterparty identity. - """ - if nick not in self.active_orders: - return - d = self.callRemote(JMTXReceived, - nick=nick, - txhex=txhex, - offer="none") - self.defaultCallbacks(d) - - # Remaining callbacks are mostly to be no-ops (answering - # !orderbook as maker, though, makes sense; on-error is also - # fine of course, and on-push-tx because it does nothing.) - def on_order_fill(self, nick, oid, amount, taker_pk, commit): - pass - def on_seen_auth(self, nick, commitment_revelation): - pass - def on_commitment_seen(self, nick, commitment): - pass - def on_commitment_transferred(self, nick, commitment): - pass - def on_ioauth(self, nick, utxo_list, auth_pub, cj_addr, change_addr, - btc_sig): - pass - def on_sig(self, nick, sig): - pass - - def versioned_pubkey_msg(self, pubkey_hex): - # TODO genericize when updating p2ep version - # Note that this is deliberately an instance method - # as in future will depend on self.role - return pubkey_hex + "0000" - class JMDaemonServerProtocolFactory(ServerFactory): protocol = JMDaemonServerProtocol def buildProtocol(self, addr): return JMDaemonServerProtocol(self) -class P2EPDaemonServerProtocolFactory(ServerFactory): - protocol = P2EPDaemonServerProtocol - - def buildProtocol(self, addr): - return P2EPDaemonServerProtocol(self) - def start_daemon(host, port, factory, usessl=False, sslkey=None, sslcert=None): if usessl: assert sslkey diff --git a/scripts/joinmarket-qt.py b/scripts/joinmarket-qt.py index c8aeb1c..94e553c 100755 --- a/scripts/joinmarket-qt.py +++ b/scripts/joinmarket-qt.py @@ -77,11 +77,12 @@ from jmclient import load_program_config, get_network, update_persist_config,\ NO_ROUNDING, get_max_cj_fee_values, get_default_max_absolute_fee, \ get_default_max_relative_fee, RetryableStorageError, add_base_options, \ BTCEngine, BTC_P2SH_P2WPKH, FidelityBondMixin, wallet_change_passphrase, \ - parse_payjoin_setup, send_payjoin + parse_payjoin_setup, send_payjoin, JMBIP78ReceiverManager from qtsupport import ScheduleWizard, TumbleRestartWizard, config_tips,\ config_types, QtHandler, XStream, Buttons, OkButton, CancelButton,\ PasswordDialog, MyTreeWidget, JMQtMessageBox, BLUE_FG,\ - donation_more_message, BitcoinAmountEdit, JMIntValidator + donation_more_message, BitcoinAmountEdit, JMIntValidator,\ + ReceiveBIP78Dialog from twisted.internet import task @@ -1467,6 +1468,10 @@ class JMMainWindow(QMainWindow): # was already shown self.syncmsg = "" + # BIP 78 Receiver manager object, only + # created when user starts a payjoin event: + self.backend_receiver = None + self.reactor = reactor self.initUI() @@ -1503,6 +1508,9 @@ class JMMainWindow(QMainWindow): changePassAction = QAction('&Change passphrase...', self) changePassAction.setStatusTip('Change wallet encryption passphrase') changePassAction.triggered.connect(self.changePassphrase) + receivePayjoinAction = QAction('Receive &payjoin...', self) + receivePayjoinAction.setStatusTip('Receive BIP78 style payment') + receivePayjoinAction.triggered.connect(self.receiver_bip78_init) quitAction = QAction(QIcon('exit.png'), '&Quit', self) quitAction.setShortcut('Ctrl+Q') quitAction.setStatusTip('Quit application') @@ -1519,12 +1527,59 @@ class JMMainWindow(QMainWindow): walletMenu.addAction(showSeedAction) walletMenu.addAction(exportPrivAction) walletMenu.addAction(changePassAction) + walletMenu.addAction(receivePayjoinAction) walletMenu.addAction(quitAction) aboutMenu = menubar.addMenu('&About') aboutMenu.addAction(aboutAction) self.show() + + def receiver_bip78_init(self): + """ Initializes BIP78 workflow with modal dialog. + """ + if not self.wallet_service: + JMQtMessageBox(self, + "No wallet loaded.", + mbtype='crit', + title="Error") + return + self.receiver_bip78_dialog = ReceiveBIP78Dialog( + self.startReceiver, self.stopReceiver) + + def startReceiver(self): + """ Initializes BIP78 Receiving object and + starts the setup of onion service to serve + request. + Returns False in case we are not able to start + due to bad parameters, otherwise True (not affected + by success of whole request generation process). + """ + assert self.receiver_bip78_dialog + amount = btc.amount_to_sat( + self.receiver_bip78_dialog.get_amount_text()) + mixdepth = self.receiver_bip78_dialog.get_mixdepth() + if mixdepth > self.wallet_service.mixdepth: + JMQtMessageBox(self, + "Wallet does not have mixdepth " + str(mixdepth), + mbtype='crit', title="Error") + return False + if self.wallet_service.get_balance_by_mixdepth()[mixdepth] == 0: + JMQtMessageBox(self, "Mixdepth " + str(mixdepth) + \ + " has no coins.", mbtype='crit', title="Error") + return False + self.backend_receiver = JMBIP78ReceiverManager(self.wallet_service, + mixdepth, amount, 80, self.receiver_bip78_dialog.info_update, + uri_created_callback=self.receiver_bip78_dialog.update_uri, + mode="gui") + self.backend_receiver.start_pj_server_and_tor() + return True + + def stopReceiver(self): + if self.backend_receiver is None: + return + self.backend_receiver.shutdown() + def showAboutDialog(self): msgbox = QDialog(self) lyt = QVBoxLayout(msgbox) diff --git a/scripts/qtsupport.py b/scripts/qtsupport.py index 51156b7..3260298 100644 --- a/scripts/qtsupport.py +++ b/scripts/qtsupport.py @@ -921,3 +921,153 @@ class RestartSettingsPage(QWizardPage): self.registerField("mincjamount", results[0][1]) self.registerField("maxrelfee", results[1][1]) self.registerField("maxabsfee", results[2][1]) + +class CopyOnClickLineEdit(QLineEdit): + """ Small wrapper class around QLineEdit + to support copy-to-clipboard-on-click. + """ + def __init__(self, s): + # This is needed to prevent + # infinite loop, but + # TODO: This is very suboptimal + # since the copy can only be done once. + self.was_copied = False + super().__init__(s) + + def focusInEvent(self, event): + super().focusInEvent(event) + self.selectAll() + self.copy() + if not self.was_copied: + JMQtMessageBox(self, + "URI copied to clipboard", mbtype="info") + self.was_copied = True + +class ReceiveBIP78Dialog(QDialog): + + parameter_names = ['Amount to receive', 'Mixdepth'] + parameter_tooltips = [ + "How much you should receive (after any fees) in BTC or sats.", + "The mixdepth you source coins from to create inputs for the " + "payjoin. Note your receiving address will be chosen from the " + "*next* mixdepth after this (or 0 if last)."] + parameter_types = ["btc", int] + parameter_settings = ["", 0] + + def __init__(self, action_fn, cancel_fn, parameter_settings=None): + """ Parameter action_fn: + each time the user opens the dialog they will + pass a function to be connected to the action-button. + Signature: no arguments, return value False if action initiation + is aborted, otherwise True. + """ + super().__init__() + if parameter_settings: + self.parameter_settings = parameter_settings + # these QLineEdit or QLabel objects will contain the + # settings for the receiver as chosen by the user: + self.receiver_settings_ql = [] + self.action_fn = action_fn + # callback for actions to take when closing this dialog: + self.cancel_fn = cancel_fn + self.updates_final = False + self.initUI() + + def initUI(self): + self.setModal(1) + self.setWindowTitle("Receive Payjoin") + self.setLayout(self.get_receive_bip78_dialog()) + self.show() + + def info_update(self, msg): + """ Sets update text in the dialog to the str + parameter msg, but does not overwrite after that, + if the message ends with ":FINAL". + TODO: Info updates need to be richer, supporting + multiple messages. + """ + if not self.updates_final: + if msg.endswith(":FINAL"): + self.updates_final = True + msg = msg.split(":FINAL")[0] + self.updates_label.setText(msg) + + def get_amount_text(self): + return self.receiver_settings_ql[0][1].text() + + def get_mixdepth(self): + return int(self.receiver_settings_ql[1][1].text()) + + def update_uri(self, uri): + self.bip21_widget.setDisabled(False) + self.bip21_widget.setText(uri) + self.bip21_widget.was_copied = False + + def shutdown_actions(self): + self.cancel_fn() + self.close() + + def start_generate(self): + """ Before starting up the + hidden service and initiating the payment + workflow, disallow starting again; user + will need to close and reopen to restart. + If the 'start generate request' action is + aborted, we reset the generate button. + """ + self.btnbox.buttons()[1].setDisabled(True) + if not self.action_fn(): + self.btnbox.buttons()[1].setDisabled(False) + + def get_receive_bip78_dialog(self): + """ Displays editable parameters and + BIP21 URI once the receiver is ready. + """ + # TODO: allow custom mixdepths + valid_ranges = [None, (0, 4)] + + # note that this iteration is not currently helpful, + # if anything making the code *more* verbose, but could be + # if we add several more fields: + for x in zip(self.parameter_names, self.parameter_tooltips, + self.parameter_types, self.parameter_settings, + valid_ranges): + ql = QLabel(x[0]) + ql.setToolTip(x[1]) + editfield = BitcoinAmountEdit if x[2] == "btc" else QLineEdit + ql2 = editfield(str(x[3])) + if x[4]: + if x[2] == int: + ql2.setValidator(QIntValidator(*x[4])) + elif x[2] == float: + ql2.setValidator(QDoubleValidator(*x[4])) + # note no validators for the btc type as that + # has its own internal validation. + self.receiver_settings_ql.append((ql, ql2)) + layout = QGridLayout(self) + layout.setSpacing(4) + for i, x in enumerate(self.receiver_settings_ql): + layout.addWidget(x[0], i + 1, 0) + layout.addWidget(x[1], i + 1, 1, 1, 2) + + # As well as editable settings, we also need two more + # fields: one for information updates, and one for the + # final (copyable) URI: + self.updates_label = QLabel("Waiting ...") + self.bip21_widget = CopyOnClickLineEdit("") + self.bip21_widget.setReadOnly(True) + # Note that the initial state is disabled, meaning + # click events won't register and it won't look editable: + self.bip21_widget.setDisabled(True) + layout.addWidget(self.updates_label, i+2, 0, 1, 2) + layout.addWidget(self.bip21_widget, i+3, 0, 1, 2) + + # Buttons for start/cancel: + self.btnbox = QDialogButtonBox() + self.btnbox.setStandardButtons(QDialogButtonBox.Cancel) + btnname = "Generate request" + self.btnbox.addButton(btnname, QDialogButtonBox.ActionRole) + layout.addWidget(self.btnbox, i+4, 0) + self.btnbox.rejected.connect(self.shutdown_actions) + self.btnbox.buttons()[1].clicked.connect(self.start_generate) + return layout diff --git a/scripts/receive-payjoin.py b/scripts/receive-payjoin.py index 581d8c1..8a3bc99 100755 --- a/scripts/receive-payjoin.py +++ b/scripts/receive-payjoin.py @@ -4,19 +4,21 @@ from optparse import OptionParser import sys from twisted.python.log import startLogging -from jmbase import get_log, set_logging_level -from jmclient import P2EPMaker, jm_single, load_program_config, \ - WalletService, JMClientProtocolFactory, start_reactor, \ - open_test_wallet_maybe, get_wallet_path, check_regtest, \ - add_base_options +from twisted.internet import reactor +from jmbase import get_log, set_logging_level, jmprint +from jmclient import jm_single, load_program_config, \ + WalletService, open_test_wallet_maybe, get_wallet_path, check_regtest, \ + add_base_options, JMBIP78ReceiverManager from jmbase.support import EXIT_FAILURE, EXIT_ARGERROR from jmbitcoin import amount_to_sat - jlog = get_log() -def receive_payjoin_main(makerclass): +def receive_payjoin_main(): parser = OptionParser(usage='usage: %prog [options] [wallet file] [amount-to-receive]') add_base_options(parser) + parser.add_option('-P', '--hs-port', action='store', type='int', + dest='hsport', default=80, + help='port on which to serve the ephemeral hidden service.') parser.add_option('-g', '--gap-limit', action='store', type="int", dest='gaplimit', default=6, help='gap limit for wallet, default=6') @@ -37,22 +39,18 @@ def receive_payjoin_main(makerclass): sys.exit(EXIT_ARGERROR) wallet_name = args[0] try: - receiving_amount = amount_to_sat(args[1]) + # amount is stored internally in sats, but will be decimal in URL. + bip78_amount = amount_to_sat(args[1]) except: - parser.error("Invalid receiving amount passed: " + receiving_amount) + parser.error("Invalid receiving amount passed: " + bip78_amount) sys.exit(EXIT_FAILURE) - if receiving_amount < 0: + if bip78_amount < 0: parser.error("Receiving amount must be a positive number") sys.exit(EXIT_FAILURE) load_program_config(config_path=options.datadir) check_regtest() - # This workflow requires command line reading; we force info level logging - # to remove noise, and mostly communicate to the user with the fn - # log.info (via P2EPMaker.user_info). - set_logging_level("INFO") - wallet_path = get_wallet_path(wallet_name, None) max_mix_depth = max([options.mixdepth, options.amtmixdepths - 1]) wallet = open_test_wallet_maybe( @@ -70,20 +68,11 @@ def receive_payjoin_main(makerclass): jlog.error("Cannot do payjoin from mixdepth " + str( options.mixdepth) + ", no coins. Shutting down.") sys.exit(EXIT_ARGERROR) - - maker = makerclass(wallet_service, options.mixdepth, receiving_amount) - - jlog.info('starting receive-payjoin') - clientfactory = JMClientProtocolFactory(maker, proto_type="MAKER") - - nodaemon = jm_single().config.getint("DAEMON", "no_daemon") - daemon = True if nodaemon == 1 else False - if jm_single().config.get("BLOCKCHAIN", "network") in ["regtest", "testnet"]: - startLogging(sys.stdout) - start_reactor(jm_single().config.get("DAEMON", "daemon_host"), - jm_single().config.getint("DAEMON", "daemon_port"), - clientfactory, daemon=daemon, p2ep=True) + receiver_manager = JMBIP78ReceiverManager(wallet_service, options.mixdepth, + bip78_amount, options.hsport) + receiver_manager.start_pj_server_and_tor() + reactor.run() if __name__ == "__main__": - receive_payjoin_main(P2EPMaker) - print('done') + receive_payjoin_main() + jmprint('done') diff --git a/scripts/sendpayment.py b/scripts/sendpayment.py index 756313c..798f3db 100755 --- a/scripts/sendpayment.py +++ b/scripts/sendpayment.py @@ -11,7 +11,7 @@ import sys from twisted.internet import reactor import pprint -from jmclient import Taker, P2EPTaker, load_program_config, get_schedule,\ +from jmclient import Taker, load_program_config, get_schedule,\ JMClientProtocolFactory, start_reactor, validate_address, is_burn_destination, \ jm_single, estimate_tx_fee, direct_send, WalletService,\ open_test_wallet_maybe, get_wallet_path, NO_ROUNDING, \ @@ -51,11 +51,8 @@ def main(): parser = get_sendpayment_parser() (options, args) = parser.parse_args() load_program_config(config_path=options.datadir) - if options.p2ep and len(args) != 3: - parser.error("Joinmarket peer-to-peer PayJoin requires exactly three " - "arguments: wallet, amount and destination address.") - sys.exit(EXIT_ARGERROR) - elif options.schedule == '': + + if options.schedule == '': if ((len(args) < 2) or (btc.is_bip21_uri(args[1]) and len(args) != 2) or (not btc.is_bip21_uri(args[1]) and len(args) != 3)): @@ -76,13 +73,7 @@ def main(): parser.error("Given BIP21 URI does not contain amount.") sys.exit(EXIT_ARGERROR) destaddr = parsed['address'] - if 'jmnick' in parsed: - if "pj" in parsed: - parser.error("Cannot specify both BIP78 and Joinmarket " - "peer-to-peer payjoin at the same time!") - sys.exit(EXIT_ARGERROR) - options.p2ep = parsed['jmnick'] - elif "pj" in parsed: + if "pj" in parsed: # note that this is a URL; its validity # checking is deferred to twisted.web.client.Agent bip78url = parsed["pj"] @@ -102,12 +93,12 @@ def main(): mixdepth = options.mixdepth addr_valid, errormsg = validate_address(destaddr) command_to_burn = (is_burn_destination(destaddr) and sweeping and - options.makercount == 0 and not options.p2ep) + options.makercount == 0) if not addr_valid and not command_to_burn: jmprint('ERROR: Address invalid. ' + errormsg, "error") if is_burn_destination(destaddr): jmprint("The required options for burning coins are zero makers" - + " (-N 0), sweeping (amount = 0) and not using P2EP", "info") + + " (-N 0), sweeping (amount = 0) and not using BIP78 Payjoin", "info") sys.exit(EXIT_ARGERROR) if sweeping == False and amount < DUST_THRESHOLD: jmprint('ERROR: Amount ' + btc.amount_to_str(amount) + @@ -128,9 +119,6 @@ def main(): if btc.is_bip21_uri(args[1]): parser.error("Schedule files are not compatible with bip21 uris.") sys.exit(EXIT_ARGERROR) - if options.p2ep: - parser.error("Schedule files are not compatible with PayJoin") - sys.exit(EXIT_FAILURE) result, schedule = get_schedule(options.schedule) if not result: log.error("Failed to load schedule file, quitting. Check the syntax.") @@ -167,8 +155,7 @@ def main(): fee_per_cp_guess)) maxcjfee = (1, float('inf')) - if not options.p2ep and not options.pickorders and \ - options.makercount != 0: + if not options.pickorders and options.makercount != 0: maxcjfee = get_max_cj_fee_values(jm_single().config, options) log.info("Using maximum coinjoin fee limits per maker of {:.4%}, {} " "".format(maxcjfee[0], btc.amount_to_str(maxcjfee[1]))) @@ -211,7 +198,7 @@ def main(): log.info("Estimated miner/tx fees for this coinjoin amount: {:.1%}" .format(exp_tx_fees_ratio)) - if options.makercount == 0 and not options.p2ep and not bip78url: + if options.makercount == 0 and not bip78url: tx = direct_send(wallet_service, amount, mixdepth, destaddr, options.answeryes, with_final_psbt=options.with_psbt) if options.with_psbt: @@ -307,22 +294,7 @@ def main(): log.info("All transactions completed correctly") reactor.stop() - if options.p2ep: - # This workflow requires command line reading; we force info level logging - # to remove noise, and mostly communicate to the user with the fn - # log.info (directly or via default taker_info_callback). - set_logging_level("INFO") - # in the case where the payment just hangs for a long period, allow - # it to fail gracefully with an information message; this is triggered - # only by the stallMonitor, which gives up after 20*maker_timeout_sec: - def p2ep_on_finished_callback(res, fromtx=False, waittime=0.0, - txdetails=None): - log.error("PayJoin payment was NOT made, timed out.") - reactor.stop() - taker = P2EPTaker(options.p2ep, wallet_service, schedule, - callbacks=(None, None, p2ep_on_finished_callback)) - - elif bip78url: + if bip78url: # TODO sanity check wallet type is segwit manager = parse_payjoin_setup(args[1], wallet_service, options.mixdepth) reactor.callWhenRunning(send_payjoin, manager) @@ -338,12 +310,11 @@ def main(): clientfactory = JMClientProtocolFactory(taker) nodaemon = jm_single().config.getint("DAEMON", "no_daemon") daemon = True if nodaemon == 1 else False - p2ep = True if options.p2ep != "" else False if jm_single().config.get("BLOCKCHAIN", "network") in ["regtest", "testnet"]: startLogging(sys.stdout) start_reactor(jm_single().config.get("DAEMON", "daemon_host"), jm_single().config.getint("DAEMON", "daemon_port"), - clientfactory, daemon=daemon, p2ep=p2ep) + clientfactory, daemon=daemon) if __name__ == "__main__": main() diff --git a/test/payjoinserver.py b/test/payjoinserver.py index dec54c5..83e0f88 100644 --- a/test/payjoinserver.py +++ b/test/payjoinserver.py @@ -16,13 +16,12 @@ 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 + SegwitWallet, SegwitLegacyWallet, cryptoengine, PayjoinServer import txtorcon @@ -60,115 +59,6 @@ def get_ssl_context(): 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): - # can be used e.g. to check if an ephemeral HS is up - # on Tor Browser: - return "Only for testing.".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 but this is out of scope, - # since we only created this server (currently) to test our - # BIP78 client. - - # 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.human_readable_transaction(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.human_readable_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.human_readable_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):