From 80e17df4a6bf6929bb132b8f3c8017e42a4ee28d Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Wed, 5 Aug 2020 12:47:48 +0100 Subject: [PATCH 1/3] Add jmwalletd script as RPC server. Uses Klein to provide HTTP server support. Adds cookie based auth to requests (made JWT token based in later commits). Basic routes are: /unlock, /lock, /display, /create of wallet. Encapsulates WalletDaemon as a Service Add snicker receiver service start, stop Adds yg/maker function as stoppable service. Adds a JMShutdown command to the AMP protocol, allowing a clean shutdown of a long running bot (e.g. maker) by shutting down its message channel connections, without shutting down the entire process. Adds payment(direct send) request, first draft --- docs/JSON-RPC-API-using-jmwalletd.md | 118 ++++++++ jmbase/jmbase/commands.py | 7 + jmclient/jmclient/__init__.py | 9 +- jmclient/jmclient/client_protocol.py | 49 ++- jmclient/jmclient/snicker_receiver.py | 47 ++- jmclient/jmclient/wallet_utils.py | 33 +- jmclient/jmclient/yieldgenerator.py | 34 +++ jmdaemon/jmdaemon/daemon_protocol.py | 6 + jmdaemon/jmdaemon/irc.py | 12 +- scripts/jmwalletd.py | 420 ++++++++++++++++++++++++++ 10 files changed, 712 insertions(+), 23 deletions(-) create mode 100644 docs/JSON-RPC-API-using-jmwalletd.md create mode 100644 scripts/jmwalletd.py diff --git a/docs/JSON-RPC-API-using-jmwalletd.md b/docs/JSON-RPC-API-using-jmwalletd.md new file mode 100644 index 000000000..6601d3a3d --- /dev/null +++ b/docs/JSON-RPC-API-using-jmwalletd.md @@ -0,0 +1,118 @@ +## JSON-RPC API for Joinmarket using jmwalletd.py + +### Introduction - how to start the server + +After installing Joinmarket as per the [INSTALL GUIDE](INSTALL.md), navigate to the `scripts/` directory as usual and start the server with: + +``` +(jmvenv) $python jmwalletd.py +``` + +which with defaults will start serving the RPC over HTTP on port 28183. + +This HTTP server does *NOT* currently support multiple sessions; it is intended as a manager/daemon for all the Joinmarket services for a single user. + +#### Rules about making requests + +Currently authentication is done by providing a cookie on first request, which must then be reused to keep the same session. The cookie is sent in an HTTP header with name `b'JMCookie'`. This is fine for an early testing stage, but will be improved/reworked, and that will be documented here. + +GET requests are used in case no content or parameters need to be provided with the request. + +POST requests are used in case content or parameters are to be provided with the request, and they are provided as utf-8 encoded serialized JSON, in the *body* of the POST request. + +Note that for some methods, it's particularly important to deal with the HTTP response asynchronously, since it can take some time for wallet synchronization, service startup etc. to occur. + +### Methods + +#### `createwallet` + +Make a new wallet. The variable "wallettype" should be "sw" for native segwit wallets (now the Joinmarket default), otherwise a segwit legacy wallet (BIP49) will be created. + +* HTTP Request type: POST +* Route: `/wallet/create` +* POST body contents: {"walletname": walletname, "password": password, "wallettype": wallettype} +* Returns: on success, {"walletname": walletname, "already_loaded": False} + +(TODO some confusion over two different walletnames here, I need to check, but the wallet name sent by the caller will be used for the file name, I believe). + +#### `unlockwallet` + +Open an existing wallet using a password. + +* HTTP Request type: POST +* Route: `/wallet//unlock` +* POST body contents: {"password": password} +* Returns: on success, {"walletname": walletname, "already_loaded": True} + +(see previous on walletname, same applies here). + +#### `lockwallet` + +Stops the wallet service for the current wallet; meaning it cannot then be accessed without re-authentication. + +* HTTP Request type: GET +* Route: `/wallet//lock` +* Returns: on success, {"walletname": walletname} + +(see previous on walletname, same applies here). + +#### `displaywallet` + +Get JSON representation of wallet contents for wallet named `walletname`: + +* HTTP Request type: GET +* Route: `/wallet//display` +* Returns: a JSON object which is the entire wallet contents, mixdepth by mixdepth. + - Example output from a signet wallet is given at the bottom of the document. + +#### `maker/start` + +Starts the yield generator/maker service for the given wallet, using the IRC and tor network connections +in the backend (inside the process started with jmwalletd). +See Joinmarket yield generator config defaults in `jmclient.configure` module for info on the data that must +be specified in the POST body contents. + +* HTTP Request type: POST +* Route: `/wallet//maker/start` +* POST body contents: {"txfee", "cjfee_a", "cjfee_r", "ordertype", "minsize"] +* Returns: on success, {"walletname": walletname} + +(see previous on walletname, same applies here). + +#### `maker/stop` + +Stops the yieldgenerator/maker service if currently running for the given wallet. + +* HTTP Request type: GET +* Route: `/wallet//maker/start` +* Returns: on success, {"walletname": walletname} + +(see previous on walletname, same applies here). + +#### `snicker/start` + +Starts the SNICKER service (see [here](SNICKER.md)) for the given wallet. Note that this requires +no configuration for now, though that is likely to change. Also note this is not yet supported for +mainnet. + +* HTTP Request type: GET +* Route: `/wallet//snicker/start` +* Returns: on success, {"walletname": walletname} + +(see previous on walletname, same applies here). + +#### `snicker/stop` + +Stops the snicker service if currently running for the given wallet. + +* HTTP Request type: GET +* Route: `/wallet//snicker/start` +* Returns: on success, {"walletname": walletname} + +(see previous on walletname, same applies here). + +##### Example wallet display JSON output from signet wallet + +``` +{'wallet_name': 'JM wallet', 'total_balance': '0.15842426', 'accounts': [{'account': '0', 'account_balance': '0.00861458', 'branches': [{'branch': "external addresses\tm/84'/1'/0'/0\ttpubDFGxEsV7NvVc4h2XL4QEppZt3CrDiCFksP97H6YbFPmCTKM6KMP2xUxW57gAu7bzDfB3YTqnMeKQaQRS5GJM3xMcrhbi5AGsQUd7p4PLMDV", 'balance': '0.00000000', 'entries': [{'hd_path': "m/84'/1'/0'/0/4", 'address': 'tb1qzugshsm85x6luegyjc6mk5zces2zqr0j8m4zkd', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/0'/0/5", 'address': 'tb1qcwmdkg229ghmd8r3xgq4a9zxp459crws66n4ve', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/0'/0/6", 'address': 'tb1q7lv6dwex3mhwp32vhku0fvpar9faar2lu595su', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/0'/0/7", 'address': 'tb1qm42ltytvp22kj9efp995yu0r0r7x570d8j8crc', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/0'/0/8", 'address': 'tb1qwvux8g0khuvvkla3zaqdslj6xpgwtq7jlvwmgu', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/0'/0/9", 'address': 'tb1q3xr7l9nylsdlyqf9rkw0rg3f0yx6slguhtwpzp', 'amount': '0.00000000', 'labels': 'new'}]}, {'branch': "internal addresses\tm/84'/1'/0'/1\t", 'balance': '0.00861458', 'entries': [{'hd_path': "m/84'/1'/0'/1/7", 'address': 'tb1qjrzxkulgc5dnlyz0rjqj68zxgqjesqn839ue2w', 'amount': '0.00396839', 'labels': 'cj-out'}, {'hd_path': "m/84'/1'/0'/1/12", 'address': 'tb1qeqkk4te2t6gqt7jfgu8a9k4je2wwfw3d2m7gku', 'amount': '0.00464619', 'labels': 'non-cj-change'}]}]}, {'account': '1', 'account_balance': '0.09380968', 'branches': [{'branch': "external addresses\tm/84'/1'/1'/0\ttpubDE1TKa8tm3WWh4f9fV325BgYWX9i7WFMaQRd1C3tSFYU9RJEyE8w2Cw2KnhgXSKyjS4keeWAkc3iLEqp3pxUEG9T49RCtQiMpjuZM71FLpL", 'balance': '0.00000000', 'entries': [{'hd_path': "m/84'/1'/1'/0/0", 'address': 'tb1qd6qqg3uzk9sw88yhvpqpwt3tx5ls4hau3mwh3g', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/1'/0/1", 'address': 'tb1qhkrmqn9e4ldzlwna8w5w9l5vaw978zlrl54hmh', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/1'/0/2", 'address': 'tb1qp83afad8dl98w366vnvct0zc49qu33c2nfx386', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/1'/0/3", 'address': 'tb1qjv0elh4kn5yaywajedgcrf93ujzz3m3q7ld7k3', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/1'/0/4", 'address': 'tb1qk25u4ch7w0xylzh0krn4hefphe6xpyh0vc33sl', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/1'/0/5", 'address': 'tb1qs3ep9nlypwn43swv75zwv6lgl3wgsmha20g87p', 'amount': '0.00000000', 'labels': 'new'}]}, {'branch': "internal addresses\tm/84'/1'/1'/1\t", 'balance': '0.09380968', 'entries': [{'hd_path': "m/84'/1'/1'/1/44", 'address': 'tb1qgmgpk22ueq9xk8f722aqjnuwd6s3jv58nwwan2', 'amount': '0.00009631', 'labels': 'non-cj-change'}, {'hd_path': "m/84'/1'/1'/1/49", 'address': 'tb1qjq86y8nzvafv5dsde93zf0emv7yrsphvupv69e', 'amount': '0.00013383', 'labels': 'non-cj-change'}, {'hd_path': "m/84'/1'/1'/1/54", 'address': 'tb1q7lvxk407xs38t24hfzy7vprp9t7tfsemv4rfym', 'amount': '0.00371951', 'labels': 'non-cj-change'}, {'hd_path': "m/84'/1'/1'/1/56", 'address': 'tb1qn2azshrkcg0d7py5apgfr0jh29nt9w2fmx9fyy', 'amount': '0.08986003', 'labels': 'non-cj-change'}]}, {'branch': 'Imported keys\tm/0\t', 'balance': '0.00000000', 'entries': [{'hd_path': 'imported/1/0', 'address': 'tb1q8znprh8c85za3mpwzn3qf9m0vwqzjkfu4qdncy', 'amount': '0.00000000', 'labels': 'empty'}, {'hd_path': 'imported/1/1', 'address': 'tb1qu4ajg3enea90xxtjuwcurj3d6lkqrud8p7w0yu', 'amount': '0.00000000', 'labels': 'empty'}, {'hd_path': 'imported/1/2', 'address': 'tb1qg7saqx69yalcqshfr8mjndy0gpx2umxrwqs823', 'amount': '0.00000000', 'labels': 'empty'}]}]}, {'account': '2', 'account_balance': '0.05600000', 'branches': [{'branch': "external addresses\tm/84'/1'/2'/0\ttpubDF8K7wXCrRXX1CQLVZGwMvEg9YEWF2VRpM1tjCwpMZDRRqKjpJ5YaeaDaLkqN1D7YM4pkX32FcCnosbhLQz2BgRiPNNdybWuvSBKp72mJsJ", 'balance': '0.00000000', 'entries': [{'hd_path': "m/84'/1'/2'/0/0", 'address': 'tb1qw95x9m84t6hqcun560vqfk3yc6ptl4g9arsty0', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/2'/0/1", 'address': 'tb1qek4humez7rcwl53ly6uzr4mfwd0s2lu92e356q', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/2'/0/2", 'address': 'tb1qxne4hyyeq2vrh0dfzs56th29qsymp9eq5pljdc', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/2'/0/3", 'address': 'tb1qz3jk544j5vtwztznxfdwfgt8zcw77mjcut8vdz', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/2'/0/4", 'address': 'tb1qg902humlsuc5s6aua6ew3d893hlgcxr05ntpyd', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/2'/0/5", 'address': 'tb1qukz3l34ydy9snq8rkjaknk0ns04kfnlh34neqd', 'amount': '0.00000000', 'labels': 'new'}]}, {'branch': "internal addresses\tm/84'/1'/2'/1\t", 'balance': '0.05600000', 'entries': [{'hd_path': "m/84'/1'/2'/1/1", 'address': 'tb1qrtz5cwpneheg2v2v32wzc3h9yv0rzplxjtx9vc', 'amount': '0.00800000', 'labels': 'non-cj-change'}, {'hd_path': "m/84'/1'/2'/1/2", 'address': 'tb1qp4276g23y2w8g3367de25ustxkygjydmwk4fw2', 'amount': '0.00800000', 'labels': 'non-cj-change'}, {'hd_path': "m/84'/1'/2'/1/3", 'address': 'tb1qtqgvw445807tzcm8yhq6xgu3vmdfh66czx8jea', 'amount': '0.00800000', 'labels': 'non-cj-change'}, {'hd_path': "m/84'/1'/2'/1/4", 'address': 'tb1qxj7ulxdthe0dwxr5457p5d0w5u3jg7rwmc05pm', 'amount': '0.02400000', 'labels': 'non-cj-change'}, {'hd_path': "m/84'/1'/2'/1/5", 'address': 'tb1qv3kfe9ew42z0ldncgzmqcjznatsxz0vudvcjrv', 'amount': '0.00800000', 'labels': 'non-cj-change'}]}]}, {'account': '3', 'account_balance': '0.00000000', 'branches': [{'branch': "external addresses\tm/84'/1'/3'/0\ttpubDE9VN56aLW9BurCxHHGAWidSnVuU86ZsKPYQgxpTgkZxbogJYfj1vWJbtYip7WV5REcgmtjETb5eShXV8VUBzvCAMzuRm5Kv4ZGnnCiX6Jg", 'balance': '0.00000000', 'entries': [{'hd_path': "m/84'/1'/3'/0/0", 'address': 'tb1qp2w6ezmqn8nk9kc4gkpetgjj2mzqgp5x3hk86m', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/3'/0/1", 'address': 'tb1qd0tt93aulqs508mtap5p8gls5z57fqa4ggnfx7', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/3'/0/2", 'address': 'tb1qsp4hv46vgz4yjwt4p2wekh2gfmek7vgznrnd96', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/3'/0/3", 'address': 'tb1qvs322uyrwh7a74dsxel0xcrgucm27c6dzdmj9j', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/3'/0/4", 'address': 'tb1qnq9uk9azs9s7m5474ws7z7wxnwv3s3lxrtjter', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/3'/0/5", 'address': 'tb1q5tlq36q6ps0m9zu6h08gd3azsgkgvm73sjcmxw', 'amount': '0.00000000', 'labels': 'new'}]}, {'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\ttpubDE6QfTimeNgCFSYuxPPaLc1Cp3VokAuJAusYoiGwWtVHVtQDsepf5dRAFNLWMwpBCgKDYkXdWGs2JspxXPokrtooPh7db5fniqYbdKGqD4F", 'balance': '0.00000000', 'entries': [{'hd_path': "m/84'/1'/4'/0/3", 'address': 'tb1qr2llfup6cnh27n77nm7egcyf9r7c0ykucrcu8k', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/4'/0/4", 'address': 'tb1qahqjnd2y8j770l2m4kpf4fyfve9425c0zdumms', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/4'/0/5", 'address': 'tb1q0jm0cxwcm2g60489fvtmeeaf7mzg658t8f8fk4', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/4'/0/6", 'address': 'tb1qtpm5putpkzmrmecden0yytuuk4n9emhvxwqu8m', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/4'/0/7", 'address': 'tb1qn60fc04pmprn9wpzkt0dnt80awu0rpy99w376g', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/4'/0/8", 'address': 'tb1qakvrpp2hd3a3303zx7w2shmvfc7tqk28pwa9sj', 'amount': '0.00000000', 'labels': 'new'}]}, {'branch': "internal addresses\tm/84'/1'/4'/1\t", 'balance': '0.00000000', 'entries': []}]}]} +``` \ No newline at end of file diff --git a/jmbase/jmbase/commands.py b/jmbase/jmbase/commands.py index 2d8eced41..a937b7ce9 100644 --- a/jmbase/jmbase/commands.py +++ b/jmbase/jmbase/commands.py @@ -70,6 +70,13 @@ class JMMsgSignatureVerify(JMCommand): (b'fullmsg', Unicode()), (b'hostid', Unicode())] +class JMShutdown(JMCommand): + """ Requests shutdown of the current + message channel connections (to be used + when the client is shutting down). + """ + arguments = [] + """TAKER specific commands """ diff --git a/jmclient/jmclient/__init__.py b/jmclient/jmclient/__init__.py index 5315029ef..11c6d687b 100644 --- a/jmclient/jmclient/__init__.py +++ b/jmclient/jmclient/__init__.py @@ -31,7 +31,8 @@ from .snicker_receiver import SNICKERError, SNICKERReceiver from .client_protocol import (JMTakerClientProtocol, JMClientProtocolFactory, start_reactor, SNICKERClientProtocolFactory, - BIP78ClientProtocolFactory) + BIP78ClientProtocolFactory, + get_daemon_serving_params) from .podle import (set_commitment_file, get_commitment_file, add_external_commitments, PoDLE, generate_podle, get_podle_commitments, @@ -58,8 +59,10 @@ wallet_change_passphrase) from .wallet_service import WalletService from .maker import Maker -from .yieldgenerator import YieldGenerator, YieldGeneratorBasic, ygmain -from .payjoin import (parse_payjoin_setup, send_payjoin, +from .yieldgenerator import YieldGenerator, YieldGeneratorBasic, ygmain, \ + YieldGeneratorService +from .snicker_receiver import SNICKERError, SNICKERReceiver, SNICKERReceiverService +from .payjoin import (parse_payjoin_setup, send_payjoin, PayjoinServer, JMBIP78ReceiverManager) # Set default logging handler to avoid "No handler found" warnings. diff --git a/jmclient/jmclient/client_protocol.py b/jmclient/jmclient/client_protocol.py index 10062b917..cbdaff99f 100644 --- a/jmclient/jmclient/client_protocol.py +++ b/jmclient/jmclient/client_protocol.py @@ -20,6 +20,15 @@ SNICKERReceiver, process_shutdown) import jmbitcoin as btc +# module level variable representing the port +# on which the daemon is running. +# note that this var is only set if we are running +# client+daemon in one process. +daemon_serving_port = -1 +daemon_serving_host = "" + +def get_daemon_serving_params(): + return (daemon_serving_host, daemon_serving_port) jlog = get_log() @@ -366,6 +375,15 @@ def make_tx(self, nick_list, tx): tx=tx) self.defaultCallbacks(d) + def request_mc_shutdown(self): + """ To ensure that lingering message channel + connections are shut down when the client itself + is shutting down. + """ + d = self.callRemote(commands.JMShutdown) + self.defaultCallbacks(d) + return {'accepted': True} + class JMMakerClientProtocol(JMClientProtocol): def __init__(self, factory, maker, nick_priv=None): self.factory = factory @@ -779,9 +797,9 @@ def start_reactor(host, port, factory=None, snickerfactory=None, #(Cannot start the reactor in tests) #Not used in prod (twisted logging): #startLogging(stdout) - usessl = True if jm_single().config.get("DAEMON", - "use_ssl") != 'false' else False - + global daemon_serving_host + global daemon_serving_port + usessl = True if jm_single().config.get("DAEMON", "use_ssl") != 'false' else False jmcport, snickerport, bip78port = [port]*3 if daemon: try: @@ -821,10 +839,13 @@ def start_daemon_on_port(p, f, name, port_offset): p[0] += 1 return p[0] + 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) + 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 @@ -840,17 +861,17 @@ def start_daemon_on_port(p, f, name, port_offset): # Note the reactor.connect*** entries do not include BIP78 which # starts in jmclient.payjoin: - if usessl: - if factory: - reactor.connectSSL(host, jmcport, factory, ClientContextFactory()) - if snickerfactory: - reactor.connectSSL(host, snickerport, snickerfactory, - ClientContextFactory()) - else: - if factory: - reactor.connectTCP(host, jmcport, factory) - if snickerfactory: - reactor.connectTCP(host, snickerport, snickerfactory) + if usessl: + if factory: + reactor.connectSSL(host, jmcport, factory, ClientContextFactory()) + if snickerfactory: + reactor.connectSSL(host, snickerport, snickerfactory, + ClientContextFactory()) + else: + if factory: + reactor.connectTCP(host, jmcport, factory) + if snickerfactory: + reactor.connectTCP(host, snickerport, snickerfactory) if rs: if not gui: reactor.run(installSignalHandlers=ish) diff --git a/jmclient/jmclient/snicker_receiver.py b/jmclient/jmclient/snicker_receiver.py index cdc2f03e7..a17f40651 100644 --- a/jmclient/jmclient/snicker_receiver.py +++ b/jmclient/jmclient/snicker_receiver.py @@ -1,5 +1,8 @@ #! /usr/bin/env python +import os +from twisted.application.service import Service +from twisted.internet import task import jmbitcoin as btc from jmclient.configure import jm_single from jmbase import (get_log, utxo_to_utxostr, @@ -11,7 +14,45 @@ class SNICKERError(Exception): pass -class SNICKERReceiver(Service): +class SNICKERReceiverService(Service): + def __init__(self, receiver): + assert isinstance(receiver, SNICKERReceiver) + self.receiver = receiver + # main monitor loop + self.monitor_loop = task.LoopingCall(self.receiver.poll_for_proposals) + + def startService(self): + """ Encapsulates start up actions. + This service depends on the receiver's + wallet service to start, so wait for that. + """ + self.wait_for_wallet = task.LoopingCall(self.wait_for_wallet_sync) + self.wait_for_wallet.start(5.0) + + def wait_for_wallet_sync(self): + if self.receiver.wallet_service.isRunning(): + jlog.info("SNICKER service starting because wallet service is up.") + self.wait_for_wallet.stop() + self.monitor_loop.start(5.0) + super().startService() + + def stopService(self, wallet=False): + """ Encapsulates shut down actions. + Optionally also shut down the underlying + wallet service (default False). + """ + if self.monitor_loop: + self.monitor_loop.stop() + if wallet: + self.receiver.wallet_service.stopService() + super().stopService() + + def isRunning(self): + if self.running == 1: + return True + return False + +class SNICKERReceiver(object): supported_flags = [] def __init__(self, wallet_service, acceptance_callback=None, @@ -66,6 +107,10 @@ def __init__(self, wallet_service, acceptance_callback=None, def default_info_callback(self, msg): jlog.info(msg) + if not os.path.exists(self.proposals_source): + with open(self.proposals_source, "wb") as f: + jlog.info("created proposals source file.") + def default_acceptance_callback(self, our_ins, their_ins, our_outs, their_outs): diff --git a/jmclient/jmclient/wallet_utils.py b/jmclient/jmclient/wallet_utils.py index 649541fa4..798c7b37a 100644 --- a/jmclient/jmclient/wallet_utils.py +++ b/jmclient/jmclient/wallet_utils.py @@ -177,6 +177,12 @@ def serialize(self): extradata = self.serialize_extra_data() return self.serclass(self.separator.join([left, addr, amounts, extradata])) + def serialize_json(self): + return {"hd_path": self.wallet_path_repr, + "address": self.serialize_address(), + "amount": self.serialize_amounts(), + "labels": self.serialize_extra_data()} + def serialize_wallet_position(self): return self.wallet_path_repr.ljust(20) @@ -229,6 +235,14 @@ def serialize(self, entryseparator="\n", summarize=False): 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]} + def serialize_branch_header(self): start = "external addresses" if self.address_type == 0 else "internal addresses" if self.address_type == -1: @@ -263,6 +277,14 @@ def serialize(self, entryseparator="\n", summarize=False): return self.serclass(entryseparator.join([header] + [ x.serialize(entryseparator) for x in self.branches] + [footer])) + def serialize_json(self, summarize=False): + result = {"account": str(self.account), + "account_balance": self.get_fmt_balance()} + if summarize: + return result + result["branches"] = [x.serialize_json() for x in self.branches] + return result + class WalletView(WalletViewBase): def __init__(self, wallet_path_repr, accounts, wallet_name="JM wallet", serclass=str, custom_separator=None): @@ -286,6 +308,10 @@ def serialize(self, entryseparator="\n", summarize=False): return self.serclass(entryseparator.join([header] + [ x.serialize(entryseparator, summarize=False) for x in self.accounts] + [footer])) + def serialize_json(self, summarize=False): + return {"wallet_name": self.wallet_name, + "total_balance": self.get_fmt_balance(), + "accounts": [x.serialize_json(summarize=summarize) for x in self.accounts]} def get_tx_info(txid, tx_cache=None): """ @@ -393,7 +419,7 @@ def wallet_showutxos(wallet_service, showprivkey): def wallet_display(wallet_service, showprivkey, displayall=False, - serialized=True, summarized=False, mixdepth=None): + serialized=True, summarized=False, mixdepth=None, jsonified=False): """build the walletview object, then return its serialization directly if serialized, else return the WalletView object. @@ -546,7 +572,10 @@ def get_addr_status(addr_path, utxos, is_new, is_internal): path = wallet_service.get_path_repr(wallet_service.get_path()) walletview = WalletView(path, acctlist) if serialized: - return walletview.serialize(summarize=summarized) + if jsonified: + return walletview.serialize_json(summarize=summarized) + else: + return walletview.serialize(summarize=summarized) else: return walletview diff --git a/jmclient/jmclient/yieldgenerator.py b/jmclient/jmclient/yieldgenerator.py index 4443765c2..a08c0c049 100644 --- a/jmclient/jmclient/yieldgenerator.py +++ b/jmclient/jmclient/yieldgenerator.py @@ -6,6 +6,7 @@ import abc import base64 from twisted.python.log import startLogging +from twisted.application.service import Service from optparse import OptionParser from jmbase import get_log from jmclient import (Maker, jm_single, load_program_config, @@ -263,6 +264,39 @@ def select_output_address(self, input_mixdepth, offer, amount): cjoutmix = (input_mixdepth + 1) % (self.wallet_service.mixdepth + 1) return self.wallet_service.get_internal_addr(cjoutmix) +class YieldGeneratorService(Service): + def __init__(self, wallet_service, daemon_host, daemon_port, yg_config): + self.wallet_service = wallet_service + self.daemon_host = daemon_host + self.daemon_port = daemon_port + self.yg_config = yg_config + self.yieldgen = None + + def startService(self): + """ We instantiate the Maker class only + here as its constructor will automatically + create orders based on the wallet. + Note makers already intrinsically handle + not-yet-synced wallet services, so there is + no need to check this here. + """ + # TODO genericise to any YG class: + self.yieldgen = YieldGeneratorBasic(self.wallet_service, self.yg_config) + self.clientfactory = JMClientProtocolFactory(self.yieldgen, proto_type="MAKER") + # here 'start_reactor' does not start the reactor but instantiates + # the connection to the daemon backend; note daemon=False, i.e. the daemon + # backend is assumed to be started elsewhere; we just connect to it with a client. + start_reactor(self.daemon_host, self.daemon_port, self.clientfactory, rs=False) + super().startService() + + def stopService(self): + """ TODO need a method exposed to gracefully + shut down a maker bot. + """ + if self.running: + jlog.info("Shutting down YieldGenerator service.") + self.clientfactory.proto_client.request_mc_shutdown() + super().stopService() def ygmain(ygclass, nickserv_password='', gaplimit=6): import sys diff --git a/jmdaemon/jmdaemon/daemon_protocol.py b/jmdaemon/jmdaemon/daemon_protocol.py index 2430cd303..97652f75e 100644 --- a/jmdaemon/jmdaemon/daemon_protocol.py +++ b/jmdaemon/jmdaemon/daemon_protocol.py @@ -585,6 +585,12 @@ def on_JM_MSGSIGNATURE_VERIFY(self, verif_result, nick, fullmsg, hostid): self.mcc.on_verified_privmsg(nick, fullmsg, hostid) return {'accepted': True} + @JMShutdown.responder + def on_JM_SHUTDOWN(self): + self.mc_shutdown() + self.jm_state = 0 + return {'accepted': True} + """Taker specific responders """ diff --git a/jmdaemon/jmdaemon/irc.py b/jmdaemon/jmdaemon/irc.py index 25e25e61d..3697b3513 100644 --- a/jmdaemon/jmdaemon/irc.py +++ b/jmdaemon/jmdaemon/irc.py @@ -105,6 +105,10 @@ def __init__(self, self.tx_irc_client = None #TODO can be configuration var, how long between reconnect attempts: self.reconnect_interval = 10 + + # service is used to wrap endpoints for Tor connections: + self.reconnecting_service = None + #implementation of abstract base class methods; #these are mostly but not exclusively acting as pass through #to the wrapped twisted IRC client protocol @@ -115,6 +119,8 @@ def run(self): def shutdown(self): self.tx_irc_client.quit() self.give_up = True + if self.reconnecting_service: + self.reconnecting_service.stopService() def _pubmsg(self, msg): self.tx_irc_client._pubmsg(msg) @@ -157,8 +163,8 @@ def build_irc(self): use_tls = False ircEndpoint = TorSocksEndpoint(torEndpoint, self.serverport[0], self.serverport[1], tls=use_tls) - myRS = ClientService(ircEndpoint, factory) - myRS.startService() + self.reconnecting_service = ClientService(ircEndpoint, factory) + self.reconnecting_service.startService() else: try: factory = TxIRCFactory(self) @@ -203,7 +209,7 @@ def connectionMade(self): def connectionLost(self, reason=protocol.connectionDone): wlog("INFO", "Lost IRC connection to: " + str(self.hostname) + " . Should reconnect automatically soon.") - if self.wrapper.on_disconnect: + if not self.wrapper.give_up and self.wrapper.on_disconnect: reactor.callLater(0.0, self.wrapper.on_disconnect, self.wrapper) return irc.IRCClient.connectionLost(self, reason) diff --git a/scripts/jmwalletd.py b/scripts/jmwalletd.py new file mode 100644 index 000000000..d7f1aa91b --- /dev/null +++ b/scripts/jmwalletd.py @@ -0,0 +1,420 @@ +#! /usr/bin/env python + +import datetime +import os +import time +import abc +import json +import atexit +from io import BytesIO +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 + +from optparse import OptionParser +from jmbase import get_log +from jmbitcoin import human_readable_transaction +from jmclient import 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_display, SegwitLegacyWallet, \ + SegwitWallet, get_daemon_serving_params, YieldGeneratorService, \ + SNICKERReceiverService, SNICKERReceiver, create_wallet, \ + StorageError, StoragePasswordError +from jmbase.support import EXIT_ARGERROR, 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). + """ + # 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() + + @app.route('/wallet//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) + + # 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//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//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//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. + """ + 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, 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//maker/start', methods=['POST']) + def start_maker(self, request, walletname): + """ Use the configuration in the POST body to start the yield generator: + """ + 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() + + 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"]]) + self.services["maker"].startService() + return response(request, walletname=walletname) + + @app.route('/wallet//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//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.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"], + 4, wallet_cls=wallet_cls) + except StorageError as e: + raise NotAuthorized(repr(e)) + + # finally, after the wallet is successfully created, we should + # start the wallet service: + return self.initialize_wallet_service(request, wallet) + + def initialize_wallet_service(self, request, wallet): + """ 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. + """ + 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: + return response(request, + walletname=self.wallet_service.get_wallet_name(), + already_loaded=False) + + @app.route('/wallet//unlock', methods=['POST']) + def unlockwallet(self, request, walletname): + 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("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) + +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') + # TODO: remove the non-relevant base options: + add_base_options(parser) + + (options, args) = parser.parse_args() + + load_program_config(config_path=options.datadir) + + if jm_single().bc_interface is None: + jlog.error("Running jmwallet-daemon requires configured " + + "blockchain source.") + sys.exit(EXIT_FAILURE) + jlog.info("Starting jmwalletd on port: " + str(options.port)) + + jm_wallet_daemon = JMWalletDaemon(options.port) + 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() From 1688d2d2dc61d7e3d16a07f466e2e36696d6cd16 Mon Sep 17 00:00:00 2001 From: abhishek0405 Date: Sun, 8 Aug 2021 23:31:07 +0530 Subject: [PATCH 2/3] Adds listutxos and heartbeat route, several fixes The /utxos route is the equivalent of the showutxos wallet tool method. The heartbeat route /session allows a client to make sure the backend is still running and in the expected state (but see later commits for the coinjoin state update via the websocket). Fixes to sendpayment, the maker service, the create wallet function. sendpayment fix Also substantially improved and made functional the coinjoin route, a schedule is now created and a complete taker-side coinjoin is now possible. --- jmclient/jmclient/client_protocol.py | 12 +- jmclient/jmclient/taker_utils.py | 6 +- jmclient/jmclient/wallet_service.py | 2 +- jmclient/jmclient/yieldgenerator.py | 6 +- scripts/jmwalletd.py | 257 +++++++++++++++++++++++++-- 5 files changed, 257 insertions(+), 26 deletions(-) diff --git a/jmclient/jmclient/client_protocol.py b/jmclient/jmclient/client_protocol.py index cbdaff99f..5a306c3a8 100644 --- a/jmclient/jmclient/client_protocol.py +++ b/jmclient/jmclient/client_protocol.py @@ -861,16 +861,16 @@ def start_daemon_on_port(p, f, name, port_offset): # Note the reactor.connect*** entries do not include BIP78 which # starts in jmclient.payjoin: - if usessl: - if factory: + if usessl: + if factory: reactor.connectSSL(host, jmcport, factory, ClientContextFactory()) - if snickerfactory: + if snickerfactory: reactor.connectSSL(host, snickerport, snickerfactory, ClientContextFactory()) - else: - if factory: + else: + if factory: reactor.connectTCP(host, jmcport, factory) - if snickerfactory: + if snickerfactory: reactor.connectTCP(host, snickerport, snickerfactory) if rs: if not gui: diff --git a/jmclient/jmclient/taker_utils.py b/jmclient/jmclient/taker_utils.py index 867154c0a..a8d9767f3 100644 --- a/jmclient/jmclient/taker_utils.py +++ b/jmclient/jmclient/taker_utils.py @@ -21,7 +21,7 @@ Currently re-used by CLI script tumbler.py and joinmarket-qt """ -def direct_send(wallet_service, amount, mixdepth, destination, answeryes=False, +def direct_send(wallet_service, amount, mixdepth, destination, answeryes=True, accept_callback=None, info_callback=None, error_callback=None, return_transaction=False, with_final_psbt=False, optin_rbf=False, custom_change_addr=None): @@ -189,15 +189,19 @@ def direct_send(wallet_service, amount, mixdepth, destination, answeryes=False, 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 diff --git a/jmclient/jmclient/wallet_service.py b/jmclient/jmclient/wallet_service.py index 22046b159..a390071d7 100644 --- a/jmclient/jmclient/wallet_service.py +++ b/jmclient/jmclient/wallet_service.py @@ -714,7 +714,7 @@ def sync_addresses(self): 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): diff --git a/jmclient/jmclient/yieldgenerator.py b/jmclient/jmclient/yieldgenerator.py index a08c0c049..638ab4750 100644 --- a/jmclient/jmclient/yieldgenerator.py +++ b/jmclient/jmclient/yieldgenerator.py @@ -82,11 +82,14 @@ class YieldGeneratorBasic(YieldGenerator): thus is somewhat suboptimal in giving more information to spies. """ def __init__(self, wallet_service, offerconfig): - # note the randomizing entries are ignored in this base class: + #note the randomizing entries are ignored in this base class: + self.txfee, self.cjfee_a, self.cjfee_r, self.ordertype, self.minsize, \ self.txfee_factor, self.cjfee_factor, self.size_factor = offerconfig super().__init__(wallet_service) + + def create_my_orders(self): mix_balance = self.get_available_mixdepths() if len([b for m, b in mix_balance.items() if b > 0]) == 0: @@ -295,6 +298,7 @@ def stopService(self): """ if self.running: jlog.info("Shutting down YieldGenerator service.") + print("client fac is ",self.clientfactory) self.clientfactory.proto_client.request_mc_shutdown() super().stopService() diff --git a/scripts/jmwalletd.py b/scripts/jmwalletd.py index d7f1aa91b..289a1be9d 100644 --- a/scripts/jmwalletd.py +++ b/scripts/jmwalletd.py @@ -1,5 +1,6 @@ #! /usr/bin/env python +from jmbitcoin import * import datetime import os import time @@ -7,6 +8,7 @@ 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 @@ -16,17 +18,21 @@ from optparse import OptionParser from jmbase import get_log from jmbitcoin import human_readable_transaction -from jmclient import Maker, jm_single, load_program_config, \ +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_display, SegwitLegacyWallet, \ + open_test_wallet_maybe, wallet, wallet_display, SegwitLegacyWallet, \ SegwitWallet, get_daemon_serving_params, YieldGeneratorService, \ SNICKERReceiverService, SNICKERReceiver, create_wallet, \ - StorageError, StoragePasswordError -from jmbase.support import EXIT_ARGERROR, EXIT_FAILURE + 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 jlog = get_log() + # for debugging; twisted.web.server.Request objects do not easily serialize: def print_req(request): print(request) @@ -91,6 +97,7 @@ 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 @@ -167,9 +174,24 @@ 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): - request_cookie = request.getHeader(b"JMCookie") - if self.cookie != request_cookie: + 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() @@ -185,6 +207,16 @@ def displaywallet(self, request, walletname): 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): @@ -224,17 +256,26 @@ 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, payment_info_json["amount_sats"], - payment_info_json["mixdepth"], - optin_rbf=payment_info_json["optin_rbf"], - return_transaction=True) + + 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)) @@ -242,6 +283,7 @@ def send_direct(self, request, walletname): 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"]) @@ -255,10 +297,21 @@ def start_maker(self, request, walletname): 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"]]) + "cjfee_r", "ordertype", "minsize","txfee_factor","cjfee_factor","size_factor"]]) self.services["maker"].startService() return response(request, walletname=walletname) @@ -281,6 +334,7 @@ def lockwallet(self, request, walletname): raise NoWalletFound() else: self.wallet_service.stopService() + self.cookie = None self.wallet_service = None # success status implicit: return response(request, walletname=walletname) @@ -313,6 +367,8 @@ def createwallet(self, request): 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() @@ -324,17 +380,25 @@ def createwallet(self, request): # 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"], + 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 self.initialize_wallet_service(request, wallet) - def initialize_wallet_service(self, request, wallet): + #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). @@ -342,7 +406,18 @@ def initialize_wallet_service(self, request, wallet): no expiry currently implemented), or until the user switches to a new wallet. """ - self.cookie = request.getHeader(b"JMCookie") + + 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") @@ -355,13 +430,21 @@ def initialize_wallet_service(self, request, wallet): self.wallet_service.startService() # now that the WalletService instance is active and ready to # respond to requests, we return the status to the client: - return response(request, + + #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) + 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//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: @@ -386,6 +469,144 @@ def unlockwallet(self, request, walletname): 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/',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]') @@ -416,5 +637,7 @@ def jmwalletd_main(): jm_single().config.getint("DAEMON", "daemon_port"), None, daemon=daemon) + + if __name__ == "__main__": jmwalletd_main() From 7e73e4caa9d03b9237ad322cd5ba02e877778458 Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Thu, 16 Sep 2021 14:44:54 +0100 Subject: [PATCH 3/3] 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. --- docs/JSON-RPC-API-using-jmwalletd.md | 165 ++--- jmclient/jmclient/__init__.py | 7 +- jmclient/jmclient/client_protocol.py | 31 +- jmclient/jmclient/maker.py | 14 +- jmclient/jmclient/snicker_receiver.py | 5 +- jmclient/jmclient/taker_utils.py | 6 +- jmclient/jmclient/wallet-rpc-api.md | 580 ++++++++++++++++++ jmclient/jmclient/wallet-rpc-api.yaml | 820 +++++++++++++++++++++++++ jmclient/jmclient/wallet_rpc.py | 830 ++++++++++++++++++++++++++ jmclient/jmclient/wallet_service.py | 2 +- jmclient/jmclient/wallet_utils.py | 20 +- jmclient/jmclient/websocketserver.py | 84 +++ jmclient/jmclient/yieldgenerator.py | 42 +- jmclient/setup.py | 3 +- jmclient/test/test_wallet_rpc.py | 411 +++++++++++++ jmclient/test/test_websocket.py | 109 ++++ jmdaemon/jmdaemon/daemon_protocol.py | 5 +- scripts/jmwalletd.py | 624 +------------------ test/regtest_joinmarket.cfg | 4 + 19 files changed, 3028 insertions(+), 734 deletions(-) create mode 100644 jmclient/jmclient/wallet-rpc-api.md create mode 100644 jmclient/jmclient/wallet-rpc-api.yaml create mode 100644 jmclient/jmclient/wallet_rpc.py create mode 100644 jmclient/jmclient/websocketserver.py create mode 100644 jmclient/test/test_wallet_rpc.py create mode 100644 jmclient/test/test_websocket.py diff --git a/docs/JSON-RPC-API-using-jmwalletd.md b/docs/JSON-RPC-API-using-jmwalletd.md index 6601d3a3d..b9d481b3d 100644 --- a/docs/JSON-RPC-API-using-jmwalletd.md +++ b/docs/JSON-RPC-API-using-jmwalletd.md @@ -2,117 +2,120 @@ ### Introduction - how to start the server +Create an ssl certificate and store it in `/ssl/{key,cert}.pem`; the `datadir` is set by `--datadir` in scripts or is `~/.joinmarket` by default, or `.` by default in testing. + After installing Joinmarket as per the [INSTALL GUIDE](INSTALL.md), navigate to the `scripts/` directory as usual and start the server with: ``` (jmvenv) $python jmwalletd.py ``` -which with defaults will start serving the RPC over HTTP on port 28183. - -This HTTP server does *NOT* currently support multiple sessions; it is intended as a manager/daemon for all the Joinmarket services for a single user. - -#### Rules about making requests - -Currently authentication is done by providing a cookie on first request, which must then be reused to keep the same session. The cookie is sent in an HTTP header with name `b'JMCookie'`. This is fine for an early testing stage, but will be improved/reworked, and that will be documented here. - -GET requests are used in case no content or parameters need to be provided with the request. - -POST requests are used in case content or parameters are to be provided with the request, and they are provided as utf-8 encoded serialized JSON, in the *body* of the POST request. - -Note that for some methods, it's particularly important to deal with the HTTP response asynchronously, since it can take some time for wallet synchronization, service startup etc. to occur. - -### Methods - -#### `createwallet` +which with defaults will start serving the RPC over `https://` on port 28183, and a (secure) websocket server (`wss://`) on port 28283. -Make a new wallet. The variable "wallettype" should be "sw" for native segwit wallets (now the Joinmarket default), otherwise a segwit legacy wallet (BIP49) will be created. +Documentation of the websocket functionality [below](#websocket). -* HTTP Request type: POST -* Route: `/wallet/create` -* POST body contents: {"walletname": walletname, "password": password, "wallettype": wallettype} -* Returns: on success, {"walletname": walletname, "already_loaded": False} +This HTTP server does *NOT* currently support multiple sessions; it is intended as a manager/daemon for all the Joinmarket services for a single user. Note that in particular it allows only control of *one wallet at a time*. -(TODO some confusion over two different walletnames here, I need to check, but the wallet name sent by the caller will be used for the file name, I believe). - -#### `unlockwallet` - -Open an existing wallet using a password. - -* HTTP Request type: POST -* Route: `/wallet//unlock` -* POST body contents: {"password": password} -* Returns: on success, {"walletname": walletname, "already_loaded": True} - -(see previous on walletname, same applies here). - -#### `lockwallet` - -Stops the wallet service for the current wallet; meaning it cannot then be accessed without re-authentication. +#### Rules about making requests -* HTTP Request type: GET -* Route: `/wallet//lock` -* Returns: on success, {"walletname": walletname} +Authentication is with the [JSON Web Token](https://jwt.io/) scheme, provided using the Python package [PyJWT](https://pypi.org/project/PyJWT/). -(see previous on walletname, same applies here). +Note that for some methods, it's particularly important to deal with the HTTP response asynchronously, since it can take some time for wallet synchronization, service startup etc. to occur; in these cases a HTTP return code of 202 is sent. -#### `displaywallet` +### API documentation -Get JSON representation of wallet contents for wallet named `walletname`: +Current API version: v1. -* HTTP Request type: GET -* Route: `/wallet//display` -* Returns: a JSON object which is the entire wallet contents, mixdepth by mixdepth. - - Example output from a signet wallet is given at the bottom of the document. +The [OpenAPI](https://github.com/OAI/OpenAPI-Specification) spec is given in [this yaml file](../jmclient/jmclient/wallet-rpc-api.yaml). Human readable documentation of the API is provided in [this document](../jmclient/jmclient/wallet-rpc-api.md), which is auto-generated with the node utility [swagger-markdown](https://www.npmjs.com/package/swagger-markdown). -#### `maker/start` +Those wishing to write client code should adhere to that specification. -Starts the yield generator/maker service for the given wallet, using the IRC and tor network connections -in the backend (inside the process started with jmwalletd). -See Joinmarket yield generator config defaults in `jmclient.configure` module for info on the data that must -be specified in the POST body contents. +#### What is and is not provided in the current version of the API. -* HTTP Request type: POST -* Route: `/wallet//maker/start` -* POST body contents: {"txfee", "cjfee_a", "cjfee_r", "ordertype", "minsize"] -* Returns: on success, {"walletname": walletname} +As a brief summary, the functionality currently available is: -(see previous on walletname, same applies here). +* list existing wallets +* create a wallet +* unlock (decrypt) a wallet +* lock a wallet +* display contents of a wallet +* list the utxos in the wallet +* get a new address for deposit in a given account +* send a payment without coinjoin +* send a payment with coinjoin +* start the yield generator +* stop the yield generator +* get the value of a specific config variable +* set the value of a specific config variable (only in memory) +* a 'heartbeat' check that also reports whether a wallet is loaded, whether the maker is running, whether a coinjoin is in process. -#### `maker/stop` +Clearly there are several further functionalities currently available in the CLI and Qt versions of Joinmarket which are not yet supported. It is likely that several or all of these will be added in future (e.g.: payjoin, utxo freezing). -Stops the yieldgenerator/maker service if currently running for the given wallet. +In addition to the above, a websocket service currently allowing subscription only to transaction events, and coinjoining state, is provided, see next. -* HTTP Request type: GET -* Route: `/wallet//maker/start` -* Returns: on success, {"walletname": walletname} + -(see previous on walletname, same applies here). +### Websocket -#### `snicker/start` +When a wallet service is started via a call to `create` or `unlock` (see above), the websocket automatically starts to serve notifications to any connected client. The client must send the authentication token it has received in the create/unlock call, over the websocket, when it connects, otherwise it will not receive any notifications. -Starts the SNICKER service (see [here](SNICKER.md)) for the given wallet. Note that this requires -no configuration for now, though that is likely to change. Also note this is not yet supported for -mainnet. +Any authenticated connection is currently automatically subscribed to both of the following events: -* HTTP Request type: GET -* Route: `/wallet//snicker/start` -* Returns: on success, {"walletname": walletname} +#### Coinjoin state change event -(see previous on walletname, same applies here). +When the backend switches from doing nothing, to running a coinjoin as taker over the messaging channels, or to running as a yield generator, or stopping either of these, an event is sent on the websocket noting the new current state. The message is json encoded as: -#### `snicker/stop` +``` +{"coinjoin_state": 1} +``` -Stops the snicker service if currently running for the given wallet. +where the values are: -* HTTP Request type: GET -* Route: `/wallet//snicker/start` -* Returns: on success, {"walletname": walletname} +0 - Taker running +1 - Maker running +2 - Neither are running -(see previous on walletname, same applies here). +#### Transaction event -##### Example wallet display JSON output from signet wallet +When a transaction is seen for the first time in the Joinmarket wallet, a notification is sent to the client over the websocket as encoded json, containing the txid and a detailed human-readable deserialization of the transaction details. See this example: ``` -{'wallet_name': 'JM wallet', 'total_balance': '0.15842426', 'accounts': [{'account': '0', 'account_balance': '0.00861458', 'branches': [{'branch': "external addresses\tm/84'/1'/0'/0\ttpubDFGxEsV7NvVc4h2XL4QEppZt3CrDiCFksP97H6YbFPmCTKM6KMP2xUxW57gAu7bzDfB3YTqnMeKQaQRS5GJM3xMcrhbi5AGsQUd7p4PLMDV", 'balance': '0.00000000', 'entries': [{'hd_path': "m/84'/1'/0'/0/4", 'address': 'tb1qzugshsm85x6luegyjc6mk5zces2zqr0j8m4zkd', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/0'/0/5", 'address': 'tb1qcwmdkg229ghmd8r3xgq4a9zxp459crws66n4ve', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/0'/0/6", 'address': 'tb1q7lv6dwex3mhwp32vhku0fvpar9faar2lu595su', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/0'/0/7", 'address': 'tb1qm42ltytvp22kj9efp995yu0r0r7x570d8j8crc', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/0'/0/8", 'address': 'tb1qwvux8g0khuvvkla3zaqdslj6xpgwtq7jlvwmgu', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/0'/0/9", 'address': 'tb1q3xr7l9nylsdlyqf9rkw0rg3f0yx6slguhtwpzp', 'amount': '0.00000000', 'labels': 'new'}]}, {'branch': "internal addresses\tm/84'/1'/0'/1\t", 'balance': '0.00861458', 'entries': [{'hd_path': "m/84'/1'/0'/1/7", 'address': 'tb1qjrzxkulgc5dnlyz0rjqj68zxgqjesqn839ue2w', 'amount': '0.00396839', 'labels': 'cj-out'}, {'hd_path': "m/84'/1'/0'/1/12", 'address': 'tb1qeqkk4te2t6gqt7jfgu8a9k4je2wwfw3d2m7gku', 'amount': '0.00464619', 'labels': 'non-cj-change'}]}]}, {'account': '1', 'account_balance': '0.09380968', 'branches': [{'branch': "external addresses\tm/84'/1'/1'/0\ttpubDE1TKa8tm3WWh4f9fV325BgYWX9i7WFMaQRd1C3tSFYU9RJEyE8w2Cw2KnhgXSKyjS4keeWAkc3iLEqp3pxUEG9T49RCtQiMpjuZM71FLpL", 'balance': '0.00000000', 'entries': [{'hd_path': "m/84'/1'/1'/0/0", 'address': 'tb1qd6qqg3uzk9sw88yhvpqpwt3tx5ls4hau3mwh3g', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/1'/0/1", 'address': 'tb1qhkrmqn9e4ldzlwna8w5w9l5vaw978zlrl54hmh', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/1'/0/2", 'address': 'tb1qp83afad8dl98w366vnvct0zc49qu33c2nfx386', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/1'/0/3", 'address': 'tb1qjv0elh4kn5yaywajedgcrf93ujzz3m3q7ld7k3', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/1'/0/4", 'address': 'tb1qk25u4ch7w0xylzh0krn4hefphe6xpyh0vc33sl', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/1'/0/5", 'address': 'tb1qs3ep9nlypwn43swv75zwv6lgl3wgsmha20g87p', 'amount': '0.00000000', 'labels': 'new'}]}, {'branch': "internal addresses\tm/84'/1'/1'/1\t", 'balance': '0.09380968', 'entries': [{'hd_path': "m/84'/1'/1'/1/44", 'address': 'tb1qgmgpk22ueq9xk8f722aqjnuwd6s3jv58nwwan2', 'amount': '0.00009631', 'labels': 'non-cj-change'}, {'hd_path': "m/84'/1'/1'/1/49", 'address': 'tb1qjq86y8nzvafv5dsde93zf0emv7yrsphvupv69e', 'amount': '0.00013383', 'labels': 'non-cj-change'}, {'hd_path': "m/84'/1'/1'/1/54", 'address': 'tb1q7lvxk407xs38t24hfzy7vprp9t7tfsemv4rfym', 'amount': '0.00371951', 'labels': 'non-cj-change'}, {'hd_path': "m/84'/1'/1'/1/56", 'address': 'tb1qn2azshrkcg0d7py5apgfr0jh29nt9w2fmx9fyy', 'amount': '0.08986003', 'labels': 'non-cj-change'}]}, {'branch': 'Imported keys\tm/0\t', 'balance': '0.00000000', 'entries': [{'hd_path': 'imported/1/0', 'address': 'tb1q8znprh8c85za3mpwzn3qf9m0vwqzjkfu4qdncy', 'amount': '0.00000000', 'labels': 'empty'}, {'hd_path': 'imported/1/1', 'address': 'tb1qu4ajg3enea90xxtjuwcurj3d6lkqrud8p7w0yu', 'amount': '0.00000000', 'labels': 'empty'}, {'hd_path': 'imported/1/2', 'address': 'tb1qg7saqx69yalcqshfr8mjndy0gpx2umxrwqs823', 'amount': '0.00000000', 'labels': 'empty'}]}]}, {'account': '2', 'account_balance': '0.05600000', 'branches': [{'branch': "external addresses\tm/84'/1'/2'/0\ttpubDF8K7wXCrRXX1CQLVZGwMvEg9YEWF2VRpM1tjCwpMZDRRqKjpJ5YaeaDaLkqN1D7YM4pkX32FcCnosbhLQz2BgRiPNNdybWuvSBKp72mJsJ", 'balance': '0.00000000', 'entries': [{'hd_path': "m/84'/1'/2'/0/0", 'address': 'tb1qw95x9m84t6hqcun560vqfk3yc6ptl4g9arsty0', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/2'/0/1", 'address': 'tb1qek4humez7rcwl53ly6uzr4mfwd0s2lu92e356q', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/2'/0/2", 'address': 'tb1qxne4hyyeq2vrh0dfzs56th29qsymp9eq5pljdc', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/2'/0/3", 'address': 'tb1qz3jk544j5vtwztznxfdwfgt8zcw77mjcut8vdz', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/2'/0/4", 'address': 'tb1qg902humlsuc5s6aua6ew3d893hlgcxr05ntpyd', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/2'/0/5", 'address': 'tb1qukz3l34ydy9snq8rkjaknk0ns04kfnlh34neqd', 'amount': '0.00000000', 'labels': 'new'}]}, {'branch': "internal addresses\tm/84'/1'/2'/1\t", 'balance': '0.05600000', 'entries': [{'hd_path': "m/84'/1'/2'/1/1", 'address': 'tb1qrtz5cwpneheg2v2v32wzc3h9yv0rzplxjtx9vc', 'amount': '0.00800000', 'labels': 'non-cj-change'}, {'hd_path': "m/84'/1'/2'/1/2", 'address': 'tb1qp4276g23y2w8g3367de25ustxkygjydmwk4fw2', 'amount': '0.00800000', 'labels': 'non-cj-change'}, {'hd_path': "m/84'/1'/2'/1/3", 'address': 'tb1qtqgvw445807tzcm8yhq6xgu3vmdfh66czx8jea', 'amount': '0.00800000', 'labels': 'non-cj-change'}, {'hd_path': "m/84'/1'/2'/1/4", 'address': 'tb1qxj7ulxdthe0dwxr5457p5d0w5u3jg7rwmc05pm', 'amount': '0.02400000', 'labels': 'non-cj-change'}, {'hd_path': "m/84'/1'/2'/1/5", 'address': 'tb1qv3kfe9ew42z0ldncgzmqcjznatsxz0vudvcjrv', 'amount': '0.00800000', 'labels': 'non-cj-change'}]}]}, {'account': '3', 'account_balance': '0.00000000', 'branches': [{'branch': "external addresses\tm/84'/1'/3'/0\ttpubDE9VN56aLW9BurCxHHGAWidSnVuU86ZsKPYQgxpTgkZxbogJYfj1vWJbtYip7WV5REcgmtjETb5eShXV8VUBzvCAMzuRm5Kv4ZGnnCiX6Jg", 'balance': '0.00000000', 'entries': [{'hd_path': "m/84'/1'/3'/0/0", 'address': 'tb1qp2w6ezmqn8nk9kc4gkpetgjj2mzqgp5x3hk86m', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/3'/0/1", 'address': 'tb1qd0tt93aulqs508mtap5p8gls5z57fqa4ggnfx7', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/3'/0/2", 'address': 'tb1qsp4hv46vgz4yjwt4p2wekh2gfmek7vgznrnd96', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/3'/0/3", 'address': 'tb1qvs322uyrwh7a74dsxel0xcrgucm27c6dzdmj9j', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/3'/0/4", 'address': 'tb1qnq9uk9azs9s7m5474ws7z7wxnwv3s3lxrtjter', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/3'/0/5", 'address': 'tb1q5tlq36q6ps0m9zu6h08gd3azsgkgvm73sjcmxw', 'amount': '0.00000000', 'labels': 'new'}]}, {'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\ttpubDE6QfTimeNgCFSYuxPPaLc1Cp3VokAuJAusYoiGwWtVHVtQDsepf5dRAFNLWMwpBCgKDYkXdWGs2JspxXPokrtooPh7db5fniqYbdKGqD4F", 'balance': '0.00000000', 'entries': [{'hd_path': "m/84'/1'/4'/0/3", 'address': 'tb1qr2llfup6cnh27n77nm7egcyf9r7c0ykucrcu8k', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/4'/0/4", 'address': 'tb1qahqjnd2y8j770l2m4kpf4fyfve9425c0zdumms', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/4'/0/5", 'address': 'tb1q0jm0cxwcm2g60489fvtmeeaf7mzg658t8f8fk4', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/4'/0/6", 'address': 'tb1qtpm5putpkzmrmecden0yytuuk4n9emhvxwqu8m', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/4'/0/7", 'address': 'tb1qn60fc04pmprn9wpzkt0dnt80awu0rpy99w376g', 'amount': '0.00000000', 'labels': 'new'}, {'hd_path': "m/84'/1'/4'/0/8", 'address': 'tb1qakvrpp2hd3a3303zx7w2shmvfc7tqk28pwa9sj', 'amount': '0.00000000', 'labels': 'new'}]}, {'branch': "internal addresses\tm/84'/1'/4'/1\t", 'balance': '0.00000000', 'entries': []}]}]} -``` \ No newline at end of file +{"txid": "ca606efc5ba8f6669ba15e9262e5d38e745345ea96106d5a919688d1ff0da0cc", + "txdetails": { + "hex": "02000000000102578770b2732aed421ffe62d54fd695cf281ca336e4f686d2adbb2e8c3bedb2570000000000ffffffff4719a259786b4237f92460629181edcc3424419592529103143090f07d85ec330100000000ffffffff0324fd9b0100000000160014d38fa4a6ac8db7495e5e2b5d219dccd412dd9bae24fd9b0100000000160014564aead56de8f4d445fc5b74a61793b5c8a819667af6c208000000001600146ec55c2e1d1a7a868b5ec91822bf40bba842bac502473044022078f8106a5645cc4afeef36d4addec391a5b058cc51053b42c89fcedf92f4db1002200cdf1b66a922863fba8dc1b1b1a0dce043d952fa14dcbe86c427fda25e930a53012102f1f750bfb73dbe4c7faec2c9c301ad0e02176cd47bcc909ff0a117e95b2aad7b02483045022100b9a6c2295a1b0f7605381d416f6ed8da763bd7c20f2402dd36b62dd9dd07375002207d40eaff4fc6ee219a7498abfab6bdc54b7ce006ac4b978b64bff960fbf5f31e012103c2a7d6e44acdbd503c578ec7d1741a44864780be0186e555e853eee86e06f11f00000000", + "inputs": [ + { + "outpoint": "57b2ed3b8c2ebbadd286f6e436a31c28cf95d64fd562fe1f42ed2a73b2708757:0", + "scriptSig": "", + "nSequence": 4294967295, + "witness": "02473044022078f8106a5645cc4afeef36d4addec391a5b058cc51053b42c89fcedf92f4db1002200cdf1b66a922863fba8dc1b1b1a0dce043d952fa14dcbe86c427fda25e930a53012102f1f750bfb73dbe4c7faec2c9c301ad0e02176cd47bcc909ff0a117e95b2aad7b" + }, + { + "outpoint": "33ec857df09030140391529295412434cced8191626024f937426b7859a21947:1", + "scriptSig": "", + "nSequence": 4294967295, + "witness": "02483045022100b9a6c2295a1b0f7605381d416f6ed8da763bd7c20f2402dd36b62dd9dd07375002207d40eaff4fc6ee219a7498abfab6bdc54b7ce006ac4b978b64bff960fbf5f31e012103c2a7d6e44acdbd503c578ec7d1741a44864780be0186e555e853eee86e06f11f" + } + ], + "outputs": [ + { + "value_sats": 27000100, + "scriptPubKey": "0014d38fa4a6ac8db7495e5e2b5d219dccd412dd9bae", + "address": "bcrt1q6w86ff4v3km5jhj79dwjr8wv6sfdmxawzzx47z" + }, + { + "value_sats": 27000100, + "scriptPubKey": "0014564aead56de8f4d445fc5b74a61793b5c8a81966", + "address": "bcrt1q2e9w44tdar6dg30utd62v9unkhy2sxtxr0p4md" + }, + { + "value_sats": 146994810, + "scriptPubKey": "00146ec55c2e1d1a7a868b5ec91822bf40bba842bac5", + "address": "bcrt1qdmz4ctsarfagdz67eyvz906qhw5y9wk990rz48" + } + ], + "txid": "ca606efc5ba8f6669ba15e9262e5d38e745345ea96106d5a919688d1ff0da0cc", + "nLockTime": 0, + "nVersion": 2 +}} + ``` diff --git a/jmclient/jmclient/__init__.py b/jmclient/jmclient/__init__.py index 11c6d687b..6066b61af 100644 --- a/jmclient/jmclient/__init__.py +++ b/jmclient/jmclient/__init__.py @@ -60,10 +60,13 @@ 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: diff --git a/jmclient/jmclient/client_protocol.py b/jmclient/jmclient/client_protocol.py index 5a306c3a8..692e9a3bf 100644 --- a/jmclient/jmclient/client_protocol.py +++ b/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_daemon_on_port(p, f, name, port_offset): 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_daemon_on_port(p, f, name, port_offset): "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_daemon_on_port(p, f, name, port_offset): # 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) diff --git a/jmclient/jmclient/maker.py b/jmclient/jmclient/maker.py index 3fd90660f..27a054d69 100644 --- a/jmclient/jmclient/maker.py +++ b/jmclient/jmclient/maker.py @@ -4,7 +4,7 @@ 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 @@ def try_to_create_my_orders(self): 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 diff --git a/jmclient/jmclient/snicker_receiver.py b/jmclient/jmclient/snicker_receiver.py index a17f40651..ff3d9810c 100644 --- a/jmclient/jmclient/snicker_receiver.py +++ b/jmclient/jmclient/snicker_receiver.py @@ -7,7 +7,6 @@ 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 @@ def stopService(self, wallet=False): super().stopService() def isRunning(self): - if self.running == 1: - return True - return False + return self.running == 1 class SNICKERReceiver(object): supported_flags = [] diff --git a/jmclient/jmclient/taker_utils.py b/jmclient/jmclient/taker_utils.py index a8d9767f3..867154c0a 100644 --- a/jmclient/jmclient/taker_utils.py +++ b/jmclient/jmclient/taker_utils.py @@ -21,7 +21,7 @@ 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 diff --git a/jmclient/jmclient/wallet-rpc-api.md b/jmclient/jmclient/wallet-rpc-api.md new file mode 100644 index 000000000..fbeab571a --- /dev/null +++ b/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** +
bcrt1qujp2x2fv437493sm25gfjycns7d39exjnpptzw
+ +#### 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 | diff --git a/jmclient/jmclient/wallet-rpc-api.yaml b/jmclient/jmclient/wallet-rpc-api.yaml new file mode 100644 index 000000000..d67f8948e --- /dev/null +++ b/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' \ No newline at end of file diff --git a/jmclient/jmclient/wallet_rpc.py b/jmclient/jmclient/wallet_rpc.py new file mode 100644 index 000000000..2a2b604d7 --- /dev/null +++ b/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//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//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//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//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//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//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//address/new/', 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//address/timelock/new/', 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//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//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//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//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) diff --git a/jmclient/jmclient/wallet_service.py b/jmclient/jmclient/wallet_service.py index a390071d7..22046b159 100644 --- a/jmclient/jmclient/wallet_service.py +++ b/jmclient/jmclient/wallet_service.py @@ -714,7 +714,7 @@ def sync_addresses(self): 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): diff --git a/jmclient/jmclient/wallet_utils.py b/jmclient/jmclient/wallet_utils.py index 798c7b37a..310fda235 100644 --- a/jmclient/jmclient/wallet_utils.py +++ b/jmclient/jmclient/wallet_utils.py @@ -227,21 +227,19 @@ def __init__(self, wallet_path_repr, account, address_type, branchentries=None, 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" diff --git a/jmclient/jmclient/websocketserver.py b/jmclient/jmclient/websocketserver.py new file mode 100644 index 000000000..1c126cbad --- /dev/null +++ b/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}) diff --git a/jmclient/jmclient/yieldgenerator.py b/jmclient/jmclient/yieldgenerator.py index 638ab4750..19a3979db 100644 --- a/jmclient/jmclient/yieldgenerator.py +++ b/jmclient/jmclient/yieldgenerator.py @@ -7,6 +7,7 @@ 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 @@ def select_output_address(self, input_mixdepth, offer, amount): 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 @@ def __init__(self, wallet_service, daemon_host, daemon_port, yg_config): 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 @@ def startService(self): 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 @@ def startService(self): # 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 diff --git a/jmclient/setup.py b/jmclient/setup.py index 130ca79d4..de4fc8ed2 100644 --- a/jmclient/setup.py +++ b/jmclient/setup.py @@ -10,6 +10,7 @@ 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) diff --git a/jmclient/test/test_wallet_rpc.py b/jmclient/test/test_wallet_rpc.py new file mode 100644 index 000000000..b2234091a --- /dev/null +++ b/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": []}]}]}}] +""" diff --git a/jmclient/test/test_websocket.py b/jmclient/test/test_websocket.py new file mode 100644 index 000000000..511732da7 --- /dev/null +++ b/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() diff --git a/jmdaemon/jmdaemon/daemon_protocol.py b/jmdaemon/jmdaemon/daemon_protocol.py index 97652f75e..709157668 100644 --- a/jmdaemon/jmdaemon/daemon_protocol.py +++ b/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 diff --git a/scripts/jmwalletd.py b/scripts/jmwalletd.py index 289a1be9d..d2d4974fa 100644 --- a/scripts/jmwalletd.py +++ b/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//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//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//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//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//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//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//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//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/',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() diff --git a/test/regtest_joinmarket.cfg b/test/regtest_joinmarket.cfg index 83df0b0ae..4d3c211cf 100644 --- a/test/regtest_joinmarket.cfg +++ b/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