Browse Source

Merge #996: Rpc api 2

7e73e4c Add websocket for subscription, OpenAPI spec (Adam Gibson)
1688d2d Adds listutxos and heartbeat route, several fixes (abhishek0405)
80e17df Add jmwalletd script as RPC server. (Adam Gibson)
master
Adam Gibson 4 years ago
parent
commit
ff10262c78
No known key found for this signature in database
GPG Key ID: 141001A1AF77F20B
  1. 121
      docs/JSON-RPC-API-using-jmwalletd.md
  2. 7
      jmbase/jmbase/commands.py
  3. 10
      jmclient/jmclient/__init__.py
  4. 48
      jmclient/jmclient/client_protocol.py
  5. 12
      jmclient/jmclient/maker.py
  6. 46
      jmclient/jmclient/snicker_receiver.py
  7. 580
      jmclient/jmclient/wallet-rpc-api.md
  8. 820
      jmclient/jmclient/wallet-rpc-api.yaml
  9. 830
      jmclient/jmclient/wallet_rpc.py
  10. 31
      jmclient/jmclient/wallet_utils.py
  11. 84
      jmclient/jmclient/websocketserver.py
  12. 80
      jmclient/jmclient/yieldgenerator.py
  13. 3
      jmclient/setup.py
  14. 411
      jmclient/test/test_wallet_rpc.py
  15. 109
      jmclient/test/test_websocket.py
  16. 11
      jmdaemon/jmdaemon/daemon_protocol.py
  17. 12
      jmdaemon/jmdaemon/irc.py
  18. 43
      scripts/jmwalletd.py
  19. 4
      test/regtest_joinmarket.cfg

121
docs/JSON-RPC-API-using-jmwalletd.md

