Browse Source

Add websocket for subscription, OpenAPI spec

1. Moves the JMWalletDaemon service class into
the jmclient package (see the wallet_rpc.py module).
2. Adds dependencies "klein" and "autobahn" to the
jmclient package, as well as "pyjwt".
3. Adds another module websocketserver.py, using
autobahn, to allow the JMWalletDaemon service to
serve subscriptions over a websocket, for e.g.
transaction notifications.
4. Adds tests both for the websocket connection
and for the JSON-RPC HTTP connection.

JmwalletdWebSocketServerFactory.sendTxNotification
sends the json-ified transaction details using
jmbitcoin.human_readable_transaction (as is currently
used in our CLI), along with the txid.
Also adds a coinjoin state update event sent via
the websocket (switch from taker/maker/none).
Require authentication to connect to websocket.

5. Add OpenApi definition of API in yaml;
also auto-create human-readable API docs in markdown.
6. Add fidelity bond function to API
7. Add config read/write route to API
8. Remove snicker rpc calls temporarily
9. Updates to docoinjoin: corrects taker_finished
   for this custom case, does not shut down at end.
10. Address detailed review comments of @PulpCattel.
master
Adam Gibson 4 years ago
parent
commit
7e73e4caa9
No known key found for this signature in database
GPG Key ID: 141001A1AF77F20B
  1. 165
      docs/JSON-RPC-API-using-jmwalletd.md
  2. 7
      jmclient/jmclient/__init__.py
  3. 31
      jmclient/jmclient/client_protocol.py
  4. 14
      jmclient/jmclient/maker.py
  5. 5
      jmclient/jmclient/snicker_receiver.py
  6. 6
      jmclient/jmclient/taker_utils.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. 2
      jmclient/jmclient/wallet_service.py
  11. 20
      jmclient/jmclient/wallet_utils.py
  12. 84
      jmclient/jmclient/websocketserver.py
  13. 42
      jmclient/jmclient/yieldgenerator.py
  14. 3
      jmclient/setup.py
  15. 411
      jmclient/test/test_wallet_rpc.py
  16. 109
      jmclient/test/test_websocket.py
  17. 5
      jmdaemon/jmdaemon/daemon_protocol.py
  18. 624
      scripts/jmwalletd.py
  19. 4
      test/regtest_joinmarket.cfg

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

File diff suppressed because one or more lines are too long

7
jmclient/jmclient/__init__.py

