Browse Source

Adds SNICKER functionality

For detailed description of functionality in
this commit, see the newly created docs/SNICKER.md
master
Adam Gibson 5 years ago
parent
commit
53a1822fa6
No known key found for this signature in database
GPG Key ID: 141001A1AF77F20B
  1. 4
      .gitignore
  2. 349
      docs/SNICKER.md
  3. 5
      jmbase/jmbase/__init__.py
  4. 53
      jmbase/jmbase/commands.py
  5. 41
      jmbase/jmbase/proof_of_work.py
  6. 17
      jmbase/jmbase/support.py
  7. 62
      jmbase/jmbase/twisted_utils.py
  8. 1
      jmbitcoin/jmbitcoin/__init__.py
  9. 65
      jmbitcoin/jmbitcoin/blocks.py
  10. 13
      jmbitcoin/jmbitcoin/secp256k1_ecies.py
  11. 105
      jmbitcoin/jmbitcoin/secp256k1_transaction.py
  12. 109
      jmbitcoin/jmbitcoin/snicker.py
  13. 96
      jmbitcoin/test/test_btc_snicker.py
  14. 6
      jmclient/jmclient/__init__.py
  15. 15
      jmclient/jmclient/blockchaininterface.py
  16. 196
      jmclient/jmclient/client_protocol.py
  17. 73
      jmclient/jmclient/configure.py
  18. 143
      jmclient/jmclient/snicker_receiver.py
  19. 154
      jmclient/jmclient/wallet.py
  20. 39
      jmclient/jmclient/wallet_service.py
  21. 17
      jmclient/jmclient/yieldgenerator.py
  22. 20
      jmclient/test/test_snicker.py
  23. 2
      jmdaemon/jmdaemon/__init__.py
  24. 191
      jmdaemon/jmdaemon/daemon_protocol.py
  25. 3
      scripts/joinmarketd.py
  26. 226
      scripts/snicker/create-snicker-proposal.py
  27. 89
      scripts/snicker/receive-snicker.py
  28. 115
      scripts/snicker/snicker-finder.py
  29. 206
      scripts/snicker/snicker-recovery.py
  30. 213
      scripts/snicker/snicker-seed-tx.py
  31. 344
      scripts/snicker/snicker-server.py
  32. 11
      test/ygrunner.py

4
.gitignore vendored

@ -29,3 +29,7 @@ scripts/commitmentlist
tmp/
wallets/
scripts/jm-tx-history.txt
scripts/snicker/joinmarket.cfg
scripts/snicker/snicker-proposals.txt
scripts/snicker/candidates.txt

349
docs/SNICKER.md

