Browse Source

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.
master
Adam Gibson 5 years ago
parent
commit
23d0b8f39c
No known key found for this signature in database
GPG Key ID: 141001A1AF77F20B
  1. 345
      docs/PAYJOIN.md
  2. 4
      jmbitcoin/jmbitcoin/bip21.py
  3. 9
      jmclient/jmclient/__init__.py
  4. 9
      jmclient/jmclient/cli_options.py
  5. 59
      jmclient/jmclient/client_protocol.py
  6. 11
      jmclient/jmclient/configure.py
  7. 357
      jmclient/jmclient/maker.py
  8. 695
      jmclient/jmclient/payjoin.py
  9. 331
      jmclient/jmclient/taker.py
  10. 21
      jmclient/jmclient/wallet.py
  11. 207
      jmclient/test/test_payjoin.py
  12. 8
      jmclient/test/test_psbt_wallet.py
  13. 2
      jmdaemon/jmdaemon/__init__.py
  14. 154
      jmdaemon/jmdaemon/daemon_protocol.py
  15. 59
      scripts/joinmarket-qt.py
  16. 150
      scripts/qtsupport.py
  17. 49
      scripts/receive-payjoin.py
  18. 49
      scripts/sendpayment.py
  19. 112
      test/payjoinserver.py

345
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.
<a name="bip78" />
#### 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):
<a name="fees" />
```
(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.
<a name="native" />
#### 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).
<a name="receiving" />
* 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.
<a name="torconfig" />
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
```
<a name="native" />
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.
<a name="using-qt" />
### 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
<a name="sample" />
@ -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.
```

4
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

9
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:

9
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',

59
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)

11
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()

357
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()

695
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 "<html>Only for testing.</html>".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")

331
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):

21
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

207
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()

8
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]

2
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

154
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

59
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)

150
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

49
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')

49
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()

112
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 "<html>Only for testing.</html>".encode("utf-8")
def render_POST(self, request):
""" The sender will use POST to send the initial
payment transaction.
"""
jmprint("The server got this POST request: ")
print(request)
print(request.method)
print(request.uri)
print(request.args)
print(request.path)
print(request.content)
proposed_tx = request.content
assert isinstance(proposed_tx, BytesIO)
payment_psbt_base64 = proposed_tx.read()
payment_psbt = btc.PartiallySignedTransaction.from_base64(
payment_psbt_base64)
all_receiver_utxos = self.wallet_service.get_all_utxos()
# TODO is there a less verbose way to get any 2 utxos from the dict?
receiver_utxos_keys = list(all_receiver_utxos.keys())[:2]
receiver_utxos = {k: v for k, v in all_receiver_utxos.items(
) if k in receiver_utxos_keys}
# receiver will do other checks 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):

Loading…
Cancel
Save