@ -0,0 +1,121 @@
## JSON-RPC API for Joinmarket using jmwalletd.py
### Introduction - how to start the server
Create an ssl certificate and store it in `<datadir>/ssl/{key,cert}.pem`; the `datadir` is set by `--datadir` in scripts or is `~/.joinmarket` by default, or `.` by default in testing.
After installing Joinmarket as per the [INSTALL GUIDE](INSTALL.md), navigate to the `scripts/` directory as usual and start the server with:
```
(jmvenv) $python jmwalletd.py
```
which with defaults will start serving the RPC over `https://` on port 28183, and a (secure) websocket server (`wss://`) on port 28283.
Documentation of the websocket functionality [below](#websocket).
This HTTP server does *NOT* currently support multiple sessions; it is intended as a manager/daemon for all the Joinmarket services for a single user. Note that in particular it allows only control of *one wallet at a time*.
#### Rules about making requests
Authentication is with the [JSON Web Token](https://jwt.io/) scheme, provided using the Python package [PyJWT](https://pypi.org/project/PyJWT/).
Note that for some methods, it's particularly important to deal with the HTTP response asynchronously, since it can take some time for wallet synchronization, service startup etc. to occur; in these cases a HTTP return code of 202 is sent.
### API documentation
Current API version: v1.
The [OpenAPI](https://github.com/OAI/OpenAPI-Specification) spec is given in [this yaml file](../jmclient/jmclient/wallet-rpc-api.yaml). Human readable documentation of the API is provided in [this document](../jmclient/jmclient/wallet-rpc-api.md), which is auto-generated with the node utility [swagger-markdown](https://www.npmjs.com/package/swagger-markdown).
Those wishing to write client code should adhere to that specification.
#### What is and is not provided in the current version of the API.
As a brief summary, the functionality currently available is:
* list existing wallets
* create a wallet
* unlock (decrypt) a wallet
* lock a wallet
* display contents of a wallet
* list the utxos in the wallet
* get a new address for deposit in a given account
* send a payment without coinjoin
* send a payment with coinjoin
* start the yield generator
* stop the yield generator
* get the value of a specific config variable
* set the value of a specific config variable (only in memory)
* a 'heartbeat' check that also reports whether a wallet is loaded, whether the maker is running, whether a coinjoin is in process.
Clearly there are several further functionalities currently available in the CLI and Qt versions of Joinmarket which are not yet supported. It is likely that several or all of these will be added in future (e.g.: payjoin, utxo freezing).
In addition to the above, a websocket service currently allowing subscription only to transaction events, and coinjoining state, is provided, see next.
<a name="websocket" />
### Websocket
When a wallet service is started via a call to `create` or `unlock` (see above), the websocket automatically starts to serve notifications to any connected client. The client must send the authentication token it has received in the create/unlock call, over the websocket, when it connects, otherwise it will not receive any notifications.
Any authenticated connection is currently automatically subscribed to both of the following events:
#### Coinjoin state change event
When the backend switches from doing nothing, to running a coinjoin as taker over the messaging channels, or to running as a yield generator, or stopping either of these, an event is sent on the websocket noting the new current state. The message is json encoded as:
```
{"coinjoin_state": 1}
```
where the values are:
0 - Taker running
1 - Maker running
2 - Neither are running
#### Transaction event
When a transaction is seen for the first time in the Joinmarket wallet, a notification is sent to the client over the websocket as encoded json, containing the txid and a detailed human-readable deserialization of the transaction details. See this example:
```
{"txid": "ca606efc5ba8f6669ba15e9262e5d38e745345ea96106d5a919688d1ff0da0cc",
"txdetails": {
"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": "bcrt1q6w86ff4v3km5jhj79dwjr8wv6sfdmxawzzx47z"
},
{
"value_sats": 27000100,
"scriptPubKey": "0014564aead56de8f4d445fc5b74a61793b5c8a81966",
"address": "bcrt1q2e9w44tdar6dg30utd62v9unkhy2sxtxr0p4md"
},
{
"value_sats": 146994810,
"scriptPubKey": "00146ec55c2e1d1a7a868b5ec91822bf40bba842bac5",
"address": "bcrt1qdmz4ctsarfagdz67eyvz906qhw5y9wk990rz48"
}
],
"txid": "ca606efc5ba8f6669ba15e9262e5d38e745345ea96106d5a919688d1ff0da0cc",
"nLockTime": 0,
"nVersion": 2
}}
```

7
jmbase/jmbase/commands.py

@ -70,6 +70,13 @@ class JMMsgSignatureVerify(JMCommand):
(b'fullmsg', Unicode()),
(b'hostid', Unicode())]
class JMShutdown(JMCommand):
""" Requests shutdown of the current
message channel connections (to be used
when the client is shutting down).
"""
arguments = []
"""TAKER specific commands
"""

10
jmclient/jmclient/__init__.py

@ -31,7 +31,8 @@ from .blockchaininterface import (BlockchainInterface,
from .snicker_receiver import SNICKERError, SNICKERReceiver
from .client_protocol import (JMTakerClientProtocol, JMClientProtocolFactory,
start_reactor, SNICKERClientProtocolFactory,
BIP78ClientProtocolFactory)
BIP78ClientProtocolFactory,
get_daemon_serving_params)
from .podle import (set_commitment_file, get_commitment_file,
add_external_commitments,
PoDLE, generate_podle, get_podle_commitments,
@ -58,9 +59,14 @@ from .wallet_utils import (
wallet_change_passphrase)
from .wallet_service import WalletService
from .maker import Maker
from .yieldgenerator import YieldGenerator, YieldGeneratorBasic, ygmain
from .yieldgenerator import YieldGenerator, YieldGeneratorBasic, ygmain, \
YieldGeneratorService, YieldGeneratorServiceSetupFailed
from .snicker_receiver import SNICKERError, SNICKERReceiver, SNICKERReceiverService
from .payjoin import (parse_payjoin_setup, send_payjoin,
JMBIP78ReceiverManager)
from .websocketserver import JmwalletdWebSocketServerFactory, \
JmwalletdWebSocketServerProtocol
from .wallet_rpc import JMWalletDaemon
# Set default logging handler to avoid "No handler found" warnings.
try:

48
jmclient/jmclient/client_protocol.py

@ -20,6 +20,15 @@ from jmclient import (jm_single, get_irc_mchannels,
SNICKERReceiver, process_shutdown)
import jmbitcoin as btc
# module level variable representing the port
# on which the daemon is running.
# note that this var is only set if we are running
# client+daemon in one process.
daemon_serving_port = -1
daemon_serving_host = ""
def get_daemon_serving_params():
return (daemon_serving_host, daemon_serving_port)
jlog = get_log()
@ -366,6 +375,15 @@ class JMClientProtocol(BaseClientProtocol):
tx=tx)
self.defaultCallbacks(d)
def request_mc_shutdown(self):
""" To ensure that lingering message channel
connections are shut down when the client itself
is shutting down.
"""
d = self.callRemote(commands.JMShutdown)
self.defaultCallbacks(d)
return {'accepted': True}
class JMMakerClientProtocol(JMClientProtocol):
def __init__(self, factory, maker, nick_priv=None):
self.factory = factory
@ -779,9 +797,18 @@ def start_reactor(host, port, factory=None, snickerfactory=None,
#(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
global daemon_serving_host
global daemon_serving_port
# in case we are starting connections but not the
# reactor, we can return a handle to the connections so
# that they can be cleaned up properly.
# TODO: currently *only* used in tests, with only one
# server protocol listening.
serverconn = None
clientconn = None
usessl = jm_single().config.get("DAEMON", "use_ssl") != 'false'
jmcport, snickerport, bip78port = [port]*3
if daemon:
try:
@ -806,7 +833,7 @@ def start_reactor(host, port, factory=None, snickerfactory=None,
orgp = p[0]
while True:
try:
start_daemon(host, p[0] - port_offset, f, usessl,
serverconn = start_daemon(host, p[0] - port_offset, f, usessl,
'./ssl/key.pem', './ssl/cert.pem')
jlog.info("{} daemon listening on port {}".format(
name, str(p[0] - port_offset)))
@ -819,15 +846,19 @@ def start_reactor(host, port, factory=None, snickerfactory=None,
"listen on any of them. Quitting.")
sys.exit(EXIT_FAILURE)
p[0] += 1
return p[0]
return (p[0], serverconn)
if jm_coinjoin:
# TODO either re-apply this port incrementing logic
# to other protocols, or re-work how the ports work entirely.
jmcport = start_daemon_on_port(port_a, dfactory, "Joinmarket", 0)
jmcport, serverconn = start_daemon_on_port(port_a, dfactory,
"Joinmarket", 0)
daemon_serving_port = jmcport
daemon_serving_host = host
# (See above) For now these other two are just on ports that are 1K offsets.
if snickerfactory:
snickerport = start_daemon_on_port(port_a, sdfactory, "SNICKER", 1000) - 1000
snickerport, serverconn = start_daemon_on_port(port_a, sdfactory,
"SNICKER", 1000) - 1000
if bip78:
start_daemon_on_port(port_a, bip78factory, "BIP78", 2000)
@ -848,7 +879,7 @@ def start_reactor(host, port, factory=None, snickerfactory=None,
ClientContextFactory())
else:
if factory:
reactor.connectTCP(host, jmcport, factory)
clientconn = reactor.connectTCP(host, jmcport, factory)
if snickerfactory:
reactor.connectTCP(host, snickerport, snickerfactory)
if rs:
@ -856,3 +887,4 @@ def start_reactor(host, port, factory=None, snickerfactory=None,
reactor.run(installSignalHandlers=ish)
if isinstance(jm_single().bc_interface, RegtestBitcoinCoreInterface):
jm_single().bc_interface.shutdown_signal = True
return (serverconn, clientconn)

12
jmclient/jmclient/maker.py

@ -4,7 +4,7 @@ import abc
import atexit
import jmbitcoin as btc
from jmbase import bintohex, hexbin, get_log, EXIT_FAILURE, stop_reactor
from jmbase import bintohex, hexbin, get_log, EXIT_FAILURE
from jmclient.wallet_service import WalletService
from jmclient.configure import jm_single
from jmclient.support import calc_cj_fee
@ -39,12 +39,18 @@ class Maker(object):
if not self.wallet_service.synced:
return
self.freeze_timelocked_utxos()
try:
self.offerlist = self.create_my_orders()
except AssertionError:
jlog.error("Failed to create offers.")
self.aborted = True
return
self.fidelity_bond = self.get_fidelity_bond_template()
self.sync_wait_loop.stop()
if not self.offerlist:
jlog.info("Failed to create offers, giving up.")
stop_reactor()
jlog.error("Failed to create offers.")
self.aborted = True
return
jlog.info('offerlist={}'.format(self.offerlist))
@hexbin

46
jmclient/jmclient/snicker_receiver.py

@ -1,17 +1,55 @@
#! /usr/bin/env python
import os
from twisted.application.service import Service
from twisted.internet import task
import jmbitcoin as btc
from jmclient.configure import jm_single
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(Service):
class SNICKERReceiverService(Service):
def __init__(self, receiver):
assert isinstance(receiver, SNICKERReceiver)
self.receiver = receiver
# main monitor loop
self.monitor_loop = task.LoopingCall(self.receiver.poll_for_proposals)
def startService(self):
""" Encapsulates start up actions.
This service depends on the receiver's
wallet service to start, so wait for that.
"""
self.wait_for_wallet = task.LoopingCall(self.wait_for_wallet_sync)
self.wait_for_wallet.start(5.0)
def wait_for_wallet_sync(self):
if self.receiver.wallet_service.isRunning():
jlog.info("SNICKER service starting because wallet service is up.")
self.wait_for_wallet.stop()
self.monitor_loop.start(5.0)
super().startService()
def stopService(self, wallet=False):
""" Encapsulates shut down actions.
Optionally also shut down the underlying
wallet service (default False).
"""
if self.monitor_loop:
self.monitor_loop.stop()
if wallet:
self.receiver.wallet_service.stopService()
super().stopService()
def isRunning(self):
return self.running == 1
class SNICKERReceiver(object):
supported_flags = []
def __init__(self, wallet_service, acceptance_callback=None,
@ -66,6 +104,10 @@ class SNICKERReceiver(Service):
def default_info_callback(self, msg):
jlog.info(msg)
if not os.path.exists(self.proposals_source):
with open(self.proposals_source, "wb") as f:
jlog.info("created proposals source file.")
def default_acceptance_callback(self, our_ins, their_ins,
our_outs, their_outs):

580
jmclient/jmclient/wallet-rpc-api.md

@ -0,0 +1,580 @@
# Joinmarket wallet API
Joinmarket wallet API
## Version: 1
### /wallet/create
#### POST
##### Summary
create a new wallet
##### Description
Give a filename (.jmdat must be included) and a password, create the wallet and get back the seedphrase for the newly persisted wallet file. The wallettype variable must be one of "sw" - segwit native, "sw-legacy" - segwit legacy or "sw-fb" - segwit native with fidelity bonds supported, the last of which is the default. Note that this operation cannot be performed when a wallet is already loaded (unlocked).
##### Responses
| Code | Description |
| ---- | ----------- |
| 201 | wallet created successfully |
| 400 | Bad request format. |
| 401 | Unable to authorise the credentials that were supplied. |
| 409 | Unable to complete request because object already exists. |
### /wallet/{walletname}/unlock
#### POST
##### Summary
decrypt an existing wallet
##### Description
Give the password for the specified (existing) wallet file, and it will be decrypted ready for use. Note that this operation cannot be performed when another wallet is already loaded (unlocked).
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ---- |
| walletname | path | name of wallet including .jmdat | Yes | string |
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | wallet unlocked successfully |
| 400 | Bad request format. |
| 401 | Unable to authorise the credentials that were supplied. |
| 404 | Item not found. |
| 409 | Unable to complete request because object already exists. |
### /wallet/{walletname}/lock
#### GET
##### Summary
block access to a currently decrypted wallet
##### Description
After this (authenticated) action, the wallet will not be readable or writeable.
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ---- |
| walletname | path | name of wallet including .jmdat | Yes | string |
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | wallet unlocked successfully |
| 400 | Bad request format. |
| 401 | Unable to authorise the credentials that were supplied. |
##### Security
| Security Schema | Scopes |
| --- | --- |
| bearerAuth | |
### /wallet/{walletname}/display
#### GET
##### Summary
get detailed breakdown of wallet contents by account.
##### Description
get detailed breakdown of wallet contents by account.
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ---- |
| walletname | path | name of wallet including .jmdat | Yes | string |
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | wallet display contents retrieved successfully. |
| 400 | Bad request format. |
| 401 | Unable to authorise the credentials that were supplied. |
| 404 | Item not found. |
##### Security
| Security Schema | Scopes |
| --- | --- |
| bearerAuth | |
### /session
#### GET
##### Summary
get current status of backend
##### Description
get whether a wallet is loaded and whether coinjoin/maker are happening.
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | successful heartbeat response |
### /wallet/all
#### GET
##### Summary
get current available wallets
##### Description
get all wallet filenames in standard location as a list
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | successful response to listwallets |
### /wallet/{walletname}/address/new/{mixdepth}
#### GET
##### Summary
get a fresh address in the given account for depositing funds.
##### Description
get a fresh address in the given account for depositing funds.
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ---- |
| walletname | path | name of wallet including .jmdat | Yes | string |
| mixdepth | path | account or mixdepth to source the address from (0..4) | Yes | string |
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | successful retrieval of new address |
| 400 | Bad request format. |
| 401 | Unable to authorise the credentials that were supplied. |
| 404 | Item not found. |
##### Security
| Security Schema | Scopes |
| --- | --- |
| bearerAuth | |
### /wallet/{walletname}/address/timelock/new/{lockdate}
#### GET
##### Summary
get a fresh timelock address
##### Description
get a new timelocked address, for depositing funds, to create a fidelity bond, which will automatically be used when the maker is started. specify the date in YYYY-mm as the last path parameter. Note that mixdepth is not specified as timelock addresses are always in mixdepth(account) zero.
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ---- |
| walletname | path | name of wallet including .jmdat | Yes | string |
| lockdate | path | month whose first day will be the end of the timelock, for this address. | Yes | string |
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | successful retrieval of new address |
| 400 | Bad request format. |
| 401 | Unable to authorise the credentials that were supplied. |
| 404 | Item not found. |
##### Security
| Security Schema | Scopes |
| --- | --- |
| bearerAuth | |
### /wallet/{walletname}/utxos
#### GET
##### Summary
list details of all utxos currently in the wallet.
##### Description
list details of all utxos currently in the wallet.
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ---- |
| walletname | path | name of wallet including .jmdat | Yes | string |
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | successful retrieval of utxo list |
| 400 | Bad request format. |
| 401 | Unable to authorise the credentials that were supplied. |
| 404 | Item not found. |
##### Security
| Security Schema | Scopes |
| --- | --- |
| bearerAuth | |
### /wallet/{walletname}/taker/direct-send
#### POST
##### Summary
create and broadcast a transaction (without coinjoin)
##### Description
create and broadcast a transaction (without coinjoin)
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ---- |
| walletname | path | name of wallet including .jmdat | Yes | string |
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | transaction broadcast OK. |
| 400 | Bad request format. |
| 401 | Unable to authorise the credentials that were supplied. |
| 409 | Transaction failed to broadcast. |
##### Security
| Security Schema | Scopes |
| --- | --- |
| bearerAuth | |
### /wallet/{walletname}/maker/start
#### POST
##### Summary
Start the yield generator service.
##### Description
Start the yield generator service with the configuration settings specified in the POST request. Note that if fidelity bonds are enabled in the wallet, and a timelock address has been generated, and then funded, the fidelity bond will automatically be advertised without any specific configuration in this request.
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ---- |
| walletname | path | name of wallet including .jmdat | Yes | string |
##### Responses
| Code | Description |
| ---- | ----------- |
| 202 | The request has been submitted successfully for processing, but the processing has not been completed. |
| 400 | Bad request format. |
| 401 | Unable to authorise the credentials that were supplied. |
| 503 | The server is not ready to process the request. |
##### Security
| Security Schema | Scopes |
| --- | --- |
| bearerAuth | |
### /wallet/{walletname}/maker/stop
#### GET
##### Summary
stop the yield generator service
##### Description
stop the yield generator service
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ---- |
| walletname | path | name of wallet including .jmdat | Yes | string |
##### Responses
| Code | Description |
| ---- | ----------- |
| 202 | The request has been submitted successfully for processing, but the processing has not been completed. |
| 400 | Bad request format. |
| 401 | Unable to authorise the credentials that were supplied. |
##### Security
| Security Schema | Scopes |
| --- | --- |
| bearerAuth | |
### /wallet/{walletname}/taker/coinjoin
#### POST
##### Summary
initiate a coinjoin as taker
##### Description
initiate a coinjoin as taker
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ---- |
| walletname | path | name of wallet including .jmdat | Yes | string |
##### Responses
| Code | Description |
| ---- | ----------- |
| 202 | The request has been submitted successfully for processing, but the processing has not been completed. |
| 400 | Bad request format. |
| 401 | Unable to authorise the credentials that were supplied. |
| 404 | Item not found. |
| 409 | Unable to complete request because config settings are missing. |
| 503 | The server is not ready to process the request. |
##### Security
| Security Schema | Scopes |
| --- | --- |
| bearerAuth | |
### /wallet/{walletname}/configset
#### POST
##### Summary
change a config variable
##### Description
change a config variable (for the duration of this backend daemon process instance)
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ---- |
| walletname | path | name of wallet including .jmdat | Yes | string |
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | successful update of config value |
| 400 | Bad request format. |
| 401 | Unable to authorise the credentials that were supplied. |
| 409 | Unable to complete request because config settings are missing. |
##### Security
| Security Schema | Scopes |
| --- | --- |
| bearerAuth | |
### /wallet/{walletname}/configget
#### POST
##### Summary
get the value of a specific config setting
##### Description
Get the value of a specific config setting. Note values are always returned as string.
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ---- |
| walletname | path | name of wallet including .jmdat | Yes | string |
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | successful retrieval of config value |
| 400 | Bad request format. |
| 401 | Unable to authorise the credentials that were supplied. |
| 409 | Unable to complete request because config settings are missing. |
##### Security
| Security Schema | Scopes |
| --- | --- |
| bearerAuth | |
### Models
#### ConfigSetRequest
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| section | string | | Yes |
| field | string | | Yes |
| value | string | | Yes |
#### ConfigGetRequest
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| section | string | | Yes |
| field | string | | Yes |
#### ConfigGetResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| configvalue | string | | Yes |
#### ConfigSetResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| ConfigSetResponse | object | | |
#### DoCoinjoinRequest
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| mixdepth | integer | _Example:_ `0` | Yes |
| amount_sats | integer |_Example:_ `100000000` | Yes |
| counterparties | integer | _Example:_ `9` | Yes |
| destination | string | _Example:_ `"bcrt1qujp2x2fv437493sm25gfjycns7d39exjnpptzw"` | Yes |
#### StartMakerRequest
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| txfee | string | _Example:_ `"0"` | Yes |
| cjfee_a | string |_Example:_ `"5000"` | Yes |
| cjfee_r | string |_Example:_ `"0.00004"` | Yes |
| ordertype | string | _Example:_ `"reloffer"` | Yes |
| minsize | string | _Example:_ `"8000000"` | Yes |
#### GetAddressResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| GetAddressResponse | string | | |
**Example**
<pre>bcrt1qujp2x2fv437493sm25gfjycns7d39exjnpptzw</pre>
#### ListWalletsResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| wallets | [ string ] | | No |
#### SessionResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| session | boolean | | Yes |
| maker_running | boolean | | Yes |
| coinjoin_in_process | boolean | | Yes |
| wallet_name | string |_Example:_ `"wallet.jmdat"` | Yes |
#### ListUtxosResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| utxos | [ object ] | | No |
#### WalletDisplayResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| walletname | string | | Yes |
| walletinfo | object | | Yes |
#### CreateWalletResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| walletname | string | _Example:_ `"wallet.jmdat"` | Yes |
| token | byte | | Yes |
| seedphrase | string | | Yes |
#### UnlockWalletResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| walletname | string | _Example:_ `"wallet.jmdat"` | Yes |
| token | byte | | Yes |
#### DirectSendResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| txinfo | object | | Yes |
#### LockWalletResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| walletname | string | _Example:_ `"wallet.jmdat"` | Yes |
| already_locked | boolean |_Example:_ `false` | Yes |
#### CreateWalletRequest
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| walletname | string | _Example:_ `"wallet.jmdat"` | Yes |
| password | password | _Example:_ `"hunter2"` | Yes |
| wallettype | string | _Example:_ `"sw-fb"` | Yes |
#### UnlockWalletRequest
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| password | password | _Example:_ `"hunter2"` | Yes |
#### DirectSendRequest
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| mixdepth | integer | _Example:_ `0` | Yes |
| amount_sats | integer |_Example:_ `100000000` | Yes |
| destination | string | _Example:_ `"bcrt1qu7k4dppungsqp95nwc7ansqs9m0z95h72j9mze"` | Yes |
#### ErrorMessage
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| message | string | | No |

820
jmclient/jmclient/wallet-rpc-api.yaml

@ -0,0 +1,820 @@
openapi: 3.0.0
info:
description: Joinmarket wallet API
version: "1"
title: Joinmarket wallet API
paths:
/wallet/create:
post:
summary: create a new wallet
operationId: createwallet
description: Give a filename (.jmdat must be included) and a password, create the wallet and get back the seedphrase for the newly persisted wallet file. The wallettype variable must be one of "sw" - segwit native, "sw-legacy" - segwit legacy or "sw-fb" - segwit native with fidelity bonds supported, the last of which is the default. Note that this operation cannot be performed when a wallet is already loaded (unlocked).
responses:
'201':
$ref: '#/components/responses/Create-201-OK'
'400':
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: '#/components/responses/401-Unauthorized'
'409':
$ref: '#/components/responses/409-AlreadyExists'
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/CreateWalletRequest'
description: wallet creation parameters
/wallet/{walletname}/unlock:
post:
summary: decrypt an existing wallet
operationId: unlockwallet
description: Give the password for the specified (existing) wallet file, and it will be decrypted ready for use. Note that this operation cannot be performed when another wallet is already loaded (unlocked).
parameters:
- name: walletname
in: path
description: name of wallet including .jmdat
required: true
schema:
type: string
responses:
'200':
$ref: "#/components/responses/Unlock-200-OK"
'400':
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: '#/components/responses/401-Unauthorized'
'404':
$ref: '#/components/responses/404-NotFound'
'409':
$ref: '#/components/responses/409-AlreadyExists'
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/UnlockWalletRequest'
description: wallet unlocking parameters
/wallet/{walletname}/lock:
get:
security:
- bearerAuth: []
summary: block access to a currently decrypted wallet
operationId: lockwallet
description: After this (authenticated) action, the wallet will not be readable or writeable.
parameters:
- name: walletname
in: path
description: name of wallet including .jmdat
required: true
schema:
type: string
responses:
'200':
$ref: "#/components/responses/Unlock-200-OK"
'400':
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: '#/components/responses/401-Unauthorized'
/wallet/{walletname}/display:
get:
security:
- bearerAuth: []
summary: get detailed breakdown of wallet contents by account.
operationId: displaywallet
description: get detailed breakdown of wallet contents by account.
parameters:
- name: walletname
in: path
description: name of wallet including .jmdat
required: true
schema:
type: string
responses:
'200':
$ref: "#/components/responses/Display-200-OK"
'400':
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: '#/components/responses/401-Unauthorized'
'404':
$ref: '#/components/responses/404-NotFound'
/session:
get:
summary: get current status of backend
operationId: session
description: get whether a wallet is loaded and whether coinjoin/maker are happening.
responses:
'200':
$ref: "#/components/responses/Session-200-OK"
/wallet/all:
get:
summary: get current available wallets
operationId: listwallets
description: get all wallet filenames in standard location as a list
responses:
'200':
$ref: "#/components/responses/ListWallets-200-OK"
/wallet/{walletname}/address/new/{mixdepth}:
get:
security:
- bearerAuth: []
summary: get a fresh address in the given account for depositing funds.
operationId: getaddress
description: get a fresh address in the given account for depositing funds.
parameters:
- name: walletname
in: path
description: name of wallet including .jmdat
required: true
schema:
type: string
- name: mixdepth
in: path
description: account or mixdepth to source the address from (0..4)
required: true
schema:
type: string
responses:
'200':
$ref: "#/components/responses/GetAddress-200-OK"
'400':
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: '#/components/responses/401-Unauthorized'
'404':
$ref: '#/components/responses/404-NotFound'
/wallet/{walletname}/address/timelock/new/{lockdate}:
get:
security:
- bearerAuth: []
summary: get a fresh timelock address
operationId: gettimelockaddress
description: get a new timelocked address, for depositing funds, to create a fidelity bond, which will automatically be used when the maker is started. specify the date in YYYY-mm as the last path parameter. Note that mixdepth is not specified as timelock addresses are always in mixdepth(account) zero.
parameters:
- name: walletname
in: path
description: name of wallet including .jmdat
required: true
schema:
type: string
example: wallet.jmdat
- name: lockdate
in: path
description: month whose first day will be the end of the timelock, for this address.
required: true
schema:
type: string # note- not a standard date-time string for OpenAPI, so not marked as such
example: "2021-09"
responses:
'200':
$ref: "#/components/responses/GetAddress-200-OK"
'400':
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: '#/components/responses/401-Unauthorized'
'404':
$ref: '#/components/responses/404-NotFound'
/wallet/{walletname}/utxos:
get:
security:
- bearerAuth: []
summary: list details of all utxos currently in the wallet.
operationId: listutxos
description: list details of all utxos currently in the wallet.
parameters:
- name: walletname
in: path
description: name of wallet including .jmdat
required: true
schema:
type: string
example: "2021-09"
responses:
'200':
$ref: "#/components/responses/ListUtxos-200-OK"
'400':
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: '#/components/responses/401-Unauthorized'
'404':
$ref: '#/components/responses/404-NotFound'
/wallet/{walletname}/taker/direct-send:
post:
security:
- bearerAuth: []
summary: create and broadcast a transaction (without coinjoin)
operationId: directsend
description: create and broadcast a transaction (without coinjoin)
parameters:
- name: walletname
in: path
description: name of wallet including .jmdat
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DirectSendRequest'
description: transaction creation parameters
responses:
'200':
$ref: "#/components/responses/DirectSend-200-Accepted"
'400':
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: '#/components/responses/401-Unauthorized'
'409':
$ref: '#/components/responses/409-TransactionFailed'
/wallet/{walletname}/maker/start:
post:
security:
- bearerAuth: []
summary: Start the yield generator service.
operationId: startmaker
description: Start the yield generator service with the configuration settings specified in the POST request. Note that if fidelity bonds are enabled in the wallet, and a timelock address has been generated, and then funded, the fidelity bond will automatically be advertised without any specific configuration in this request.
parameters:
- name: walletname
in: path
description: name of wallet including .jmdat
required: true
schema:
type: string
example: wallet.jmdat
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/StartMakerRequest'
description: yield generator config parameters
responses:
# note we use a default response, no data returned:
'202':
$ref: "#/components/responses/202-Accepted"
'400':
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: '#/components/responses/401-Unauthorized'
'503':
$ref: '#/components/responses/503-ServiceUnavailable'
/wallet/{walletname}/maker/stop:
get:
security:
- bearerAuth: []
summary: stop the yield generator service
operationId: stopmaker
description: stop the yield generator service
parameters:
- name: walletname
in: path
description: name of wallet including .jmdat
required: true
schema:
type: string
responses:
'202':
$ref: "#/components/responses/202-Accepted"
'400':
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: "#/components/responses/401-Unauthorized"
/wallet/{walletname}/taker/coinjoin:
post:
security:
- bearerAuth: []
summary: initiate a coinjoin as taker
operationId: docoinjoin
description: initiate a coinjoin as taker
parameters:
- name: walletname
in: path
description: name of wallet including .jmdat
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DoCoinjoinRequest'
description: taker side coinjoin parameters
responses:
'202':
$ref: "#/components/responses/202-Accepted"
'400':
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: '#/components/responses/401-Unauthorized'
'404':
$ref: '#/components/responses/404-NotFound'
'409':
$ref: '#/components/responses/409-NoConfig'
'503':
$ref: '#/components/responses/503-ServiceUnavailable'
/wallet/{walletname}/configset:
post:
security:
- bearerAuth: []
summary: change a config variable
operationId: configsetting
description: change a config variable (for the duration of this backend daemon process instance)
parameters:
- name: walletname
in: path
description: name of wallet including .jmdat
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/ConfigSetRequest'
description: config editing parameters
responses:
'200':
$ref: "#/components/responses/ConfigSet-200-OK"
'400':
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: '#/components/responses/401-Unauthorized'
'409':
$ref: '#/components/responses/409-NoConfig'
/wallet/{walletname}/configget:
post:
security:
- bearerAuth: []
summary: get the value of a specific config setting
operationId: configget
description: Get the value of a specific config setting. Note values are always returned as string.
parameters:
- name: walletname
in: path
description: name of wallet including .jmdat
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/ConfigGetRequest'
responses:
'200':
$ref: "#/components/responses/ConfigGet-200-OK"
'400':
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: "#/components/responses/401-Unauthorized"
'409':
$ref: '#/components/responses/409-NoConfig'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
schemas:
ConfigSetRequest:
type: object
required:
- section
- field
- value
properties:
section:
type: string
field:
type: string
value:
type: string
ConfigGetRequest:
type: object
required:
- section
- field
properties:
section:
type: string
field:
type: string
ConfigGetResponse:
type: object
required:
- configvalue
properties:
configvalue:
type: string
ConfigSetResponse:
type: object
DoCoinjoinRequest:
type: object
required:
- mixdepth
- amount_sats
- counterparties
- destination
properties:
mixdepth:
type: integer
example: 0
amount_sats:
type: integer
example: 100000000
counterparties:
type: integer
example: 9
destination:
type: string
example: "bcrt1qujp2x2fv437493sm25gfjycns7d39exjnpptzw"
StartMakerRequest:
type: object
required:
- txfee
- cjfee_a
- cjfee_r
- ordertype
- minsize
properties:
txfee:
type: string
example: "0"
cjfee_a:
type: string
example: "5000"
cjfee_r:
type: string
example: "0.00004"
ordertype:
type: string
example: "reloffer"
minsize:
type: string
example: "8000000"
GetAddressResponse:
type: string
example: "bcrt1qujp2x2fv437493sm25gfjycns7d39exjnpptzw"
ListWalletsResponse:
type: object
properties:
wallets:
type: array
items:
type: string
example: wallet.jmdat
SessionResponse:
type: object
required:
- session
- maker_running
- coinjoin_in_process
- wallet_name
properties:
session:
type: boolean
maker_running:
type: boolean
coinjoin_in_process:
type: boolean
wallet_name:
type: string
example: wallet.jmdat
ListUtxosResponse:
type: object
properties:
utxos:
type: array
items:
type: object
properties:
utxo:
type: string
address:
type: string
value:
type: integer
tries:
type: integer
tries_remaining:
type: integer
external:
type: boolean
mixdepth:
type: integer
confirmations:
type: integer
frozen:
type: boolean
WalletDisplayResponse:
type: object
required:
- walletname
- walletinfo
properties:
walletname:
type: string
walletinfo:
type: object
required:
- wallet_name
- total_balance
- accounts
properties:
wallet_name:
type: string
total_balance:
type: string
accounts:
type: array
items:
type: object
properties:
account:
type: string
account_balance:
type: string
branches:
type: array
items:
type: object
properties:
branch:
type: string
balance:
type: string
entries:
type: array
items:
type: object
properties:
hd_path:
type: string
address:
type: string
amount:
type: string
labels:
type: string
CreateWalletResponse:
type: object
required:
- walletname
- token
- seedphrase
properties:
walletname:
type: string
example: wallet.jmdat
token:
type: string
format: byte
seedphrase:
type: string
UnlockWalletResponse:
type: object
required:
- walletname
- token
properties:
walletname:
type: string
example: wallet.jmdat
token:
type: string
format: byte
DirectSendResponse:
type: object
required:
- txinfo
properties:
txinfo:
type: object
properties:
hex:
type: string
inputs:
type: array
items:
type: object
properties:
outpoint:
type: string
scriptSig:
type: string
nSequence:
type: number
witness:
type: string
outputs:
type: array
items:
type: object
properties:
value_sats:
type: number
scriptPubKey:
type: string
address:
type: string
txid:
type: string
nLockTime:
type: number
nVersion:
type: number
LockWalletResponse:
type: object
required:
- walletname
- already_locked
properties:
walletname:
type: string
example: wallet.jmdat
already_locked:
type: boolean
example: false
CreateWalletRequest:
type: object
required:
- walletname
- password
- wallettype
properties:
walletname:
type: string
example: wallet.jmdat
password:
type: string
format: password
example: hunter2
wallettype:
type: string
example: "sw-fb"
UnlockWalletRequest:
type: object
required:
- password
properties:
password:
type: string
format: password
example: hunter2
DirectSendRequest:
type: object
required:
- mixdepth
- amount_sats
- destination
properties:
mixdepth:
type: integer
example: 0
amount_sats:
type: integer
example: 100000000
destination:
type: string
example: bcrt1qu7k4dppungsqp95nwc7ansqs9m0z95h72j9mze
ErrorMessage:
type: object
properties:
message:
type: string
responses:
# Success responses
DirectSend-200-Accepted:
description: "transaction broadcast OK."
content:
application/json:
schema:
$ref: "#/components/schemas/DirectSendResponse"
ListUtxos-200-OK:
description: "successful retrieval of utxo list"
content:
application/json:
schema:
$ref: "#/components/schemas/ListUtxosResponse"
ConfigGet-200-OK:
description: "successful retrieval of config value"
content:
application/json:
schema:
$ref: "#/components/schemas/ConfigGetResponse"
ConfigSet-200-OK:
description: "successful update of config value"
content:
application/json:
schema:
$ref: "#/components/schemas/ConfigSetResponse"
GetAddress-200-OK:
description: "successful retrieval of new address"
content:
application/json:
schema:
$ref: "#/components/schemas/GetAddressResponse"
ListWallets-200-OK:
description: "successful response to listwallets"
content:
application/json:
schema:
$ref: "#/components/schemas/ListWalletsResponse"
Session-200-OK:
description: "successful heartbeat response"
content:
application/json:
schema:
$ref: "#/components/schemas/SessionResponse"
Create-201-OK:
description: "wallet created successfully"
content:
application/json:
schema:
$ref: "#/components/schemas/CreateWalletResponse"
Unlock-200-OK:
description: "wallet unlocked successfully"
content:
application/json:
schema:
$ref: "#/components/schemas/UnlockWalletResponse"
Display-200-OK:
description: "wallet display contents retrieved successfully."
content:
application/json:
schema:
$ref: "#/components/schemas/WalletDisplayResponse"
Lock-200-OK:
description: "wallet locked successfully"
content:
application/json:
schema:
$ref: "#/components/schemas/LockWalletResponse"
202-Accepted:
description: The request has been submitted successfully for processing, but the processing has not been completed.
204-NoResultFound:
description: No result found for matching search criteria.
# Clientside error responses
400-BadRequest:
description: Bad request format.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
401-Unauthorized:
description: Unable to authorise the credentials that were supplied.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
409-AlreadyExists:
description: Unable to complete request because object already exists.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
409-NoConfig:
description: Unable to complete request because config settings are missing.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
409-TransactionFailed:
description: Transaction failed to broadcast.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
404-NotFound:
description: Item not found.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
422-UnprocessableEntity:
description: Business rule validation failure.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
429-TooManyRequests:
description: There are too many requests in a given amount of time.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
# Serverside error responses
503-ServiceUnavailable:
description: The server is not ready to process the request.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
5XX-UnexpectedError:
description: There was an internal issue calling the service.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'

830
jmclient/jmclient/wallet_rpc.py

@ -0,0 +1,830 @@
from jmbitcoin import *
import datetime
import os
import json
import atexit
from io import BytesIO
from jmclient.wallet_utils import wallet_showutxos
from twisted.internet import reactor, ssl
from twisted.web.server import Site
from twisted.application.service import Service
from autobahn.twisted.websocket import listenWS
from klein import Klein
import jwt
from jmbitcoin import human_readable_transaction
from jmclient import Taker, jm_single, \
JMClientProtocolFactory, start_reactor, \
WalletService, get_wallet_path, direct_send, \
open_test_wallet_maybe, wallet_display, SegwitLegacyWallet, \
SegwitWallet, get_daemon_serving_params, YieldGeneratorService, \
create_wallet, get_max_cj_fee_values, \
StorageError, StoragePasswordError, JmwalletdWebSocketServerFactory, \
JmwalletdWebSocketServerProtocol, RetryableStorageError, \
SegwitWalletFidelityBonds, wallet_gettimelockaddress, \
YieldGeneratorServiceSetupFailed
from jmbase.support import get_log
jlog = get_log()
api_version_string = "/api/v1"
# for debugging; twisted.web.server.Request objects do not easily serialize:
def print_req(request):
print(request)
print(request.method)
print(request.uri)
print(request.args)
print(request.path)
print(request.content)
print(list(request.requestHeaders.getAllRawHeaders()))
class NotAuthorized(Exception):
pass
class NoWalletFound(Exception):
pass
class InvalidRequestFormat(Exception):
pass
class BackendNotReady(Exception):
pass
# error class for services which are only
# started once:
class ServiceAlreadyStarted(Exception):
pass
# for the special case of the wallet service:
class WalletAlreadyUnlocked(Exception):
pass
# in wallet creation, if the file exists:
class WalletAlreadyExists(Exception):
pass
# if the file cannot be created or opened
# due to existing lock:
class LockExists(Exception):
pass
# some actions require configuration variables
# to proceed (related to fees, in particular);
# if those are not allowed to fall back to defaults,
# we return an error:
class ConfigNotPresent(Exception):
pass
class ServiceNotStarted(Exception):
pass
# raised when a requested transaction did
# not successfully broadcast.
class TransactionFailed(Exception):
pass
def get_ssl_context(cert_directory):
"""Construct an SSL context factory from the user's privatekey/cert.
TODO:
Currently just hardcoded for tests.
"""
return ssl.DefaultOpenSSLContextFactory(os.path.join(cert_directory, "key.pem"),
os.path.join(cert_directory, "cert.pem"))
def make_jmwalletd_response(request, status=200, **kwargs):
"""
Build the response body as JSON and set the proper content-type
header.
"""
request.setHeader('Content-Type', 'application/json')
request.setHeader('Access-Control-Allow-Origin', '*')
request.setHeader("Cache-Control", "no-cache, must-revalidate")
request.setHeader("Pragma", "no-cache")
request.setHeader("Expires", "Sat, 26 Jul 1997 05:00:00 GMT")
request.setResponseCode(status)
return json.dumps(kwargs)
CJ_TAKER_RUNNING, CJ_MAKER_RUNNING, CJ_NOT_RUNNING = range(3)
class JMWalletDaemon(Service):
""" This class functions as an HTTP/TLS server,
with acccess control, allowing a single client(user)
to control functioning of encapsulated Joinmarket services.
"""
app = Klein()
def __init__(self, port, wss_port, tls=True):
""" Port is the port to serve this daemon
(using HTTP/TLS).
wss_factory is a twisted protocol factory for the
websocket connections for clients to subscribe to updates.
"""
# cookie tracks single user's state.
self.cookie = None
self.port = port
self.wss_port = wss_port
self.tls = tls
pref = "wss" if self.tls else "ws"
self.wss_url = pref + "://127.0.0.1:" + str(wss_port)
# the collection of services which this
# daemon may switch on and off:
self.services = {}
# master single wallet service which we
# allow the client to start/stop.
self.services["wallet"] = None
self.wallet_name = "None"
# label for convenience:
self.wallet_service = self.services["wallet"]
# Client may start other services, but only
# one instance.
self.services["snicker"] = None
self.services["maker"] = None
# our taker object will handle doing sends/taker-cjs:
self.taker = None
# the factory of type JmwalletdWebsocketServerFactory,
# which has notification methods that can be passed
# as callbacks for in-wallet events:
self.wss_factory = None
# keep track of whether we're running actively as maker
# or taker:
self.coinjoin_state = CJ_NOT_RUNNING
# keep track of client side connections so they
# can be shut down cleanly:
self.coinjoin_connection = None
# ensure shut down does not leave dangling services:
atexit.register(self.stopService)
def activate_coinjoin_state(self, state):
""" To be set when a maker or taker
operation is initialized; they cannot
both operate at once, nor can we run repeated
instances of either (hence 'activate' rather than 'set').
Since running the maker means running the
YieldGeneratorService, the start and stop of that service
is encapsulated here.
Returns:
True if and only if the switching on of the chosen state
(including the 'switching on' of the 'not running' state!)
was actually enacted. If the new chosen state cannot be
switched on, returns False.
"""
assert state in [CJ_MAKER_RUNNING, CJ_TAKER_RUNNING, CJ_NOT_RUNNING]
if state == self.coinjoin_state:
# cannot re-active currently active state, as per above;
# note that this rejects switching "off" when we're already
# off.
return False
elif self.coinjoin_state == CJ_NOT_RUNNING:
self.coinjoin_state = state
self.wss_factory.sendCoinjoinStatusUpdate(self.coinjoin_state)
return True
elif state == CJ_NOT_RUNNING:
# currently active, switching off.
self.coinjoin_state = state
self.wss_factory.sendCoinjoinStatusUpdate(self.coinjoin_state)
return True
# anything else is a conflict and we can't change:
return False
def startService(self):
""" Encapsulates start up actions.
Here starting the TLS server.
"""
super().startService()
# we do not auto-start any service, including the base
# wallet service, since the client must actively request
# that with the appropriate credential (password).
# initialise the web socket service for subscriptions
self.wss_factory = JmwalletdWebSocketServerFactory(self.wss_url)
self.wss_factory.protocol = JmwalletdWebSocketServerProtocol
if self.tls:
cf = get_ssl_context(os.path.join(jm_single().datadir, "ssl"))
listener_rpc = reactor.listenSSL(self.port, Site(
self.app.resource()), contextFactory=cf)
listener_ws = listenWS(self.wss_factory, contextFactory=cf)
else:
listener_rpc = reactor.listenTCP(self.port, Site(
self.app.resource()))
listener_ws = listenWS(self.wss_factory, contextFactory=None)
return (listener_rpc, listener_ws)
def stopService(self):
""" Encapsulates shut down actions.
"""
# Currently valid authorization tokens must be removed
# from the daemon:
self.cookie = None
# if the wallet-daemon is shut down, all services
# it encapsulates must also be shut down.
for name, service in self.services.items():
if service:
service.stopService()
super().stopService()
def err(self, request, message):
""" Return errors in a standard format.
"""
request.setHeader('Content-Type', 'application/json')
return json.dumps({"message": message})
@app.handle_errors(NotAuthorized)
def not_authorized(self, request, failure):
request.setResponseCode(401)
return self.err(request, "Invalid credentials.")
@app.handle_errors(NoWalletFound)
def no_wallet_found(self, request, failure):
request.setResponseCode(404)
return self.err(request, "No wallet loaded.")
@app.handle_errors(BackendNotReady)
def backend_not_ready(self, request, failure):
request.setResponseCode(503)
return self.err(request, "Backend daemon not available")
@app.handle_errors(InvalidRequestFormat)
def invalid_request_format(self, request, failure):
request.setResponseCode(400)
return self.err(request, "Invalid request format.")
@app.handle_errors(ServiceAlreadyStarted)
def service_already_started(self, request, failure):
request.setResponseCode(401)
return self.err(request, "Service already started.")
@app.handle_errors(WalletAlreadyUnlocked)
def wallet_already_unlocked(self, request, failure):
request.setResponseCode(401)
return self.err(request, "Wallet already unlocked.")
@app.handle_errors(WalletAlreadyExists)
def wallet_already_exists(self, request, failure):
request.setResponseCode(409)
return self.err(request, "Wallet file cannot be overwritten.")
@app.handle_errors(LockExists)
def lock_exists(self, request, failure):
request.setResponseCode(409)
return self.err(request,
"Wallet cannot be created/opened, it is locked.")
@app.handle_errors(ConfigNotPresent)
def config_not_present(self, request, failure):
request.setResponseCode(409)
return self.err(request,
"Action cannot be performed, config vars are not set.")
@app.handle_errors(ServiceNotStarted)
def service_not_started(self, request, failure):
request.setResponseCode(401)
return self.err(request,
"Service cannot be stopped as it is not running.")
@app.handle_errors(TransactionFailed)
def transaction_failed(self, request, failure):
# TODO 409 as 'conflicted state' may not be ideal?
request.setResponseCode(409)
return self.err(request, "Transaction failed.")
def check_cookie(self, request):
#part after bearer is what we need
try:
auth_header=((request.getHeader('Authorization')))
request_cookie = None
if auth_header is not None:
request_cookie=auth_header[7:]
except Exception:
# deliberately catching anything
raise NotAuthorized()
if request_cookie==None or self.cookie != request_cookie:
jlog.warn("Invalid cookie: " + str(
request_cookie) + ", request rejected.")
raise NotAuthorized()
def get_POST_body(self, request, keys):
""" given a request object, retrieve values corresponding
to keys keys in a dict, assuming they were encoded using JSON.
If *any* of the keys are not present, return False, else
returns a dict of those key-value pairs.
"""
assert isinstance(request.content, BytesIO)
# we swallow any formatting failure here:
try:
json_data = json.loads(request.content.read().decode(
"utf-8"))
return {k: json_data[k] for k in keys}
except:
return False
def initialize_wallet_service(self, request, wallet, wallet_name, **kwargs):
""" Called only when the wallet has loaded correctly, so
authorization is passed, so set cookie for this wallet
(currently THE wallet, daemon does not yet support multiple).
This is maintained for 30 minutes currently, or until the user
switches to a new wallet.
Here we must also register transaction update callbacks, to fire
events in the websocket connection.
"""
# any random secret is OK, as long as it is not deducible/predictable:
secret_key = bintohex(os.urandom(16))
encoded_token = jwt.encode({"wallet": wallet_name,
"exp" :datetime.datetime.utcnow(
)+datetime.timedelta(minutes=30)},
secret_key)
encoded_token = encoded_token.strip()
self.cookie = encoded_token
if self.cookie is None:
raise NotAuthorized("No cookie")
self.wallet_service = WalletService(wallet)
# restart callback needed, otherwise wallet creation will
# automatically lead to shutdown.
# TODO: this means that it's possible, in non-standard usage
# patterns, for the sync to complete without a full record of
# balances; there are various approaches to passing warnings
# or requesting rescans, none are implemented yet.
def dummy_restart_callback(msg):
jlog.warn("Ignoring rescan request from backend wallet service: " + msg)
self.wallet_service.add_restart_callback(dummy_restart_callback)
self.wallet_name = wallet_name
# the daemon blocks here until the wallet synchronization
# from the blockchain interface completes; currently this is
# fine as long as the client handles the response asynchronously:
while not self.wallet_service.synced:
self.wallet_service.sync_wallet(fast=True)
self.wallet_service.register_callbacks(
[self.wss_factory.sendTxNotification], None)
self.wallet_service.startService()
# now that the service is intialized, we want to
# make sure that any websocket clients use the correct
# token:
self.wss_factory.valid_token = encoded_token
# now that the WalletService instance is active and ready to
# respond to requests, we return the status to the client:
if('seedphrase' in kwargs):
return make_jmwalletd_response(request,
walletname=self.wallet_name,
token=encoded_token,
seedphrase=kwargs.get('seedphrase'))
else:
return make_jmwalletd_response(request,
walletname=self.wallet_name,
token=encoded_token)
def taker_finished(self, res, fromtx=False, waittime=0.0, txdetails=None):
# This is a slimmed down version compared with what is seen in
# the CLI code, since that code encompasses schedules with multiple
# entries; for now, the RPC only supports single joins.
# TODO this may be updated.
# It is also different in that the event loop must not shut down
# when processing finishes.
assert fromtx is False
if not res:
jlog.info("Coinjoin did not complete successfully.")
#Should usually be unreachable, unless conf received out of order;
#because we should stop on 'unconfirmed' for last (see above)
else:
jlog.info("Coinjoin completed correctly")
# reset our state on completion, we are no longer coinjoining:
self.taker = None
# Note; it's technically possible for this to return False if somehow
# we are currently in inactive state, but it isn't an error:
self.activate_coinjoin_state(CJ_NOT_RUNNING)
# remove dangling connections
if self.clientfactory:
self.clientfactory.proto_client.request_mc_shutdown()
if self.coinjoin_connection:
try:
self.coinjoin_connection.disconnect()
# note that "serverconn" here is the jm messaging daemon,
# listening for new connections, so we don't shut it down
# as both makers and takers will assume it's started up.
except Exception as e:
# Should not happen, but avoid crash if trying to
# shut down something that already disconnected:
jlog.warn("Failed to shut down connection: " + repr(e))
self.coinjoin_connection = None
def filter_orders_callback(self,orderfees, cjamount):
""" Currently we rely on the user's fee limit choices
and don't allow them to inspect the offers before acceptance.
TODO: two phase response to client.
"""
return True
def check_daemon_ready(self):
# daemon must be up before coinjoins start.
daemon_serving_host, daemon_serving_port = get_daemon_serving_params()
if daemon_serving_port == -1 or daemon_serving_host == "":
raise BackendNotReady()
return (daemon_serving_host, daemon_serving_port)
""" RPC begins here.
"""
# handling CORS preflight for any route:
# TODO is this ever needed?
@app.route('/', branch=True, methods=['OPTIONS'])
def preflight(self, request):
request.setHeader("Access-Control-Allow-Origin", "*")
request.setHeader("Access-Control-Allow-Methods", "POST")
with app.subroute(api_version_string) as app:
@app.route('/wallet/<string:walletname>/display', methods=['GET'])
def displaywallet(self, request, walletname):
print_req(request)
self.check_cookie(request)
if not self.wallet_service:
jlog.warn("displaywallet called, but no wallet loaded")
raise NoWalletFound()
if not self.wallet_name == walletname:
jlog.warn("called displaywallet with wrong wallet")
raise InvalidRequestFormat()
else:
walletinfo = wallet_display(self.wallet_service, False, jsonified=True)
return make_jmwalletd_response(request, walletname=walletname, walletinfo=walletinfo)
@app.route('/session', methods=['GET'])
def session(self, request):
""" This route functions as a heartbeat, and communicates
to the client what the current status of the wallet
and services is. TODO: add more data to send to client.
"""
#if no wallet loaded then clear frontend session info
#when no wallet status is false
session = not self.cookie==None
maker_running = self.coinjoin_state == CJ_MAKER_RUNNING
coinjoin_in_process = self.coinjoin_state == CJ_TAKER_RUNNING
if self.wallet_service:
if self.wallet_service.isRunning():
wallet_name = self.wallet_name
else:
wallet_name = "not yet loaded"
else:
wallet_name = "None"
return make_jmwalletd_response(request,session=session,
maker_running=maker_running,
coinjoin_in_process=coinjoin_in_process,
wallet_name=wallet_name)
@app.route('/wallet/<string:walletname>/taker/direct-send', methods=['POST'])
def directsend(self, request, walletname):
""" Use the contents of the POST body to do a direct send from
the active wallet at the chosen mixdepth.
"""
self.check_cookie(request)
assert isinstance(request.content, BytesIO)
payment_info_json = self.get_POST_body(request, ["mixdepth", "amount_sats",
"destination"])
if not payment_info_json:
raise InvalidRequestFormat()
if not self.wallet_service:
raise NoWalletFound()
if not self.wallet_name == walletname:
raise InvalidRequestFormat()
try:
tx = direct_send(self.wallet_service,
int(payment_info_json["amount_sats"]),
int(payment_info_json["mixdepth"]),
destination=payment_info_json["destination"],
return_transaction=True, answeryes=True)
except AssertionError:
raise InvalidRequestFormat()
if not tx:
# this should not really happen; not a coinjoin
# so tx should go through.
raise TransactionFailed()
return make_jmwalletd_response(request,
txinfo=human_readable_transaction(tx, False))
@app.route('/wallet/<string:walletname>/maker/start', methods=['POST'])
def start_maker(self, request, walletname):
""" Use the configuration in the POST body to start the yield generator:
"""
print_req(request)
self.check_cookie(request)
assert isinstance(request.content, BytesIO)
config_json = self.get_POST_body(request, ["txfee", "cjfee_a", "cjfee_r",
"ordertype", "minsize"])
if not config_json:
raise InvalidRequestFormat()
if not self.wallet_service:
raise NoWalletFound()
if not self.wallet_name == walletname:
raise InvalidRequestFormat()
dhost, dport = self.check_daemon_ready()
for key, val in config_json.items():
if(key == 'cjfee_r' or key == 'ordertype'):
pass
else:
try:
config_json[key] = int(config_json[key])
except ValueError:
raise InvalidRequestFormat()
# these fields are not used by the "basic" yg.
# TODO "upgrade" this to yg-privacyenhanced type.
config_json['txfee_factor'] = None
config_json["cjfee_factor"] = None
config_json["size_factor"] = None
self.services["maker"] = YieldGeneratorService(self.wallet_service,
dhost, dport,
[config_json[x] for x in ["txfee", "cjfee_a",
"cjfee_r", "ordertype", "minsize",
"txfee_factor", "cjfee_factor","size_factor"]])
# make sure that our state here is consistent with any unexpected
# shutdown of the maker (such as from a invalid minsize causing startup
# to fail):
def cleanup():
self.activate_coinjoin_state(CJ_NOT_RUNNING)
def setup():
# note this returns False if we cannot update the state.
return self.activate_coinjoin_state(CJ_MAKER_RUNNING)
self.services["maker"].addCleanup(cleanup)
self.services["maker"].addSetup(setup)
# Service startup now checks and updates coinjoin state:
try:
self.services["maker"].startService()
except YieldGeneratorServiceSetupFailed:
raise ServiceAlreadyStarted()
return make_jmwalletd_response(request)
@app.route('/wallet/<string:walletname>/maker/stop', methods=['GET'])
def stop_maker(self, request, walletname):
self.check_cookie(request)
if not self.wallet_service:
raise NoWalletFound()
if not self.wallet_name == walletname:
raise InvalidRequestFormat()
if not self.services["maker"] or not self.coinjoin_state == \
CJ_MAKER_RUNNING:
raise ServiceNotStarted()
self.services["maker"].stopService()
return make_jmwalletd_response(request)
@app.route('/wallet/<string:walletname>/lock', methods=['GET'])
def lockwallet(self, request, walletname):
print_req(request)
self.check_cookie(request)
if self.wallet_service and not self.wallet_name == walletname:
raise InvalidRequestFormat()
if not self.wallet_service:
jlog.warn("Called lock, but no wallet loaded")
# we could raise NoWalletFound here, but is
# easier for clients if they can gracefully call
# lock multiple times:
already_locked = True
else:
self.wallet_service.stopService()
self.cookie = None
self.wss_factory.valid_token = None
self.wallet_service = None
already_locked = False
return make_jmwalletd_response(request, walletname=walletname,
already_locked=already_locked)
@app.route('/wallet/create', methods=["POST"])
def createwallet(self, request):
print_req(request)
# we only handle one wallet at a time;
# if there is a currently unlocked wallet,
# refuse to process the request:
if self.wallet_service:
raise WalletAlreadyUnlocked()
request_data = self.get_POST_body(request,
["walletname", "password", "wallettype"])
if not request_data:
raise InvalidRequestFormat()
wallettype = request_data["wallettype"]
if wallettype == "sw":
wallet_cls = SegwitWallet
elif wallettype == "sw-legacy":
wallet_cls = SegwitLegacyWallet
elif wallettype == "sw-fb":
wallet_cls = SegwitWalletFidelityBonds
else:
raise InvalidRequestFormat()
# use the config's data location combined with the json
# data to construct the wallet path:
wallet_root_path = os.path.join(jm_single().datadir, "wallets")
wallet_name = os.path.join(wallet_root_path,
request_data["walletname"])
try:
wallet = create_wallet(wallet_name,
request_data["password"].encode("ascii"),
4, wallet_cls=wallet_cls)
# extension not yet supported in RPC create; TODO
seed, extension = wallet.get_mnemonic_words()
except RetryableStorageError:
raise LockExists()
except StorageError:
raise WalletAlreadyExists()
# finally, after the wallet is successfully created, we should
# start the wallet service, then return info to the caller:
return self.initialize_wallet_service(request, wallet,
request_data["walletname"],
seedphrase=seed)
@app.route('/wallet/<string:walletname>/unlock', methods=['POST'])
def unlockwallet(self, request, walletname):
""" If a user succeeds in authenticating and opening a
wallet, we start the corresponding wallet service.
"""
print_req(request)
assert isinstance(request.content, BytesIO)
auth_json = self.get_POST_body(request, ["password"])
if not auth_json:
raise InvalidRequestFormat()
password = auth_json["password"]
if self.wallet_service is None:
wallet_path = get_wallet_path(walletname, None)
try:
wallet = open_test_wallet_maybe(
wallet_path, walletname, 4,
password=password.encode("utf-8"),
ask_for_password=False)
except StoragePasswordError:
raise NotAuthorized()
except RetryableStorageError:
# .lock file exists
raise LockExists()
except StorageError:
# wallet is not openable
raise NoWalletFound()
except Exception:
# wallet file doesn't exist or is wrong format
raise NoWalletFound()
return self.initialize_wallet_service(request, wallet, walletname)
else:
jlog.warn('Tried to unlock wallet, but one is already unlocked.')
jlog.warn('Currently only one active wallet at a time is supported.')
raise WalletAlreadyUnlocked()
#This route should return list of current wallets created.
@app.route('/wallet/all', methods=['GET'])
def listwallets(self, request):
wallet_dir = os.path.join(jm_single().datadir, 'wallets')
# TODO: we only allow .jmdat files, and assume they
# are actually wallets; but we should validate these
# wallet files before returning them (though JM itself
# never puts any other kind of file in this directory,
# the user conceivably might).
if not os.path.exists(wallet_dir):
wallets = []
else:
wallets = os.listdir(wallet_dir)
wallets = [w for w in wallets if w.endswith("jmdat")]
return make_jmwalletd_response(request, wallets=wallets)
#route to get external address for deposit
@app.route('/wallet/<string:walletname>/address/new/<string:mixdepth>', methods=['GET'])
def getaddress(self, request, walletname, mixdepth):
self.check_cookie(request)
if not self.wallet_service:
raise NoWalletFound()
if not self.wallet_name == walletname:
raise InvalidRequestFormat()
try:
mixdepth = int(mixdepth)
except ValueError:
raise InvalidRequestFormat()
address = self.wallet_service.get_external_addr(mixdepth)
return make_jmwalletd_response(request, address=address)
@app.route('/wallet/<string:walletname>/address/timelock/new/<string:lockdate>', methods=['GET'])
def gettimelockaddress(self, request, walletname):
self.check_cookie(request)
if not self.wallet_service:
raise NoWalletFound()
if not self.wallet_name == walletname:
raise InvalidRequestFormat()
try:
timelockaddress = wallet_gettimelockaddress(self.wallet_service,
lockdate)
except Exception as e:
return InvalidRequestFormat()
if timelockaddress == "":
return InvalidRequestFormat()
return make_jmwalletd_response(request, address=address)
@app.route('/wallet/<string:walletname>/configget', methods=["POST"])
def configget(self, request, walletname):
""" Note that this requires authentication but is not wallet-specific.
Note also that return values are always strings.
"""
self.check_cookie(request)
# This is more just a sanity check; if user is using the wrong
# walletname but the right token, something has gone very wrong:
if not self.wallet_name == walletname:
raise InvalidRequestFormat()
config_json = self.get_POST_body(request, ["section", "field"])
if not config_json:
raise InvalidRequestFormat()
try:
val = jm_single().config.get(config_json["section"],
config_json["field"])
except:
# assuming failure here is a badly formed section/field:
raise ConfigNotPresent()
return make_jmwalletd_response(request, configvalue=val)
@app.route('/wallet/<string:walletname>/configset', methods=["POST"])
def configset(self, request, walletname):
""" Note that this requires authentication but is not wallet-specific.
Note also that supplied values must always be strings.
"""
self.check_cookie(request)
# This is more just a sanity check; if user is using the wrong
# walletname but the right token, something has gone very wrong:
if not self.wallet_name == walletname:
raise InvalidRequestFormat()
config_json = self.get_POST_body(request, ["section", "field", "value"])
if not config_json:
raise InvalidRequestFormat()
try:
jm_single().config.set(config_json["section"],
config_json["field"], config_json["value"])
except:
raise ConfigNotPresent()
# null return indicates success in updating:
return make_jmwalletd_response(request)
def get_listutxos_response(self, utxos):
res = []
for k, v in utxos.items():
v["utxo"] = k
res.append(v)
return res
#route to list utxos
@app.route('/wallet/<string:walletname>/utxos',methods=['GET'])
def listutxos(self, request, walletname):
self.check_cookie(request)
if not self.wallet_service:
raise NoWalletFound()
if not self.wallet_name == walletname:
raise InvalidRequestFormat()
# note: the output of `showutxos` is already a string for CLI;
# but we return json:
utxos = json.loads(wallet_showutxos(self.wallet_service, False))
utxos_response = self.get_listutxos_response(utxos)
return make_jmwalletd_response(request, utxos=utxos_response)
#route to start a coinjoin transaction
@app.route('/wallet/<string:walletname>/taker/coinjoin',methods=['POST'])
def docoinjoin(self, request, walletname):
self.check_cookie(request)
if not self.wallet_service:
raise NoWalletFound()
if not self.wallet_name == walletname:
raise InvalidRequestFormat()
request_data = self.get_POST_body(request,["mixdepth", "amount_sats",
"counterparties", "destination"])
if not request_data:
raise InvalidRequestFormat()
#see file scripts/sample-schedule-for-testnet for schedule format
waittime = 0
rounding= 16
completion_flag= 0
# A schedule is a list of lists, here we have only one item
try:
schedule = [[int(request_data["mixdepth"]),
int(request_data["amount_sats"]),
int(request_data["counterparties"]),
request_data["destination"], waittime,
rounding, completion_flag]]
except ValueError:
raise InvalidRequestFormat()
# Before actual start, update our coinjoin state:
if not self.activate_coinjoin_state(CJ_TAKER_RUNNING):
raise ServiceAlreadyStarted()
# Instantiate a Taker.
# `order_chooser` is whatever is default for Taker.
# max_cj_fee is to be set based on config values.
# If user has not set config, we only for now raise
# an error specific to this case; in future we can
# pass a request to a client to set the values, as
# we do in CLI (the usual reasoning applies as to
# why no defaults).
def dummy_user_callback(rel, abs):
raise ConfigNotPresent()
max_cj_fee= get_max_cj_fee_values(jm_single().config,
None, user_callback=dummy_user_callback)
self.taker = Taker(self.wallet_service, schedule,
max_cj_fee = max_cj_fee,
callbacks=(self.filter_orders_callback,
None, self.taker_finished))
# TODO ; this makes use of a pre-existing hack to allow
# selectively disabling the stallMonitor function that checks
# if transactions went through or not; here we want to cleanly
# destroy the Taker after an attempt is made, successful or not.
self.taker.testflag = True
self.clientfactory = JMClientProtocolFactory(self.taker)
dhost, dport = self.check_daemon_ready()
_, self.coinjoin_connection = start_reactor(dhost, dport,
self.clientfactory, rs=False)
return make_jmwalletd_response(request)

31
jmclient/jmclient/wallet_utils.py

@ -177,6 +177,12 @@ class WalletViewEntry(WalletViewBase):
extradata = self.serialize_extra_data()
return self.serclass(self.separator.join([left, addr, amounts, extradata]))
def serialize_json(self):
return {"hd_path": self.wallet_path_repr,
"address": self.serialize_address(),
"amount": self.serialize_amounts(),
"labels": self.serialize_extra_data()}
def serialize_wallet_position(self):
return self.wallet_path_repr.ljust(20)
@ -221,7 +227,6 @@ class WalletViewBranch(WalletViewBase):
def serialize(self, entryseparator="\n", summarize=False):
if summarize:
return ""
else:
lines = [self.serialize_branch_header()]
for we in self.branchentries:
lines.append(we.serialize())
@ -229,6 +234,13 @@ class WalletViewBranch(WalletViewBase):
lines.append(footer)
return self.serclass(entryseparator.join(lines))
def serialize_json(self, summarize=False):
if summarize:
return {}
return {"branch": self.serialize_branch_header(),
"balance": self.get_fmt_balance(),
"entries": [x.serialize_json() for x in self.branchentries]}
def serialize_branch_header(self):
start = "external addresses" if self.address_type == 0 else "internal addresses"
if self.address_type == -1:
@ -263,6 +275,14 @@ class WalletViewAccount(WalletViewBase):
return self.serclass(entryseparator.join([header] + [
x.serialize(entryseparator) for x in self.branches] + [footer]))
def serialize_json(self, summarize=False):
result = {"account": str(self.account),
"account_balance": self.get_fmt_balance()}
if summarize:
return result
result["branches"] = [x.serialize_json() for x in self.branches]
return result
class WalletView(WalletViewBase):
def __init__(self, wallet_path_repr, accounts, wallet_name="JM wallet",
serclass=str, custom_separator=None):
@ -286,6 +306,10 @@ class WalletView(WalletViewBase):
return self.serclass(entryseparator.join([header] + [
x.serialize(entryseparator, summarize=False) for x in self.accounts] + [footer]))
def serialize_json(self, summarize=False):
return {"wallet_name": self.wallet_name,
"total_balance": self.get_fmt_balance(),
"accounts": [x.serialize_json(summarize=summarize) for x in self.accounts]}
def get_tx_info(txid, tx_cache=None):
"""
@ -393,7 +417,7 @@ def wallet_showutxos(wallet_service, showprivkey):
def wallet_display(wallet_service, showprivkey, displayall=False,
serialized=True, summarized=False, mixdepth=None):
serialized=True, summarized=False, mixdepth=None, jsonified=False):
"""build the walletview object,
then return its serialization directly if serialized,
else return the WalletView object.
@ -546,6 +570,9 @@ def wallet_display(wallet_service, showprivkey, displayall=False,
path = wallet_service.get_path_repr(wallet_service.get_path())
walletview = WalletView(path, acctlist)
if serialized:
if jsonified:
return walletview.serialize_json(summarize=summarized)
else:
return walletview.serialize(summarize=summarized)
else:
return walletview

84
jmclient/jmclient/websocketserver.py

@ -0,0 +1,84 @@
import json
from autobahn.twisted.websocket import WebSocketServerFactory, \
WebSocketServerProtocol
from jmbitcoin import human_readable_transaction
from jmbase import get_log
jlog = get_log()
class JmwalletdWebSocketServerProtocol(WebSocketServerProtocol):
def onOpen(self):
self.token = None
self.factory.register(self)
def sendNotification(self, info):
""" Passes on an object (json encoded) to the client,
if currently authenticated.
"""
if not self.token:
# gating by token means even if this client
# is erroneously in a broadcast list, it won't get
# any data if it hasn't authenticated.
jlog.warn("Websocket not sending notification, "
"the connection is not authenticated.")
return
self.sendMessage(json.dumps(info).encode())
def connectionLost(self, reason):
""" Overridden to ensure that we aren't attempting to
send notifications on broken connections.
"""
WebSocketServerProtocol.connectionLost(self, reason)
self.factory.unregister(self)
def onMessage(self, payload, isBinary):
""" We currently only allow messages which
are JWT tokens used for authentication. Any
other message will drop the connection.
"""
if not isBinary:
self.token = payload.decode('utf8')
# check that the token set for this protocol
# instance is the same as the one that the
# JMWalletDaemon instance deems is valid.
if not self.factory.check_token(self.token):
self.dropConnection()
class JmwalletdWebSocketServerFactory(WebSocketServerFactory):
def __init__(self, url):
WebSocketServerFactory.__init__(self, url)
self.valid_token = None
self.clients = []
def check_token(self, token):
return self.valid_token == token
def register(self, client):
if client not in self.clients:
self.clients.append(client)
def unregister(self, client):
if client in self.clients:
self.clients.remove(client)
def sendTxNotification(self, txd, txid):
""" Note that this is a WalletService callback;
the return value is only important for conf/unconf
callbacks, not for 'all' callbacks, so we return
None
"""
json_tx = json.loads(human_readable_transaction(txd))
for client in self.clients:
client.sendNotification({"txid": txid,
"txdetails": json_tx})
def sendCoinjoinStatusUpdate(self, new_state):
""" The state sent is an integer, see
jmclient.wallet_rpc.
0: taker is running
1: maker is running (but not necessarily currently
coinjoining, note)
2: neither is running
"""
for client in self.clients:
client.sendNotification({"coinjoin_state": new_state})

80
jmclient/jmclient/yieldgenerator.py

@ -6,6 +6,8 @@ import time
import abc
import base64
from twisted.python.log import startLogging
from twisted.application.service import Service
from twisted.internet import task
from optparse import OptionParser
from jmbase import get_log
from jmclient import (Maker, jm_single, load_program_config,
@ -81,11 +83,14 @@ class YieldGeneratorBasic(YieldGenerator):
thus is somewhat suboptimal in giving more information to spies.
"""
def __init__(self, wallet_service, offerconfig):
# note the randomizing entries are ignored in this base class:
#note the randomizing entries are ignored in this base class:
self.txfee, self.cjfee_a, self.cjfee_r, self.ordertype, self.minsize, \
self.txfee_factor, self.cjfee_factor, self.size_factor = offerconfig
super().__init__(wallet_service)
def create_my_orders(self):
mix_balance = self.get_available_mixdepths()
if len([b for m, b in mix_balance.items() if b > 0]) == 0:
@ -263,6 +268,79 @@ class YieldGeneratorBasic(YieldGenerator):
cjoutmix = (input_mixdepth + 1) % (self.wallet_service.mixdepth + 1)
return self.wallet_service.get_internal_addr(cjoutmix)
class YieldGeneratorServiceSetupFailed(Exception):
pass
class YieldGeneratorService(Service):
def __init__(self, wallet_service, daemon_host, daemon_port, yg_config):
self.wallet_service = wallet_service
self.daemon_host = daemon_host
self.daemon_port = daemon_port
self.yg_config = yg_config
self.yieldgen = None
# setup,cleanup functions are to be run before
# starting, shutting down the service:
self.setup_fns = []
self.cleanup_fns = []
def startService(self):
""" We instantiate the Maker class only
here as its constructor will automatically
create orders based on the wallet.
Note makers already intrinsically handle
not-yet-synced wallet services, so there is
no need to check this here.
"""
for setup in self.setup_fns:
if not setup():
raise YieldGeneratorServiceSetupFailed
# TODO genericise to any YG class:
self.yieldgen = YieldGeneratorBasic(self.wallet_service, self.yg_config)
self.clientfactory = JMClientProtocolFactory(self.yieldgen, proto_type="MAKER")
# here 'start_reactor' does not start the reactor but instantiates
# the connection to the daemon backend; note daemon=False, i.e. the daemon
# backend is assumed to be started elsewhere; we just connect to it with a client.
start_reactor(self.daemon_host, self.daemon_port, self.clientfactory, rs=False)
# monitor the Maker object, just to check if it's still in an "up" state, marked
# by the aborted instance var:
self.monitor_loop = task.LoopingCall(self.monitor)
self.monitor_loop.start(0.5)
super().startService()
def monitor(self):
if self.yieldgen.aborted:
self.monitor_loop.stop()
self.stopService()
def addSetup(self, setup):
""" Setup functions as callbacks:
arguments - none
returns: must return True if the setup step
was successful, or False otherwise.
"""
self.setup_fns.append(setup)
def addCleanup(self, cleanup):
""" Cleanup functions as callbacks:
no arguments, and no return (we don't
intend to stop shutting down if the cleanup
doesn't work somehow).
"""
self.cleanup_fns.append(cleanup)
def stopService(self):
""" TODO need a method exposed to gracefully
shut down a maker bot.
"""
if self.running:
jlog.info("Shutting down YieldGenerator service.")
self.clientfactory.proto_client.request_mc_shutdown()
super().stopService()
for cleanup in self.cleanup_fns:
cleanup()
def isRunning(self):
return self.running == 1
def ygmain(ygclass, nickserv_password='', gaplimit=6):
import sys

3
jmclient/setup.py

@ -10,6 +10,7 @@ setup(name='joinmarketclient',
license='GPL',
packages=['jmclient'],
install_requires=['joinmarketbase==0.9.2', 'mnemonic', 'argon2_cffi',
'bencoder.pyx>=2.0.0', 'pyaes'],
'bencoder.pyx>=2.0.0', 'pyaes', 'klein==20.6.0',
'pyjwt==2.1.0', 'autobahn==20.7.1'],
python_requires='>=3.6',
zip_safe=False)

411
jmclient/test/test_wallet_rpc.py

@ -0,0 +1,411 @@
import os, json
from twisted.internet import reactor, defer, task
from twisted.web.client import readBody, Headers
from twisted.trial import unittest
from autobahn.twisted.websocket import WebSocketClientFactory, \
connectWS
from jmbase import get_nontor_agent, hextobin, BytesProducer, get_log
from jmbitcoin import CTransaction
from jmclient import (load_test_config, jm_single,
JMWalletDaemon, validate_address, start_reactor)
from jmclient.wallet_rpc import api_version_string
from commontest import make_wallets
from test_coinjoin import make_wallets_to_list, sync_wallets
from test_websocket import (ClientTProtocol, test_tx_hex_1,
test_tx_hex_txid, encoded_token)
testdir = os.path.dirname(os.path.realpath(__file__))
testfileloc = "testwrpc.jmdat"
jlog = get_log()
class JMWalletDaemonT(JMWalletDaemon):
def check_cookie(self, request):
if self.auth_disabled:
return True
return super().check_cookie(request)
class WalletRPCTestBase(object):
""" Base class for set up of tests of the
Wallet RPC calls using the wallet_rpc.JMWalletDaemon service.
"""
# the indices in our wallets to populate
wallet_structure = [1, 3, 0, 0, 0]
# the mean amount of each deposit in the above indices, in btc
mean_amt = 2.0
# the port for the jmwallet daemon
dport = 28183
# the port for the ws
wss_port = 28283
def setUp(self):
load_test_config()
self.clean_out_wallet_file()
jm_single().bc_interface.tick_forward_chain_interval = 5
jm_single().bc_interface.simulate_blocks()
# a client connnection object which is often but not always
# instantiated:
self.client_connector = None
# start the daemon; note we are using tcp connections
# to avoid storing certs in the test env.
# TODO change that.
self.daemon = JMWalletDaemonT(self.dport, self.wss_port, tls=False)
self.daemon.auth_disabled = False
# because we sync and start the wallet service manually here
# (and don't use wallet files yet), we won't have set a wallet name,
# so we set it here:
self.daemon.wallet_name = testfileloc
r, s = self.daemon.startService()
self.listener_rpc = r
self.listener_ws = s
wallet_structures = [self.wallet_structure] * 2
# note: to test fidelity bond wallets we should add the argument
# `wallet_cls=SegwitWalletFidelityBonds` here, but it slows the
# test down from 9 seconds to 1 minute 40s, which is too slow
# to be acceptable. TODO: add a test with FB by speeding up
# the sync for test, by some means or other.
self.daemon.wallet_service = make_wallets_to_list(make_wallets(
1, wallet_structures=[wallet_structures[0]],
mean_amt=self.mean_amt))[0]
jm_single().bc_interface.tickchain()
sync_wallets([self.daemon.wallet_service])
# dummy tx example to force a notification event:
self.test_tx = CTransaction.deserialize(hextobin(test_tx_hex_1))
def get_route_root(self):
addr = "http://127.0.0.1:" + str(self.dport)
addr += api_version_string
return addr
def clean_out_wallet_file(self):
if os.path.exists(os.path.join(".", "wallets", testfileloc)):
os.remove(os.path.join(".", "wallets", testfileloc))
def tearDown(self):
self.clean_out_wallet_file()
for dc in reactor.getDelayedCalls():
dc.cancel()
d1 = defer.maybeDeferred(self.listener_ws.stopListening)
d2 = defer.maybeDeferred(self.listener_rpc.stopListening)
if self.client_connector:
self.client_connector.disconnect()
# only fire if everything is finished:
return defer.gatherResults([d1, d2])
class TrialTestWRPC_WS(WalletRPCTestBase, unittest.TestCase):
""" class for testing websocket subscriptions/events etc.
"""
def test_notif(self):
# simulate the daemon already having created
# a valid token (which it usually does when
# starting the WalletService:
self.daemon.wss_factory.valid_token = encoded_token
self.client_factory = WebSocketClientFactory(
"ws://127.0.0.1:"+str(self.wss_port))
self.client_factory.protocol = ClientTProtocol
self.client_connector = connectWS(self.client_factory)
d = task.deferLater(reactor, 0.1, self.fire_tx_notif)
# create a small delay between the instruction to send
# the notification, and the checking of its receipt,
# otherwise the client will be queried before the notification
# arrived:
d.addCallback(self.wait_to_receive)
return d
def wait_to_receive(self, res):
d = task.deferLater(reactor, 0.1, self.checkNotifs)
return d
def checkNotifs(self):
assert self.client_factory.notifs == 1
def fire_tx_notif(self):
self.daemon.wss_factory.sendTxNotification(self.test_tx,
test_tx_hex_txid)
class TrialTestWRPC_DisplayWallet(WalletRPCTestBase, unittest.TestCase):
@defer.inlineCallbacks
def do_request(self, agent, method, addr, body, handler, token=None):
if token:
headers = Headers({"Authorization": ["Bearer " + self.jwt_token]})
else:
headers = None
response = yield agent.request(method, addr, headers, bodyProducer=body)
yield self.response_handler(response, handler)
@defer.inlineCallbacks
def response_handler(self, response, handler):
body = yield readBody(response)
# these responses should always be 200 OK.
assert response.code == 200
# handlers check the body is as expected; no return.
yield handler(body)
return True
@defer.inlineCallbacks
def test_create_list_lock_unlock(self):
""" A batch of tests in sequence here,
so we can track the state of a created
wallet and check it is what is expected.
We test create first, so we have a wallet.
1. create a wallet and have it persisted
to disk in ./wallets, and get a token.
2. list wallets and check they contain the new
wallet.
3. lock the existing wallet service, using the token.
4. Unlock the wallet with /unlock, get a token.
"""
# before starting, we have to shut down the existing
# wallet service (usually this would be `lock`):
self.daemon.wallet_service = None
self.daemon.stopService()
self.daemon.auth_disabled = False
agent = get_nontor_agent()
root = self.get_route_root()
addr = root + "/wallet/create"
addr = addr.encode()
body = BytesProducer(json.dumps({"walletname": testfileloc,
"password": "hunter2", "wallettype": "sw"}).encode())
yield self.do_request(agent, b"POST", addr, body,
self.process_create_wallet_response)
addr = root + "/wallet/all"
addr = addr.encode()
# does not require a token, though we just got one.
yield self.do_request(agent, b"GET", addr, None,
self.process_list_wallets_response)
# now *lock* the existing, which will shut down the wallet
# service associated.
addr = root + "/wallet/" + self.daemon.wallet_name + "/lock"
addr = addr.encode()
jlog.info("Using address: {}".format(addr))
yield self.do_request(agent, b"GET", addr, None,
self.process_lock_response, token=self.jwt_token)
# wallet service should now be stopped.
addr = root + "/wallet/" + self.daemon.wallet_name + "/unlock"
addr = addr.encode()
body = BytesProducer(json.dumps({"password": "hunter2"}).encode())
yield self.do_request(agent, b"POST", addr, body,
self.process_unlock_response)
def process_create_wallet_response(self, response):
json_body = json.loads(response.decode("utf-8"))
assert json_body["walletname"] == testfileloc
self.jwt_token = json_body["token"]
# we don't use this in test, but it must exist:
assert json_body["seedphrase"]
def process_list_wallets_response(self, body):
json_body = json.loads(body.decode("utf-8"))
assert json_body["wallets"] == [testfileloc]
@defer.inlineCallbacks
def test_direct_send_and_display_wallet(self):
""" First spend a coin, then check the balance
via the display wallet output.
"""
self.daemon.auth_disabled = True
agent = get_nontor_agent()
addr = self.get_route_root()
addr += "/wallet/"
addr += self.daemon.wallet_name
addr += "/taker/direct-send"
addr = addr.encode()
body = BytesProducer(json.dumps({"mixdepth": "1",
"amount_sats": "100000000",
"destination": "2N2JD6wb56AfK4tfmM6PwdVmoYk2dCKf4Br"}).encode())
yield self.do_request(agent, b"POST", addr, body,
self.process_direct_send_response)
# force the wallet service txmonitor to wake up, to see the new
# tx before querying /display:
self.daemon.wallet_service.transaction_monitor()
addr = self.get_route_root()
addr += "/wallet/"
addr += self.daemon.wallet_name
addr += "/display"
addr = addr.encode()
yield self.do_request(agent, b"GET", addr, None,
self.process_wallet_display_response)
def process_direct_send_response(self, response):
json_body = json.loads(response.decode("utf-8"))
assert "txinfo" in json_body
# TODO tx check
print(json_body["txinfo"])
def process_wallet_display_response(self, response):
json_body = json.loads(response.decode("utf-8"))
latest_balance = float(json_body["walletinfo"]["total_balance"])
jlog.info("Wallet display currently shows balance: {}".format(
latest_balance))
assert latest_balance > self.mean_amt * 4.0 - 1.1
assert latest_balance <= self.mean_amt * 4.0 - 1.0
@defer.inlineCallbacks
def test_getaddress(self):
""" Tests that we can source a valid address
for deposits using getaddress.
"""
self.daemon.auth_disabled = True
agent = get_nontor_agent()
addr = self.get_route_root()
addr += "/wallet/"
addr += self.daemon.wallet_name
addr += "/address/new/3"
addr = addr.encode()
yield self.do_request(agent, b"GET", addr, None,
self.process_new_addr_response)
def process_new_addr_response(self, response):
json_body = json.loads(response.decode("utf-8"))
assert validate_address(json_body["address"])[0]
@defer.inlineCallbacks
def test_listutxos(self):
self.daemon.auth_disabled = True
agent = get_nontor_agent()
addr = self.get_route_root()
addr += "/wallet/"
addr += self.daemon.wallet_name
addr += "/utxos"
addr = addr.encode()
yield self.do_request(agent, b"GET", addr, None,
self.process_listutxos_response)
def process_listutxos_response(self, response):
json_body = json.loads(response.decode("utf-8"))
# some fragility in test structure here: what utxos we
# have depend on what other tests occurred.
# For now, we at least check that we have 3 utxos in mixdepth
# 1 because none of the other tests spend them:
mixdepth1_utxos = 0
for d in json_body["utxos"]:
if d["mixdepth"] == 1:
mixdepth1_utxos += 1
assert mixdepth1_utxos == 3
@defer.inlineCallbacks
def test_session(self):
agent = get_nontor_agent()
addr = self.get_route_root()
addr += "/session"
addr = addr.encode()
yield self.do_request(agent, b"GET", addr, None,
self.process_session_response)
def process_session_response(self, response):
json_body = json.loads(response.decode("utf-8"))
assert json_body["maker_running"] is False
assert json_body["coinjoin_in_process"] is False
def process_unlock_response(self, response):
json_body = json.loads(response.decode("utf-8"))
assert json_body["walletname"] == testfileloc
self.jwt_token = json_body["token"]
def process_lock_response(self, response):
json_body = json.loads(response.decode("utf-8"))
assert json_body["walletname"] == testfileloc
@defer.inlineCallbacks
def test_do_coinjoin(self):
""" This slightly weird test curently only
tests *requesting* a coinjoin; because there are
no makers running in the test suite, the Taker will
give up early due to the empty orderbook, but that is
OK since this API call only makes the request.
"""
self.daemon.auth_disabled = True
# in normal operations, the RPC call will trigger
# the jmclient to connect to an *existing* daemon
# that was created on startup, but here, that daemon
# does not yet exist, so we will get 503 Backend Not Ready,
# unless we manually create it:
scon, ccon = start_reactor(jm_single().config.get("DAEMON",
"daemon_host"), jm_single().config.getint("DAEMON",
"daemon_port"), None, daemon=True, rs=False)
# must be manually set:
self.scon = scon
agent = get_nontor_agent()
addr = self.get_route_root()
addr += "/wallet/"
addr += self.daemon.wallet_name
addr += "/taker/coinjoin"
addr = addr.encode()
body = BytesProducer(json.dumps({"mixdepth": "1",
"amount_sats": "22000000",
"counterparties": "2",
"destination": "2N2JD6wb56AfK4tfmM6PwdVmoYk2dCKf4Br"}).encode())
yield self.do_request(agent, b"POST", addr, body,
self.process_do_coinjoin_response)
def process_do_coinjoin_response(self, response):
# response code is already checked to be 200
clientconn = self.daemon.coinjoin_connection
# backend's AMP connection must be cleaned up, otherwise
# test will fail for unclean reactor:
self.addCleanup(clientconn.disconnect)
self.addCleanup(self.scon.stopListening)
assert json.loads(response.decode("utf-8")) == {}
"""
Sample listutxos response for reference:
{
"utxos": [{
"utxo": "e01f349b1b5659c01f09ec70ca418a26d34f573e13f878db46dff39763e4dd15:0",
"address": "bcrt1qxgqw54x46kmkkg6g23kdfuy76mfhc4m88shg4n",
"value": 200000000,
"tries": 0,
"tries_remaining": 3,
"external": false,
"mixdepth": 0,
"confirmations": 5,
"frozen": false
}, {
"utxo": "eba94a0011e0f3f97a9c49be7f6ae38eb75bbeacd8c1797425e9005d80ec2f70:0",
"address": "bcrt1qz5p304dj54g9nxh87afyvwpkv0jd3lydka6nfp",
"value": 200000000,
"tries": 0,
"tries_remaining": 3,
"external": false,
"mixdepth": 1,
"confirmations": 4,
"frozen": false
}, {
"utxo": "fd5f181f1c1d1d47f3f110c3426769e60450e779addabf3f57f1732099ecdf97:0",
"address": "bcrt1qu7k4dppungsqp95nwc7ansqs9m0z95h72j9mze",
"value": 200000000,
"tries": 0,
"tries_remaining": 3,
"external": false,
"mixdepth": 1,
"confirmations": 3,
"frozen": false
}, {
"utxo": "03de36659e18068d272e182b2a57fdf8364d0d8c9aaf1b8c971a1590fa983cd5:0",
"address": "bcrt1qk0thvwz8djvnynv2cmq7706ff9tjxcjef3cr7l",
"value": 200000000,
"tries": 0,
"tries_remaining": 3,
"external": false,
"mixdepth": 1,
"confirmations": 2,
"frozen": false
}]
}
"""
"""
Sample displaywallet response for reference:
[{"succeed": true, "status": 200, "walletname": "testwrpc.jmdat", "walletinfo": {"wallet_name": "JM wallet", "total_balance": "6.99998570", "accounts": [{"account": "0", "account_balance": "2.00000000", "branches": [{"branch": "external addresses\tm/84'/1'/0'/0\ttpubDExGchYUujKhNNYvVMjW6S9X4B3Cd3mNqm19vknwovH8buM7GJACi6gCi8Qc9Q9ejBx7phVRUrJFNT5GwpcUSTLqEKNbdCEaKLMdKfgp6Yd", "balance": "2.00000000", "entries": [{"hd_path": "m/84'/1'/0'/0/0", "address": "bcrt1qk4txxx2xzdz8y6yg2w60l9lea6h3k3el7jqnxk", "amount": "2.00000000", "labels": "used"}]}, {"branch": "internal addresses\tm/84'/1'/0'/1\t", "balance": "0.00000000", "entries": []}]}, {"account": "1", "account_balance": "4.99998570", "branches": [{"branch": "external addresses\tm/84'/1'/1'/0\ttpubDET2QAFuGCcmMhzJ6E7yTKUD5Fc8PqnL81yxmb2YZuWcG2MmhoUjLERK7S2gwyGPM1wiaCxWRjWXjnw3KgC9X2wMN38YRj3z4yz43HoMP67", "balance": "4.00000000", "entries": [{"hd_path": "m/84'/1'/1'/0/0", "address": "bcrt1qyqa9sawgwmkpy3pg599mv6peyg9uag8s2pdkpr", "amount": "2.00000000", "labels": "used"}, {"hd_path": "m/84'/1'/1'/0/1", "address": "bcrt1q0ky7pwdzpftd3jy6w6rt8krap2tsrcuzjte69y", "amount": "2.00000000", "labels": "used"}]}, {"branch": "internal addresses\tm/84'/1'/1'/1\t", "balance": "0.99998570", "entries": [{"hd_path": "m/84'/1'/1'/1/0", "address": "bcrt1qjdnnz5w75upqquvcsksyyeq0u9c2m5j9eld0nf", "amount": "0.99998570", "labels": "used"}]}]}, {"account": "2", "account_balance": "0.00000000", "branches": [{"branch": "external addresses\tm/84'/1'/2'/0\ttpubDEGRBmiDr2tqdcQFCVykULPzmuvTUeXCrG6w7C46wp7wrncU1hPpSzoYKn44kw6J6i5doWLSx8bzkjBeh8HvqRVPzJBetuq5xeV2iFWwS6q", "balance": "0.00000000", "entries": []}, {"branch": "internal addresses\tm/84'/1'/2'/1\t", "balance": "0.00000000", "entries": []}]}, {"account": "3", "account_balance": "0.00000000", "branches": [{"branch": "external addresses\tm/84'/1'/3'/0\ttpubDFa44cU854x2qYsHgWU1CFNaNRyQwaceXEHb41BEWw97KMmpaWP9JrbdF3mnzCq1se8GbnT5Ra7erPrh8vSCCNqPUsmsahYVZ3dgVg19dWF", "balance": "0.00000000", "entries": []}, {"branch": "internal addresses\tm/84'/1'/3'/1\t", "balance": "0.00000000", "entries": []}]}, {"account": "4", "account_balance": "0.00000000", "branches": [{"branch": "external addresses\tm/84'/1'/4'/0\ttpubDFK8hTjQBCEz3aaiDeyucPX56DBZprCpJZ5Jrb2cHiWDTudBTYtj6EHSxXypnQQFPAfJH6zVVnC6YzeHBsc79XErY1AkQrJkayySMhKhQbK", "balance": "0.00000000", "entries": []}, {"branch": "internal addresses\tm/84'/1'/4'/1\t", "balance": "0.00000000", "entries": []}]}]}}]
"""

109
jmclient/test/test_websocket.py

@ -0,0 +1,109 @@
import os
import json
import datetime
from twisted.internet import reactor, task
from twisted.trial import unittest
from autobahn.twisted.websocket import WebSocketClientFactory, \
WebSocketClientProtocol, connectWS, listenWS
import jwt
from jmbase import get_log, hextobin
from jmclient import (JmwalletdWebSocketServerFactory,
JmwalletdWebSocketServerProtocol)
from jmbitcoin import CTransaction
testdir = os.path.dirname(os.path.realpath(__file__))
jlog = get_log()
# example transaction for sending a notification with:
test_tx_hex_1 = "02000000000102578770b2732aed421ffe62d54fd695cf281ca336e4f686d2adbb2e8c3bedb2570000000000ffffffff4719a259786b4237f92460629181edcc3424419592529103143090f07d85ec330100000000ffffffff0324fd9b0100000000160014d38fa4a6ac8db7495e5e2b5d219dccd412dd9bae24fd9b0100000000160014564aead56de8f4d445fc5b74a61793b5c8a819667af6c208000000001600146ec55c2e1d1a7a868b5ec91822bf40bba842bac502473044022078f8106a5645cc4afeef36d4addec391a5b058cc51053b42c89fcedf92f4db1002200cdf1b66a922863fba8dc1b1b1a0dce043d952fa14dcbe86c427fda25e930a53012102f1f750bfb73dbe4c7faec2c9c301ad0e02176cd47bcc909ff0a117e95b2aad7b02483045022100b9a6c2295a1b0f7605381d416f6ed8da763bd7c20f2402dd36b62dd9dd07375002207d40eaff4fc6ee219a7498abfab6bdc54b7ce006ac4b978b64bff960fbf5f31e012103c2a7d6e44acdbd503c578ec7d1741a44864780be0186e555e853eee86e06f11f00000000"
test_tx_hex_txid = "ca606efc5ba8f6669ba15e9262e5d38e745345ea96106d5a919688d1ff0da0cc"
# example (valid) JWT token for test:
encoded_token = jwt.encode({"wallet": "dummywallet",
"exp" :datetime.datetime.utcnow(
)+datetime.timedelta(minutes=30)}, "secret")
encoded_token = encoded_token.strip()
class ClientTProtocol(WebSocketClientProtocol):
"""
Simple client that connects to a WebSocket server, send a HELLO
message every 2 seconds and print everything it receives.
"""
def sendAuth(self):
""" Our server will not broadcast
to us unless we authenticate.
"""
self.sendMessage(encoded_token.encode('utf8'))
def onOpen(self):
# auth on startup
self.sendAuth()
# for test, monitor how many times we
# were notified.
self.factory.notifs = 0
def onMessage(self, payload, isBinary):
if not isBinary:
payload = payload.decode("utf-8")
jlog.info("Text message received: {}".format(payload))
self.factory.notifs += 1
# ensure we got the transaction message expected:
deser_notif = json.loads(payload)
assert deser_notif["txid"] == test_tx_hex_txid
assert deser_notif["txdetails"]["txid"] == test_tx_hex_txid
class WebsocketTestBase(object):
""" This tests that a websocket client can connect to our
websocket subscription service
"""
# the port for the ws
wss_port = 28283
def setUp(self):
self.wss_url = "ws://127.0.0.1:" + str(self.wss_port)
self.wss_factory = JmwalletdWebSocketServerFactory(self.wss_url)
self.wss_factory.protocol = JmwalletdWebSocketServerProtocol
self.wss_factory.valid_token = encoded_token
self.listeningport = listenWS(self.wss_factory, contextFactory=None)
self.test_tx = CTransaction.deserialize(hextobin(test_tx_hex_1))
def stopListening(self):
return self.listeningport.stopListening()
def do_test(self):
self.client_factory = WebSocketClientFactory("ws://127.0.0.1:"+str(self.wss_port))
self.client_factory.protocol = ClientTProtocol
# keep track of the connector object so we can close it manually:
self.client_connector = connectWS(self.client_factory)
d = task.deferLater(reactor, 0.1, self.fire_tx_notif)
# create a small delay between the instruction to send
# the notification, and the checking of its receipt,
# otherwise the client will be queried before the notification
# arrived:
d.addCallback(self.wait_to_receive)
return d
def wait_to_receive(self, res):
d = task.deferLater(reactor, 0.1, self.checkNotifs)
return d
def checkNotifs(self):
assert self.client_factory.notifs == 1
def fire_tx_notif(self):
self.wss_factory.sendTxNotification(self.test_tx,
test_tx_hex_txid)
def tearDown(self):
for dc in reactor.getDelayedCalls():
dc.cancel()
self.client_connector.disconnect()
return self.stopListening()
class TrialTestWS(WebsocketTestBase, unittest.TestCase):
def test_basic_notification(self):
return self.do_test()

11
jmdaemon/jmdaemon/daemon_protocol.py

@ -585,6 +585,12 @@ class JMDaemonServerProtocol(amp.AMP, OrderbookWatch):
self.mcc.on_verified_privmsg(nick, fullmsg, hostid)
return {'accepted': True}
@JMShutdown.responder
def on_JM_SHUTDOWN(self):
self.mc_shutdown()
self.jm_state = 0
return {'accepted': True}
"""Taker specific responders
"""
@ -1038,8 +1044,9 @@ def start_daemon(host, port, factory, usessl=False, sslkey=None, sslcert=None):
if usessl:
assert sslkey
assert sslcert
reactor.listenSSL(
serverconn = reactor.listenSSL(
port, factory, ssl.DefaultOpenSSLContextFactory(sslkey, sslcert),
interface=host)
else:
reactor.listenTCP(port, factory, interface=host)
serverconn = reactor.listenTCP(port, factory, interface=host)
return serverconn

12
jmdaemon/jmdaemon/irc.py

@ -105,6 +105,10 @@ class IRCMessageChannel(MessageChannel):
self.tx_irc_client = None
#TODO can be configuration var, how long between reconnect attempts:
self.reconnect_interval = 10
# service is used to wrap endpoints for Tor connections:
self.reconnecting_service = None
#implementation of abstract base class methods;
#these are mostly but not exclusively acting as pass through
#to the wrapped twisted IRC client protocol
@ -115,6 +119,8 @@ class IRCMessageChannel(MessageChannel):
def shutdown(self):
self.tx_irc_client.quit()
self.give_up = True
if self.reconnecting_service:
self.reconnecting_service.stopService()
def _pubmsg(self, msg):
self.tx_irc_client._pubmsg(msg)
@ -157,8 +163,8 @@ class IRCMessageChannel(MessageChannel):
use_tls = False
ircEndpoint = TorSocksEndpoint(torEndpoint, self.serverport[0],
self.serverport[1], tls=use_tls)
myRS = ClientService(ircEndpoint, factory)
myRS.startService()
self.reconnecting_service = ClientService(ircEndpoint, factory)
self.reconnecting_service.startService()
else:
try:
factory = TxIRCFactory(self)
@ -203,7 +209,7 @@ class txIRC_Client(irc.IRCClient, object):
def connectionLost(self, reason=protocol.connectionDone):
wlog("INFO", "Lost IRC connection to: " + str(self.hostname)
+ " . Should reconnect automatically soon.")
if self.wrapper.on_disconnect:
if not self.wrapper.give_up and self.wrapper.on_disconnect:
reactor.callLater(0.0, self.wrapper.on_disconnect, self.wrapper)
return irc.IRCClient.connectionLost(self, reason)

43
scripts/jmwalletd.py

@ -0,0 +1,43 @@
#! /usr/bin/env python
import sys
from optparse import OptionParser
from jmclient import (load_program_config, jm_single,
add_base_options, JMWalletDaemon,
start_reactor)
from jmbase.support import get_log, EXIT_FAILURE
jlog = get_log()
def jmwalletd_main():
parser = OptionParser(usage='usage: %prog [options] [wallet file]')
parser.add_option('-p', '--port', action='store', type='int',
dest='port', default=28183,
help='the port over which to serve RPC, default 28183')
parser.add_option('-w', '--wss-port', action='store', type='int',
dest='wss_port', default=28283,
help='the port over which to serve websocket '
'subscriptions, default 28283')
# TODO: remove the non-relevant base options:
add_base_options(parser)
(options, args) = parser.parse_args()
load_program_config(config_path=options.datadir)
if jm_single().bc_interface is None:
jlog.error("Running jmwallet-daemon requires configured " +
"blockchain source.")
sys.exit(EXIT_FAILURE)
jlog.info("Starting jmwalletd on port: " + str(options.port))
jm_wallet_daemon = JMWalletDaemon(options.port, options.wss_port)
jm_wallet_daemon.startService()
nodaemon = jm_single().config.getint("DAEMON", "no_daemon")
daemon = True if nodaemon == 1 else False
start_reactor(jm_single().config.get("DAEMON", "daemon_host"),
jm_single().config.getint("DAEMON", "daemon_port"),
None, daemon=daemon)
if __name__ == "__main__":
jmwalletd_main()

4
test/regtest_joinmarket.cfg

@ -63,6 +63,10 @@ minimum_makers = 1
listunspent_args = [0]
max_sats_freeze_reuse = -1
# ONLY for testing!
max_cj_fee_abs = 200000
max_cj_fee_rel = 0.2
[PAYJOIN]
# for the majority of situations, the defaults
# need not be altered - they will ensure you don't pay

Loading…
Cancel
Save