@ -0,0 +1,349 @@
# HOW TO USE THE SNICKER FEATURES IN JOINMARKET
# Contents
1. [Basic concepts and definition](#basic)
a. [Quick read: advice](#quick)
b. [Slightly longer description](#longer)
c. [Proof of work for anti-spam](#pow)
d. [Servers](#servers)
e. [New script tools](#scripts)
2. [Updating config](#configure)
3. [Alternative to mainnet usage](#network)
4. [Running SNICKER as a receiver](#receiver)
a. [Manually running the receiver](#manually)
b. [Running a yield-generator with SNICKER active](#yieldgen)
5. [Use of wallets](#wallets)
6. [Checking for SNICKER coinjoins](#checking)
7. [A testing workflow](#testing)
8. [Appendix: Example SNICKER transaction](#appendix1)
<a name="basic" />
## Basic concepts and definition
<a name="quick" />
### Quick read: advice
For the time constraints, consider these points:
* This is *somewhat* experimental; better run it on signet for now, mainnet is not advised.
* If you do run it on mainnet, make an effort to keep backups of your jmdat wallet file; recovery with seed only is possible (a tool is provided), but it's a pain.
* This basically allows coinjoins to be proposed and executed without any interaction by the participants, even over a message channel. You can run it passsively in a yield generator, for example. You can even be paid some small amount of sats for that to happen. But the coinjoins are only 2-party.
<a name="longer" />
### Slightly longer read on what this is:
For formal specs as currently implemented, please use [this](https://gist.github.com/AdamISZ/2c13fb5819bd469ca318156e2cf25d79). Less technical description [here](https://joinmarket.me/blog/blog/snicker/).
Essentially, this is a two party but non-interactive protocol. The **proposer** will identify, on the blockchain, a candidate transaction where he has some confidence that one or more inputs are owned by the same party as one output, and that that party has SNICKER receiver functionality.
Given those conditions, he'll create one or more **proposals** which are of form `base64-encoded-ECIES-encrypted-PSBT,hex-encoded-pubkey` (the separator is literally a comma; this is ASCII encoded), and send them to a **snicker-server** which is hosted at an onion address (possibly TLS but let's stick with onion for now, it's easier). They could also be sent manually. We'll talk more about these two possibilities below.
To understand the step-by-step of how this is done "under the hood", you may find the [section on testing](#testing) a useful read. If you're only interested in "switching on this feature", notice this is not advised on mainnet (see the section on [alternatives to mainnet](#network)), but read more below about editing the config and switching it on in a yieldgenerator.
<a name="pow" />
### Proof of work for mild anti-spam
As implemented here, in fact, the proposer attaches a proof of work in the form of a 10-byte nonce appended to the end of the above string (hex encoded; so in fact: `base64-encoded-ECIES-encrypted-PSBT,hex-encoded-pubkey,hex-encoded-nonce` is what is sent over the wire). This nonce is grinded to get a 32-byte-truncated-hash512 of that string to be less than a target calculated by a request number of bits from the server. The target bits is requested by the proposer with a `GET /target` request to the server, before sending the proposals themselves with a `POST /` request with the proposals in the body. For the proof of work, see `jmbase/jmbase/proof_of_work.py`; it's pretty elementary. Note of course this is no defence against a serious attempt to jam the system, it's only a "script-kiddie-defence", so to speak.
<a name="servers" />
### Servers
The **snicker-server** just hosts the proposals and lets others read them. For the purpose of testing it's fine that we don't have a very sophisticated version of this, but for now note:
* You can run tests using the `-n` option of `create-snicker-proposal.py` to just output to terminal instead of uploading to server; this may be in particular more useful for local tests where it's only your own wallets involved.
* The server serves only over a Tor hidden service.
* It stores the accepted proposals in a sqlite3 database `proposals.db`. The table is `proposals` and has only two fields: `pubkey` and `proposal` (see above).
* Defends against spam with proof of work as per above (this is very limited but better than nothing).
* Currently has NO maintenance or performance feature such as flushing out proposals after a time limit, or allowing filtered queries. This seems the most obvious way to improve what exists, here.
<a name="scripts" />
### New script tools
The new tools for SNICKER are in the directory `scripts/snicker`, consisting of:
* `snicker-seed-tx.py` - create a fake SNICKER transaction in your own wallet.
* `snicker-finder.py` - scan recent blocks for Joinmarket or SNICKER candidate transactions.
* `create-snicker-proposal.py` - takes transactions found from the above and makes proposals, uploading them to a server or outputting to command line.
* `snicker-server.py` - implements a simple server serving over *.onion, with a sqlite database to store proposals, and defends against spam only mildly with a proof of work requirement (see below).
* `receive-snicker.py` - polls above server to read new proposals, parse them and broadcasts completed SNICKER coinjoins when found, storing the new keys as imports (see details on wallet handling below).
* `snicker-recovery.py` - can be used to recover a wallet from seedphrase which contains SNICKER utxos, though it needs (possibly multiple) rescanblockchain calls (and informs the user how to do this, including blockheights).
<a name="configure" />
## Updating config
Recreate your joinmarket.cfg in the usual way. You can then edit the new `[SNICKER]` section as desired:
```
[SNICKER]
# any other value than 'true' will be treated as False,
# and no SNICKER actions will be enabled in that case:
enabled = false
# in satoshis, we require any SNICKER to pay us at least
# this much (can be negative), otherwise we will refuse
# to sign it:
lowest_net_gain = 0
# comma separated list of servers (if port is omitted as :port, it
# is assumed to be 80) which we will poll against (all, in sequence); note
# that they are allowed to be *.onion or cleartext servers, and no
# scheme (http(s) etc) needs to be added to the start.
servers = cn5lfwvrswicuxn3gjsxoved6l2gu5hdvwy5l3ev7kg6j7lbji2k7hqd.onion,
# how many minutes between each polling event to each server above:
polling_interval_minutes = 60
```
Notice that it is of course *NOT* enabled by default, so switch that to `true`.
If you are running tests, a 60 minute polling interval is slow, feel free to cut it down to a minute or two.
The default server is currently running on a VPS. As mentioned above, you can easily run your own server with `snicker-server.py`. It is possible to poll multiple servers, comma separated in the list.
<a name="network" />
## Alternative to mainnet usage
Choosing the network for this function means editing the `[BLOCKCHAIN]` section in `joinmarket.cfg`, just as for other Joinmarket functionality.
**Regtest** is a good option if you are interested in testing functionality quickly, on your own. See [here](TESTING.md) for some info on regtest setup.
**Signet** (a new testnet) will be helpful especially if you intend to test with others. This can be done by simply sharing proposals as per the `-n` comments above, or by sharing proposal server locations as onion addresses, and possibly communicating with other testers to identify candidate transactions (obviously this strays far from the intended way SNICKER will be used, but it is convenient to test workflow).
For more information about using Joinmarket with signet, see the [0.8.1 release notes](release-notes/release-notes-0.8.1.md) and [this gist](https://gist.github.com/AdamISZ/325716a66c7be7dd3fc4acdfce449fb1).
**Testnet3** - it may be possible but it will be far less convenient than signet.
**Mainnet** - as of Feb 2021, this isn't recommended yet; it should in theory work no different, but any usage would be at your own risk.
<a name="receiver" />
## Running SNICKER as a receiver
The **receiver** (unless handling manually with `-n` as per above) polls this server (for testing, you can make the polling loop fast; in real usage it should be slow), reads all the existing proposals using a `GET /` request with no parameters, and if it can decrypt and sanity check the transaction OK, it co-signs it and broadcasts it. Note: *the receiver wallet will store its new coins output from the coinjoin, as imported keys; they are not part of the HD tree, although derivable from history*. See `use of wallets` below for important notes on this aspect.
<a name="manually" />
### Manually running the receiver
For this you can use the `receive-snicker.py` script as detailed above, passing the chosen wallet file as argument, and it will "one-shot" poll for proposals and process them, or, you can pass the base64 proposal manually instead.
<a name="yieldgen" />
### Running SNICKER in a yield generator.
This will presumably be the most normal way to be a SNICKER receiver over time; if `enabled=true` for SNICKER is in the above config settings, this will happen automatically, under the hood. Make sure the polling loop interval is not too fast if you leave this running longer term (even if a test bot). If valid proposals are found that follow our requirements, the transactions are broadcast.
<a name="wallets" />
## Use of wallets
**Wallet type** - please stick with native segwit (`native=true` in config *before* you generate), but you can also choose p2sh-p2wpkh, it should work. No other script type (including p2pkh) will work here. We don't want mixed script type SNICKER coinjoins.
**Persistence in the wallet** - this is very important and not at all obvious! But, on regtest and testnet *by default*, we use hex seeds instead of wallet files and `VolatileStorage` (wallet storage in memory; wiped on shutdown). This is fine and convenient for many tests, but will not work for a key part of SNICKER - imported keys.
The upshot - **make sure you actually generate wallet files for all wallets you're going to test SNICKER with**, otherwise you will not even see the created coins on the receiver side.
Additionally, when you view the wallet with wallet-tool, you need to use `--recoversync`, as the default fast sync won't see imported keys. If you took these two steps, your tests should correctly show the post-SNICKER created coins.
<a name="checking" />
## Checking for SNICKER coinjoins
SNICKER is logged to:
`~/.joinmarket/logs/SNICKER/SNICKER-joinmarket-wallet-xxxxxx.log`
i.e. there is one SNICKER log file for each wallet.
This should show all transactions that were detected and broadcast.
<a name="testing" />
## A testing workflow
This is a scenario for a single user, using either regtest or signet.
Generate a minimum of two joinmarket wallets with `python wallet-tool.py generate`, as noted above, native (or at least, both the same type).
Fund them both. The receiver needs at least two utxos to create the seed transaction.
Create a seed fake-SNICKER transaction in the receiver wallet, using the script `snicker-seed-tx.py`.
Start the test server. Navigate to `scripts/snicker` and run `python snicker-server.py` - no arguments should be needed, and this will generate an onion running serving on port 80; the onion hostname is displayed:
```
(jmvenv) waxwing@here~/testjminstall/joinmarket-clientserver/scripts/snicker$ python snicker-server.py
User data location:
Attempting to start onion service on port: 80 ...
Your hidden service is available:
xpkqk2cy2h2ay5iecwcod5ka36nxj2tsiyczk2w5c6o7h5g57w3xg4id.onion
```
This is ephemeral, obviously we intend the real servers to be long-running. The one in the default config should exist and be long-running already, but of course your tests don't need to rely on this. Add:
```
[SNICKER]
enabled = true
servers= xpkqk2cy2h2ay5iecwcod5ka36nxj2tsiyczk2w5c6o7h5g57w3xg4id.onion,
```
... to a `joinmarket.cfg` that you add inside `scripts/snicker`, by copying it from `scripts/` or wherever you keep your testing `joinmarket.cfg` file. (This manual annoyance is part of testing, it won't be needed in mainnet usage of course ... alternatively just put a signet/regtest custom `joinmarket.cfg` in your `.joinmarket` folder, but this is hardly less annoying).
`servers=` requires a comma separated list.
You're now ready to do the two steps: (a) create a proposal and upload it, (b) download proposals (as the receiver identity/wallet) and complete coinjoins. It could be different people doing (a) and (b) of course but here we're assuming one tester doing everything (see two wallets above).
### Creating one or more proposals
Having done the above seed transaction, do a scan operation to find the candidate:
`cd script/snicker; python snicker-finder.py --datadir=. 330`
Here, 330 is the starting block number on my regtest blockchain; the ending block is the current block. On signet use a block explorer to find current height (or `bitcoin-cli getblockchaininfo`). It will look for all transactions with a SNICKER pattern and you should see returned something like this:
```
2020-12-28 16:09:04,329 [INFO] Finished processing block: 790
2020-12-28 16:09:04,334 [INFO] Found SNICKER transaction: 32f80807b3ba4ca477b25e8ab608a8a3134a34c8c3787cad95c653d1805d7533 in block: 791
2020-12-28 16:09:04,338 [INFO] Finished processing block: 791
2020-12-28 16:09:04,340 [INFO] Finished processing block: 792
done
```
Then look in `./candidates.txt` to find the details of the identified transaction, including its full hex, which you need to copy:
```
2020-12-16 11:13:01,708 [INFO] {
"hex": "0200000000010138e8a90b3df7740b9d5f5ae9af2cf6769f314d290b2e12bf25bfa4aae2c0cbe20000000000feffffff0280ba8c010000000016001471b09afbac6204627225c10f3a8d4a0749364fdb6d7c36220000000016001447ae59f32c504cbb56e18b77f7842fb58b55025b02473044022075354351ad4c619ba662f9abd25e8ee434f8381795001606a29fa959d36aeb7f022018f8bf1ec0407dad586baeb7e4d977aaacbd8fb15579293e6d739ad69ac3c6cf012103f8e827464fb83209c194376c53ae8f4e7ab5f1baf0948705fec6dd421f2b65c37a020000",
"inputs": [
.................
Full transaction hex for creating a proposal is found in the above.
The unspent indices are: 0 1 2
```
Copy that fully signed transaction hex, and note the unspent outputs. You're going to assume (in this case correctly of course) that all of the inputs are valid options for the SNICKER public key (why? because it was a *seed* transaction, it was a fake SNICKER, and so all the inputs belonged to the same wallet).
At this point you're ready to run the proposal creator:
```
python create-encrypted-proposal.py --datadir=. proposerwallet.jmdat "0200000000010138e8a90b3df7740b9d5f5ae9af2cf6769f314d290b2e12bf25bfa4aae2c0cbe20000000000feffffff0280ba8c010000000016001471b09afbac6204627225c10f3a8d4a0749364fdb6d7c36220000000016001447ae59f32c504cbb56e18b77f7842fb58b55025b02473044022075354351ad4c619ba662f9abd25e8ee434f8381795001606a29fa959d36aeb7f022018f8bf1ec0407dad586baeb7e4d977aaacbd8fb15579293e6d739ad69ac3c6cf012103f8e827464fb83209c194376c53ae8f4e7ab5f1baf0948705fec6dd421f2b65c37a020000" 0 1 100 -m1 -n
```
Obviously see the `--help` for details, but in this example we chose:
* input index 0 to source the pubkey for the encryption.
* output index 1 for the coin we want the receiver to spend with us in the coinjoin.
* 100 sats as the amount we will bump their output by as an incentive to do the coinjoin (you *can* make this number negative, to receive, instead - the proposer is paying the tx fee otherwise, note).
* mixdepth 1 as the mixdepth from which we source *our* coins for the coinjoin. Make sure of course that mixdepth 1 has at least a little bit more bitcoin than the size of that output at index 1 aforementioned.
* The `-n` option can be used to output the proposal to stdout (it is base64+hex so copy-pasteable).
If you choose not to use -n, but instead use a proposals server as above, then assuming it connects to the server OK, you will see:
```
Response from server: http://xpkqk2cy2h2ay5iecwcod5ka36nxj2tsiyczk2w5c6o7h5g57w3xg4id.onion was: 1 proposals-accepted
```
### Receiving the created proposals.
The last phase is pretty simple, if it works - just run the receiver script (from `scripts/snicker`) as follows:
```
python receive-snicker.py --datadir=. receiver.jmdat [proposal]
User data location: .
2020-12-16 11:43:03,779 [DEBUG] rpc: getblockchaininfo []
2020-12-16 11:43:03,781 [DEBUG] rpc: getnewaddress []
Enter passphrase to decrypt wallet:
2020-12-16 11:43:07,501 [DEBUG] rpc: listaddressgroupings []
2020-12-16 11:43:07,562 [DEBUG] Fast sync in progress. Got this many used addresses: 3
2020-12-16 11:43:08,075 [DEBUG] rpc: listunspent [0]
2020-12-16 11:43:08,216 [DEBUG] bitcoind sync_unspent took 0.14214825630187988sec
2020-12-16 11:43:08,280 [WARNING] Cannot listen on port 27183, trying next port
2020-12-16 11:43:08,281 [WARNING] Cannot listen on port 27184, trying next port
2020-12-16 11:43:08,281 [WARNING] Cannot listen on port 27185, trying next port
2020-12-16 11:43:08,281 [INFO] Listening on port 27186
2020-12-16 11:43:08,282 [INFO] (SNICKER) Listening on port 26186
2020-12-16 11:43:08,282 [INFO] Starting transaction monitor in walletservice
2020-12-16 11:43:08,339 [INFO] Starting SNICKER polling loop
2020-12-16 11:43:22,676 [DEBUG] rpc: sendrawtransaction ['020000000001028ffa6a6f0184ed8123993273ecbb4af82d1b1c0963c815fec4e92525eaba56b30000000000ffffffffa5015509e0e241ef25ee7ccc1936295c908e572cb222105e16c197d66f0599640000000000ffffffff03e4ba8c0100000000160014190ec76b7843f47bc367b65119b98c32074536255dfd5e0a00000000160014d38fa4a6ac8db7495e5e2b5d219dccd412dd9baee4ba8c01000000001600147b4676f859b993257bc8d5880650fcab470db8a1024830450221008480d553177a020f58ca0e45b9e20aa027305a279a3de1014f55ff22909b89b1022054e848285ee60c169b5de19bb4d3637b606ff14bc4cca4506ad05a42fff6af400121029a82a00f05d023f188dfd1db82ef8ec136b0500bbd33bb1f65930c5b74e3199802463043021f01d3f4567c32fc0c5c0cd33db233a3c74100a36940d743b72042b55e60b89d022073ab203ad0fee389f2a2c9e62197244cea95b07ae78a5516ca9f866a8e348d2c01210245d8623c4b06505dffd21bdd314a84b73afe2b9d49a93fe89397b48a85b718bd00000000']
2020-12-16 11:43:22,678 [INFO] Successfully broadcast SNICKER coinjoin: 33ec857df09030140391529295412434cced8191626024f937426b7859a21947
2020-12-16 11:43:23,359 [INFO] Removed utxos=
b356baea2525e9c4fe15c863091c1b2df84abbec7332992381ed84016f6afa8f:0 - path: m/84'/1'/4'/0/0, address: bcrt1qwxcf47avvgzxyu39cy8n4r22qaynvn7mc359ap, value: 26000000
2020-12-16 11:43:23,360 [INFO] Added utxos=
33ec857df09030140391529295412434cced8191626024f937426b7859a21947:0 - path: imported/1/0, address: bcrt1qry8vw6mcg068hsm8keg3nwvvxgr52d3923gg45, value: 26000100
```
Obviously this is the ideal case: if no errors occur. If invalid proposals, or proposals on coins that no longer exist because you already spent them, are encountered, logging messages are displayed to that effect.
If you did choose the `-n` option then you can pass the copy-pasted proposal on the command line and it will just process that instead of polling.
### Other kinds of testing
The above is the baseline workflow. Additionally, you can test:
#### Wallet recovery from seed
This case is already somewhat tricky in Joinmarket, but for the worst possible scenario of only having a seedphrase and no address imports in the wallet and no Joinmarket jmdat file, and having used SNICKER recently and not spent those coins, recovery is particularly tricky (which is why users who enable this feature must be warned that it's very important not to lose the jmdat file). However even in this case full recovery is possible, using the script `snicker-recovery.py`. To fully test this try making multiple SNICKER transactions in a wallet, then deleting the jmdat and creating a new Core wallet (on the same regtest instance of course!), enabling it in joinmarket.cfg, then running `wallet-tool.py recover` with the seedphrase, then running the aforementioned snicker recovery script; it will prompt you to `rescanblockchain` from certain heights, potentially more than once; the reason for this is that Core cannot find arbitrarily the transactions which spend custom keys which we discover during wallet recovery, we need to import and rescan before going to the next step, although this will only be an edge case.
<a name="appendix1" />
## Appendix: Example SNICKER transaction
This is what is produced by `print(jmbitcoin.human_readable_transaction(jmbitcoin.CTransaction.deserialize(jmbase.hextobin('020000000001028ffa6a6f0184ed8123993273ecbb4af82d1b1c0963c815fec4e92525eaba56b30000000000ffffffffa5015509e0e241ef25ee7ccc1936295c908e572cb222105e16c197d66f0599640000000000ffffffff03e4ba8c0100000000160014190ec76b7843f47bc367b65119b98c32074536255dfd5e0a00000000160014d38fa4a6ac8db7495e5e2b5d219dccd412dd9baee4ba8c01000000001600147b4676f859b993257bc8d5880650fcab470db8a1024830450221008480d553177a020f58ca0e45b9e20aa027305a279a3de1014f55ff22909b89b1022054e848285ee60c169b5de19bb4d3637b606ff14bc4cca4506ad05a42fff6af400121029a82a00f05d023f188dfd1db82ef8ec136b0500bbd33bb1f65930c5b74e3199802463043021f01d3f4567c32fc0c5c0cd33db233a3c74100a36940d743b72042b55e60b89d022073ab203ad0fee389f2a2c9e62197244cea95b07ae78a5516ca9f866a8e348d2c01210245d8623c4b06505dffd21bdd314a84b73afe2b9d49a93fe89397b48a85b718bd00000000'))))`:
```
{
"hex": "02000000000102578770b2732aed421ffe62d54fd695cf281ca336e4f686d2adbb2e8c3bedb2570000000000ffffffff4719a259786b4237f92460629181edcc3424419592529103143090f07d85ec330100000000ffffffff0324fd9b0100000000160014d38fa4a6ac8db7495e5e2b5d219dccd412dd9bae24fd9b0100000000160014564aead56de8f4d445fc5b74a61793b5c8a819667af6c208000000001600146ec55c2e1d1a7a868b5ec91822bf40bba842bac502473044022078f8106a5645cc4afeef36d4addec391a5b058cc51053b42c89fcedf92f4db1002200cdf1b66a922863fba8dc1b1b1a0dce043d952fa14dcbe86c427fda25e930a53012102f1f750bfb73dbe4c7faec2c9c301ad0e02176cd47bcc909ff0a117e95b2aad7b02483045022100b9a6c2295a1b0f7605381d416f6ed8da763bd7c20f2402dd36b62dd9dd07375002207d40eaff4fc6ee219a7498abfab6bdc54b7ce006ac4b978b64bff960fbf5f31e012103c2a7d6e44acdbd503c578ec7d1741a44864780be0186e555e853eee86e06f11f00000000",
"inputs": [
{
"outpoint": "57b2ed3b8c2ebbadd286f6e436a31c28cf95d64fd562fe1f42ed2a73b2708757:0",
"scriptSig": "",
"nSequence": 4294967295,
"witness": "02473044022078f8106a5645cc4afeef36d4addec391a5b058cc51053b42c89fcedf92f4db1002200cdf1b66a922863fba8dc1b1b1a0dce043d952fa14dcbe86c427fda25e930a53012102f1f750bfb73dbe4c7faec2c9c301ad0e02176cd47bcc909ff0a117e95b2aad7b"
},
{
"outpoint": "33ec857df09030140391529295412434cced8191626024f937426b7859a21947:1",
"scriptSig": "",
"nSequence": 4294967295,
"witness": "02483045022100b9a6c2295a1b0f7605381d416f6ed8da763bd7c20f2402dd36b62dd9dd07375002207d40eaff4fc6ee219a7498abfab6bdc54b7ce006ac4b978b64bff960fbf5f31e012103c2a7d6e44acdbd503c578ec7d1741a44864780be0186e555e853eee86e06f11f"
}
],
"outputs": [
{
"value_sats": 27000100,
"scriptPubKey": "0014d38fa4a6ac8db7495e5e2b5d219dccd412dd9bae",
"address": "bc1q6w86ff4v3km5jhj79dwjr8wv6sfdmxaw2dytjc"
},
{
"value_sats": 27000100,
"scriptPubKey": "0014564aead56de8f4d445fc5b74a61793b5c8a81966",
"address": "bc1q2e9w44tdar6dg30utd62v9unkhy2sxtxtqrthh"
},
{
"value_sats": 146994810,
"scriptPubKey": "00146ec55c2e1d1a7a868b5ec91822bf40bba842bac5",
"address": "bc1qdmz4ctsarfagdz67eyvz906qhw5y9wk9dqpuea"
}
],
"txid": "ca606efc5ba8f6669ba15e9262e5d38e745345ea96106d5a919688d1ff0da0cc",
"nLockTime": 0,
"nVersion": 2
}
```

5
jmbase/jmbase/__init__.py

@ -7,8 +7,9 @@ from .support import (get_log, chunks, debug_silence, jmprint,
utxo_to_utxostr, EXIT_ARGERROR, EXIT_FAILURE,
EXIT_SUCCESS, hexbin, dictchanger, listchanger,
JM_WALLET_NAME_PREFIX, JM_APP_NAME,
IndentedHelpFormatterWithNL)
from .twisted_utils import stop_reactor
IndentedHelpFormatterWithNL, wrapped_urlparse)
from .proof_of_work import get_pow, verify_pow
from .twisted_utils import stop_reactor, is_hs_uri, get_tor_agent, get_nontor_agent
from .bytesprod import BytesProducer
from .commands import *

53
jmbase/jmbase/commands.py

@ -228,3 +228,56 @@ class JMTXBroadcast(JMCommand):
broadcast.
"""
arguments = [(b'txhex', Unicode())]
"""SNICKER related commands.
"""
class SNICKERReceiverInit(JMCommand):
""" Initialization data for a SNICKER service.
See documentation of `netconfig` in
jmdaemon.HTTPPassThrough.on_INIT
"""
arguments = [(b'netconfig', Unicode())]
class SNICKERProposerInit(JMCommand):
""" As for receiver.
"""
arguments = [(b'netconfig', Unicode())]
class SNICKERReceiverUp(JMCommand):
arguments = []
class SNICKERProposerUp(JMCommand):
arguments = []
class SNICKERReceiverGetProposals(JMCommand):
arguments = []
class SNICKERReceiverProposals(JMCommand):
""" Sends the retrieved proposal list from
a specific server, from daemon back to client.
"""
arguments = [(b'proposals', BigUnicode()),
(b'server', Unicode())]
class SNICKERProposerPostProposals(JMCommand):
""" Sends a list of proposals to be uploaded
to a server.
"""
arguments = [(b'proposals', BigUnicode()),
(b'server', Unicode())]
class SNICKERProposalsServerResponse(JMCommand):
arguments = [(b'response', Unicode()),
(b'server', Unicode())]
class SNICKERServerError(JMCommand):
arguments = [(b'server', Unicode()),
(b'errorcode', Integer())]
class SNICKERRequestPowTarget(JMCommand):
arguments = [(b'server', Unicode())]
class SNICKERReceivePowTarget(JMCommand):
arguments = [(b'server', Unicode()),
(b'targetbits', Integer())]

41
jmbase/jmbase/proof_of_work.py

@ -0,0 +1,41 @@
import hashlib
from .support import bintohex
def get_pow(data, noncelen=10, hashfn=hashlib.sha512,
hashlen=64, nbits=1, truncate=0, maxiterations=10**8):
""" Arguments:
data - a string of bytes.
noncelen - an int, the number of additional bytes to be appended
to the bytestring `data` which will be used for grinding.
hashfn - a function that outputs a finalized hash state that can
be converted to a bytestring with .digest() (see hashlib).
hashlen - the length of the bytestring created with the .digest()
call just mentioned.
nbits - an integer, the number of bits of proof of work required.
truncate - an integer number of bytes to be truncated from the end
of the hash digest created.
maxiterations - an integer, how many grinding attempts maximum allowed
to attempt to reach the target, before giving up.
Returns:
(nonceval, pow-preimage, niterations)
where pow-preimage is data+nonce-in-bytes
or
(None, failure-reason, None)
"""
maxbits = (hashlen-truncate)*8
pow_target = 2 ** (maxbits - nbits)
# note since we are using a trivial counter, two
# elements of returned tuple are the same, this needn't be the case.
for nonceval in range(maxiterations):
x = data + bintohex(nonceval.to_bytes(noncelen, "big")).encode(
"utf-8")
pow_candidate = hashfn(x).digest()[:truncate]
if int.from_bytes(pow_candidate, "big") < pow_target:
return (nonceval, x, nonceval)
return (None, "exceeded max-iterations: {}".format(maxiterations), None)
def verify_pow(data, hashfn=hashlib.sha512, hashlen=64, nbits=1, truncate=0):
return int.from_bytes(hashfn(data).digest()[:truncate],
"big") < 2 ** ((hashlen - truncate) * 8 - nbits)

17
jmbase/jmbase/support.py

@ -5,6 +5,7 @@ from getpass import getpass
from os import path, environ
from functools import wraps
from optparse import IndentedHelpFormatter
import urllib.parse as urlparse
# JoinMarket version
JM_CORE_VERSION = '0.8.2dev'
@ -287,4 +288,18 @@ def hexbin(func):
newargs.append(_convert(arg))
return func(inst, *newargs, **kwargs)
return func_wrapper
return func_wrapper
def wrapped_urlparse(url):
""" This wrapper is unfortunately necessary as there appears
to be a bug in the urlparse handling of *.onion strings:
If http:// is prepended, the url parses correctly, but if it
is not, the .hostname property is erroneously None.
"""
if isinstance(url, str):
a, b = (".onion", "http://")
else:
a, b = (b".onion", b"http://")
if url.endswith(a) and not url.startswith(b):
url = b + url
return urlparse.urlparse(url)

62
jmbase/jmbase/twisted_utils.py

@ -1,6 +1,43 @@
from twisted.internet.error import ReactorNotRunning, AlreadyCancelled
from zope.interface import implementer
from twisted.internet.error import ReactorNotRunning
from twisted.internet import reactor
from twisted.internet.endpoints import TCP4ClientEndpoint
from twisted.web.client import Agent, BrowserLikePolicyForHTTPS
from txtorcon.web import tor_agent
from twisted.web.iweb import IPolicyForHTTPS
from twisted.internet.ssl import CertificateOptions
from .support import wrapped_urlparse
# 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
the correct one in production (i.e. uses local trust store).
"""
@implementer(IPolicyForHTTPS)
class WhitelistContextFactory(object):
def __init__(self, good_domains=None):
"""
:param good_domains: List of domains. The URLs must be in bytes
"""
if not good_domains:
self.good_domains = []
else:
self.good_domains = good_domains
# by default, handle requests like a browser would
self.default_policy = BrowserLikePolicyForHTTPS()
def creatorForNetloc(self, hostname, port):
# check if the hostname is in the the whitelist,
# otherwise return the default policy
if hostname in self.good_domains:
return CertificateOptions(verify=False)
return self.default_policy.creatorForNetloc(hostname, port)
def stop_reactor():
""" The value of the bool `reactor.running`
@ -14,3 +51,26 @@ def stop_reactor():
reactor.stop()
except ReactorNotRunning:
pass
def is_hs_uri(s):
x = wrapped_urlparse(s)
if x.hostname.endswith(".onion"):
return (x.scheme, x.hostname, x.port)
return False
def get_tor_agent(socks5_host, socks5_port):
torEndpoint = TCP4ClientEndpoint(reactor, socks5_host, socks5_port)
return tor_agent(reactor, torEndpoint)
def get_nontor_agent(tls_whitelist=[]):
""" The tls_whitelist argument must be a list of hosts for which
TLS certificate verification may be omitted, default none.
"""
if len(tls_whitelist) == 0:
agent = Agent(reactor)
else:
agent = Agent(reactor,
contextFactory=WhitelistContextFactory(tls_whitelist))
return agent

1
jmbitcoin/jmbitcoin/__init__.py

@ -32,4 +32,5 @@ from bitcointx.core.script import (CScript, OP_0, SignatureHash, SIGHASH_ALL,
SIGVERSION_WITNESS_V0, CScriptWitness)
from bitcointx.core.psbt import (PartiallySignedTransaction, PSBT_Input,
PSBT_Output)
from .blocks import get_transactions_in_block

65
jmbitcoin/jmbitcoin/blocks.py

@ -0,0 +1,65 @@
"""
Module implementing actions that can be taken on
network-serialized bitcoin blocks.
"""
import struct
from jmbase import hextobin, bintohex
from bitcointx.core import CBitcoinTransaction
from bitcointx.core.serialize import VarIntSerializer, VarBytesSerializer
def decode_varint(data):
n, tail = VarIntSerializer.deserialize_partial(data)
head = data[0: len(data) - len(tail)]
return n, len(head)
def get_transactions_in_block(block):
""" `block` is hex output from RPC `getblock`.
Return:
yields the block's transactions, type CBitcoinTransaction
"""
block = hextobin(block)
# Skipping the header
transaction_data = block[80:]
# Decoding the number of transactions, offset is the size of
# the varint (1 to 9 bytes)
n_transactions, offset = decode_varint(transaction_data)
for i in range(n_transactions):
# This 'strat' of reading in small chunks optimistically is taken from:
# https://github.com/alecalve/python-bitcoin-blockchain-parser/blob/7a9e15c236b10d2a6dff5e696801c0641af72628/blockchain_parser/utils.py
# Try from 1024 (1KiB) -> 1073741824 (1GiB) slice widths
for j in range(0, 20):
try:
offset_e = offset + (1024 * 2 ** j)
transaction = CBitcoinTransaction.deserialize(
transaction_data[offset:offset_e], allow_padding=True)
yield transaction
break
except:
continue
# Skipping to the next transaction
offset += len(transaction.serialize())
""" Example block from a regtest:
# Found using `getblockhash 222` followed by `getblock <resultinghash> 0`:
0000003066327ecf2f3e72ec43f358c9c7b34f47374f23f4fcce965d4e18273a5b98f325d11b3b9c3a592c830d49f6281d4055f5732a79a19f9bd8d4afad729772cbf393fa7bdf5fffff7f2000000000010200000
00001010000000000000000000000000000000000000000000000000000000000000000ffffffff0502de000101ffffffff0200f902950000000017a914d2e1d0ea5135f0cbeb4aef06e3cee785d394876a870000000000000000266a24aa21a9ede2f61c3f71d1defd3fa999dfa36953755c690689799962b48bebd836974e8cf90120000000000000000000000000000000000000000000000000000000000000000000000000
Gives (x = above block):
y = list(btc.get_block_transactions(x))
>>> y[0]
CBitcoinTransaction([CBitcoinTxIn(CBitcoinOutPoint(), CBitcoinScript([x('de00'), x('01')]), 0xffffffff)],
[CBitcoinTxOut(25.0*COIN, CBitcoinScript([OP_HASH160, x('d2e1d0ea5135f0cbeb4aef06e3cee785d394876a'), OP_EQUAL])),
CBitcoinTxOut(0.0*COIN, CBitcoinScript([OP_RETURN, x('aa21a9ede2f61c3f71d1defd3fa999dfa36953755c690689799962b48bebd836974e8cf9')]))],
0, 2, CBitcoinTxWitness([CBitcoinTxInWitness(CScriptWitness([x('0000000000000000000000000000000000000000000000000000000000000000')]))]))
(coinbase transaction)
"""

13
jmbitcoin/jmbitcoin/secp256k1_ecies.py

@ -34,14 +34,11 @@ def aes_decrypt(key, data, iv):
return dec_data
def ecies_encrypt(message, pubkey):
""" Take a privkey in raw byte serialization,
and a pubkey serialized in compressed, binary format (33 bytes),
and output the shared secret as a 32 byte hash digest output.
The exact calculation is:
shared_secret = SHA256(privkey * pubkey)
.. where * is elliptic curve scalar multiplication.
See https://github.com/bitcoin/bitcoin/blob/master/src/secp256k1/src/modules/ecdh/main_impl.h
for implementation details.
""" Take a message in bytes and a secp256k1 public key
in compressed byte serialization, and output the
ECIES encryption, using magic bytes as defined in this module,
sha512 for the key expansion, and AES-CBC for the encryption;
these choices are aligned with that used by Electrum.
"""
# create an ephemeral pubkey for this encryption:
while True:

105
jmbitcoin/jmbitcoin/secp256k1_transaction.py

@ -3,13 +3,14 @@
# note, only used for non-cryptographic randomness:
import random
import json
import itertools
# needed for single sha256 evaluation, which is used
# in bitcoin (p2wsh) but not exposed in python-bitcointx:
import hashlib
from jmbitcoin.secp256k1_main import *
from jmbase import bintohex, utxo_to_utxostr
from bitcointx.core import (CMutableTransaction, Hash160, CTxInWitness,
from bitcointx.core import (CMutableTransaction, CTxInWitness,
CMutableOutPoint, CMutableTxIn, CTransaction,
CMutableTxOut, CTxIn, CTxOut, ValidationError)
from bitcointx.core.script import *
@ -314,7 +315,7 @@ def mktx(ins, outs, version=1, locktime=0):
def make_shuffled_tx(ins, outs, version=1, locktime=0):
""" Simple wrapper to ensure transaction
inputs and outputs are randomly ordered.
Can possibly be replaced by BIP69 in future
NB: This mutates ordering of `ins` and `outs`.
"""
random.shuffle(ins)
random.shuffle(outs)
@ -332,3 +333,103 @@ def verify_tx_input(tx, i, scriptSig, scriptPubKey, amount=None, witness=None):
except ValidationError as e:
return False
return True
def extract_witness(tx, i):
"""Given `tx` of type CTransaction, extract,
as a list of objects of type CScript, which constitute the
witness at the index i, followed by "success".
If the witness is not present for this index, (None, "errmsg")
is returned.
Callers must distinguish the case 'tx is unsigned' from the
case 'input is not type segwit' externally.
"""
assert isinstance(tx, CTransaction)
assert i >= 0
if not tx.has_witness():
return None, "Tx witness not present"
if len(tx.vin) < i:
return None, "invalid input index"
witness = tx.wit.vtxinwit[i]
return (witness, "success")
def extract_pubkey_from_witness(tx, i):
""" Extract the pubkey used to sign at index i,
in CTransaction tx, assuming it is of type p2wpkh
(including wrapped segwit version).
Returns (pubkey, "success") or (None, "errmsg").
"""
witness, msg = extract_witness(tx, i)
sWitness = [a for a in iter(witness.scriptWitness)]
if not sWitness:
return None, msg
else:
if len(sWitness) != 2:
return None, "invalid witness for p2wpkh."
if not is_valid_pubkey(sWitness[1], True):
return None, "invalid pubkey in witness"
return sWitness[1], "success"
def get_equal_outs(tx):
""" If 2 or more transaction outputs have the same
bitcoin value, return then as a list of CTxOuts.
If there is not exactly one equal output size, return False.
"""
retval = []
l = [x.nValue for x in tx.vout]
eos = [i for i in l if l.count(i)>=2]
if len(eos) > 0:
eos = set(eos)
if len(eos) > 1:
return False
for i, vout in enumerate(tx.vout):
if vout.nValue == list(eos)[0]:
retval.append((i, vout))
assert len(retval) > 1
return retval
def is_jm_tx(tx, min_cj_amount=75000, min_participants=3):
""" Identify Joinmarket-patterned transactions.
TODO: this should be in another module.
Given a CBitcoinTransaction tx, check:
nins >= number of coinjoin outs (equal sized)
non-equal outs = coinjoin outs or coinjoin outs -1
at least 3 coinjoin outs (2 technically possible but excluded)
also possible to try to get clever about fees, but won't bother.
note: BlockSci's algo additionally addresses subset sum, so will
give better quality data, but this is kept simple for now.
We filter out joins with less than 3 participants as they are
not really in Joinmarket "correct usage" and there will be a lot
of false positives.
We filter out "joins" less than 75000 sats as they are unlikely to
be Joinmarket and there tend to be many low-value false positives.
Returns:
(False, None) for non-matches
(coinjoin amount, number of participants) for matches.
"""
def assumed_cj_out_num(nout):
"""Return the value ceil(nout/2)
"""
x = nout//2
if nout %2: return x+1
return x
def most_common_value(x):
return max(set(x), key=x.count)
assumed_coinjoin_outs = assumed_cj_out_num(len(tx.vout))
if assumed_coinjoin_outs < min_participants:
return (False, None)
if len(tx.vin) < assumed_coinjoin_outs:
return (False, None)
outvals = [x.nValue for x in tx.vout]
# it's not possible for the coinjoin out to not be
# the most common value:
mcov = most_common_value(outvals)
if mcov < min_cj_amount:
return (False, None)
cjoutvals = [x for x in outvals if x == mcov]
if len(cjoutvals) != assumed_coinjoin_outs:
return (False, None)
# number of participants is the number of assumed
# coinjoin outputs:
return (mcov, assumed_coinjoin_outs)

109
jmbitcoin/jmbitcoin/snicker.py

@ -6,6 +6,7 @@
from jmbitcoin.secp256k1_ecies import *
from jmbitcoin.secp256k1_main import *
from jmbitcoin.secp256k1_transaction import *
from collections import Counter
SNICKER_MAGIC_BYTES = b'SNICKER'
@ -34,16 +35,23 @@ def snicker_privkey_tweak(priv, tweak):
base_priv = secp256k1.PrivateKey(priv)
return base_priv.add(tweak).secret + b'\x01'
def verify_snicker_output(tx, pub, tweak, spk_type='p2sh-p2wpkh'):
def verify_snicker_output(tx, pub, tweak, spk_type="p2wpkh"):
""" A convenience function to check that one output address in a transaction
is a SNICKER-type tweak of an existing key. Returns the index of the output
for which this is True (and there must be only 1), and the derived spk,
or -1 and None if it is not found exactly once.
TODO Add support for other scriptPubKey types.
Only standard segwit spk types (as used in Joinmarket) are supported.
"""
assert isinstance(tx, btc.CTransaction)
expected_destination_pub = snicker_pubkey_tweak(pub, tweak)
expected_destination_spk = pubkey_to_p2sh_p2wpkh_script(expected_destination_pub)
if spk_type == "p2wpkh":
expected_destination_spk = pubkey_to_p2wpkh_script(
expected_destination_pub)
elif spk_type == "p2sh-p2wpkh":
expected_destination_spk = pubkey_to_p2sh_p2wpkh_script(
expected_destination_pub)
else:
assert False, "JM SNICKER only supports p2sh/p2wpkh"
found = 0
for i, o in enumerate(tx.vout):
if o.scriptPubKey == expected_destination_spk:
@ -52,3 +60,98 @@ def verify_snicker_output(tx, pub, tweak, spk_type='p2sh-p2wpkh'):
if found != 1:
return -1, None
return found_index, expected_destination_spk
def construct_snicker_outputs(proposer_input_amount, receiver_input_amount,
receiver_addr, proposer_addr, change_addr,
network_fee, net_transfer):
""" This is abstracted from full SNICKER transaction proposal (see
`jmclient.wallet.SNICKERWalletMixin`) construction, as it is also useful
for making fake SNICKERs.
total_input_amount (int) : value of sum of inputs in sats
receiver_input_amount (int): value of single utxo input of receiver in sats
receiver_addr (str): address for tweaked destination of receiver
proposer_addr (str): address for proposer's coinjoin output
change_addr (str): address for proposer's change output
network_fee (int): bitcoin network transaction fee in sats
net_transfer (int): how much the proposer gives to the receiver in sats
Returns:
list of outputs, each is of form {"address": x, "value": y}
"""
total_input_amount = proposer_input_amount + receiver_input_amount
total_output_amount = total_input_amount - network_fee
receiver_output_amount = receiver_input_amount + net_transfer
proposer_output_amount = total_output_amount - receiver_output_amount
change_output_amount = total_output_amount - 2 * receiver_output_amount
# callers should only request sane values:
assert all([x>0 for x in [receiver_output_amount, change_output_amount]])
# now we must construct the three outputs with correct output amounts.
outputs = [{"address": receiver_addr, "value": receiver_output_amount}]
outputs.append({"address": proposer_addr, "value": receiver_output_amount})
outputs.append({"address": change_addr,
"value": change_output_amount})
return outputs
def is_snicker_tx(tx, snicker_version=bytes([1])):
""" Returns True if the CTransaction object `tx`
fits the pattern of a SNICKER coinjoin of type
defined in `snicker_version`, or False otherwise.
"""
if not snicker_version == b"\x01":
raise NotImplementedError("Only v1 SNICKER currently implemented.")
return is_snicker_v1_tx(tx)
def is_snicker_v1_tx(tx):
""" We expect:
* 2 equal outputs, same script type, pubkey hash variant.
* 1 other output (0 is negligible probability hence ignored - if it
was included it would create a lot of false positives).
* >=2 inputs, same script type, pubkey hash variant.
* Input sequence numbers are both 0xffffffff
* nVersion 2
* nLockTime 0
The above rules are for matching the v1 variant of SNICKER.
"""
assert isinstance(tx, CTransaction)
if tx.nVersion != 2:
return False
if tx.nLockTime != 0:
return False
if len(tx.vin) < 2:
return False
if len(tx.vout) != 3:
return False
for vi in tx.vin:
if vi.nSequence != 0xffffffff:
return False
# identify if there are two equal sized outs
c = Counter([vo.nValue for vo in tx.vout])
equal_out = -1
for x in c:
if c[x] not in [1, 2]:
# note three equal outs technically agrees
# with spec, but negligible prob and will
# create false positives.
return False
if c[x] == 2:
equal_out = x
if equal_out == -1:
return False
# ensure that the equal sized outputs have the
# same script type
matched_spk = None
for vo in tx.vout:
if vo.nValue == equal_out:
if not matched_spk:
matched_spk = btc.CCoinAddress.from_scriptPubKey(
vo.scriptPubKey).get_scriptPubKey_type()
else:
if not btc.CCoinAddress.from_scriptPubKey(
vo.scriptPubKey).get_scriptPubKey_type() == matched_spk:
return False
assert matched_spk
return True

96
jmbitcoin/test/test_btc_snicker.py

@ -0,0 +1,96 @@
import pytest
import copy
import jmbitcoin as btc
""" Awkward test module name `test_btc_snicker` is to avoid
conflicts with snicker tests in jmclient.
"""
@pytest.mark.parametrize(
"our_input_val, their_input_val, network_fee, script_type, net_transfer", [
(24000000, 20000000, 2000, "p2wpkh", 100),
(124000000, 20000000, 800, "p2wpkh", -100),
(24000000, 20000000, 2000, "p2sh-p2wpkh", 100),
(124000000, 20000000, 800, "p2sh-p2wpkh", -100),
])
def test_is_snicker_tx(our_input_val, their_input_val, network_fee,
script_type, net_transfer):
our_input = (bytes([1])*32, 0)
their_input = (bytes([2])*32, 1)
assert our_input_val - their_input_val - network_fee > 0
total_input_amount = our_input_val + their_input_val
total_output_amount = total_input_amount - network_fee
receiver_output_amount = their_input_val + net_transfer
proposer_output_amount = total_output_amount - receiver_output_amount
# all keys are just made up; only the script type will be checked
privs = [bytes([i])*32 + bytes([1]) for i in range(1,4)]
pubs = [btc.privkey_to_pubkey(x) for x in privs]
if script_type == "p2wpkh":
spks = [btc.pubkey_to_p2wpkh_script(x) for x in pubs]
elif script_type == "p2sh-p2wpkh":
spks = [btc.pubkey_to_p2sh_p2wpkh_script(x) for x in pubs]
else:
assert False
tweaked_addr, our_addr, change_addr = [str(
btc.CCoinAddress.from_scriptPubKey(x)) for x in spks]
# now we must construct the three outputs with correct output amounts.
outputs = [{"address": tweaked_addr, "value": receiver_output_amount}]
outputs.append({"address": our_addr, "value": receiver_output_amount})
outputs.append({"address": change_addr,
"value": total_output_amount - 2 * receiver_output_amount})
assert all([x["value"] > 0 for x in outputs])
# make_shuffled_tx mutates ordering (yuck), work with copies only:
outputs1 = copy.deepcopy(outputs)
# version and locktime as currently specified in the BIP
# for 0/1 version SNICKER. (Note the locktime is partly because
# of expected delays).
tx = btc.make_shuffled_tx([our_input, their_input], outputs1,
version=2, locktime=0)
assert btc.is_snicker_tx(tx)
# construct variants which will be invalid.
# mixed script types in outputs
wrong_tweaked_spk = btc.pubkey_to_p2pkh_script(pubs[1])
wrong_tweaked_addr = str(btc.CCoinAddress.from_scriptPubKey(
wrong_tweaked_spk))
outputs2 = copy.deepcopy(outputs)
outputs2[0] = {"address": wrong_tweaked_addr,
"value": receiver_output_amount}
tx2 = btc.make_shuffled_tx([our_input, their_input], outputs2,
version=2, locktime=0)
assert not btc.is_snicker_tx(tx2)
# nonequal output amounts
outputs3 = copy.deepcopy(outputs)
outputs3[1] = {"address": our_addr, "value": receiver_output_amount - 1}
tx3 = btc.make_shuffled_tx([our_input, their_input], outputs3,
version=2, locktime=0)
assert not btc.is_snicker_tx(tx3)
# too few outputs
outputs4 = copy.deepcopy(outputs)
outputs4 = outputs4[:2]
tx4 = btc.make_shuffled_tx([our_input, their_input], outputs4,
version=2, locktime=0)
assert not btc.is_snicker_tx(tx4)
# too many outputs
outputs5 = copy.deepcopy(outputs)
outputs5.append({"address": change_addr, "value": 200000})
tx5 = btc.make_shuffled_tx([our_input, their_input], outputs5,
version=2, locktime=0)
assert not btc.is_snicker_tx(tx5)
# wrong nVersion
tx6 = btc.make_shuffled_tx([our_input, their_input], outputs,
version=1, locktime=0)
assert not btc.is_snicker_tx(tx6)
# wrong nLockTime
tx7 = btc.make_shuffled_tx([our_input, their_input], outputs,
version=2, locktime=1)
assert not btc.is_snicker_tx(tx7)

6
jmclient/jmclient/__init__.py

@ -24,11 +24,12 @@ 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,
is_native_segwit_mode)
is_native_segwit_mode, JMPluginService)
from .blockchaininterface import (BlockchainInterface,
RegtestBitcoinCoreInterface, BitcoinCoreInterface)
from .snicker_receiver import SNICKERError, SNICKERReceiver
from .client_protocol import (JMTakerClientProtocol, JMClientProtocolFactory,
start_reactor)
start_reactor, SNICKERClientProtocolFactory)
from .podle import (set_commitment_file, get_commitment_file,
add_external_commitments,
PoDLE, generate_podle, get_podle_commitments,
@ -55,7 +56,6 @@ from .wallet_utils import (
from .wallet_service import WalletService
from .maker import Maker
from .yieldgenerator import YieldGenerator, YieldGeneratorBasic, ygmain
from .snicker_receiver import SNICKERError, SNICKERReceiver
from .payjoin import (parse_payjoin_setup, send_payjoin, PayjoinServer,
JMBIP78ReceiverManager)
# Set default logging handler to avoid "No handler found" warnings.

15
jmclient/jmclient/blockchaininterface.py

@ -204,10 +204,12 @@ class BitcoinCoreInterface(BlockchainInterface):
restart when the connection is healed, but that is tricky).
Should not be called directly from outside code.
"""
# TODO: flip the logic of this. We almost never want to print these
# out even to debug as they are noisy.
if method not in ['importaddress', 'walletpassphrase', 'getaccount',
'gettransaction', 'getrawtransaction', 'gettxout',
'importmulti', 'listtransactions', 'getblockcount',
'scantxoutset']:
'scantxoutset', 'getblock', 'getblockhash']:
log.debug('rpc: ' + method + " " + str(args))
try:
res = self.jsonRpc.call(method, args)
@ -394,6 +396,17 @@ class BitcoinCoreInterface(BlockchainInterface):
result.append(result_dict)
return result
def get_unspent_indices(self, transaction):
""" Given a CTransaction object, identify the list of
indices of outputs which are unspent (returned as list of ints).
"""
bintxid = transaction.GetTxid()[::-1]
res = self.query_utxo_set([(bintxid, i) for i in range(
len(transaction.vout))])
# QUS returns 'None' for spent outputs, so filter them out
# and return the indices of the others:
return [i for i, val in enumerate(res) if val]
def estimate_fee_per_kb(self, N):
""" The argument N may be either a number of blocks target,
for estimation of feerate by Core, or a number of satoshis

196
jmclient/jmclient/client_protocol.py

@ -16,27 +16,14 @@ import sys
from jmbase import (get_log, EXIT_FAILURE, hextobin, bintohex,
utxo_to_utxostr)
from jmclient import (jm_single, get_irc_mchannels,
RegtestBitcoinCoreInterface)
RegtestBitcoinCoreInterface,
SNICKERReceiver, process_shutdown)
import jmbitcoin as btc
jlog = get_log()
class JMProtocolError(Exception):
pass
class JMClientProtocol(amp.AMP):
def __init__(self, factory, client, nick_priv=None):
self.client = client
self.factory = factory
if not nick_priv:
self.nick_priv = hashlib.sha256(
os.urandom(16)).digest() + b"\x01"
else:
self.nick_priv = nick_priv
self.shutdown_requested = False
class BaseClientProtocol(amp.AMP):
def checkClientResponse(self, response):
"""A generic check of client acceptance; any failure
is considered criticial.
@ -54,6 +41,146 @@ class JMClientProtocol(amp.AMP):
d.addCallback(self.checkClientResponse)
d.addErrback(self.defaultErrback)
class JMProtocolError(Exception):
pass
class SNICKERClientProtocol(BaseClientProtocol):
def __init__(self, client, servers, tls_whitelist=[], oneshot=False):
# if client is type JMSNICKERReceiver, this will flag
# the use of the receiver workflow (polling loop).
# Otherwise it is assumed to be a proposer workloop,
# which does not have active polling, but only the
# ability to upload when clients call for it.
self.client = client
self.servers = servers
if len(tls_whitelist) == 0:
if isinstance(jm_single().bc_interface,
RegtestBitcoinCoreInterface):
tls_whitelist = ["127.0.0.1"]
self.tls_whitelist = tls_whitelist
self.processed_proposals = []
self.oneshot = oneshot
def connectionMade(self):
netconfig = {"socks5_host": jm_single().config.get("PAYJOIN", "onion_socks5_host"),
"socks5_port": jm_single().config.get("PAYJOIN", "onion_socks5_port"),
"servers": self.servers,
"tls_whitelist": ",".join(self.tls_whitelist),
"filterconfig": "",
"credentials": ""}
if isinstance(self.client, SNICKERReceiver):
d = self.callRemote(commands.SNICKERReceiverInit,
netconfig=json.dumps(netconfig))
else:
d = self.callRemote(commands.SNICKERProposerInit,
netconfig=json.dumps(netconfig))
self.defaultCallbacks(d)
def shutdown(self):
""" Encapsulates shut down actions.
"""
if self.proposal_poll_loop:
self.proposals_poll_loop.stop()
def poll_for_proposals(self):
""" May be invoked in a LoopingCall or other
event loop.
Retrieves any entries in the proposals_source, then
compares with existing,
and invokes parse_proposal on all new entries.
# TODO considerable thought should go into how to store
proposals cross-runs, and also handling of keys, which
must be optional.
"""
# always check whether the service is still intended to
# be active, before starting the polling actions:
if jm_single().config.get("SNICKER", "enabled") != "true":
self.shutdown()
return
d = self.callRemote(commands.SNICKERReceiverGetProposals)
self.defaultCallbacks(d)
@commands.SNICKERProposerUp.responder
def on_SNICKER_PROPOSER_UP(self):
jlog.info("SNICKER proposer daemon ready.")
# TODO handle multiple servers correctly
for s in self.servers:
if s == "":
continue
d = self.callRemote(commands.SNICKERRequestPowTarget,
server=s)
self.defaultCallbacks(d)
return {"accepted": True}
@commands.SNICKERReceivePowTarget.responder
def on_SNICKER_RECEIVE_POW_TARGET(self, server, targetbits):
proposals = self.client.get_proposals(targetbits)
d = self.callRemote(commands.SNICKERProposerPostProposals,
proposals="\n".join([x.decode("utf-8") for x in proposals]),
server = server)
self.defaultCallbacks(d)
return {"accepted": True}
@commands.SNICKERServerError.responder
def on_SNICKER_SERVER_ERROR(self, server, errorcode):
self.client.info_callback("Server: " + str(
server) + " returned error code: " + str(errorcode))
return {"accepted": True}
@commands.SNICKERReceiverUp.responder
def on_SNICKER_RECEIVER_UP(self):
if self.oneshot:
jlog.info("Starting single query to SNICKER server(s).")
reactor.callLater(0.0, self.poll_for_proposals)
else:
jlog.info("Starting SNICKER polling loop")
self.proposal_poll_loop = task.LoopingCall(
self.poll_for_proposals)
poll_interval = int(60.0 * float(
jm_single().config.get("SNICKER", "polling_interval_minutes")))
self.proposal_poll_loop.start(poll_interval, now=False)
return {"accepted": True}
@commands.SNICKERReceiverProposals.responder
def on_SNICKER_RECEIVER_PROPOSALS(self, proposals, server):
""" Just passes through the proposals retrieved from
any server, to the SNICKERReceiver client object, asynchronously.
The proposals data must be newline separated.
"""
try:
proposals = proposals.split("\n")
except:
jlog.warn("Error in parsing proposals from server: " + str(server))
return {"accepted": True}
reactor.callLater(0.0, self.process_proposals, proposals)
return {"accepted": True}
def process_proposals(self, proposals):
self.client.process_proposals(proposals)
if self.oneshot:
process_shutdown()
@commands.SNICKERProposalsServerResponse.responder
def on_SNICKER_PROPOSALS_SERVER_RESPONSE(self, response, server):
self.client.info_callback("Response from server: " + str(server) +\
" was: " + str(response))
self.client.end_requests_callback(None)
return {"accepted": True}
class JMClientProtocol(BaseClientProtocol):
def __init__(self, factory, client, nick_priv=None):
self.client = client
self.factory = factory
if not nick_priv:
self.nick_priv = hashlib.sha256(
os.urandom(16)).digest() + b"\x01"
else:
self.nick_priv = nick_priv
self.shutdown_requested = False
def connectionMade(self):
jlog.debug('connection was made, starting client.')
self.factory.setClient(self)
@ -499,6 +626,15 @@ class JMTakerClientProtocol(JMClientProtocol):
txhex=str(txhex_to_push))
self.defaultCallbacks(d)
class SNICKERClientProtocolFactory(protocol.ClientFactory):
protocol = SNICKERClientProtocol
def buildProtocol(self, addr):
return self.protocol(self.client, self.servers, oneshot=self.oneshot)
def __init__(self, client, servers, oneshot=False):
self.client = client
self.servers = servers
self.oneshot = oneshot
class JMClientProtocolFactory(protocol.ClientFactory):
protocol = JMTakerClientProtocol
@ -517,15 +653,17 @@ class JMClientProtocolFactory(protocol.ClientFactory):
def buildProtocol(self, addr):
return self.protocol(self, self.client)
def start_reactor(host, port, factory, ish=True, daemon=False, rs=True,
gui=False): #pragma: no cover
def start_reactor(host, port, factory=None, snickerfactory=None, ish=True,
daemon=False, rs=True, 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
usessl = True if jm_single().config.get("DAEMON",
"use_ssl") != 'false' else False
if daemon:
try:
from jmdaemon import JMDaemonServerProtocolFactory, start_daemon
from jmdaemon import JMDaemonServerProtocolFactory, start_daemon, \
SNICKERDaemonServerProtocolFactory
except ImportError:
jlog.error("Cannot start daemon without jmdaemon package; "
"either install it, and restart, or, if you want "
@ -533,6 +671,8 @@ def start_reactor(host, port, factory, ish=True, daemon=False, rs=True,
"section of the config. Quitting.")
return
dfactory = JMDaemonServerProtocolFactory()
if snickerfactory:
sdfactory = SNICKERDaemonServerProtocolFactory()
orgport = port
while True:
try:
@ -546,11 +686,21 @@ 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
if snickerfactory:
start_daemon(host, port-1000, sdfactory, usessl,
'./ssl/key.pem', './ssl/cert.pem')
jlog.info("(SNICKER) Listening on port " + str(port-1000))
if usessl:
ctx = ClientContextFactory()
reactor.connectSSL(host, port, factory, ctx)
if factory:
reactor.connectSSL(host, port, factory, ClientContextFactory())
if snickerfactory:
reactor.connectSSL(host, port-1000, snickerfactory,
ClientContextFactory())
else:
reactor.connectTCP(host, port, factory)
if factory:
reactor.connectTCP(host, port, factory)
if snickerfactory:
reactor.connectTCP(host, port-1000, snickerfactory)
if rs:
if not gui:
reactor.run(installSignalHandlers=ish)

73
jmclient/jmclient/configure.py

@ -397,6 +397,26 @@ minsize = 100000
size_factor = 0.1
gaplimit = 6
[SNICKER]
# any other value than 'true' will be treated as False,
# and no SNICKER actions will be enabled in that case:
enabled = false
# in satoshis, we require any SNICKER to pay us at least
# this much (can be negative), otherwise we will refuse
# to sign it:
lowest_net_gain = 0
# comma separated list of servers (if port is omitted as :port, it
# is assumed to be 80) which we will poll against (all, in sequence); note
# that they are allowed to be *.onion or cleartext servers, and no
# scheme (http(s) etc) needs to be added to the start.
servers = cn5lfwvrswicuxn3gjsxoved6l2gu5hdvwy5l3ev7kg6j7lbji2k7hqd.onion,
# how many minutes between each polling event to each server above:
polling_interval_minutes = 60
"""
#This allows use of the jmclient package with a
@ -466,6 +486,36 @@ def get_config_irc_channel(channel_name):
channel += '-sig'
return channel
class JMPluginService(object):
""" Allows us to configure on-startup
any additional service (such as SNICKER).
For now only covers logging.
"""
def __init__(self, name, requires_logging=True):
self.name = name
self.requires_logging = requires_logging
def start_plugin_logging(self, wallet):
""" This requires the name of the active wallet
to set the logfile; TODO other plugin services may
need a different setup.
"""
self.wallet = wallet
self.logfilename = "{}-{}.log".format(self.name,
self.wallet.get_wallet_name())
self.start_logging()
def set_log_dir(self, logdirname):
self.logdirname = logdirname
def start_logging(self):
logFormatter = logging.Formatter(
('%(asctime)s [%(levelname)-5.5s] {} - %(message)s'.format(
self.name)))
fileHandler = logging.FileHandler(
self.logdirname + '/{}'.format(self.logfilename))
fileHandler.setFormatter(logFormatter)
get_log().addHandler(fileHandler)
def get_network():
"""Returns network name"""
@ -510,7 +560,7 @@ def remove_unwanted_default_settings(config):
if section.startswith('MESSAGING:'):
config.remove_section(section)
def load_program_config(config_path="", bs=None):
def load_program_config(config_path="", bs=None, plugin_services=[]):
global_singleton.config.readfp(io.StringIO(defaultconfig))
if not config_path:
config_path = lookup_appdata_folder(global_singleton.APPNAME)
@ -608,6 +658,27 @@ def load_program_config(config_path="", bs=None):
set_commitment_file(os.path.join(config_path,
global_singleton.commit_file_location))
for p in plugin_services:
# for now, at this config level, the only significance
# of a "plugin" is that it keeps its own separate log.
# We require that a section exists in the config file,
# and that it has enabled=true:
assert isinstance(p, JMPluginService)
if not (global_singleton.config.has_section(p.name) and \
global_singleton.config.has_option(p.name, "enabled") and \
global_singleton.config.get(p.name, "enabled") == "true"):
break
if p.requires_logging:
# make sure the environment can accept a logfile by
# creating the directory in the correct place,
# and setting that in the plugin object; the plugin
# itself will switch on its own logging when ready,
# attaching a filehandler to the global log.
plogsdir = os.path.join(os.path.dirname(
global_singleton.config_location), "logs", p.name)
if not os.path.exists(plogsdir):
os.makedirs(plogsdir)
p.set_log_dir(plogsdir)
def load_test_config(**kwargs):
if "config_path" not in kwargs:

143
jmclient/jmclient/snicker_receiver.py

@ -1,26 +1,21 @@
#! /usr/bin/env python
import sys
import jmbitcoin as btc
from jmclient.configure import jm_single
from jmbase import (get_log, EXIT_FAILURE, utxo_to_utxostr,
bintohex, hextobin)
from jmbase import (get_log, utxo_to_utxostr,
hextobin, bintohex)
from twisted.application.service import Service
jlog = get_log()
class SNICKERError(Exception):
pass
class SNICKERReceiver(object):
class SNICKERReceiver(Service):
supported_flags = []
import_branch = 0
# TODO implement http api or similar
# for polling, here just a file:
proposals_source = "proposals.txt"
def __init__(self, wallet_service, income_threshold=0,
acceptance_callback=None):
def __init__(self, wallet_service, acceptance_callback=None,
info_callback=None):
"""
Class to manage processing of SNICKER proposals and
co-signs and broadcasts in case the application level
@ -36,9 +31,9 @@ class SNICKERReceiver(object):
# The simplest filter on accepting SNICKER joins:
# that they pay a minimum of this value in satoshis,
# which can be negative (to account for fees).
# TODO this will be a config variable.
self.income_threshold = income_threshold
# which can be negative (e.g. to account for fees).
self.income_threshold = jm_single().config.getint("SNICKER",
"lowest_net_gain")
# The acceptance callback which defines if we accept
# a valid proposal and sign it, or not.
@ -47,6 +42,11 @@ class SNICKERReceiver(object):
else:
self.acceptance_callback = acceptance_callback
# callback for information messages to UI
if not info_callback:
self.info_callback = self.default_info_callback
else:
self.info_callback = info_callback
# A list of currently viable key candidates; these must
# all be (pub)keys for which the privkey is accessible,
# i.e. they must be in-wallet keys.
@ -61,27 +61,11 @@ class SNICKERReceiver(object):
# SNICKER transactions in the current run.
self.successful_txs = []
def poll_for_proposals(self):
""" Intended to be invoked in a LoopingCall or other
event loop.
Retrieves any entries in the proposals_source, then
compares with existing,
and invokes parse_proposal on all new entries.
# TODO considerable thought should go into how to store
proposals cross-runs, and also handling of keys, which
must be optional.
"""
new_proposals = []
with open(self.proposals_source, "rb") as f:
current_entries = f.readlines()
for entry in current_entries:
if entry in self.processed_proposals:
continue
new_proposals.append(entry)
if not self.process_proposals(new_proposals):
jlog.error("Critical logic error, shutting down.")
sys.exit(EXIT_FAILURE)
self.processed_proposals.extend(new_proposals)
# the main monitoring loop that checks for proposals:
self.proposal_poll_loop = None
def default_info_callback(self, msg):
jlog.info(msg)
def default_acceptance_callback(self, our_ins, their_ins,
our_outs, their_outs):
@ -99,16 +83,16 @@ class SNICKERReceiver(object):
# we use get_all* because for these purposes mixdepth
# is irrelevant.
utxos = self.wallet_service.get_all_utxos()
print("gau returned these utxos: ", utxos)
our_in_amts = []
our_out_amts = []
for i in our_ins:
utxo_for_i = (i.prevout.hash[::-1], i.prevout.n)
if utxo_for_i not in utxos.keys():
success, utxostr =utxo_to_utxostr(utxo_for_i)
success, utxostr = utxo_to_utxostr(utxo_for_i)
if not success:
jlog.error("Code error: input utxo in wrong format.")
jlog.debug("The input utxo was not found: " + utxostr)
jlog.debug("NB: This can simply mean the coin is already spent.")
return False
our_in_amts.append(utxos[utxo_for_i]["value"])
for o in our_outs:
@ -117,8 +101,19 @@ class SNICKERReceiver(object):
return False
return True
def log_successful_tx(self, tx):
""" TODO: add dedicated SNICKER log file.
"""
self.successful_txs.append(tx)
jlog.info(btc.human_readable_transaction(tx))
def process_proposals(self, proposals):
""" Each entry in `proposals` is of form:
""" This is the "meat" of the SNICKERReceiver service.
It parses proposals and creates and broadcasts transactions
with the wallet, assuming all conditions are met.
Note that this is ONLY called from the proposals poll loop.
Each entry in `proposals` is of form:
encrypted_proposal - base64 string
key - hex encoded compressed pubkey, or ''
if the key is not null, we attempt to decrypt and
@ -141,29 +136,31 @@ class SNICKERReceiver(object):
"""
for kp in proposals:
# handle empty list entries:
if not kp:
continue
try:
p, k = kp.split(b',')
p, k = kp.split(',')
except:
jlog.error("Invalid proposal string, ignoring: " + kp)
# could argue for info or warning debug level,
# but potential for a lot of unwanted output.
jlog.debug("Invalid proposal string, ignoring: " + kp)
continue
if k is not None:
# note that this operation will succeed as long as
# the key is in the wallet._script_map, which will
# be true if the key is at an HD index lower than
# the current wallet.index_cache
k = hextobin(k.decode('utf-8'))
k = hextobin(k)
addr = self.wallet_service.pubkey_to_addr(k)
if not self.wallet_service.is_known_addr(addr):
jlog.debug("Key not recognized as part of our "
"wallet, ignoring.")
continue
# TODO: interface/API of SNICKERWalletMixin would better take
# address as argument here, not privkey:
priv = self.wallet_service.get_key_from_addr(addr)
result = self.wallet_service.parse_proposal_to_signed_tx(
priv, p, self.acceptance_callback)
addr, p, self.acceptance_callback)
if result[0] is not None:
tx, tweak, out_spk = result
# We will: rederive the key as a sanity check,
# and see if it matches the claimed spk.
# Then, we import the key into the wallet
@ -172,45 +169,53 @@ class SNICKERReceiver(object):
# Finally, we co-sign, then push.
# (Again, simplest function: checks already passed,
# so do it automatically).
# TODO: the more sophisticated actions.
tweaked_key = btc.snicker_pubkey_tweak(k, tweak)
tweaked_spk = btc.pubkey_to_p2sh_p2wpkh_script(tweaked_key)
tweaked_spk = self.wallet_service.pubkey_to_script(
tweaked_key)
# Derive original path to make sure we change
# mixdepth:
source_path = self.wallet_service.script_to_path(
self.wallet_service.pubkey_to_script(k))
# NB This will give the correct source mixdepth independent
# of whether the key is imported or not:
source_mixdepth = self.wallet_service.get_details(
source_path)[0]
if not tweaked_spk == out_spk:
jlog.error("The spk derived from the pubkey does "
"not match the scriptPubkey returned from "
"the snicker module - code error.")
return False
# before import, we should derive the tweaked *private* key
# from the tweak, also:
tweaked_privkey = btc.snicker_privkey_tweak(priv, tweak)
if not btc.privkey_to_pubkey(tweaked_privkey) == tweaked_key:
jlog.error("Was not able to recover tweaked pubkey "
"from tweaked privkey - code error.")
jlog.error("Expected: " + bintohex(tweaked_key))
jlog.error("Got: " + bintohex(btc.privkey_to_pubkey(
tweaked_privkey)))
# from the tweak, also; failure of this critical sanity check
# is a code error. If the recreated private key matches, we
# import to the wallet. Note that this happens *before* pushing
# the coinjoin transaction to the network, which is advisably
# conservative (never possible to have broadcast a tx without
# having already stored the output's key).
success, msg = self.wallet_service.check_tweak_matches_and_import(
addr, tweak, tweaked_key, source_mixdepth)
if not success:
jlog.error(msg)
return False
# the recreated private key matches, so we import to the wallet,
# note that type = None here is because we use the same
# scriptPubKey type as the wallet, this has been implicitly
# checked above by deriving the scriptPubKey.
self.wallet_service.import_private_key(self.import_branch,
self.wallet_service._ENGINE.privkey_to_wif(tweaked_privkey))
# TODO condition on automatic brdcst or not
if not jm_single().bc_interface.pushtx(tx.serialize()):
jlog.error("Failed to broadcast SNICKER CJ.")
return False
self.successful_txs.append(tx)
return True
# this represents an error about state (or conceivably,
# an ultra-short window in which the spent utxo was
# consumed in another transaction), but not really
# an internal logic error, so we do NOT return False
jlog.error("Failed to broadcast SNICKER coinjoin: " +\
bintohex(tx.GetTxid()[::-1]))
jlog.info(btc.human_readable_transaction(tx))
jlog.info("Successfully broadcast SNICKER coinjoin: " +\
bintohex(tx.GetTxid()[::-1]))
self.log_successful_tx(tx)
else:
jlog.debug('Failed to parse proposal: ' + result[1])
continue
else:
# Some extra work to implement checking all possible
# keys.
raise NotImplementedError()
jlog.info("Proposal without pubkey was not processed.")
# Completed processing all proposals without any logic
# errors (whether the proposals were valid or accepted

154
jmclient/jmclient/wallet.py

@ -5,6 +5,7 @@ import functools
import collections
import numbers
import random
import copy
import base64
import json
from binascii import hexlify, unhexlify
@ -627,6 +628,24 @@ class BaseWallet(object):
mixdepth = self._get_mixdepth_from_path(path)
self._utxos.add_utxo(txid, index, path, value, mixdepth, height=height)
def inputs_consumed_by_tx(self, tx):
""" Given a transaction tx, checks
which if any of the inputs belonged to this
wallet, and returns [(index, CTxIn),..] for each.
"""
retval = []
for i, txin in len(tx.vin):
pub, msg = btc.extract_pubkey_from_witness(tx, i)
if not pub:
# this can certainly occur since other inputs
# may not be a spending a script we recognize;
# so we ignore the msg
continue
script = self.pubkey_to_script(pub)
if script in self._script_map:
retval.append((i, txin))
return retval
def process_new_tx(self, txd, height=None):
""" Given a newly seen transaction, deserialized as
CMutableTransaction txd,
@ -1274,16 +1293,45 @@ class SNICKERWalletMixin(object):
def __init__(self, storage, **kwargs):
super().__init__(storage, **kwargs)
def create_snicker_proposal(self, our_input, their_input, our_input_utxo,
def check_tweak_matches_and_import(self, addr, tweak, tweaked_key,
source_mixdepth):
""" Given the address from our HD wallet, the tweak bytes and
the tweaked public key, check the tweak correctly generates the
claimed tweaked public key. If not, (False, errmsg is returned),
if so, we import the private key to a mixdepth *distinct* from
source_mixdepth, and (True, None) is returned, if it works.
"""
try:
priv = self.get_key_from_addr(addr)
except:
return False, "Not our address"
tweaked_privkey = btc.snicker_privkey_tweak(priv, tweak)
if not btc.privkey_to_pubkey(tweaked_privkey) == tweaked_key:
hextk = bintohex(tweaked_key)
hexftpk = bintohex(btc.privkey_to_pubkey(tweaked_privkey))
return False, ("Was not able to recover tweaked pubkey "
"from tweaked privkey - code error."
"Expected: " + hextk + ", got: " + hexftpk)
# note that the WIF is not preserving SPK type, it's implied
# for the wallet.
try:
self.import_private_key((source_mixdepth + 1) % (self.mixdepth + 1),
self._ENGINE.privkey_to_wif(tweaked_privkey))
self.save()
except:
return False, "Failed to import private key."
return True, None
def create_snicker_proposal(self, our_inputs, their_input, our_input_utxos,
their_input_utxo, net_transfer, network_fee,
our_priv, their_pub, our_spk, change_spk,
encrypted=True, version_byte=1):
""" Creates a SNICKER proposal from the given transaction data.
This only applies to existing specification, i.e. SNICKER v 00 or 01.
This is only to be used for Joinmarket and only segwit wallets.
`our_input`, `their_input` - utxo format used in JM wallets,
`our_inputs`, `their_input` - utxo format used in JM wallets (first is list),
keyed by (tixd, n), as dicts (currently of single entry).
`our_input_utxo`, `their..` - type CTxOut (contains value, scriptPubKey)
`our_input_utxos`, `their..` - type CTxOut (contains value, scriptPubKey)
net_transfer - amount, after bitcoin transaction fee, transferred from
Proposer (our) to Receiver (their). May be negative.
network_fee - total bitcoin network transaction fee to be paid (so estimates
@ -1292,7 +1340,7 @@ class SNICKERWalletMixin(object):
the tweak as per the BIP. Note `their_pub` may or may not be associated with
the input of the receiver, so is specified here separately. Note also that
according to the BIP the privkey we use *must* be the one corresponding to
the input we provided, else (properly coded) Receivers will reject our
the first input we provided, else (properly coded) Receivers will reject our
proposal.
`our_spk` - a scriptPubKey for the Proposer coinjoin output
`change_spk` - a change scriptPubkey for the proposer as per BIP
@ -1309,61 +1357,67 @@ class SNICKERWalletMixin(object):
assert isinstance(self, PSBTWalletMixin)
# before constructing the bitcoin transaction we must calculate the output
# amounts
# TODO investigate arithmetic for negative transfer
if our_input_utxo.nValue - their_input_utxo.nValue - network_fee <= 0:
# find our total input value:
our_input_val = sum([x.nValue for x in our_input_utxos])
if our_input_val - their_input_utxo.nValue - network_fee <= 0:
raise Exception(
"Cannot create SNICKER proposal, Proposer input too small")
total_input_amount = our_input_utxo.nValue + their_input_utxo.nValue
total_output_amount = total_input_amount - network_fee
receiver_output_amount = their_input_utxo.nValue + net_transfer
proposer_output_amount = total_output_amount - receiver_output_amount
# we must also use ecdh to calculate the output scriptpubkey for the
# receiver
# First, check that `our_priv` corresponds to scriptPubKey in
# `our_input_utxo` to prevent callers from making useless proposals.
# first entry in `our_input_utxos` to prevent callers from making
# useless proposals.
expected_pub = btc.privkey_to_pubkey(our_priv)
expected_spk = self.pubkey_to_script(expected_pub)
assert our_input_utxo.scriptPubKey == expected_spk
assert our_input_utxos[0].scriptPubKey == expected_spk
# now we create the ecdh based tweak:
tweak_bytes = btc.ecdh(our_priv[:-1], their_pub)
tweaked_pub = btc.snicker_pubkey_tweak(their_pub, tweak_bytes)
# TODO: remove restriction to one scriptpubkey type
tweaked_spk = btc.pubkey_to_p2sh_p2wpkh_script(tweaked_pub)
wtype = self.get_txtype()
if wtype == "p2wpkh":
tweaked_spk = btc.pubkey_to_p2wpkh_script(tweaked_pub)
elif wtype == "p2sh-p2wpkh":
tweaked_spk = btc.pubkey_to_p2sh_p2wpkh_script(tweaked_pub)
else:
raise NotImplementedError("SNICKER only supports "
"p2sh-p2wpkh or p2wpkh outputs.")
tweaked_addr, our_addr, change_addr = [str(
btc.CCoinAddress.from_scriptPubKey(x)) for x in (
tweaked_spk, expected_spk, change_spk)]
# now we must construct the three outputs with correct output amounts.
outputs = [{"address": tweaked_addr, "value": receiver_output_amount}]
outputs.append({"address": our_addr, "value": receiver_output_amount})
outputs.append({"address": change_addr,
"value": total_output_amount - 2 * receiver_output_amount})
tweaked_spk, our_spk, change_spk)]
outputs = btc.construct_snicker_outputs(our_input_val,
their_input_utxo.nValue,
tweaked_addr,
our_addr,
change_addr,
network_fee,
net_transfer)
assert all([x["value"] > 0 for x in outputs])
# version and locktime as currently specified in the BIP
# for 0/1 version SNICKER.
tx = btc.make_shuffled_tx([our_input, their_input], outputs,
# for 0/1 version SNICKER. (Note the locktime is partly because
# of expected delays).
# We do not use `make_shuffled_tx` because we need a specific order
# on the input side: the receiver's is placed randomly, but the first
# *of ours* is the one used for ECDH.
all_inputs = copy.deepcopy(our_inputs)
insertion_index = random.randrange(len(all_inputs)+1)
all_inputs.insert(insertion_index, their_input)
all_input_utxos = copy.deepcopy(our_input_utxos)
all_input_utxos.insert(insertion_index, their_input_utxo)
# for outputs, we must shuffle as normal:
random.shuffle(outputs)
tx = btc.mktx(all_inputs, outputs,
version=2, locktime=0)
# we need to know which randomized input is ours:
our_index = -1
for i, inp in enumerate(tx.vin):
if our_input == (inp.prevout.hash[::-1], inp.prevout.n):
our_index = i
assert our_index in [0, 1], "code error: our input not in tx"
spent_outs = [our_input_utxo, their_input_utxo]
if our_index == 1:
spent_outs = spent_outs[::-1]
# create the psbt and then sign our input.
snicker_psbt = self.create_psbt_from_tx(tx, spent_outs=spent_outs)
snicker_psbt = self.create_psbt_from_tx(tx,
spent_outs=all_input_utxos)
# having created the PSBT, sign our input
# TODO this requires bitcointx updated minor version else fails
signed_psbt_and_signresult, err = self.sign_psbt(
snicker_psbt.serialize(), with_sign_result=True)
assert err is None
signresult, partially_signed_psbt = signed_psbt_and_signresult
assert signresult.num_inputs_signed == 1
assert signresult.num_inputs_final == 1
assert signresult.num_inputs_signed == len(our_inputs)
assert signresult.num_inputs_final == len(our_inputs)
assert not signresult.is_final
snicker_serialized_message = btc.SNICKER_MAGIC_BYTES + bytes(
[version_byte]) + btc.SNICKER_FLAG_NONE + tweak_bytes + \
@ -1376,7 +1430,7 @@ class SNICKERWalletMixin(object):
# we apply ECIES in the form given by the BIP.
return btc.ecies_encrypt(snicker_serialized_message, their_pub)
def parse_proposal_to_signed_tx(self, privkey, proposal,
def parse_proposal_to_signed_tx(self, addr, proposal,
acceptance_callback):
""" Given a candidate privkey (binary and compressed format),
and a candidate encrypted SNICKER proposal, attempt to decrypt
@ -1400,6 +1454,11 @@ class SNICKERWalletMixin(object):
"""
assert isinstance(self, PSBTWalletMixin)
try:
privkey = self.get_key_from_addr(addr)
except:
return None, "Could not derive privkey from address"
our_pub = btc.privkey_to_pubkey(privkey)
if len(proposal) < 5:
@ -1445,16 +1504,16 @@ class SNICKERWalletMixin(object):
# which populates the 'is_signed' info fields for us. Note that
# we do not use the PSBTWalletMixin.sign_psbt() which automatically
# signs with our keys.
if not len(utx.vin) == 2:
return None, "PSBT proposal does not contain 2 inputs."
if len(utx.vin) < 2:
return None, "PSBT proposal does not contain at least 2 inputs."
testsignresult = cpsbt.sign(btc.KeyStore(), finalize=False)
print("got sign result: ", testsignresult)
# Note: "num_inputs_signed" refers to how many *we* signed,
# which is obviously none here as we provided no keys.
if not (testsignresult.num_inputs_signed == 0 and \
testsignresult.num_inputs_final == 1 and \
testsignresult.num_inputs_final == len(utx.vin)-1 and \
not testsignresult.is_final):
return None, "PSBT proposal does not contain 1 signature."
return None, "PSBT proposal is not fully signed by proposer."
# Validate that we own one SNICKER style output:
spk = btc.verify_snicker_output(utx, our_pub, tweak_bytes)
@ -1477,12 +1536,17 @@ class SNICKERWalletMixin(object):
# To allow the acceptance callback to assess validity, we must identify
# which input is ours and which is(are) not.
# TODO This check may (will) change if we allow non-p2sh-pwpkh inputs:
# See https://github.com/Simplexum/python-bitcointx/issues/39
# for background on why this is different per wallet type
unsigned_index = -1
for i, psbtinputsigninfo in enumerate(testsignresult.inputs_info):
if psbtinputsigninfo is None:
unsigned_index = i
break
if psbtinputsigninfo.num_new_sigs == 0 and \
psbtinputsigninfo.is_final == False:
unsigned_index = i
break
assert unsigned_index != -1
# All validation checks passed. We now check whether the
#transaction is acceptable according to the caller:
@ -1499,7 +1563,7 @@ class SNICKERWalletMixin(object):
return None, "Unable to sign proposed PSBT, reason: " + err
signresult, signed_psbt = signresult_and_signedpsbt
assert signresult.num_inputs_signed == 1
assert signresult.num_inputs_final == 2
assert signresult.num_inputs_final == len(utx.vin)
assert signresult.is_final
# we now know the transaction is valid and fully signed; return to caller,
# along with supporting data for this tx:

39
jmclient/jmclient/wallet_service.py

@ -663,6 +663,45 @@ class WalletService(Service):
self.wallet.set_next_index(mixdepth, address_type, highest_used_index + 1)
def get_all_transactions(self):
""" Get all transactions (spending or receiving) that
are currently recorded by our blockchain interface as relating
to this wallet, as a list.
"""
res = []
processed_txids = []
for r in self.bci._yield_transactions(
self.get_wallet_name()):
txid = r["txid"]
if txid not in processed_txids:
tx = self.bci.get_transaction(hextobin(txid))
res.append(self.bci.get_deser_from_gettransaction(tx))
processed_txids.append(txid)
return res
def get_transaction(self, txid):
""" If the transaction for txid is an in-wallet
transaction, will return a CTransaction object for it;
if not, will return None.
"""
tx = self.bci.get_transaction(txid)
if not tx:
return None
return self.bci.get_deser_from_gettransaction(tx)
def get_block_height(self, blockhash):
return self.bci.get_block_height(blockhash)
def get_transaction_block_height(self, tx):
""" Given a CTransaction object tx, return
the block height at which it was mined, or False
if it is not found in the blockchain (including if
it is not a wallet tx and so can't be queried).
"""
txid = tx.GetTxid()[::-1]
return self.get_block_height(self.bci.get_transaction(
txid)["blockhash"])
def sync_addresses(self):
""" Triggered by use of --recoversync option in scripts,
attempts a full scan of the blockchain without assuming

17
jmclient/jmclient/yieldgenerator.py

@ -7,9 +7,10 @@ import abc
from twisted.python.log import startLogging
from optparse import OptionParser
from jmbase import get_log
from jmclient import Maker, jm_single, load_program_config, \
JMClientProtocolFactory, start_reactor, calc_cj_fee, \
WalletService, add_base_options
from jmclient import (Maker, jm_single, load_program_config,
JMClientProtocolFactory, start_reactor, calc_cj_fee,
WalletService, add_base_options, SNICKERReceiver,
SNICKERClientProtocolFactory)
from .wallet_utils import open_test_wallet_maybe, get_wallet_path
from jmbase.support import EXIT_ARGERROR, EXIT_FAILURE
@ -310,11 +311,17 @@ def ygmain(ygclass, nickserv_password='', gaplimit=6):
cjfee_factor, size_factor])
jlog.info('starting yield generator')
clientfactory = JMClientProtocolFactory(maker, proto_type="MAKER")
if jm_single().config.get("SNICKER", "enabled") == "true":
snicker_r = SNICKERReceiver(wallet_service)
servers = jm_single().config.get("SNICKER", "servers").split(",")
snicker_factory = SNICKERClientProtocolFactory(snicker_r, servers)
else:
snicker_factory = None
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", "signet"]:
startLogging(sys.stdout)
start_reactor(jm_single().config.get("DAEMON", "daemon_host"),
jm_single().config.getint("DAEMON", "daemon_port"),
clientfactory, daemon=daemon)
clientfactory, snickerfactory=snicker_factory,
daemon=daemon)

20
jmclient/test/test_snicker.py

@ -11,7 +11,6 @@ from jmbase import get_log, bintohex
from jmclient import (load_test_config, estimate_tx_fee, SNICKERReceiver,
direct_send, SegwitLegacyWallet, BaseWallet)
TEST_PROPOSALS_FILE = "test_proposals.txt"
log = get_log()
@pytest.mark.parametrize(
@ -33,8 +32,7 @@ def test_snicker_e2e(setup_snicker, nw, wallet_structures,
"""
# TODO: Make this test work with native segwit wallets
wallets = make_wallets(nw, wallet_structures, mean_amt, sdev_amt,
wallet_cls=SegwitLegacyWallet)
wallets = make_wallets(nw, wallet_structures, mean_amt, sdev_amt)
for w in wallets.values():
w['wallet'].sync_wallet(fast=True)
print(wallets)
@ -82,7 +80,8 @@ def test_snicker_e2e(setup_snicker, nw, wallet_structures,
their_input = (txid1, txid1_index)
our_input_utxo = btc.CMutableTxOut(prop_utxo['value'],
prop_utxo['script'])
fee_est = estimate_tx_fee(len(tx.vin), 2)
fee_est = estimate_tx_fee(len(tx.vin), 2,
txtype=wallet_p.get_txtype())
change_spk = wallet_p.get_new_script(0, BaseWallet.ADDRESS_TYPE_INTERNAL)
encrypted_proposals = []
@ -92,8 +91,8 @@ def test_snicker_e2e(setup_snicker, nw, wallet_structures,
# not just one guessed output, if desired.
encrypted_proposals.append(
wallet_p.create_snicker_proposal(
our_input, their_input,
our_input_utxo,
[our_input], their_input,
[our_input_utxo],
tx.vout[txid1_index],
net_transfer,
fee_est,
@ -102,11 +101,8 @@ def test_snicker_e2e(setup_snicker, nw, wallet_structures,
prop_utxo['script'],
change_spk,
version_byte=1) + b"," + bintohex(p).encode('utf-8'))
with open(TEST_PROPOSALS_FILE, "wb") as f:
f.write(b"\n".join(encrypted_proposals))
sR = SNICKERReceiver(wallet_r)
sR.proposals_source = TEST_PROPOSALS_FILE # avoid clashing with mainnet
sR.poll_for_proposals()
sR.process_proposals([x.decode("utf-8") for x in encrypted_proposals])
assert len(sR.successful_txs) == 1
wallet_r.process_new_tx(sR.successful_txs[0])
end_utxos = wallet_r.get_all_utxos()
@ -117,7 +113,3 @@ def test_snicker_e2e(setup_snicker, nw, wallet_structures,
@pytest.fixture(scope="module")
def setup_snicker(request):
load_test_config()
def teardown():
if os.path.exists(TEST_PROPOSALS_FILE):
os.remove(TEST_PROPOSALS_FILE)
request.addfinalizer(teardown)

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)
start_daemon, SNICKERDaemonServerProtocolFactory)
from .protocol import (COMMAND_PREFIX, ORDER_KEYS, NICK_HASH_LENGTH,
NICK_MAX_ENCODED, JM_VERSION, JOINMARKET_NICK_HEADER)
from .message_channel import MessageChannelCollection

191
jmdaemon/jmdaemon/daemon_protocol.py

@ -9,14 +9,20 @@ from .protocol import (COMMAND_PREFIX, ORDER_KEYS, NICK_HASH_LENGTH,
COMMITMENT_PREFIXES)
from .irc import IRCMessageChannel
from jmbase import hextobin
from jmbase import (hextobin, is_hs_uri, get_tor_agent,
get_nontor_agent, BytesProducer, wrapped_urlparse)
from jmbase.commands import *
from twisted.protocols import amp
from twisted.internet import reactor, ssl
from twisted.internet.protocol import ServerFactory
from twisted.internet.error import (ConnectionLost, ConnectionAborted,
ConnectionClosed, ConnectionDone)
from twisted.web.http_headers import Headers
from twisted.web.client import ResponseFailed, readBody
from txtorcon.socks import HostUnreachableError
from twisted.python import log
import urllib.parse as urlparse
from urllib.parse import urlencode
import json
import threading
import os
@ -87,6 +93,186 @@ def check_utxo_blacklist(commitment, persist=False):
class JMProtocolError(Exception):
pass
class HTTPPassThrough(amp.AMP):
""" This class supports passing through
requests over HTTPS or over a socks proxy to a remote
onion service, or multiple.
"""
def on_INIT(self, netconfig):
""" The network config must be passed in json
and contains these fields:
socks5_host
socks5_proxy
servers (comma separated list)
tls_whitelist (comma separated list)
filterconfig (not yet defined)
credentials (not yet defined)
"""
netconfig = json.loads(netconfig)
self.socks5_host = netconfig["socks5_host"]
self.socks5_port = int(netconfig["socks5_port"])
self.servers = [a for a in netconfig["servers"] if a != ""]
self.tls_whitelist = [a for a in netconfig["tls_whitelist"].split(
",") if a != ""]
def getAgentDestination(self, server, params=None):
tor_url_data = is_hs_uri(server)
if tor_url_data:
# note: SSL over Tor not supported at the moment:
agent = get_tor_agent(self.socks5_host, self.socks5_port)
else:
agent = get_nontor_agent(self.tls_whitelist)
destination_url = server.encode("utf-8")
url_parts = list(wrapped_urlparse(destination_url))
if params:
url_parts[4] = urlencode(params).encode("utf-8")
destination_url = urlparse.urlunparse(url_parts)
return (agent, destination_url)
def getRequest(self, server, success_callback, url=None, headers=None):
""" Make GET request to server server, if response received OK,
passed to success_callback, which must have function signature
(response, server).
"""
agent, destination_url = self.getAgentDestination(server)
if url:
destination_url = destination_url + url
# Deliberately sending NO headers; this could be a tricky point
# for anonymity of users, as much boilerplate code will not create
# requests that look like this.
headers = Headers({}) if not headers else headers
d = agent.request(b"GET", destination_url, headers)
d.addCallback(success_callback, server)
# 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, ConnectionLost)
log.msg(failure.value)
d.addErrback(noResponse)
def postRequest(self, body, server, success_callback, headers=None):
""" Pass body of post request as string, will be encoded here.
"""
agent, destination_url = self.getAgentDestination(server)
body = BytesProducer(body.encode("utf-8"))
d = agent.request(b"POST", destination_url,
Headers({}), bodyProducer=body)
d.addCallback(success_callback, server)
# 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, ConnectionLost)
log.msg(failure.value)
self.callRemote(SNICKERProposalsServerResponse,
response="failure to connect",
server=server)
d.addErrback(noResponse)
def checkClientResponse(self, response):
"""A generic check of client acceptance; any failure
is considered criticial.
"""
if 'accepted' not in response or not response['accepted']:
reactor.stop() #pragma: no cover
def defaultErrback(self, failure):
"""TODO better network error handling.
"""
failure.trap(ConnectionAborted, ConnectionClosed,
ConnectionDone, ConnectionLost)
def defaultCallbacks(self, d):
d.addCallback(self.checkClientResponse)
d.addErrback(self.defaultErrback)
class SNICKERDaemonServerProtocol(HTTPPassThrough):
@SNICKERProposerPostProposals.responder
def on_SNICKER_PROPOSER_POST_PROPOSALS(self, proposals, server):
""" Receives a list of proposals to be posted to a specific
server.
"""
self.postRequest(proposals, server, self.receive_proposals_response)
return {"accepted": True}
def receive_proposals_response(self, response, server):
d = readBody(response)
if int(response.code) != 200:
log.msg("Server returned error code: " + str(response.code))
d = self.callRemote(SNICKERServerError, server=server,
errorcode=response.code)
self.defaultCallbacks(d)
return
d.addCallback(self.process_proposals_response_from_server, server)
@SNICKERReceiverInit.responder
def on_SNICKER_RECEIVER_INIT(self, netconfig):
self.on_INIT(netconfig)
d = self.callRemote(SNICKERReceiverUp)
self.defaultCallbacks(d)
return {'accepted': True}
@SNICKERProposerInit.responder
def on_SNICKER_PROPOSER_INIT(self, netconfig):
self.on_INIT(netconfig)
d = self.callRemote(SNICKERProposerUp)
self.defaultCallbacks(d)
return {"accepted": True}
@SNICKERReceiverGetProposals.responder
def on_SNICKER_RECEIVER_GET_PROPOSALS(self):
for server in self.servers:
self.getRequest(server, self.receive_proposals_from_server)
return {'accepted': True}
def receive_proposals_from_server(self, response, server):
""" Parses the response from one server.
"""
# if the response code is not 200 OK, we must let the client
# know that this server is not responding as expected.
if int(response.code) != 200:
d = self.callRemote(SNICKERServerError,
server=server,
errorcode = response.code)
self.defaultCallbacks(d)
return
d = readBody(response)
d.addCallback(self.process_proposals_from_server, server)
@SNICKERRequestPowTarget.responder
def on_SNICKER_REQUEST_POW_TARGET(self, server):
self.getRequest(server, self.receive_pow_target,
url=b"/target")
return {"accepted": True}
def receive_pow_target(self, response, server):
d = readBody(response)
d.addCallback(self.process_pow_target, server)
def process_pow_target(self, response_body, server):
d = self.callRemote(SNICKERReceivePowTarget,
server=server,
targetbits=int(response_body.decode("utf-8")))
self.defaultCallbacks(d)
def process_proposals_from_server(self, response, server):
d = self.callRemote(SNICKERReceiverProposals,
proposals=response.decode("utf-8"),
server=server)
self.defaultCallbacks(d)
def process_proposals_response_from_server(self, response_body, server):
d = self.callRemote(SNICKERProposalsServerResponse,
response=response_body.decode("utf-8"),
server=server)
self.defaultCallbacks(d)
class JMDaemonServerProtocol(amp.AMP, OrderbookWatch):
def __init__(self, factory):
@ -647,6 +833,9 @@ class JMDaemonServerProtocolFactory(ServerFactory):
def buildProtocol(self, addr):
return JMDaemonServerProtocol(self)
class SNICKERDaemonServerProtocolFactory(ServerFactory):
protocol = SNICKERDaemonServerProtocol
def start_daemon(host, port, factory, usessl=False, sslkey=None, sslcert=None):
if usessl:
assert sslkey

3
scripts/joinmarketd.py

@ -14,7 +14,8 @@ def startup_joinmarketd(host, port, usessl, factories=None,
"""
startLogging(sys.stdout)
if not factories:
factories = [jmdaemon.JMDaemonServerProtocolFactory(),]
factories = [jmdaemon.JMDaemonServerProtocolFactory(),
jmdaemon.SNICKERDaemonServerProtocolFactory()]
for factory in factories:
jmdaemon.start_daemon(host, port, factory, usessl,
'./ssl/key.pem', './ssl/cert.pem')

226
scripts/snicker/create-snicker-proposal.py

@ -0,0 +1,226 @@
#!/usr/bin/env python3
description="""A rudimentary implementation of creation of a SNICKER proposal.
**THIS TOOL DOES NOT SCAN FOR CANDIDATE TRANSACTIONS**
It only creates proposals on candidate transactions (individually)
that you have already found.
Input: the user's wallet, mixdepth to source their (1) coin from,
and a hex encoded pre-existing bitcoin transaction (fully signed)
as target.
User chooses the input to source the pubkey from, and the output
to use to create the SNICKER coinjoin. Tx fees are sourced from
the config, and the user specifies interactively the number of sats
to award the receiver (can be negative).
Once the proposal is created, it is uploaded to the servers as per
the `servers` setting in `joinmarket.cfg`, unless the -n option is
specified (see help for options), in which case the proposal is
output to stdout in the same string format: base64proposal,hexpubkey.
"""
import sys
from optparse import OptionParser
from jmbase import BytesProducer, bintohex, jmprint, hextobin, \
EXIT_ARGERROR, EXIT_FAILURE, EXIT_SUCCESS, get_pow
import jmbitcoin as btc
from jmclient import (RegtestBitcoinCoreInterface, process_shutdown,
jm_single, load_program_config, check_regtest, select_one_utxo,
estimate_tx_fee, SNICKERReceiver, add_base_options, get_wallet_path,
open_test_wallet_maybe, WalletService, SNICKERClientProtocolFactory,
start_reactor, JMPluginService)
from jmclient.configure import get_log
log = get_log()
def main():
parser = OptionParser(
usage=
'usage: %prog [options] walletname hex-tx input-index output-index net-transfer',
description=description
)
add_base_options(parser)
parser.add_option('-m',
'--mixdepth',
action='store',
type='int',
dest='mixdepth',
help='mixdepth/account to spend from, default=0',
default=0)
parser.add_option(
'-g',
'--gap-limit',
action='store',
type='int',
dest='gaplimit',
default = 6,
help='gap limit for Joinmarket wallet, default 6.'
)
parser.add_option(
'-n',
'--no-upload',
action='store_true',
dest='no_upload',
default=False,
help="if set, we don't upload the new proposal to the servers"
)
parser.add_option(
'-f',
'--txfee',
action='store',
type='int',
dest='txfee',
default=-1,
help='Bitcoin miner tx_fee to use for transaction(s). A number higher '
'than 1000 is used as "satoshi per KB" tx fee. A number lower than that '
'uses the dynamic fee estimation of your blockchain provider as '
'confirmation target. This temporarily overrides the "tx_fees" setting '
'in your joinmarket.cfg. Works the same way as described in it. Check '
'it for examples.')
parser.add_option('-a',
'--amtmixdepths',
action='store',
type='int',
dest='amtmixdepths',
help='number of mixdepths in wallet, default 5',
default=5)
(options, args) = parser.parse_args()
snicker_plugin = JMPluginService("SNICKER")
load_program_config(config_path=options.datadir,
plugin_services=[snicker_plugin])
if len(args) != 5:
jmprint("Invalid arguments, see --help")
sys.exit(EXIT_ARGERROR)
wallet_name, hextx, input_index, output_index, net_transfer = args
input_index, output_index, net_transfer = [int(x) for x in [
input_index, output_index, net_transfer]]
check_regtest()
# If tx_fees are set manually by CLI argument, override joinmarket.cfg:
if int(options.txfee) > 0:
jm_single().config.set("POLICY", "tx_fees", str(options.txfee))
max_mix_depth = max([options.mixdepth, options.amtmixdepths - 1])
wallet_path = get_wallet_path(wallet_name, None)
wallet = open_test_wallet_maybe(
wallet_path, wallet_name, max_mix_depth,
wallet_password_stdin=options.wallet_password_stdin,
gap_limit=options.gaplimit)
wallet_service = WalletService(wallet)
if wallet_service.rpc_error:
sys.exit(EXIT_FAILURE)
snicker_plugin.start_plugin_logging(wallet_service)
# in this script, we need the wallet synced before
# logic processing for some paths, so do it now:
while not wallet_service.synced:
wallet_service.sync_wallet(fast=not options.recoversync)
# the sync call here will now be a no-op:
wallet_service.startService()
# now that the wallet is available, we can construct a proposal
# before encrypting it:
originating_tx = btc.CMutableTransaction.deserialize(hextobin(hextx))
txid1 = originating_tx.GetTxid()[::-1]
# the proposer wallet needs to choose a single utxo, from his selected
# mixdepth, that is bigger than the output amount of tx1 at the given
# index.
fee_est = estimate_tx_fee(2, 3, txtype=wallet_service.get_txtype())
amt_required = originating_tx.vout[output_index].nValue + fee_est
prop_utxo_dict = wallet_service.select_utxos(options.mixdepth,
amt_required)
prop_utxos = list(prop_utxo_dict)
prop_utxo_vals = [prop_utxo_dict[x] for x in prop_utxos]
# get the private key for that utxo
priv = wallet_service.get_key_from_addr(
wallet_service.script_to_addr(prop_utxo_vals[0]['script']))
# construct the arguments for the snicker proposal:
our_input_utxos = [btc.CMutableTxOut(x['value'],
x['script']) for x in prop_utxo_vals]
# destination must be a different mixdepth:
prop_destn_spk = wallet_service.get_new_script((
options.mixdepth + 1) % (wallet_service.mixdepth + 1), 1)
change_spk = wallet_service.get_new_script(options.mixdepth, 1)
their_input = (txid1, output_index)
# we also need to extract the pubkey of the chosen input from
# the witness; we vary this depending on our wallet type:
pubkey, msg = btc.extract_pubkey_from_witness(originating_tx, input_index)
if not pubkey:
log.error("Failed to extract pubkey from transaction: {}".format(msg))
sys.exit(EXIT_FAILURE)
encrypted_proposal = wallet_service.create_snicker_proposal(
prop_utxos, their_input,
our_input_utxos,
originating_tx.vout[output_index],
net_transfer,
fee_est,
priv,
pubkey,
prop_destn_spk,
change_spk,
version_byte=1) + b"," + bintohex(pubkey).encode('utf-8')
if options.no_upload:
jmprint(encrypted_proposal.decode("utf-8"))
sys.exit(EXIT_SUCCESS)
nodaemon = jm_single().config.getint("DAEMON", "no_daemon")
daemon = True if nodaemon == 1 else False
snicker_client = SNICKERPostingClient([encrypted_proposal])
servers = jm_single().config.get("SNICKER", "servers").split(",")
snicker_pf = SNICKERClientProtocolFactory(snicker_client, servers)
start_reactor(jm_single().config.get("DAEMON", "daemon_host"),
jm_single().config.getint("DAEMON", "daemon_port"),
None, snickerfactory=snicker_pf,
daemon=daemon)
class SNICKERPostingClient(object):
""" A client object which stores proposals
ready to be sent to the server/servers, and appends
proof of work to them according to the server's rules.
"""
def __init__(self, pre_nonce_proposals, info_callback=None,
end_requests_callback=None):
# the encrypted proposal without the nonce appended for PoW
self.pre_nonce_proposals = pre_nonce_proposals
self.proposals_with_nonce = []
# callback for conveying information messages
if not info_callback:
self.info_callback = self.default_info_callback
else:
self.info_callback = info_callback
# callback for action at the end of a set of
# submissions to servers; by default, this
# is "one-shot"; we submit to all servers in the
# config, then shut down the script.
if not end_requests_callback:
self.end_requests_callback = \
self.default_end_requests_callback
def default_end_requests_callback(self, response):
process_shutdown()
def default_info_callback(self, msg):
jmprint(msg)
def get_proposals(self, targetbits):
# the data sent to the server is base64encryptedtx,key,nonce; the nonce
# part is generated in get_pow().
for p in self.pre_nonce_proposals:
nonceval, preimage, niter = get_pow(p+b",", nbits=targetbits,
truncate=32)
log.debug("Got POW preimage: {}".format(preimage.decode("utf-8")))
if nonceval is None:
log.error("Failed to generate proof of work, message:{}".format(
preimage))
sys.exit(EXIT_FAILURE)
self.proposals_with_nonce.append(preimage)
return self.proposals_with_nonce
if __name__ == "__main__":
main()
jmprint('done', "success")

89
scripts/snicker/receive-snicker.py

@ -0,0 +1,89 @@
#!/usr/bin/env python3
from optparse import OptionParser
import sys
from jmbase import get_log, jmprint
from jmclient import (jm_single, load_program_config, WalletService,
open_test_wallet_maybe, get_wallet_path,
check_regtest, add_base_options, start_reactor,
SNICKERClientProtocolFactory, SNICKERReceiver,
JMPluginService)
from jmbase.support import EXIT_ARGERROR
jlog = get_log()
def receive_snicker_main():
usage = """ Use this script to receive proposals for SNICKER
coinjoins, parse them and then broadcast coinjoins
that fit your criteria. See the SNICKER section of
joinmarket.cfg to set your criteria.
The only argument to this script is the (JM) wallet
file against which to check.
Once all proposals have been parsed, the script will
quit.
Usage: %prog [options] wallet file [proposal]
"""
parser = OptionParser(usage=usage)
add_base_options(parser)
parser.add_option('-g', '--gap-limit', action='store', type="int",
dest='gaplimit', default=6,
help='gap limit for wallet, default=6')
parser.add_option('-m', '--mixdepth', action='store', type='int',
dest='mixdepth', default=0,
help="mixdepth to source coins from")
parser.add_option('-a',
'--amtmixdepths',
action='store',
type='int',
dest='amtmixdepths',
help='number of mixdepths in wallet, default 5',
default=5)
parser.add_option(
'-n',
'--no-upload',
action='store_true',
dest='no_upload',
default=False,
help="if set, we read the proposal from the command line"
)
(options, args) = parser.parse_args()
if len(args) < 1:
parser.error('Needs a wallet file as argument')
sys.exit(EXIT_ARGERROR)
wallet_name = args[0]
snicker_plugin = JMPluginService("SNICKER")
load_program_config(config_path=options.datadir,
plugin_services=[snicker_plugin])
check_regtest()
wallet_path = get_wallet_path(wallet_name, None)
max_mix_depth = max([options.mixdepth, options.amtmixdepths - 1])
wallet = open_test_wallet_maybe(
wallet_path, wallet_name, max_mix_depth,
wallet_password_stdin=options.wallet_password_stdin,
gap_limit=options.gaplimit)
wallet_service = WalletService(wallet)
snicker_plugin.start_plugin_logging(wallet_service)
while not wallet_service.synced:
wallet_service.sync_wallet(fast=not options.recoversync)
wallet_service.startService()
nodaemon = jm_single().config.getint("DAEMON", "no_daemon")
daemon = True if nodaemon == 1 else False
snicker_r = SNICKERReceiver(wallet_service)
if options.no_upload:
proposal = args[1]
snicker_r.process_proposals([proposal])
return
servers = jm_single().config.get("SNICKER", "servers").split(",")
snicker_pf = SNICKERClientProtocolFactory(snicker_r, servers, oneshot=True)
start_reactor(jm_single().config.get("DAEMON", "daemon_host"),
jm_single().config.getint("DAEMON", "daemon_port"),
None, snickerfactory=snicker_pf,
daemon=daemon)
if __name__ == "__main__":
receive_snicker_main()
jmprint('done')

115
scripts/snicker/snicker-finder.py

@ -0,0 +1,115 @@
#!/usr/bin/env python3
description="""Find SNICKER candidate transactions on
the blockchain.
Using a connection to Bitcoin Core, which allows retrieving
full blocks, this script will list the transaction IDs of
transactions that fit the pattern of SNICKER, as codified in
https://gist.github.com/AdamISZ/2c13fb5819bd469ca318156e2cf25d79
and as checked in the `jmbitcoin.snicker` module function
`is_snicker_tx`, and also optionally, transactions that fit the
pattern of Joinmarket coinjoins (see -j).
Pass a starting and finishing block value as argument. If the
finishing block is not provided, it is assumed to be the latest
block.
**Note that this is slow.**
This script does *NOT* require a wallet, but it does require
a connection to Core, so does not work with `no-blockchain`.
Note that this script obviates the need to have txindex enabled
in Bitcoin Core in order to get full transactions, since it
parses the raw blocks.
"""
from optparse import OptionParser
from jmbase import bintohex, EXIT_ARGERROR, EXIT_FAILURE, jmprint
import jmbitcoin as btc
from jmclient import (jm_single, add_base_options, load_program_config,
check_regtest)
from jmclient.configure import get_log
log = get_log()
def found_str(ttype, tx, b):
return "Found {} transaction: {} in block: {}".format(
ttype, bintohex(tx.GetTxid()[::-1]), b)
def write_candidate_to_file(ttype, candidate, blocknum, unspents, filename):
""" Appends the details for the candidate
transaction to the chosen textfile.
"""
with open(filename, "a") as f:
f.write(found_str(ttype, candidate, blocknum) + "\n")
f.write(btc.human_readable_transaction(candidate)+"\n")
f.write("Full transaction hex for creating a proposal is "
"found in the above.\n")
f.write("The unspent indices are: " + " ".join(
(str(u) for u in unspents)) + "\n")
def main():
parser = OptionParser(
usage=
'usage: %prog [options] startingblock [endingblock]',
description=description
)
add_base_options(parser)
parser.add_option('-f',
'--filename',
action='store',
type='str',
dest='candidate_file_name',
help='filename to write details of candidate '
'transactions, default ./candidates.txt',
default='candidates.txt')
parser.add_option(
'-j',
'--include-jm',
action='store_true',
dest='include_joinmarket',
default=True,
help="scan for Joinmarket coinjoin outputs, as well as SNICKER.")
(options, args) = parser.parse_args()
load_program_config(config_path=options.datadir)
if len(args) not in [1,2]:
log.error("Invalid arguments, see --help")
sys.exit(EXIT_ARGERROR)
startblock = int(args[0])
if len(args) == 1:
endblock = jm_single().bc_interface.get_current_block_height()
else:
endblock = int(args[1])
check_regtest()
for b in range(startblock, endblock + 1):
block = jm_single().bc_interface.get_block(b)
for t in btc.get_transactions_in_block(block):
if btc.is_snicker_tx(t):
log.info(found_str("SNICKER", t, b))
# get list of unspent outputs; if empty, skip,
# otherwise, persist to candidate file with unspents
# marked.
unspents = jm_single().bc_interface.get_unspent_indices(t)
if len(unspents) == 0:
continue
write_candidate_to_file("SNICKER", t, b, unspents,
options.candidate_file_name)
# note elif avoids wasting computation if we already found SNICKER:
elif options.include_joinmarket:
cj_amount, n = btc.is_jm_tx(t)
# here we don't care about the stats; the tx is printed anyway.
if cj_amount:
log.info(found_str("Joinmarket coinjoin", t, b))
unspents = jm_single().bc_interface.get_unspent_indices(t)
if len(unspents) == 0:
continue
write_candidate_to_file("Joinmarket coinjoin", t, b,
unspents, options.candidate_file_name)
log.info("Finished processing block: {}".format(b))
if __name__ == "__main__":
main()
jmprint('done', "success")

206
scripts/snicker/snicker-recovery.py

@ -0,0 +1,206 @@
#!/usr/bin/env python3
description="""This tool is to be used in a case where
a user has a BIP39 seedphrase but has no wallet file and no
backup of imported keys, and they had earlier used SNICKER.
This will usually not be needed as you should keep a backup
of your *.jmdat joinmarket wallet file, which contains all
this information.
Before using this tool, you need to do:
`python wallet-tool.py recover` to recover the wallet from
seed, and then:
`bitcoin-cli rescanblockchain ...`
for an appropriate range of blocks in order for Bitcoin Core
to get a record of the transactions that happened with your
HD addresses.
Then, you can run this script to find all the SNICKER-generated
imported addresses that either did have, or still do have, keys
and have them imported back into the wallet.
(Note that this of course won't find any other non-SNICKER imported
keys, so as a reminder, *always* back up either jmdat wallet files,
or at least, the imported keys themselves.)
"""
from optparse import OptionParser
from jmbase import bintohex, EXIT_ARGERROR, EXIT_FAILURE, jmprint
import jmbitcoin as btc
from jmclient import (jm_single, add_base_options, load_program_config,
check_regtest, get_wallet_path, open_test_wallet_maybe,
WalletService)
from jmclient.configure import get_log
log = get_log()
def get_pubs_and_indices_of_inputs(tx, wallet_service, ours):
""" Returns a list of items (pubkey, index),
one per input at index index, in transaction
tx, spending pubkey pubkey, if the input is ours
if ours is True, else returns the complementary list.
"""
our_ins = []
not_our_ins = []
for i in range(len(tx.vin)):
pub, msg = btc.extract_pubkey_from_witness(tx, i)
if not pub:
continue
if not wallet_service.is_known_script(
wallet_service.pubkey_to_script(pub)):
not_our_ins.append((pub, i))
else:
our_ins.append((pub, i))
if ours:
return our_ins
else:
return not_our_ins
def get_pubs_and_indices_of_ancestor_inputs(txin, wallet_service, ours):
""" For a transaction input txin, retrieve the spent transaction,
and iterate over its inputs, returning a list of items
(pubkey, index) all of which belong to us if ours is True,
or else the complementary set.
Note: the ancestor transactions must be in the dict txlist, which is
keyed by txid and values are CTransaction; if not,
an error occurs. This is assumed to be the case because all ancestors
must be either in the set returned by wallet_sync, or else in the set
of SNICKER transactions found so far.
"""
tx = wallet_service.get_transaction(txin.prevout.hash[::-1])
return get_pubs_and_indices_of_inputs(tx, wallet_service, ours=ours)
def main():
parser = OptionParser(
usage=
'usage: %prog [options] walletname',
description=description
)
parser.add_option('-m', '--mixdepth', action='store', type='int',
dest='mixdepth', default=0,
help="mixdepth to source coins from")
parser.add_option('-a',
'--amtmixdepths',
action='store',
type='int',
dest='amtmixdepths',
help='number of mixdepths in wallet, default 5',
default=5)
parser.add_option('-g',
'--gap-limit',
type="int",
action='store',
dest='gaplimit',
help='gap limit for wallet, default=6',
default=6)
add_base_options(parser)
(options, args) = parser.parse_args()
load_program_config(config_path=options.datadir)
check_regtest()
if len(args) != 1:
log.error("Invalid arguments, see --help")
sys.exit(EXIT_ARGERROR)
wallet_name = args[0]
wallet_path = get_wallet_path(wallet_name, None)
max_mix_depth = max([options.mixdepth, options.amtmixdepths - 1])
wallet = open_test_wallet_maybe(
wallet_path, wallet_name, max_mix_depth,
wallet_password_stdin=options.wallet_password_stdin,
gap_limit=options.gaplimit)
wallet_service = WalletService(wallet)
# step 1: do a full recovery style sync. this will pick up
# all addresses that we expect to match transactions against,
# from a blank slate Core wallet that originally had no imports.
if not options.recoversync:
jmprint("Recovery sync was not set, but using it anyway.")
while not wallet_service.synced:
wallet_service.sync_wallet(fast=False)
# Note that the user may be interrupted above by the rescan
# request; this is as for normal scripts; after the rescan is done
# (usually, only once, but, this *IS* needed here, unlike a normal
# wallet generation event), we just try again.
# Now all address from HD are imported, we need to grab
# all the transactions for those addresses; this includes txs
# that *spend* as well as receive our coins, so will include
# "first-out" SNICKER txs as well as ordinary spends and JM coinjoins.
seed_transactions = wallet_service.get_all_transactions()
# Search for SNICKER txs and add them if they match.
# We proceed recursively; we find all one-out matches, then
# all 2-out matches, until we find no new ones and stop.
if len(seed_transactions) == 0:
jmprint("No transactions were found for this wallet. Did you rescan?")
return False
new_txs = []
current_block_heights = set()
for tx in seed_transactions:
if btc.is_snicker_tx(tx):
jmprint("Found a snicker tx: {}".format(bintohex(tx.GetTxid()[::-1])))
equal_outs = btc.get_equal_outs(tx)
if not equal_outs:
continue
if all([wallet_service.is_known_script(
x.scriptPubKey) == False for x in [a[1] for a in equal_outs]]):
# it is now *very* likely that one of the two equal
# outputs is our SNICKER custom output
# script; notice that in this case, the transaction *must*
# have spent our inputs, since it didn't recognize ownership
# of either coinjoin output (and if it did recognize the change,
# it would have recognized the cj output also).
# We try to regenerate one of the outputs, but warn if
# we can't.
my_indices = get_pubs_and_indices_of_inputs(tx, wallet_service, ours=True)
for mypub, mi in my_indices:
for eo in equal_outs:
for (other_pub, i) in get_pubs_and_indices_of_inputs(tx, wallet_service, ours=False):
for (our_pub, j) in get_pubs_and_indices_of_ancestor_inputs(tx.vin[mi], wallet_service, ours=True):
our_spk = wallet_service.pubkey_to_script(our_pub)
our_priv = wallet_service.get_key_from_addr(
wallet_service.script_to_addr(our_spk))
tweak_bytes = btc.ecdh(our_priv[:-1], other_pub)
tweaked_pub = btc.snicker_pubkey_tweak(our_pub, tweak_bytes)
tweaked_spk = wallet_service.pubkey_to_script(tweaked_pub)
if tweaked_spk == eo[1].scriptPubKey:
# TODO wallet.script_to_addr has a dubious assertion, that's why
# we use btc method directly:
address_found = str(btc.CCoinAddress.from_scriptPubKey(btc.CScript(tweaked_spk)))
#address_found = wallet_service.script_to_addr(tweaked_spk)
jmprint("Found a new SNICKER output belonging to us.")
jmprint("Output address {} in the following transaction:".format(
address_found))
jmprint(btc.human_readable_transaction(tx))
jmprint("Importing the address into the joinmarket wallet...")
# NB for a recovery we accept putting any imported keys all into
# the same mixdepth (0); TODO investigate correcting this, it will
# be a little complicated.
success, msg = wallet_service.check_tweak_matches_and_import(wallet_service.script_to_addr(our_spk),
tweak_bytes, tweaked_pub, wallet_service.mixdepth)
if not success:
jmprint("Failed to import SNICKER key: {}".format(msg), "error")
return False
else:
jmprint("... success.")
# we want the blockheight to track where the next-round rescan
# must start from
current_block_heights.add(wallet_service.get_transaction_block_height(tx))
# add this transaction to the next round.
new_txs.append(tx)
if len(new_txs) == 0:
return True
seed_transactions.extend(new_txs)
earliest_new_blockheight = min(current_block_heights)
jmprint("New SNICKER addresses were imported to the Core wallet; "
"do rescanblockchain again, starting from block {}, before "
"restarting this script.".format(earliest_new_blockheight))
return False
if __name__ == "__main__":
res = main()
if not res:
jmprint("Script finished, recovery is NOT complete.", level="warning")
else:
jmprint("Script finished, recovery is complete.")

213
scripts/snicker/snicker-seed-tx.py

@ -0,0 +1,213 @@
#!/usr/bin/env python3
description="""Make fake SNICKER transactions to aid discovery.
Use this script to send money to yourself in a transaction which
fits the format of SNICKER v1 (so it will have two equal sized
outputs and a change output, also obeying the other minor rules
for SNICKER).
Having done this your transaction will be picked up by blockchain
scanners looking for the SNICKER "fingerprint", allowing them
to propose coinjoins with your coins.
The transaction is generated with at least TWO utxos from your chosen
source mixdepth/account (-m), so it must contain at least two.
The reason for using one account, not two, is to prevent violating
the principle of not co-spending from different accounts; even though
this is a simulated coinjoin, it may be deducible that it is only really
a *signalling* fake coinjoin, so it is better not to violate the principle.
"""
import sys
import random
from optparse import OptionParser
from jmbase import BytesProducer, bintohex, jmprint, hextobin, \
EXIT_ARGERROR, EXIT_FAILURE, EXIT_SUCCESS
import jmbitcoin as btc
from jmclient import (RegtestBitcoinCoreInterface, process_shutdown,
jm_single, load_program_config, check_regtest, select_one_utxo,
estimate_tx_fee, SNICKERReceiver, add_base_options, get_wallet_path,
open_test_wallet_maybe, WalletService, SNICKERClientProtocolFactory,
start_reactor, JMPluginService)
from jmclient.support import select_greedy, NotEnoughFundsException
from jmclient.configure import get_log
log = get_log()
def main():
parser = OptionParser(
usage=
'usage: %prog [options] walletname',
description=description
)
add_base_options(parser)
parser.add_option('-m',
'--mixdepth',
action='store',
type='int',
dest='mixdepth',
help='mixdepth/account, default 0',
default=0)
parser.add_option(
'-g',
'--gap-limit',
action='store',
type='int',
dest='gaplimit',
default = 6,
help='gap limit for Joinmarket wallet, default 6.'
)
parser.add_option(
'-f',
'--txfee',
action='store',
type='int',
dest='txfee',
default=-1,
help='Bitcoin miner tx_fee to use for transaction(s). A number higher '
'than 1000 is used as "satoshi per KB" tx fee. A number lower than that '
'uses the dynamic fee estimation of your blockchain provider as '
'confirmation target. This temporarily overrides the "tx_fees" setting '
'in your joinmarket.cfg. Works the same way as described in it. Check '
'it for examples.')
parser.add_option('-a',
'--amtmixdepths',
action='store',
type='int',
dest='amtmixdepths',
help='number of mixdepths in wallet, default 5',
default=5)
parser.add_option('-N',
'--net-transfer',
action='store',
type='int',
dest='net_transfer',
help='how many sats are sent to the "receiver", default randomised.',
default=-1000001)
(options, args) = parser.parse_args()
snicker_plugin = JMPluginService("SNICKER")
load_program_config(config_path=options.datadir,
plugin_services=[snicker_plugin])
if len(args) != 1:
log.error("Invalid arguments, see --help")
sys.exit(EXIT_ARGERROR)
wallet_name = args[0]
check_regtest()
# If tx_fees are set manually by CLI argument, override joinmarket.cfg:
if int(options.txfee) > 0:
jm_single().config.set("POLICY", "tx_fees", str(options.txfee))
max_mix_depth = max([options.mixdepth, options.amtmixdepths - 1])
wallet_path = get_wallet_path(wallet_name, None)
wallet = open_test_wallet_maybe(
wallet_path, wallet_name, max_mix_depth,
wallet_password_stdin=options.wallet_password_stdin,
gap_limit=options.gaplimit)
wallet_service = WalletService(wallet)
if wallet_service.rpc_error:
sys.exit(EXIT_FAILURE)
snicker_plugin.start_plugin_logging(wallet_service)
# in this script, we need the wallet synced before
# logic processing for some paths, so do it now:
while not wallet_service.synced:
wallet_service.sync_wallet(fast=not options.recoversync)
# the sync call here will now be a no-op:
wallet_service.startService()
fee_est = estimate_tx_fee(2, 3, txtype=wallet_service.get_txtype())
# first, order the utxos in the mixepth by size. Then (this is the
# simplest algorithm; we could be more sophisticated), choose the
# *second* largest utxo as the receiver utxo; this ensures that we
# have enough for the proposer to cover. We consume utxos greedily,
# meaning we'll at least some of the time, be consolidating.
utxo_dict = wallet_service.get_utxos_by_mixdepth()[options.mixdepth]
if not len(utxo_dict) >= 2:
log.error("Cannot create fake SNICKER tx without at least two utxos, quitting")
sys.exit(EXIT_ARGERROR)
# sort utxos by size
sorted_utxos = sorted(list(utxo_dict.keys()),
key=lambda k: utxo_dict[k]['value'],
reverse=True)
# receiver is the second largest:
receiver_utxo = sorted_utxos[1]
receiver_utxo_val = utxo_dict[receiver_utxo]
# gather the other utxos into a list to select from:
nonreceiver_utxos = [sorted_utxos[0]] + sorted_utxos[2:]
# get the net transfer in our fake coinjoin:
if options.net_transfer < -1000001:
log.error("Net transfer must be greater than negative 1M sats")
sys.exit(EXIT_ARGERROR)
if options.net_transfer == -1000001:
# default; low-ish is more realistic and avoids problems
# with dusty utxos
options.net_transfer = random.randint(-1000, 1000)
# select enough to cover: receiver value + fee + transfer + breathing room
# we select relatively greedily to support consolidation, since
# this transaction does not pretend to isolate the coins.
try:
available = [{'utxo': utxo, 'value': utxo_dict[utxo]["value"]}
for utxo in nonreceiver_utxos]
# selection algos return [{"utxo":..,"value":..}]:
prop_utxos = {x["utxo"] for x in select_greedy(available,
receiver_utxo_val["value"] + fee_est + options.net_transfer + 1000)}
prop_utxos = list(prop_utxos)
prop_utxo_vals = [utxo_dict[prop_utxo] for prop_utxo in prop_utxos]
except NotEnoughFundsException as e:
log.error(repr(e))
sys.exit(EXIT_FAILURE)
# Due to the fake nature of this transaction, and its distinguishability
# (not only in trivial output pattern, but also in subset-sum), there
# is little advantage in making it use different output mixdepths, so
# here to prevent fragmentation, everything is kept in the same mixdepth.
receiver_addr, proposer_addr, change_addr = (wallet_service.script_to_addr(
wallet_service.get_new_script(options.mixdepth, 1)) for _ in range(3))
# persist index update:
wallet_service.save_wallet()
outputs = btc.construct_snicker_outputs(
sum([x["value"] for x in prop_utxo_vals]),
receiver_utxo_val["value"],
receiver_addr,
proposer_addr,
change_addr,
fee_est,
options.net_transfer)
tx = btc.make_shuffled_tx(prop_utxos + [receiver_utxo],
outputs,
version=2,
locktime=0)
# before signing, check we satisfied the criteria, otherwise
# this is pointless!
if not btc.is_snicker_tx(tx):
log.error("Code error, created non-SNICKER tx, not signing.")
sys.exit(EXIT_FAILURE)
# sign all inputs
# scripts: {input_index: (output_script, amount)}
our_inputs = {}
for index, ins in enumerate(tx.vin):
utxo = (ins.prevout.hash[::-1], ins.prevout.n)
script = utxo_dict[utxo]['script']
amount = utxo_dict[utxo]['value']
our_inputs[index] = (script, amount)
success, msg = wallet_service.sign_tx(tx, our_inputs)
if not success:
log.error("Failed to sign transaction: " + msg)
sys.exit(EXIT_FAILURE)
# TODO condition on automatic brdcst or not
if not jm_single().bc_interface.pushtx(tx.serialize()):
# this represents an error about state (or conceivably,
# an ultra-short window in which the spent utxo was
# consumed in another transaction), but not really
# an internal logic error, so we do NOT return False
log.error("Failed to broadcast fake SNICKER coinjoin: " +\
bintohex(tx.GetTxid()[::-1]))
log.info(btc.human_readable_transaction(tx))
sys.exit(EXIT_FAILURE)
log.info("Successfully broadcast fake SNICKER coinjoin: " +\
bintohex(tx.GetTxid()[::-1]))
if __name__ == "__main__":
main()
jmprint('done', "success")

344
scripts/snicker/snicker-server.py

@ -0,0 +1,344 @@
#!/usr/bin/env python3
"""
A rudimentary implementation of a server, allowing POST of proposals
in base64 format, with POW attached required,
and GET of all current proposals, for SNICKER.
Serves only over Tor onion service.
For persistent onion services, specify public port, local port and
hidden service directory:
`python snicker-server.py 80 7080 /my/hiddenservicedir`
... and (a) make sure these settings match those in your Tor config,
and also (b) note that the hidden service hostname may not be displayed
if the running user, understandably, do not have permissions to read that
directory.
If you only want an ephemeral onion service, for testing, just run without
arguments:
`python snicker-server.py`
"""
from twisted.internet import reactor
from twisted.internet.defer import Deferred
from twisted.web.server import Site
from twisted.web.resource import Resource
from twisted.internet.endpoints import TCP4ClientEndpoint, UNIXClientEndpoint, serverFromString
import txtorcon
import sys
import base64
import json
import sqlite3
import threading
import hashlib
from io import BytesIO
from jmbase import jmprint, hextobin, bintohex, verify_pow
from jmclient import process_shutdown, jm_single, load_program_config
from jmclient.configure import get_log
# Note: this is actually a duplication of the
# string in jmbitcoin.secp256k1_ecies, but this is deliberate,
# as we want this tool to have no dependency on jmbitcoin.
ECIES_MAGIC_BYTES = b'BIE1'
log = get_log()
database_file_name = "proposals.db"
database_table_name = "proposals"
class SNICKERServer(Resource):
# rudimentary: flat file, TODO location of file
DATABASE = "snicker-proposals.txt"
def __init__(self):
self.dblock = threading.Lock()
self.conn = sqlite3.connect(database_file_name, check_same_thread=False)
# TODO: ?
#con.row_factory = dict_factory
self.cursor = self.conn.cursor()
try:
self.dblock.acquire(True)
# note the pubkey is *NOT* a primary key, by
# design; we need to be able to create multiple
# proposals against one key.
self.cursor.execute("CREATE TABLE IF NOT EXISTS {}("
"pubkey TEXT NOT NULL, proposal TEXT NOT NULL, "
"unique (pubkey, proposal));".format(database_table_name))
finally:
self.dblock.release()
# initial PoW setting; todo, change this:
self.set_pow_target_bits(8)
self.nonce_length = 10
super().__init__()
isLeaf = True
def set_pow_target_bits(self, nbits):
self.nbits = nbits
def get_pow_target_bits(self):
return self.nbits
def return_error(self, request, error_meaning,
error_code="unavailable", http_code=400):
"""
We return, to the sender, stringified json in the body as per the above.
"""
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))
return json.dumps({"errorCode": error_code,
"message": error_meaning}).encode("utf-8")
def render_GET(self, request):
"""GET request to "/" retrieves the entire current data set.
GET "/target" retrieves the current nbits target for PoW.
It's intended that proposers request the target in real time
before each submission, so that the server can dynamically update
it at any time.
"""
log.debug("GET request, path: {}".format(request.path))
if request.path == b"/target":
return self.serve_pow_target(request)
if request.path != b"/":
return self.return_error(request, "Invalid request path",
"invalid-request-path")
proposals = self.get_all_current_proposals()
request.setHeader(b"content-length",
("%d" % len(proposals)).encode("ascii"))
return proposals.encode("ascii")
def serve_pow_target(self, request):
targetbits = ("%d" % self.nbits).encode("ascii")
request.setHeader(b"content-length",
("%d" % len(targetbits)).encode("ascii"))
return targetbits
def render_POST(self, request):
""" An individual proposal may be submitted in base64, with key
appended after newline separator in hex.
"""
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:
proposals = request.content
if not isinstance(proposals, BytesIO):
return self.return_error(request, "Invalid request format",
"invalid-request-format")
proposals = proposals.read()
# for now, only allowing proposals of form "base64ciphertext,hexkey",
#newline separated:
proposals = proposals.split(b"\n")
log.debug("Client send proposal list of length: " + str(
len(proposals)))
accepted_proposals = []
for proposal in proposals:
if len(proposal) == 0:
continue
try:
encryptedtx, key, nonce = proposal.split(b",")
bin_key = hextobin(key.decode('utf-8'))
bin_nonce = hextobin(nonce.decode('utf-8'))
base64.b64decode(encryptedtx)
except:
log.warn("This proposal was not accepted: " + proposal.decode(
"utf-8"))
# give up immediately in case of format error:
return self.return_error(request, "Invalid request format",
"invalid-request-format")
if not verify_pow(proposal, nbits=self.nbits, truncate=32):
return self.return_error(request, "Insufficient PoW",
"insufficient proof of work")
accepted_proposals.append((key, encryptedtx))
# the proposals are valid format-wise; add them to the database
for p in accepted_proposals:
# note we will ignore errors here and continue;
# warning will be shown in logs from called fn.
self.add_proposal(p)
content = "{} proposals accepted".format(len(accepted_proposals))
request.setHeader(b"content-length", ("%d" % len(content)).encode(
"ascii"))
return content.encode("ascii")
def add_proposal(self, p):
proposal_to_add = tuple(x.decode("utf-8") for x in p)
try:
self.cursor.execute('INSERT INTO {} VALUES(?, ?);'.format(
database_table_name),proposal_to_add)
except sqlite3.Error as e:
log.warn("Error inserting data into table: {}".format(
" ".join(e.args)))
return False
self.conn.commit()
return True
def dbquery(self, querystr, params, return_results=False):
try:
self.dblock.acquire(True)
if return_results:
return self.cursor.execute(
querystr, params).fetchall()
self.cursor.execute(querystr, params)
finally:
self.dblock.release()
def get_all_keys(self):
rows = self.dbquery('SELECT DISTINCT pubkey FROM {};'.format(
database_table_name), (), True)
if not rows:
return []
return list([x[0] for x in rows])
@classmethod
def db_row_to_proposal_string(cls, row):
assert len(row) == 2
key, proposal = row
return proposal + "," + key
def get_all_current_proposals(self):
rows = self.dbquery('SELECT * from {};'.format(
database_table_name), (), True)
return "\n".join([self.db_row_to_proposal_string(x) for x in rows])
def get_proposals_for_key(self, key):
rows = self.dbquery('SELECT proposal FROM {} WHERE pubkey=?'.format(
database_table_name), (key,), True)
if not rows:
return []
return rows
class SNICKERServerManager(object):
def __init__(self, port, local_port=None,
hsdir=None,
control_port=9051,
uri_created_callback=None,
info_callback=None,
shutdown_callback=None):
# port is the *public* port, default 80
# if local_port is None, we follow the process
# to create an ephemeral hidden service.
# if local_port is a valid port, we start the
# hidden service configured at directory hsdir.
# In the latter case, note the patch described at
# https://github.com/meejah/txtorcon/issues/347 is required.
self.port = port
self.local_port = local_port
if self.local_port is not None:
assert hsdir is not None
self.hsdir = hsdir
self.control_port = control_port
if not uri_created_callback:
self.uri_created_callback = self.default_info_callback
else:
self.uri_created_callback = uri_created_callback
if not info_callback:
self.info_callback = self.default_info_callback
else:
self.info_callback = info_callback
self.shutdown_callback =shutdown_callback
def default_info_callback(self, msg):
jmprint(msg)
def start_snicker_server_and_tor(self):
""" Packages the startup of the receiver side.
"""
self.server = SNICKERServer()
self.site = Site(self.server)
self.site.displayTracebacks = False
jmprint("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):
if self.local_port:
endpointString = "onion:{}:controlPort={}:localPort={}:hiddenServiceDir={}".format(
self.port, self.control_port,self.local_port, self.hsdir)
return serverFromString(reactor, endpointString)
else:
# ephemeral onion:
self.tor_connection = t
return t.create_onion_endpoint(self.port, version=3)
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: ")
# 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(str(ep.getHost().onion_uri))
def start_tor(self):
""" This function executes the workflow
of starting the hidden service.
"""
if not self.local_port:
control_host = jm_single().config.get("PAYJOIN", "tor_control_host")
control_port = int(jm_single().config.get("PAYJOIN", "tor_control_port"))
if str(control_host).startswith('unix:'):
control_endpoint = UNIXClientEndpoint(reactor, control_host[5:])
else:
control_endpoint = TCP4ClientEndpoint(reactor, control_host, control_port)
d = txtorcon.connect(reactor, control_endpoint)
d.addCallback(self.create_onion_ep)
d.addErrback(self.setup_failed)
else:
d = Deferred()
d.callback(None)
d.addCallback(self.create_onion_ep)
# 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")
if self.shutdown_callback:
self.shutdown_callback()
def snicker_server_start(port, local_port=None, hsdir=None):
ssm = SNICKERServerManager(port, local_port=local_port, hsdir=hsdir)
ssm.start_snicker_server_and_tor()
if __name__ == "__main__":
load_program_config(bs="no-blockchain")
# in testing, we can optionally use ephemeral;
# in testing or prod we can use persistent:
if len(sys.argv) < 2:
snicker_server_start(80)
else:
port = int(sys.argv[1])
local_port = int(sys.argv[2])
hsdir = sys.argv[3]
snicker_server_start(port, local_port, hsdir)
reactor.run()

11
test/ygrunner.py

@ -17,7 +17,7 @@ import random
from jmbase import jmprint
from jmclient import YieldGeneratorBasic, load_test_config, jm_single,\
JMClientProtocolFactory, start_reactor, SegwitWallet,\
SegwitLegacyWallet, cryptoengine
SegwitLegacyWallet, cryptoengine, SNICKERClientProtocolFactory, SNICKERReceiver
class MaliciousYieldGenerator(YieldGeneratorBasic):
@ -169,12 +169,19 @@ def test_start_ygs(setup_ygrunner, num_ygs, wallet_structures, mean_amt,
if malicious:
yg.set_maliciousness(malicious, mtype="tx")
clientfactory = JMClientProtocolFactory(yg, proto_type="MAKER")
if jm_single().config.get("SNICKER", "enabled") == "true":
snicker_r = SNICKERReceiver(wallet_service_yg)
servers = jm_single().config.get("SNICKER", "servers").split(",")
snicker_factory = SNICKERClientProtocolFactory(snicker_r, servers)
else:
snicker_factory = None
nodaemon = jm_single().config.getint("DAEMON", "no_daemon")
daemon = True if nodaemon == 1 else False
rs = True if i == num_ygs - 1 else False
start_reactor(jm_single().config.get("DAEMON", "daemon_host"),
jm_single().config.getint("DAEMON", "daemon_port"),
clientfactory, daemon=daemon, rs=rs)
clientfactory, snickerfactory=snicker_factory,
daemon=daemon, rs=rs)
@pytest.fixture(scope="module")
def setup_ygrunner():

Loading…
Cancel
Save