diff --git a/README.md b/README.md index 352a4f0..021c30c 100644 --- a/README.md +++ b/README.md @@ -28,3 +28,5 @@ Next, you can install in 3 different modes: supported, see https://github.com/AdamISZ/electrum-joinmarket-plugin for details): `python setup.py --client-only install` + + You can then access the library via `import jmclient`. \ No newline at end of file diff --git a/jmbase/commands.py b/jmbase/commands.py index 6ddc55c..27d0c06 100644 --- a/jmbase/commands.py +++ b/jmbase/commands.py @@ -15,7 +15,12 @@ class JMCommand(Command): #commands from client to daemon -class JMInit(JMCommand): +class JMInit(JMCommand): + """Communicates the client's required setup + configuration. + Blockchain source is communicated only as a naming + tag for messagechannels (currently IRC 'realname' field). + """ arguments = [('bcsource', String()), ('network', String()), ('irc_configs', String()), @@ -24,6 +29,9 @@ class JMInit(JMCommand): errors = {DaemonNotReady: 'daemon is not ready'} class JMStartMC(JMCommand): + """Will restart message channel connections if config + has changed; otherwise will only change nym/nick on MCs. + """ arguments = [('nick', String())] class JMSetup(JMCommand): diff --git a/jmclient/client_protocol.py b/jmclient/client_protocol.py index de48109..958c8a9 100644 --- a/jmclient/client_protocol.py +++ b/jmclient/client_protocol.py @@ -47,13 +47,18 @@ class JMTakerClientProtocol(amp.AMP): reactor.stop() def connectionMade(self): + self.factory.setClient(self) + self.clientStart() + + def clientStart(self): """Upon confirmation of network connection to daemon, request message channel initialization with relevant config data for our message channels """ - #needed only for channel naming convention + #needed only for naming convention in IRC currently blockchain_source = jm_single().config.get("BLOCKCHAIN", "blockchain_source") + #needed only for channel naming convention network = jm_single().config.get("BLOCKCHAIN", "network") irc_configs = get_irc_mchannels() minmakers = jm_single().config.getint("POLICY", "minimum_makers") @@ -66,9 +71,6 @@ class JMTakerClientProtocol(amp.AMP): maker_timeout_sec=maker_timeout_sec) d.addCallback(self.checkClientResponse) - def send_data(self, cmd, data): - JMProtocol.send_data(self, cmd, data) - def set_nick(self): self.nick_pubkey = btc.privtopub(self.nick_priv) self.nick_pkh_raw = hashlib.sha256(self.nick_pubkey).digest()[ @@ -220,6 +222,11 @@ class JMTakerClientProtocolFactory(protocol.ClientFactory): def __init__(self, taker): self.taker = taker + self.proto_client = None + def setClient(self, client): + self.proto_client = client + def getClient(self): + return self.proto_client def buildProtocol(self, addr): return JMTakerClientProtocol(self, self.taker) diff --git a/jmclient/taker.py b/jmclient/taker.py index 207cfaa..e9b76e7 100644 --- a/jmclient/taker.py +++ b/jmclient/taker.py @@ -48,20 +48,32 @@ class Taker(object): #allow custom wallet-based clients to use their own signing code; #currently only setting "wallet" is allowed, calls wallet.sign_tx(tx) self.sign_method = sign_method + #External callers can set any of the 3 callbacks for filtering orders, + #sending info messages to client, and action on completion. if callbacks: - self.filter_orders_callback, self.taker_info_callback = callbacks + self.filter_orders_callback, self.taker_info_callback, self.on_finished_callback = callbacks + if not self.taker_info_callback: + self.taker_info_callback = self.default_taker_info_callback + if not self.on_finished_callback: + self.on_finished_callback = self.default_on_finished_callback else: self.filter_orders_callback = None self.taker_info_callback = self.default_taker_info_callback + self.on_finished_callback = self.default_on_finished_callback def default_taker_info_callback(self, infotype, msg): jlog.debug(infotype + ":" + msg) + def default_on_finished_callback(self, result): + jlog.debug("Taker default on finished callback: " + str(result)) + def initialize(self, orderbook): """Once the daemon is active and has returned the current orderbook, select offers and prepare a commitment, then send it to the protocol to fill offers. """ + #reset destinations + self.outputs = [] if not self.filter_orderbook(orderbook): return (False,) #choose coins to spend @@ -555,7 +567,7 @@ class Taker(object): self.txid = btc.txhash(tx) jlog.debug('txid = ' + self.txid) pushed = jm_single().bc_interface.pushtx(tx) - return pushed + self.on_finished_callback(pushed) def self_sign_and_push(self): self.self_sign() diff --git a/jmdaemon/message_channel.py b/jmdaemon/message_channel.py index 59ed575..5b2d975 100644 --- a/jmdaemon/message_channel.py +++ b/jmdaemon/message_channel.py @@ -106,13 +106,15 @@ class MessageChannelCollection(object): self.welcomed = False #control access self.mc_lock = threading.Lock() + self.nick=None def set_nick(self, nick): - self.nick = nick - #protocol level var: - nickname = self.nick - for mc in self.mchannels: - mc.set_nick(self.nick) + if nick != self.nick: + self.nick = nick + #protocol level var: + nickname = self.nick + for mc in self.mchannels: + mc.set_nick(self.nick) def available_channels(self): return [x for x in self.mchannels if self.mc_status[x] == 1] diff --git a/joinmarketd.py b/joinmarketd.py index 58ee82b..cf8f69b 100644 --- a/joinmarketd.py +++ b/joinmarketd.py @@ -49,12 +49,11 @@ class JMDaemonServerProtocol(amp.AMP, OrderbookWatch): def __init__(self, factory): self.factory = factory - #Set of messages we can receive from a client: - self.supported_messages = ["JM_INIT", "JM_SETUP", "JM_FILL", - "JM_MAKE_TX", "JM_REQUEST_OFFERS", - "JM_MAKE_TX", "JM_MSGSIGNATURE", - "JM_MSGSIGNATURE_VERIFY", "JM_START_MC"] self.jm_state = 0 + self.restart_mc_required = False + self.irc_configs = None + self.mcc = None + self.sig_lock = threading.Lock() def checkClientResponse(self, response): """A generic check of client acceptance; any failure @@ -66,21 +65,37 @@ class JMDaemonServerProtocol(amp.AMP, OrderbookWatch): @JMInit.responder def on_JM_INIT(self, bcsource, network, irc_configs, minmakers, maker_timeout_sec): + """Reads in required configuration from client for a new + session; feeds back joinmarket messaging protocol constants + (required for nick creation). + If a new message channel configuration is required, the current + one is shutdown in preparation. + """ self.maker_timeout_sec = int(maker_timeout_sec) self.minmakers = int(minmakers) irc_configs = json.loads(irc_configs) - mcs = [IRCMessageChannel(c, - daemon=self, - realname='btcint=' + bcsource) - for c in irc_configs] #(bitcoin) network only referenced in channel name construction self.network = network - self.mcc = MessageChannelCollection(mcs) - OrderbookWatch.set_msgchan(self, self.mcc) - #register taker-specific msgchan callbacks here - self.mcc.register_taker_callbacks(self.on_error, self.on_pubkey, - self.on_ioauth, self.on_sig) - self.mcc.set_daemon(self) + if irc_configs == self.irc_configs: + self.restart_mc_required = False + log.msg("New init received did not require a new message channel" + " setup.") + else: + if self.irc_configs: + #close the existing connections + self.mc_shutdown() + self.irc_configs = irc_configs + self.restart_mc_required = True + mcs = [IRCMessageChannel(c, + daemon=self, + realname='btcint=' + bcsource) + for c in self.irc_configs] + self.mcc = MessageChannelCollection(mcs) + OrderbookWatch.set_msgchan(self, self.mcc) + #register taker-specific msgchan callbacks here + self.mcc.register_taker_callbacks(self.on_error, self.on_pubkey, + self.on_ioauth, self.on_sig) + self.mcc.set_daemon(self) d = self.callRemote(JMInitProto, nick_hash_length=NICK_HASH_LENGTH, nick_max_encoded=NICK_MAX_ENCODED, @@ -91,7 +106,8 @@ class JMDaemonServerProtocol(amp.AMP, OrderbookWatch): @JMStartMC.responder def on_JM_START_MC(self, nick): - """Starts message channel threads; + """Starts message channel threads, if we are working with + a new message channel configuration. Sets new nick if required. JM_UP will be called when the welcome messages are received. """ self.init_connections(nick) @@ -99,8 +115,15 @@ class JMDaemonServerProtocol(amp.AMP, OrderbookWatch): def init_connections(self, nick): self.jm_state = 0 #uninited + if self.restart_mc_required: + MCThread(self.mcc).start() + self.restart_mc_required = False + else: + #if we are not restarting the MC, + #we must simulate the on_welcome message: + self.on_welcome() self.mcc.set_nick(nick) - MCThread(self.mcc).start() + def on_welcome(self): """Fired when channel indicated state readiness @@ -241,26 +264,28 @@ class JMDaemonServerProtocol(amp.AMP, OrderbookWatch): duplication is so that the client does not need to know the message syntax. """ - d = self.callRemote(JMRequestMsgSig, - nick=str(nick), - cmd=str(cmd), - msg=str(msg), - msg_to_be_signed=str(msg_to_be_signed), - hostid=str(hostid)) - d.addCallback(self.checkClientResponse) + with self.sig_lock: + d = self.callRemote(JMRequestMsgSig, + nick=str(nick), + cmd=str(cmd), + msg=str(msg), + msg_to_be_signed=str(msg_to_be_signed), + hostid=str(hostid)) + d.addCallback(self.checkClientResponse) def request_signature_verify(self, msg, fullmsg, sig, pubkey, nick, hashlen, max_encoded, hostid): - d = self.callRemote(JMRequestMsgSigVerify, - msg=msg, - fullmsg=fullmsg, - sig=sig, - pubkey=pubkey, - nick=nick, - hashlen=hashlen, - max_encoded=max_encoded, - hostid=hostid) - d.addCallback(self.checkClientResponse) + with self.sig_lock: + d = self.callRemote(JMRequestMsgSigVerify, + msg=msg, + fullmsg=fullmsg, + sig=sig, + pubkey=pubkey, + nick=nick, + hashlen=hashlen, + max_encoded=max_encoded, + hostid=hostid) + d.addCallback(self.checkClientResponse) @JMMsgSignature.responder def on_JM_MSGSIGNATURE(self, nick, cmd, msg_to_return, hostid): @@ -287,8 +312,9 @@ class JMDaemonServerProtocol(amp.AMP, OrderbookWatch): log.msg("Unimplemented on_error") def mc_shutdown(self): - log.msg("Message channels shut down in proto") - self.mcc.shutdown() + log.msg("Message channels being shutdown by daemon") + if self.mcc: + self.mcc.shutdown() class JMDaemonServerProtocolFactory(ServerFactory): diff --git a/sendpayment.py b/sendpayment.py index 8b19567..e953b7c 100644 --- a/sendpayment.py +++ b/sendpayment.py @@ -4,9 +4,26 @@ from __future__ import absolute_import, print_function """ A sample implementation of a single coinjoin script, adapted from `sendpayment.py` in Joinmarket-Org/joinmarket. +This is primitive and not yet well tested, but it is designed +to illustrate the main functionality of the new architecture: +this code can be run in a separate environment (but not safely +over the internet, better on one machine) to the joinmarketdaemon. +Moreover, it can run several transactions using the -b option, e.g.: + +`python sendpayment.py -b 3 -N 3 -m 1 walletseed amount address`; + +note here only one destination address for multiple transactions, +only one mixdepth and other settings; this is just a proof of concept. +The idea is that the "backend" (daemon) will keep its orderbook and stay +connected on the message channel between runs, only shutting down +after all are complete. + +It should be very easy to extend this further, of course. + More complex applications can extend from Taker and add -more features, such as repeated joins. This will also allow -easier coding of non-CLI interfaces. +more features. This will also allow +easier coding of non-CLI interfaces. A plugin for Electrum is in process +and already working. Other potential customisations of the Taker object instantiation include: @@ -22,7 +39,7 @@ import random import sys import threading from optparse import OptionParser - +from twisted.internet import reactor import time from jmclient import (Taker, load_program_config, @@ -36,7 +53,8 @@ from jmclient import (Taker, load_program_config, from jmbase.support import get_log, debug_dump_object log = get_log() - +txcount = 1 +wallet = None def check_high_fee(total_fee_pc): WARNING_THRESHOLD = 0.02 # 2% @@ -94,6 +112,13 @@ def main(): dest='daemonport', help='port on which joinmarketd is running', default='12345') + parser.add_option('-b', + '--txcount', + type='int', + dest='txcount', + help=('optionally do more than 1 transaction to the ' + 'same destination, of the same amount'), + default=1) parser.add_option( '-C', '--choose-cheapest', @@ -181,19 +206,40 @@ def main(): assert (options.txfee >= 0) log.debug('starting sendpayment') - + global wallet if not options.userpcwallet: wallet = Wallet(wallet_name, options.amtmixdepths, options.gaplimit) else: wallet = BitcoinCoreWallet(fromaccount=wallet_name) jm_single().bc_interface.sync_wallet(wallet) + def taker_finished(res): + global wallet + global txcount + txcount += 1 + if res: + log.debug("Transaction finished OK, result was: ") + else: + log.info("A transaction failed, quitting") + sys.exit(1) + if txcount > options.txcount: + log.debug("Shutting down") + reactor.stop() + else: + #need to update for new transactions; only working for + #regtest at the moment (otherwise too slow) + jm_single().bc_interface.sync_wallet(wallet) + time.sleep(3) #for blocks to mine + #restarts from the entry point of the client-server protocol (JMInit) + #with the *same* Taker object. + clientfactory.getClient().clientStart() taker = Taker(wallet, options.mixdepth, amount, options.makercount, order_chooser=chooseOrdersFunc, - external_addr=destaddr) + external_addr=destaddr, + callbacks=(None, None, taker_finished)) clientfactory = JMTakerClientProtocolFactory(taker) start_reactor("localhost", options.daemonport, clientfactory)