@ -60,10 +60,13 @@ from .wallet_utils import (
from .wallet_service import WalletService
from .maker import Maker
from .yieldgenerator import YieldGenerator, YieldGeneratorBasic, ygmain, \
YieldGeneratorService
YieldGeneratorService, YieldGeneratorServiceSetupFailed
from .snicker_receiver import SNICKERError, SNICKERReceiver, SNICKERReceiverService
from .payjoin import (parse_payjoin_setup, send_payjoin, PayjoinServer,
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:

31
jmclient/jmclient/client_protocol.py

@ -799,7 +799,16 @@ def start_reactor(host, port, factory=None, snickerfactory=None,
#startLogging(stdout)
global daemon_serving_host
global daemon_serving_port
usessl = True if jm_single().config.get("DAEMON", "use_ssl") != 'false' else False
# 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:
@ -824,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)))
@ -837,18 +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)
@ -863,17 +873,18 @@ def start_reactor(host, port, factory=None, snickerfactory=None,
# starts in jmclient.payjoin:
if usessl:
if factory:
reactor.connectSSL(host, jmcport, factory, ClientContextFactory())
reactor.connectSSL(host, jmcport, factory, ClientContextFactory())
if snickerfactory:
reactor.connectSSL(host, snickerport, snickerfactory,
reactor.connectSSL(host, snickerport, snickerfactory,
ClientContextFactory())
else:
if factory:
reactor.connectTCP(host, jmcport, factory)
clientconn = reactor.connectTCP(host, jmcport, factory)
if snickerfactory:
reactor.connectTCP(host, snickerport, snickerfactory)
reactor.connectTCP(host, snickerport, snickerfactory)
if rs:
if not gui:
reactor.run(installSignalHandlers=ish)
if isinstance(jm_single().bc_interface, RegtestBitcoinCoreInterface):
jm_single().bc_interface.shutdown_signal = True
return (serverconn, clientconn)

14
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()
self.offerlist = self.create_my_orders()
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

5
jmclient/jmclient/snicker_receiver.py

@ -7,7 +7,6 @@ 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()
@ -48,9 +47,7 @@ class SNICKERReceiverService(Service):
super().stopService()
def isRunning(self):
if self.running == 1:
return True
return False
return self.running == 1
class SNICKERReceiver(object):
supported_flags = []

6
jmclient/jmclient/taker_utils.py

@ -21,7 +21,7 @@ Utility functions for tumbler-style takers;
Currently re-used by CLI script tumbler.py and joinmarket-qt
"""
def direct_send(wallet_service, amount, mixdepth, destination, answeryes=True,
def direct_send(wallet_service, amount, mixdepth, destination, answeryes=False,
accept_callback=None, info_callback=None, error_callback=None,
return_transaction=False, with_final_psbt=False,
optin_rbf=False, custom_change_addr=None):
@ -189,19 +189,15 @@ def direct_send(wallet_service, amount, mixdepth, destination, answeryes=True,
log.info(sending_info)
if not answeryes:
if not accept_callback:
if input('Would you like to push to the network? (y/n):')[0] != 'y':
log.info("You chose not to broadcast the transaction, quitting.")
return False
else:
accepted = accept_callback(human_readable_transaction(tx),
destination, actual_amount, fee_est,
custom_change_addr)
if not accepted:
return False
print("here is ",jm_single().bc_interface.pushtx(tx.serialize()))
if jm_single().bc_interface.pushtx(tx.serialize()):
txid = bintohex(tx.GetTxid()[::-1])
successmsg = "Transaction sent: " + txid

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)

2
jmclient/jmclient/wallet_service.py

@ -714,7 +714,7 @@ class WalletService(Service):
import_needed = self.bci.import_addresses_if_needed(addresses,
wallet_name)
if import_needed:
#self.display_rescan_message_and_system_exit(self.restart_callback)
self.display_rescan_message_and_system_exit(self.restart_callback)
return
if isinstance(self.wallet, FidelityBondMixin):

20
jmclient/jmclient/wallet_utils.py

@ -227,21 +227,19 @@ 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())
footer = "Balance:" + self.separator + self.get_fmt_balance()
lines.append(footer)
return self.serclass(entryseparator.join(lines))
lines = [self.serialize_branch_header()]
for we in self.branchentries:
lines.append(we.serialize())
footer = "Balance:" + self.separator + self.get_fmt_balance()
lines.append(footer)
return self.serclass(entryseparator.join(lines))
def serialize_json(self, summarize=False):
if summarize:
return {}
else:
return {"branch": self.serialize_branch_header(),
"balance": self.get_fmt_balance(),
"entries": [x.serialize_json() for x in self.branchentries]}
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"

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

42
jmclient/jmclient/yieldgenerator.py

@ -7,6 +7,7 @@ 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,
@ -267,6 +268,9 @@ 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
@ -274,6 +278,10 @@ class YieldGeneratorService(Service):
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
@ -283,6 +291,9 @@ class YieldGeneratorService(Service):
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")
@ -290,17 +301,46 @@ class YieldGeneratorService(Service):
# 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.")
print("client fac is ",self.clientfactory)
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()

5
jmdaemon/jmdaemon/daemon_protocol.py

@ -1044,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

624
scripts/jmwalletd.py

@ -1,618 +1,23 @@
#! /usr/bin/env python
from jmbitcoin import *
import datetime
import os
import time
import abc
import json
import atexit
from io import BytesIO
from jmclient.wallet_utils import wallet_showseed,wallet_showutxos
from twisted.python.log import startLogging
from twisted.internet import endpoints, reactor, ssl, task
from twisted.web.server import Site
from twisted.application.service import Service
from klein import Klein
import sys
from optparse import OptionParser
from jmbase import get_log
from jmbitcoin import human_readable_transaction
from jmclient import Taker, Maker, jm_single, load_program_config, \
JMClientProtocolFactory, start_reactor, calc_cj_fee, \
WalletService, add_base_options, get_wallet_path, direct_send, \
open_test_wallet_maybe, wallet, wallet_display, SegwitLegacyWallet, \
SegwitWallet, get_daemon_serving_params, YieldGeneratorService, \
SNICKERReceiverService, SNICKERReceiver, create_wallet, \
StorageError, StoragePasswordError, get_max_cj_fee_values
from jmbase.support import get_log, set_logging_level, jmprint,EXIT_ARGERROR, EXIT_FAILURE,DUST_THRESHOLD
import glob
import jwt
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()
# 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
class ServiceNotStarted(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 response(request, succeed=True, 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.setResponseCode(status)
return json.dumps(
[{'succeed': succeed, 'status': status, **kwargs}])
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):
""" Port is the port to serve this daemon
(using HTTP/TLS).
"""
print("in init")
# cookie tracks single user's state.
self.cookie = None
self.port = 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
# 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
# ensure shut down does not leave dangling services:
atexit.register(self.stopService)
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).
reactor.listenSSL(self.port, Site(self.app.resource()),
contextFactory=get_ssl_context("."))
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()
@app.handle_errors(NotAuthorized)
def not_authorized(self, request, failure):
request.setResponseCode(401)
return "Invalid credentials."
@app.handle_errors(NoWalletFound)
def no_wallet_found(self, request, failure):
request.setResponseCode(404)
return "No wallet loaded."
@app.handle_errors(BackendNotReady)
def backend_not_ready(self, request, failure):
request.setResponseCode(500)
return "Backend daemon not available"
@app.handle_errors(InvalidRequestFormat)
def invalid_request_format(self, request, failure):
request.setResponseCode(401)
return "Invalid request format."
@app.handle_errors(ServiceAlreadyStarted)
def service_already_started(self, request, failure):
request.setResponseCode(401)
return "Service already started."
@app.handle_errors(WalletAlreadyUnlocked)
def wallet_already_unlocked(self, request, failure):
request.setResponseCode(401)
return "Wallet already unlocked."
def service_not_started(self, request, failure):
request.setResponseCode(401)
return "Service cannot be stopped as it is not running."
# def check_cookie(self, request):
# request_cookie = request.getHeader(b"JMCookie")
# if self.cookie != request_cookie:
# jlog.warn("Invalid cookie: " + str(
# request_cookie) + ", request rejected.")
# raise NotAuthorized()
def check_cookie(self, request):
print("header details:")
#part after bearer is what we need
auth_header=((request.getHeader('Authorization')))
request_cookie = None
if auth_header is not None:
request_cookie=auth_header[7:]
print("request cookie is",request_cookie)
print("actual cookie is",self.cookie)
if request_cookie==None or self.cookie != request_cookie:
jlog.warn("Invalid cookie: " + str(
request_cookie) + ", request rejected.")
raise NotAuthorized()
@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:
print("called display but no wallet loaded")
raise NoWalletFound()
else:
walletinfo = wallet_display(self.wallet_service, False, jsonified=True)
return response(request, walletname=walletname, walletinfo=walletinfo)
#Heartbeat route
@app.route('/session',methods=['GET'])
def sessionExists(self, request):
#if no wallet loaded then clear frontend session info
#when no wallet status is false
session = not self.cookie==None
return response(request,session=session)
# handling CORS preflight for any route:
@app.route('/', branch=True, methods=['OPTIONS'])
def preflight(self, request):
print_req(request)
request.setHeader("Access-Control-Allow-Origin", "*")
request.setHeader("Access-Control-Allow-Methods", "POST")
# "Cookie" is reserved so we specifically allow our custom cookie using
# name "JMCookie".
request.setHeader("Access-Control-Allow-Headers", "Content-Type, JMCookie")
@app.route('/wallet/<string:walletname>/snicker/start', methods=['GET'])
def start_snicker(self, request, walletname):
self.check_cookie(request)
if not self.wallet_service:
raise NoWalletFound()
if self.services["snicker"] and self.services["snicker"].isRunning():
raise ServiceAlreadyStarted()
# TODO: allow client to inject acceptance callbacks to Receiver
self.services["snicker"] = SNICKERReceiverService(
SNICKERReceiver(self.wallet_service))
self.services["snicker"].startService()
# TODO waiting for startup seems perhaps not needed here?
return response(request, walletname=walletname)
@app.route('/wallet/<string:walletname>/snicker/stop', methods=['GET'])
def stop_snicker(self, request, walletname):
self.check_cookie(request)
if not self.wallet_service:
raise NoWalletFound()
if not self.services["snicker"]:
raise ServiceNotStarted()
self.services["snicker"].stopService()
return response(request, walletname=walletname)
@app.route('/wallet/<string:walletname>/taker/direct-send', methods=['POST'])
def send_direct(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()
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)
# tx = direct_send(self.wallet_service, payment_info_json["amount_sats"],
# payment_info_json["mixdepth"],
# optin_rbf=payment_info_json["optin_rbf"],
# return_transaction=True)
return response(request, walletname=walletname,
txinfo=human_readable_transaction(tx))
@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:
"""
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()
# daemon must be up before this is started; check:
daemon_serving_host, daemon_serving_port = get_daemon_serving_params()
if daemon_serving_port == -1 or daemon_serving_host == "":
raise BackendNotReady()
for key,val in config_json.items():
if(key == 'cjfee_r' or key == 'ordertype'):
pass
else:
config_json[key] = int(config_json[key])
# self.txfee_factor, self.cjfee_factor, self.size_factor
config_json['txfee_factor'] = None
config_json["cjfee_factor"] = None
config_json["size_factor"] = None
self.services["maker"] = YieldGeneratorService(self.wallet_service,
daemon_serving_host, daemon_serving_port,
[config_json[x] for x in ["txfee", "cjfee_a",
"cjfee_r", "ordertype", "minsize","txfee_factor","cjfee_factor","size_factor"]])
self.services["maker"].startService()
return response(request, walletname=walletname)
@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.services["maker"]:
raise ServiceNotStarted()
self.services["maker"].stopService()
return response(request, walletname=walletname)
@app.route('/wallet/<string:walletname>/lock', methods=['GET'])
def lockwallet(self, request, walletname):
print_req(request)
self.check_cookie(request)
if not self.wallet_service:
print("called lock but no wallet loaded")
raise NoWalletFound()
else:
self.wallet_service.stopService()
self.cookie = None
self.wallet_service = None
# success status implicit:
return response(request, walletname=walletname)
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)
json_data = json.loads(request.content.read().decode("utf-8"))
retval = {}
for k in keys:
if k in json_data:
retval[k] = json_data[k]
else:
return False
return retval
@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 or request_data["wallettype"] not in [
"sw", "sw-legacy"]:
raise InvalidRequestFormat()
wallet_cls = SegwitWallet if request_data[
"wallettype"]=="sw" else SegwitLegacyWallet
# 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)
print("seedphrase is ")
seedphrase_help_string = wallet_showseed(wallet)
except StorageError as e:
raise NotAuthorized(repr(e))
# finally, after the wallet is successfully created, we should
# start the wallet service:
#return response(request,message="Wallet Created Succesfully,unlock it for further use")
return self.initialize_wallet_service(request, wallet, seedphrase=seedphrase_help_string)
def initialize_wallet_service(self, request, wallet,**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 as long as the daemon is active (i.e.
no expiry currently implemented), or until the user switches
to a new wallet.
"""
encoded_token = jwt.encode({"wallet": "name_of_wallet","exp" :datetime.datetime.utcnow()+datetime.timedelta(minutes=30)},"secret")
encoded_token = encoded_token.strip()
print(encoded_token)
# decoded_token = jwt.decode(encoded_token,"secret",algorithms=["HS256"])
# print(decoded_token)
# request.addCookie(b'session_token', encoded_token)
# self.cookie = encoded_token
self.cookie = encoded_token
#self.cookie = request.getHeader(b"JMCookie")
if self.cookie is None:
raise NotAuthorized("No cookie")
# 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:
self.wallet_service = WalletService(wallet)
while not self.wallet_service.synced:
self.wallet_service.sync_wallet(fast=True)
self.wallet_service.startService()
# now that the WalletService instance is active and ready to
# respond to requests, we return the status to the client:
#def response(request, succeed=True, status=200, **kwargs):
if('seedphrase' in kwargs):
return response(request,
walletname=self.wallet_service.get_wallet_name(),
already_loaded=False,token=encoded_token,seedphrase = kwargs.get('seedphrase'))
else:
return response(request,
walletname=self.wallet_service.get_wallet_name(),
already_loaded=False,token=encoded_token)
@app.route('/wallet/<string:walletname>/unlock', methods=['POST'])
def unlockwallet(self, request, walletname):
print_req(request)
#print(get_current_chain_params())
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("invalid password")
except StorageError as e:
# e.g. .lock file exists:
raise NotAuthorized(repr(e))
return self.initialize_wallet_service(request, wallet)
else:
print('wallet was already unlocked.')
return response(request,
walletname=self.wallet_service.get_wallet_name(),
already_loaded=True)
#This route should return list of current wallets created.
@app.route('/wallet/all', methods=['GET'])
def listwallets(self, request):
#this is according to the assumption that wallets are there in /.joinmarket by default, also currently path for linux system only.
#first user taken for path
user_path = glob.glob('/home/*/')[0]
wallet_dir = f"{user_path}.joinmarket/wallets/*.jmdat"
wallets = (glob.glob(wallet_dir))
offset = len(user_path)+len('.joinmarket/wallets/')
#to get only names
short_wallets = [wallet[offset:] for wallet in wallets]
return response(request,wallets=short_wallets)
#route to get external address for deposit
@app.route('/address/new/<string:mixdepth>',methods=['GET'])
def getaddress(self, request, mixdepth):
self.check_cookie(request)
if not self.wallet_service:
raise NoWalletFound()
mixdepth = int(mixdepth)
address = self.wallet_service.get_external_addr(mixdepth)
return response(request,address=address)
#route to list utxos
@app.route('/wallet/utxos',methods=['GET'])
def listUtxos(self, request):
self.check_cookie(request)
if not self.wallet_service:
raise NoWalletFound()
utxos = wallet_showutxos(self.wallet_service, False)
return response(request,transactions=utxos)
#return True for now
def filter_orders_callback(self,orderfees, cjamount):
return True
#route to start a coinjoin transaction
@app.route('/wallet/taker/coinjoin',methods=['POST'])
def doCoinjoin(self, request):
self.check_cookie(request)
if not self.wallet_service:
raise NoWalletFound()
request_data = self.get_POST_body(request,["mixdepth", "amount", "counterparties","destination"])
#refer sample schedule testnet
waittime = 0
rounding=16
completion_flag=0
#list of list
schedule = [[int(request_data["mixdepth"]), int(request_data["amount"]), int(request_data["counterparties"]), request_data["destination"], waittime, rounding, completion_flag]]
print(schedule)
#instantiate a taker
#keeping order_chooser as default for now
#max_cj_feee is to be set based on config values (jmsingle.config.get policy var->max cj fee abs in configure.py)
max_cj_fee=(1,float('inf'))
print("max cj fee is,",max_cj_fee)
self.taker = Taker(self.wallet_service, schedule, max_cj_fee = max_cj_fee, callbacks=(self.filter_orders_callback, None, self.taker_finished))
clientfactory = JMClientProtocolFactory(self.taker)
nodaemon = jm_single().config.getint("DAEMON", "no_daemon")
daemon = True if nodaemon == 1 else False
dhost = jm_single().config.get("DAEMON", "daemon_host")
dport = jm_single().config.getint("DAEMON", "daemon_port")
if jm_single().config.get("BLOCKCHAIN", "network") == "regtest":
startLogging(sys.stdout)
start_reactor(dhost, dport, clientfactory, daemon=daemon, rs=False)
def taker_finished(self, res, fromtx=False, waittime=0.0, txdetails=None):
if fromtx == "unconfirmed":
#If final entry, stop *here*, don't wait for confirmation
return
if fromtx:
if res:
txd, txid = txdetails
reactor.callLater(waittime*60,
clientfactory.getClient().clientStart)
else:
#a transaction failed; we'll try to repeat without the
#troublemakers.
#If this error condition is reached from Phase 1 processing,
#and there are less than minimum_makers honest responses, we
#just give up (note that in tumbler we tweak and retry, but
#for sendpayment the user is "online" and so can manually
#try again).
#However if the error is in Phase 2 and we have minimum_makers
#or more responses, we do try to restart with the honest set, here.
if self.taker.latest_tx is None:
#can only happen with < minimum_makers; see above.
jlog.info("A transaction failed but there are insufficient "
"honest respondants to continue; giving up.")
reactor.stop()
return
#This is Phase 2; do we have enough to try again?
self.taker.add_honest_makers(list(set(
self.taker.maker_utxo_data.keys()).symmetric_difference(
set(self.taker.nonrespondants))))
if len(self.taker.honest_makers) < jm_single().config.getint(
"POLICY", "minimum_makers"):
jlog.info("Too few makers responded honestly; "
"giving up this attempt.")
reactor.stop()
return
jmprint("We failed to complete the transaction. The following "
"makers responded honestly: " + str(self.taker.honest_makers) +\
", so we will retry with them.", "warning")
#Now we have to set the specific group we want to use, and hopefully
#they will respond again as they showed honesty last time.
#we must reset the number of counterparties, as well as fix who they
#are; this is because the number is used to e.g. calculate fees.
#cleanest way is to reset the number in the schedule before restart.
self.taker.schedule[self.taker.schedule_index][2] = len(self.taker.honest_makers)
jlog.info("Retrying with: " + str(self.taker.schedule[
self.taker.schedule_index][2]) + " counterparties.")
#rewind to try again (index is incremented in Taker.initialize())
self.taker.schedule_index -= 1
self.taker.set_honest_only(True)
reactor.callLater(5.0, clientfactory.getClient().clientStart)
else:
if not res:
jlog.info("Did not complete successfully, shutting down")
#Should usually be unreachable, unless conf received out of order;
#because we should stop on 'unconfirmed' for last (see above)
else:
jlog.info("All transactions completed correctly")
reactor.stop()
def jmwalletd_main():
import sys
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)
@ -624,20 +29,15 @@ def jmwalletd_main():
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)
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
if jm_single().config.get("BLOCKCHAIN", "network") in ["regtest", "testnet"]:
startLogging(sys.stdout)
start_reactor(jm_single().config.get("DAEMON", "daemon_host"),
jm_single().config.getint("DAEMON", "daemon_port"),
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