diff --git a/jmbase/jmbase/commands.py b/jmbase/jmbase/commands.py index 770957f..a43dda2 100644 --- a/jmbase/jmbase/commands.py +++ b/jmbase/jmbase/commands.py @@ -101,9 +101,12 @@ class JMPushTx(JMCommand): class JMAnnounceOffers(JMCommand): """Send list (actually dict) of offers - to the daemon + to the daemon, along with new announcement + and cancellation lists (deltas). """ - arguments = [('offerlist', String())] + arguments = [('to_announce', String()), + ('to_cancel', String()), + ('offerlist', String())] class JMIOAuth(JMCommand): """Send contents of !ioauth message after @@ -123,7 +126,7 @@ class JMTXSigs(JMCommand): arguments = [('nick', String()), ('sigs', String())] -"""COMMANDS FROM CLIENT TO DAEMON +"""COMMANDS FROM DAEMON TO CLIENT ================================= """ diff --git a/jmclient/jmclient/client_protocol.py b/jmclient/jmclient/client_protocol.py index 61398e0..d813e84 100644 --- a/jmclient/jmclient/client_protocol.py +++ b/jmclient/jmclient/client_protocol.py @@ -24,9 +24,10 @@ import time import hashlib import os import sys +import pprint from jmclient import (Taker, Wallet, jm_single, get_irc_mchannels, - load_program_config, get_log) - + load_program_config, get_log, get_p2sh_vbyte) +from jmbase import _byteify import btc jlog = get_log() @@ -134,6 +135,8 @@ class JMClientProtocol(amp.AMP): class JMMakerClientProtocol(JMClientProtocol): def __init__(self, factory, maker, nick_priv=None): self.factory = factory + #used for keeping track of transactions for the unconf/conf callbacks + self.finalized_offers = {} JMClientProtocol.__init__(self, factory, maker, nick_priv) @commands.JMUp.responder @@ -141,21 +144,9 @@ class JMMakerClientProtocol(JMClientProtocol): d = self.callRemote(commands.JMSetup, role="MAKER", initdata=json.dumps(self.client.offerlist)) - #for as long as the maker is up, it can asynchronously pass through - #its updated offer list - offer_loop = LoopingCall(self.get_offers) - offer_loop.start(3.0) self.defaultCallbacks(d) return {'accepted': True} - def get_offers(self): - """Feeds through current offers from Maker obect, to the daemon - """ - offerlist = self.client.offerlist - d = self.callRemote(commands.JMAnnounceOffers, - offerlist=json.dumps(offerlist)) - self.defaultCallbacks(d) - @commands.JMSetupDone.responder def on_JM_SETUP_DONE(self): jlog.info("JM daemon setup complete") @@ -209,18 +200,72 @@ class JMMakerClientProtocol(JMClientProtocol): @commands.JMTXReceived.responder def on_JM_TX_RECEIVED(self, nick, txhex, offer): - offer = json.loads(offer) + offer = _byteify(json.loads(offer)) retval = self.client.on_tx_received(nick, txhex, offer) if not retval[0]: jlog.info("Maker refuses to continue on receipt of tx") else: sigs = retval[1] + self.finalized_offers[nick] = offer + tx = btc.deserialize(txhex) + self.finalized_offers[nick]["txd"] = tx + jm_single().bc_interface.add_tx_notify(tx, self.unconfirm_callback, + self.confirm_callback, + offer["cjaddr"], + vb=get_p2sh_vbyte()) d = self.callRemote(commands.JMTXSigs, nick=nick, sigs=json.dumps(sigs)) self.defaultCallbacks(d) return {"accepted": True} + def unconfirm_callback(self, txd, txid): + #find the offer for this tx + offerinfo = None + for k,v in self.finalized_offers.iteritems(): + #Tx considered defined by its output set + if v["txd"]["outs"] == txd["outs"]: + offerinfo = v + break + if not offerinfo: + jlog.info("Failed to find notified unconfirmed transaction: " + txid) + return + removed_utxos = self.client.wallet.remove_old_utxos(txd) + jlog.info('saw tx on network, removed_utxos=\n{}'.format( + pprint.pformat(removed_utxos))) + to_cancel, to_announce = self.client.on_tx_unconfirmed(offerinfo, + txid, removed_utxos) + self.client.modify_orders(to_cancel, to_announce) + d = self.callRemote(commands.JMAnnounceOffers, + to_announce=json.dumps(to_announce), + to_cancel=json.dumps(to_cancel), + offerlist=json.dumps(self.client.offerlist)) + self.defaultCallbacks(d) + + def confirm_callback(self, txd, txid, confirmations): + #find the offer for this tx + offerinfo = None + for k,v in self.finalized_offers.iteritems(): + #Tx considered defined by its output set + if v["txd"]["outs"] == txd["outs"]: + offerinfo = v + break + if not offerinfo: + jlog.info("Failed to find notified unconfirmed transaction: " + txid) + return + jm_single().bc_interface.sync_unspent(self.client.wallet) + jlog.info('tx in a block: ' + txid) + #TODO track the earning + #jlog.info('earned = ' + str(self.real_cjfee - self.txfee)) + to_cancel, to_announce = self.client.on_tx_confirmed(offerinfo, + confirmations, txid) + self.client.modify_orders(to_cancel, to_announce) + d = self.callRemote(commands.JMAnnounceOffers, + to_announce=json.dumps(to_announce), + to_cancel=json.dumps(to_cancel), + offerlist=json.dumps(self.client.offerlist)) + self.defaultCallbacks(d) + class JMTakerClientProtocol(JMClientProtocol): def __init__(self, factory, client, nick_priv=None): diff --git a/jmclient/jmclient/maker.py b/jmclient/jmclient/maker.py new file mode 100644 index 0000000..33216c1 --- /dev/null +++ b/jmclient/jmclient/maker.py @@ -0,0 +1,168 @@ +#! /usr/bin/env python +from __future__ import print_function + +import base64 +import pprint +import random +import sys +import time +import copy + +import btc +from jmclient.configure import jm_single, get_p2pk_vbyte, get_p2sh_vbyte +from jmbase.support import get_log +from jmclient.support import (calc_cj_fee) +from jmclient.wallet import estimate_tx_fee +from jmclient.podle import (generate_podle, get_podle_commitments, verify_podle, + PoDLE, PoDLEError, generate_podle_error_string) +jlog = get_log() + +class Maker(object): + def __init__(self, wallet): + self.active_orders = {} + self.wallet = wallet + self.nextoid = -1 + self.offerlist = self.create_my_orders() + self.aborted = False + + def on_auth_received(self, nick, offer, commitment, cr, amount, kphex): + """Receives data on proposed transaction offer from daemon, verifies + commitment, returns necessary data to send ioauth message (utxos etc) + """ + #deserialize the commitment revelation + cr_dict = PoDLE.deserialize_revelation(cr) + #check the validity of the proof of discrete log equivalence + tries = jm_single().config.getint("POLICY", "taker_utxo_retries") + def reject(msg): + jlog.info("Counterparty commitment not accepted, reason: " + msg) + return (False,) + if not verify_podle(str(cr_dict['P']), str(cr_dict['P2']), str(cr_dict['sig']), + str(cr_dict['e']), str(commitment), + index_range=range(tries)): + reason = "verify_podle failed" + return reject(reason) + #finally, check that the proffered utxo is real, old enough, large enough, + #and corresponds to the pubkey + res = jm_single().bc_interface.query_utxo_set([cr_dict['utxo']], + includeconf=True) + if len(res) != 1 or not res[0]: + reason = "authorizing utxo is not valid" + return reject(reason) + age = jm_single().config.getint("POLICY", "taker_utxo_age") + if res[0]['confirms'] < age: + reason = "commitment utxo not old enough: " + str(res[0]['confirms']) + return reject(reason) + reqd_amt = int(amount * jm_single().config.getint( + "POLICY", "taker_utxo_amtpercent") / 100.0) + if res[0]['value'] < reqd_amt: + reason = "commitment utxo too small: " + str(res[0]['value']) + return reject(reason) + if res[0]['address'] != btc.pubkey_to_p2sh_p2wpkh_address(cr_dict['P'], + self.wallet.get_vbyte()): + reason = "Invalid podle pubkey: " + str(cr_dict['P']) + return reject(reason) + + # authorisation of taker passed + #Find utxos for the transaction now: + utxos, cj_addr, change_addr = self.oid_to_order(offer, amount) + if not utxos: + #could not find funds + return (False,) + # Construct data for auth request back to taker. + # Need to choose an input utxo pubkey to sign with + # (no longer using the coinjoin pubkey from 0.2.0) + # Just choose the first utxo in self.utxos and retrieve key from wallet. + auth_address = utxos[utxos.keys()[0]]['address'] + auth_key = self.wallet.get_key_from_addr(auth_address) + auth_pub = btc.privtopub(auth_key) + btc_sig = btc.ecdsa_sign(kphex, auth_key) + return (True, utxos, auth_pub, cj_addr, change_addr, btc_sig) + + def on_tx_received(self, nick, txhex, offerinfo): + try: + tx = btc.deserialize(txhex) + except IndexError as e: + return (False, 'malformed txhex. ' + repr(e)) + jlog.info('obtained tx\n' + pprint.pformat(tx)) + goodtx, errmsg = self.verify_unsigned_tx(tx, offerinfo) + if not goodtx: + jlog.info('not a good tx, reason=' + errmsg) + return (False, errmsg) + jlog.info('goodtx') + sigs = [] + utxos = offerinfo["utxos"] + for index, ins in enumerate(tx['ins']): + utxo = ins['outpoint']['hash'] + ':' + str(ins['outpoint']['index']) + if utxo not in utxos.keys(): + continue + addr = utxos[utxo]['address'] + amount = utxos[utxo]["value"] + txs = self.wallet.sign(txhex, index, + self.wallet.get_key_from_addr(addr), + amount=amount) + sigmsg = btc.deserialize(txs)["ins"][index]["script"].decode("hex") + if "txinwitness" in btc.deserialize(txs)["ins"][index].keys(): + #We prepend the witness data since we want (sig, pub, scriptCode); + #also, the items in witness are not serialize_script-ed. + sigmsg = "".join([btc.serialize_script_unit( + x.decode("hex")) for x in btc.deserialize( + txs)["ins"][index]["txinwitness"]]) + sigmsg + sigs.append(base64.b64encode(sigmsg)) + return (True, sigs) + + def verify_unsigned_tx(self, txd, offerinfo): + tx_utxo_set = set(ins['outpoint']['hash'] + ':' + str( + ins['outpoint']['index']) for ins in txd['ins']) + + utxos = offerinfo["utxos"] + cjaddr = offerinfo["cjaddr"] + changeaddr = offerinfo["changeaddr"] + amount = offerinfo["amount"] + cjfee = offerinfo["offer"]["cjfee"] + txfee = offerinfo["offer"]["txfee"] + ordertype = offerinfo["offer"]["ordertype"] + my_utxo_set = set(utxos.keys()) + if not tx_utxo_set.issuperset(my_utxo_set): + return (False, 'my utxos are not contained') + + my_total_in = sum([va['value'] for va in utxos.values()]) + real_cjfee = calc_cj_fee(ordertype, cjfee, amount) + expected_change_value = (my_total_in - amount - txfee + real_cjfee) + jlog.info('potentially earned = {}'.format(real_cjfee - txfee)) + jlog.debug('mycjaddr, mychange = {}, {}'.format(cjaddr, changeaddr)) + + times_seen_cj_addr = 0 + times_seen_change_addr = 0 + for outs in txd['outs']: + addr = btc.script_to_address(outs['script'], get_p2sh_vbyte()) + if addr == cjaddr: + times_seen_cj_addr += 1 + if outs['value'] != amount: + return (False, 'Wrong cj_amount. I expect ' + str(amount)) + if addr == changeaddr: + times_seen_change_addr += 1 + if outs['value'] != expected_change_value: + return (False, 'wrong change, i expect ' + str( + expected_change_value)) + if times_seen_cj_addr != 1 or times_seen_change_addr != 1: + fmt = ('cj or change addr not in tx ' + 'outputs once, #cjaddr={}, #chaddr={}').format + return (False, (fmt(times_seen_cj_addr, times_seen_change_addr))) + return (True, None) + + def modify_orders(self, to_cancel, to_announce): + jlog.info('modifying orders. to_cancel={}\nto_announce={}'.format( + to_cancel, to_announce)) + for oid in to_cancel: + order = [o for o in self.offerlist if o['oid'] == oid] + if len(order) == 0: + fmt = 'didnt cancel order which doesnt exist, oid={}'.format + jlog.debug(fmt(oid)) + self.offerlist.remove(order[0]) + if len(to_announce) > 0: + for ann in to_announce: + oldorder_s = [order for order in self.offerlist + if order['oid'] == ann['oid']] + if len(oldorder_s) > 0: + self.offerlist.remove(oldorder_s[0]) + self.offerlist += to_announce diff --git a/jmclient/jmclient/yieldgenerator.py b/jmclient/jmclient/yieldgenerator.py new file mode 100644 index 0000000..5e5945a --- /dev/null +++ b/jmclient/jmclient/yieldgenerator.py @@ -0,0 +1,153 @@ +#! /usr/bin/env python +from __future__ import absolute_import, print_function + +import datetime +import os +import time +import abc +from optparse import OptionParser + +from jmclient import (Maker, jm_single, get_network, load_program_config, get_log, + SegwitWallet, sync_wallet, JMClientProtocolFactory, + start_reactor) + +jlog = get_log() + +MAX_MIX_DEPTH = 5 + +# is a maker for the purposes of generating a yield from held +# bitcoins +class YieldGenerator(Maker): + __metaclass__ = abc.ABCMeta + statement_file = os.path.join('logs', 'yigen-statement.csv') + + def __init__(self, wallet): + Maker.__init__(self, wallet) + self.tx_unconfirm_timestamp = {} + if not os.path.isfile(self.statement_file): + self.log_statement( + ['timestamp', 'cj amount/satoshi', 'my input count', + 'my input value/satoshi', 'cjfee/satoshi', 'earned/satoshi', + 'confirm time/min', 'notes']) + + timestamp = datetime.datetime.now().strftime("%Y/%m/%d %H:%M:%S") + self.log_statement([timestamp, '', '', '', '', '', '', 'Connected']) + + def log_statement(self, data): + if get_network() == 'testnet': + return + + data = [str(d) for d in data] + self.income_statement = open(self.statement_file, 'a') + self.income_statement.write(','.join(data) + '\n') + self.income_statement.close() + + @abc.abstractmethod + def create_my_orders(self): + """Must generate a set of orders to be displayed + according to the contents of the wallet + some algo. + (Note: should be called "create_my_offers") + """ + + @abc.abstractmethod + def oid_to_order(self, cjorder, oid, amount): + """Must convert an order with an offer/order id + into a set of utxos to fill the order. + Also provides the output addresses for the Taker. + """ + + @abc.abstractmethod + def on_tx_unconfirmed(self, cjorder, txid, removed_utxos): + """Performs action on receipt of transaction into the + mempool in the blockchain instance (e.g. announcing orders) + """ + + @abc.abstractmethod + def on_tx_confirmed(self, cjorder, confirmations, txid): + """Performs actions on receipt of 1st confirmation of + a transaction into a block (e.g. announce orders) + """ + + +def ygmain(ygclass, txfee=1000, cjfee_a=200, cjfee_r=0.002, ordertype='swreloffer', + nickserv_password='', minsize=100000, gaplimit=6): + import sys + + parser = OptionParser(usage='usage: %prog [options] [wallet file]') + parser.add_option('-o', '--ordertype', action='store', type='string', + dest='ordertype', default=ordertype, + help='type of order; can be either reloffer or absoffer') + parser.add_option('-t', '--txfee', action='store', type='int', + dest='txfee', default=txfee, + help='minimum miner fee in satoshis') + parser.add_option('-c', '--cjfee', action='store', type='string', + dest='cjfee', default='', + help='requested coinjoin fee in satoshis or proportion') + parser.add_option('-p', '--password', action='store', type='string', + dest='password', default=nickserv_password, + help='irc nickserv password') + parser.add_option('-s', '--minsize', action='store', type='int', + dest='minsize', default=minsize, + help='minimum coinjoin size in satoshis') + parser.add_option('-g', '--gap-limit', action='store', type="int", + dest='gaplimit', default=gaplimit, + help='gap limit for wallet, default='+str(gaplimit)) + parser.add_option('--fast', + action='store_true', + dest='fastsync', + default=False, + help=('choose to do fast wallet sync, only for Core and ' + 'only for previously synced wallet')) + (options, args) = parser.parse_args() + if len(args) < 1: + parser.error('Needs a wallet') + sys.exit(0) + wallet_name = args[0] + ordertype = options.ordertype + txfee = options.txfee + if ordertype == 'swreloffer': + if options.cjfee != '': + cjfee_r = options.cjfee + # minimum size is such that you always net profit at least 20% + #of the miner fee + minsize = max(int(1.2 * txfee / float(cjfee_r)), options.minsize) + elif ordertype == 'swabsoffer': + if options.cjfee != '': + cjfee_a = int(options.cjfee) + minsize = options.minsize + else: + parser.error('You specified an incorrect order type which ' +\ + 'can be either reloffer or absoffer') + sys.exit(0) + nickserv_password = options.password + + load_program_config() + if not os.path.exists(os.path.join('wallets', wallet_name)): + wallet = SegwitWallet(wallet_name, None, max_mix_depth=MAX_MIX_DEPTH, + gaplimit=options.gaplimit) + else: + while True: + try: + pwd = get_password("Enter wallet decryption passphrase: ") + wallet = SegwitWallet(wallet_name, pwd, + max_mix_depth=MAX_MIX_DEPTH, + gaplimit=options.gaplimit) + except WalletError: + print("Wrong password, try again.") + continue + except Exception as e: + print("Failed to load wallet, error message: " + repr(e)) + sys.exit(0) + break + sync_wallet(wallet, fast=options.fastsync) + + maker = ygclass(wallet, [options.txfee, cjfee_a, cjfee_r, + options.ordertype, options.minsize]) + jlog.info('starting yield generator') + clientfactory = JMClientProtocolFactory(maker, proto_type="MAKER") + nodaemon = jm_single().config.getint("DAEMON", "no_daemon") + daemon = True if nodaemon == 1 else False + start_reactor(jm_single().config.get("DAEMON", "daemon_host"), + jm_single().config.getint("DAEMON", "daemon_port"), + clientfactory, daemon=daemon) + diff --git a/jmdaemon/jmdaemon/daemon_protocol.py b/jmdaemon/jmdaemon/daemon_protocol.py index 937c8f1..30284cb 100644 --- a/jmdaemon/jmdaemon/daemon_protocol.py +++ b/jmdaemon/jmdaemon/daemon_protocol.py @@ -75,6 +75,7 @@ class JMDaemonServerProtocol(amp.AMP, OrderbookWatch): self.restart_mc_required = False self.irc_configs = None self.mcc = None + #Default role is TAKER; must be overriden to MAKER in JMSetup message. self.role = "TAKER" self.crypto_boxes = {} self.sig_lock = threading.Lock() @@ -88,7 +89,10 @@ class JMDaemonServerProtocol(amp.AMP, OrderbookWatch): reactor.stop() #pragma: no cover def defaultErrback(self, failure): - failure.trap(ConnectionAborted, ConnectionClosed, ConnectionDone, ConnectionLost) + """TODO better network error handling. + """ + failure.trap(ConnectionAborted, ConnectionClosed, + ConnectionDone, ConnectionLost) def defaultCallbacks(self, d): d.addCallback(self.checkClientResponse) @@ -152,29 +156,6 @@ class JMDaemonServerProtocol(amp.AMP, OrderbookWatch): self.init_connections(nick) return {'accepted': True} - def init_connections(self, nick): - """Sets up message channel connections - if they are not already up; re-sets joinmarket state to 0 - for a new transaction; effectively means any previous - incomplete transaction is wiped. - """ - self.jm_state = 0 #uninited - if self.restart_mc_required: - self.mcc.run() - 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) - - - def on_welcome(self): - """Fired when channel indicated state readiness - """ - d = self.callRemote(JMUp) - self.defaultCallbacks(d) - @JMSetup.responder def on_JM_SETUP(self, role, initdata): assert self.jm_state == 0 @@ -197,13 +178,21 @@ class JMDaemonServerProtocol(amp.AMP, OrderbookWatch): self.jm_state = 1 return {'accepted': True} - @JMTXSigs.responder - def on_JM_TX_SIGS(self, nick, sigs): - sigs = _byteify(json.loads(sigs)) - print('sending sigs: ' + str(sigs)) - for sig in sigs: - self.mcc.prepare_privmsg(nick, "sig", sig) - return {"accepted": True} + @JMMsgSignature.responder + def on_JM_MSGSIGNATURE(self, nick, cmd, msg_to_return, hostid): + self.mcc.privmsg(nick, cmd, msg_to_return, mc=hostid) + return {'accepted': True} + + @JMMsgSignatureVerify.responder + def on_JM_MSGSIGNATURE_VERIFY(self, verif_result, nick, fullmsg, hostid): + if not verif_result: + log.msg("Verification failed for nick: " + str(nick)) + else: + self.mcc.on_verified_privmsg(nick, fullmsg, hostid) + return {'accepted': True} + + """Taker specific responders + """ @JMRequestOffers.responder def on_JM_REQUEST_OFFERS(self): @@ -236,13 +225,78 @@ class JMDaemonServerProtocol(amp.AMP, OrderbookWatch): self.jm_state = 2 return {'accepted': True} + @JMMakeTx.responder + def on_JM_MAKE_TX(self, nick_list, txhex): + if not self.jm_state == 4: + log.msg("Make tx was called in wrong state, rejecting") + return {'accepted': False} + nick_list = json.loads(nick_list) + self.mcc.send_tx(nick_list, txhex) + return {'accepted': True} + + @JMPushTx.responder + def on_JM_PushTx(self, nick, txhex): + self.mcc.push_tx(nick, txhex) + return {'accepted': True} + + """Maker specific responders + """ + @JMAnnounceOffers.responder - def on_JM_ANNOUNCE_OFFERS(self, offerlist): + def on_JM_ANNOUNCE_OFFERS(self, to_announce, to_cancel, offerlist): if self.role != "MAKER": return + to_announce = json.loads(to_announce) + to_cancel = json.loads(to_cancel) self.offerlist = json.loads(offerlist) + if len(to_cancel) > 0: + self.mcc.cancel_orders(to_cancel) + if len(to_announce) > 0: + self.mcc.announce_orders(to_announce, None, None) + return {"accepted": True} + + @JMIOAuth.responder + def on_JM_IOAUTH(self, nick, utxolist, pubkey, cjaddr, changeaddr, pubkeysig): + nick, utxolist, pubkey, cjaddr, changeaddr, pubkeysig = [_byteify( + x) for x in nick, utxolist, pubkey, cjaddr, changeaddr, pubkeysig] + if not self.role == "MAKER": + return + if not nick in self.active_orders: + return + utxos= json.loads(utxolist) + #completed population of order/offer object + self.active_orders[nick]["cjaddr"] = cjaddr + self.active_orders[nick]["changeaddr"] = changeaddr + self.active_orders[nick]["utxos"] = utxos + msg = str(",".join(utxos.keys())) + " " + " ".join( + [pubkey, cjaddr, changeaddr, pubkeysig]) + self.mcc.prepare_privmsg(nick, "ioauth", msg) + #In case of *blacklisted (ie already used) commitments, we already + #broadcasted them on receipt; in case of valid, and now used commitments, + #we broadcast them here, and not early - to avoid accidentally + #blacklisting commitments that are broadcast between makers in real time + #for the same transaction. + self.transfer_commitment(self.active_orders[nick]["commit"]) + #now persist the fact that the commitment is actually used. + check_utxo_blacklist(self.active_orders[nick]["commit"], persist=True) + return {"accepted": True} + + @JMTXSigs.responder + def on_JM_TX_SIGS(self, nick, sigs): + sigs = _byteify(json.loads(sigs)) + for sig in sigs: + self.mcc.prepare_privmsg(nick, "sig", sig) return {"accepted": True} + """Message channel callbacks + """ + + def on_welcome(self): + """Fired when channel indicated state readiness + """ + d = self.callRemote(JMUp) + self.defaultCallbacks(d) + def on_orderbook_requested(self, nick, mc=None): """Dealt with by daemon, assuming offerlist is up to date """ @@ -278,14 +332,14 @@ class JMDaemonServerProtocol(amp.AMP, OrderbookWatch): kp = init_keypair() try: crypto_box = as_init_encryption(kp, init_pubkey(taker_pk)) - self.active_orders[nick] = {"crypto_box": crypto_box, + except NaclError as e: + log.msg("Unable to set up cryptobox with counterparty: " + repr(e)) + self.mcc.send_error(nick, "Invalid nacl pubkey: " + taker_pk) + self.active_orders[nick] = {"crypto_box": crypto_box, "kp": kp, "offer": offer, "amount": amount, "commit": scommit} - except NaclError as e: - log.msg("Unable to set up cryptobox with counterparty: " + repr(e)) - self.mcc.send_error(nick, "Invalid nacl pubkey: " + taker_pk) self.mcc.prepare_privmsg(nick, "pubkey", kp.hex_pk()) def on_seen_auth(self, nick, commitment_revelation): @@ -304,47 +358,6 @@ class JMDaemonServerProtocol(amp.AMP, OrderbookWatch): kphex=ao["kp"].hex_pk()) self.defaultCallbacks(d) - @JMIOAuth.responder - def on_JM_IOAUTH(self, nick, utxolist, pubkey, cjaddr, changeaddr, pubkeysig): - nick, utxolist, pubkey, cjaddr, changeaddr, pubkeysig = [_byteify( - x) for x in nick, utxolist, pubkey, cjaddr, changeaddr, pubkeysig] - if not self.role == "MAKER": - return - if not nick in self.active_orders: - return - utxos= json.loads(utxolist) - #completed population of order/offer object - self.active_orders[nick]["cjaddr"] = cjaddr - self.active_orders[nick]["changeaddr"] = changeaddr - self.active_orders[nick]["utxos"] = utxos - msg = str(",".join(utxos.keys())) + " " + " ".join( - [pubkey, cjaddr, changeaddr, pubkeysig]) - self.mcc.prepare_privmsg(nick, "ioauth", msg) - #In case of *blacklisted (ie already used) commitments, we already - #broadcasted them on receipt; in case of valid, and now used commitments, - #we broadcast them here, and not early - to avoid accidentally - #blacklisting commitments that are broadcast between makers in real time - #for the same transaction. - self.transfer_commitment(self.active_orders[nick]["commit"]) - #now persist the fact that the commitment is actually used. - check_utxo_blacklist(self.active_orders[nick]["commit"], persist=True) - return {"accepted": True} - - def transfer_commitment(self, commit): - """Send this commitment via privmsg to one (random) - other maker. - """ - crow = self.db.execute( - 'SELECT DISTINCT counterparty FROM orderbook ORDER BY ' + - 'RANDOM() LIMIT 1;' - ).fetchone() - if crow is None: - return - counterparty = crow['counterparty'] - #TODO de-hardcode hp2 - log.msg("Sending commitment to: " + str(counterparty)) - self.mcc.prepare_privmsg(counterparty, 'hp2', commit) - def on_commitment_seen(self, nick, commitment): """Triggered when we see a commitment for blacklisting appear in the public pit channel. If the policy is set, @@ -422,54 +435,6 @@ class JMDaemonServerProtocol(amp.AMP, OrderbookWatch): #Finish early if we got all self.respondToIoauths(True) - def respondToIoauths(self, accepted): - if self.jm_state != 2: - #this can be called a second time on timeout, in which case we - #do nothing - return - self.jm_state = 3 - if not accepted: - #use ioauth data field to return the list of non-responsive makers - nonresponders = [x for x in self.active_orders.keys() if x not - in self.ioauth_data.keys()] - ioauth_data = self.ioauth_data if accepted else nonresponders - d = self.callRemote(JMFillResponse, - success=accepted, - ioauth_data = json.dumps(ioauth_data)) - if not accepted: - #Client simply accepts failure TODO - self.defaultCallbacks(d) - else: - #Act differently if *we* provided utxos, but - #client does not accept for some reason - d.addCallback(self.checkUtxosAccepted) - d.addErrback(self.defaultErrback) - - def completeStage1(self): - """Timeout of stage 1 requests; - either send success + ioauth data if enough makers, - else send failure to client. - """ - response = True if len(self.ioauth_data.keys()) >= self.minmakers else False - self.respondToIoauths(response) - - def checkUtxosAccepted(self, accepted): - if not accepted: - log.msg("Taker rejected utxos provided; resetting.") - #TODO create re-set function to start again - else: - #only update state if client accepted - self.jm_state = 4 - - @JMMakeTx.responder - def on_JM_MAKE_TX(self, nick_list, txhex): - if not self.jm_state == 4: - log.msg("Make tx was called in wrong state, rejecting") - return {'accepted': False} - nick_list = json.loads(nick_list) - self.mcc.send_tx(nick_list, txhex) - return {'accepted': True} - def on_sig(self, nick, sig): """Pass signature through to Taker. """ @@ -478,9 +443,13 @@ class JMDaemonServerProtocol(amp.AMP, OrderbookWatch): sig=sig) self.defaultCallbacks(d) - """The following functions handle requests and responses + def on_error(self, msg): + log.msg("Received error: " + str(msg)) + + """The following 2 functions handle requests and responses from client for messaging signing and verifying. """ + def request_signed_message(self, nick, cmd, msg, msg_to_be_signed, hostid): """The daemon passes the nick and cmd fields to the client so it can be echoed back to the privmsg @@ -512,23 +481,75 @@ class JMDaemonServerProtocol(amp.AMP, OrderbookWatch): hostid=hostid) self.defaultCallbacks(d) - @JMPushTx.responder - def on_JM_PushTx(self, nick, txhex): - self.mcc.push_tx(nick, txhex) - return {'accepted': True} + def init_connections(self, nick): + """Sets up message channel connections + if they are not already up; re-sets joinmarket state to 0 + for a new transaction; effectively means any previous + incomplete transaction is wiped. + """ + self.jm_state = 0 #uninited + if self.restart_mc_required: + self.mcc.run() + 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) - @JMMsgSignature.responder - def on_JM_MSGSIGNATURE(self, nick, cmd, msg_to_return, hostid): - self.mcc.privmsg(nick, cmd, msg_to_return, mc=hostid) - return {'accepted': True} + def transfer_commitment(self, commit): + """Send this commitment via privmsg to one (random) + other maker. + """ + crow = self.db.execute( + 'SELECT DISTINCT counterparty FROM orderbook ORDER BY ' + + 'RANDOM() LIMIT 1;' + ).fetchone() + if crow is None: + return + counterparty = crow['counterparty'] + #TODO de-hardcode hp2 + log.msg("Sending commitment to: " + str(counterparty)) + self.mcc.prepare_privmsg(counterparty, 'hp2', commit) - @JMMsgSignatureVerify.responder - def on_JM_MSGSIGNATURE_VERIFY(self, verif_result, nick, fullmsg, hostid): - if not verif_result: - log.msg("Verification failed for nick: " + str(nick)) + def respondToIoauths(self, accepted): + if self.jm_state != 2: + #this can be called a second time on timeout, in which case we + #do nothing + return + self.jm_state = 3 + if not accepted: + #use ioauth data field to return the list of non-responsive makers + nonresponders = [x for x in self.active_orders.keys() if x not + in self.ioauth_data.keys()] + ioauth_data = self.ioauth_data if accepted else nonresponders + d = self.callRemote(JMFillResponse, + success=accepted, + ioauth_data = json.dumps(ioauth_data)) + if not accepted: + #Client simply accepts failure TODO + self.defaultCallbacks(d) else: - self.mcc.on_verified_privmsg(nick, fullmsg, hostid) - return {'accepted': True} + #Act differently if *we* provided utxos, but + #client does not accept for some reason + d.addCallback(self.checkUtxosAccepted) + d.addErrback(self.defaultErrback) + + def completeStage1(self): + """Timeout of stage 1 requests; + either send success + ioauth data if enough makers, + else send failure to client. + """ + response = True if len(self.ioauth_data.keys()) >= self.minmakers else False + self.respondToIoauths(response) + + def checkUtxosAccepted(self, accepted): + if not accepted: + log.msg("Taker rejected utxos provided; resetting.") + #TODO create re-set function to start again + else: + #only update state if client accepted + self.jm_state = 4 def get_crypto_box_from_nick(self, nick): """Retrieve the libsodium box object for the counterparty; @@ -543,9 +564,6 @@ class JMDaemonServerProtocol(amp.AMP, OrderbookWatch): ', message will be dropped') return None - def on_error(self, msg): - log.msg("Received error: " + str(msg)) - def mc_shutdown(self): log.msg("Message channels being shutdown by daemon") if self.mcc: diff --git a/scripts/yield-generator-basic.py b/scripts/yield-generator-basic.py new file mode 100644 index 0000000..ce40cbb --- /dev/null +++ b/scripts/yield-generator-basic.py @@ -0,0 +1,137 @@ +#! /usr/bin/env python +from __future__ import absolute_import, print_function + +import datetime +import os +import time + +from jmclient import YieldGenerator, ygmain, get_log, jm_single, calc_cj_fee +txfee = 1000 +cjfee_a = 200 +cjfee_r = '0.0002' +ordertype = 'swreloffer' #'swreloffer' or 'swabsoffer' +nickserv_password = '' +max_minsize = 100000 +gaplimit = 6 + +jlog = get_log() + +# is a maker for the purposes of generating a yield from held +# bitcoins, offering from the maximum mixdepth and trying to offer +# the largest amount within the constraints of mixing depth isolation. +# It will often (but not always) reannounce orders after transactions, +# thus is somewhat suboptimal in giving more information to spies. +class YieldGeneratorBasic(YieldGenerator): + + def __init__(self, wallet, offerconfig): + self.txfee, self.cjfee_a, self.cjfee_r, self.ordertype, self.minsize \ + = offerconfig + super(YieldGeneratorBasic,self).__init__(wallet) + + def create_my_orders(self): + mix_balance = self.wallet.get_balance_by_mixdepth() + if len([b for m, b in mix_balance.iteritems() if b > 0]) == 0: + jlog.error('do not have any coins left') + return [] + + # print mix_balance + max_mix = max(mix_balance, key=mix_balance.get) + f = '0' + if self.ordertype == 'swreloffer': + f = self.cjfee_r + #minimum size bumped if necessary such that you always profit + #least 50% of the miner fee + self.minsize = max(int(1.5 * self.txfee / float(self.cjfee_r)), + max_minsize) + elif self.ordertype == 'swabsoffer': + f = str(self.txfee + self.cjfee_a) + order = {'oid': 0, + 'ordertype': self.ordertype, + 'minsize': self.minsize, + 'maxsize': mix_balance[max_mix] - max( + jm_single().DUST_THRESHOLD,txfee), + 'txfee': self.txfee, + 'cjfee': f} + + # sanity check + assert order['minsize'] >= 0 + assert order['maxsize'] > 0 + if order['minsize'] > order['maxsize']: + jlog.info('minsize (' + str(order['minsize']) + ') > maxsize (' + str( + order['maxsize']) + ')') + return [] + + return [order] + + def oid_to_order(self, offer, amount): + total_amount = amount + offer["txfee"] + mix_balance = self.wallet.get_balance_by_mixdepth() + max_mix = max(mix_balance, key=mix_balance.get) + + filtered_mix_balance = [m + for m in mix_balance.iteritems() + if m[1] >= total_amount] + if not filtered_mix_balance: + return None, None, None + jlog.debug('mix depths that have enough = ' + str(filtered_mix_balance)) + filtered_mix_balance = sorted(filtered_mix_balance, key=lambda x: x[0]) + mixdepth = filtered_mix_balance[0][0] + jlog.info('filling offer, mixdepth=' + str(mixdepth)) + + # mixdepth is the chosen depth we'll be spending from + cj_addr = self.wallet.get_internal_addr((mixdepth + 1) % + self.wallet.max_mix_depth) + change_addr = self.wallet.get_internal_addr(mixdepth) + + utxos = self.wallet.select_utxos(mixdepth, total_amount) + my_total_in = sum([va['value'] for va in utxos.values()]) + real_cjfee = calc_cj_fee(offer["ordertype"], offer["cjfee"], amount) + change_value = my_total_in - amount - offer["txfee"] + real_cjfee + if change_value <= jm_single().DUST_THRESHOLD: + jlog.debug(('change value={} below dust threshold, ' + 'finding new utxos').format(change_value)) + try: + utxos = self.wallet.select_utxos( + mixdepth, total_amount + jm_single().DUST_THRESHOLD) + except Exception: + jlog.info('dont have the required UTXOs to make a ' + 'output above the dust threshold, quitting') + return None, None, None + + return utxos, cj_addr, change_addr + + def on_tx_unconfirmed(self, offer, txid, removed_utxos): + self.tx_unconfirm_timestamp[offer["cjaddr"]] = int(time.time()) + # if the balance of the highest-balance mixing depth change then + # reannounce it + oldoffer = self.offerlist[0] if len(self.offerlist) > 0 else None + newoffers = self.create_my_orders() + if len(newoffers) == 0: + return [0], [] # cancel old order + if oldoffer: + if oldoffer['maxsize'] == newoffers[0]['maxsize']: + return [], [] # change nothing + # announce new order, replacing the old order + return [], [newoffers[0]] + + def on_tx_confirmed(self, offer, confirmations, txid): + if offer["cjaddr"] in self.tx_unconfirm_timestamp: + confirm_time = int(time.time()) - self.tx_unconfirm_timestamp[ + offer["cjaddr"]] + else: + confirm_time = 0 + timestamp = datetime.datetime.now().strftime("%Y/%m/%d %H:%M:%S") + real_cjfee = calc_cj_fee(offer["offer"]["ordertype"], + offer["offer"]["cjfee"], offer["amount"]) + self.log_statement([timestamp, offer["amount"], len( + offer["utxos"]), sum([av['value'] for av in offer["utxos"].values( + )]), real_cjfee, real_cjfee - offer["offer"]["txfee"], round( + confirm_time / 60.0, 2), '']) + return self.on_tx_unconfirmed(offer, txid, None) + +if __name__ == "__main__": + ygmain(YieldGeneratorBasic, txfee=txfee, cjfee_a=cjfee_a, + cjfee_r=cjfee_r, ordertype=ordertype, + nickserv_password=nickserv_password, + minsize=max_minsize, gaplimit=gaplimit) + print('done')