diff --git a/joinmarket/configure.py b/joinmarket/configure.py index 20146a53..337b2cea 100644 --- a/joinmarket/configure.py +++ b/joinmarket/configure.py @@ -126,6 +126,11 @@ def jm_single(): # for most rapid dust sweeping, try merge_algorithm = greediest # but don't forget to bump your miner fees! merge_algorithm = default +# For takers: the minimum number of makers you allow in a transaction +# to complete, accounting for the fact that some makers might not be +# responsive. Should be an integer >=2 for privacy, or set to 0 if you +# want to disallow any reduction from your chosen number of makers. +minimum_makers = 2 # the fee estimate is based on a projection of how many satoshis # per kB are needed to get in one of the next N blocks, N set here # as the value of 'tx_fees'. This estimate is high if you set N=1, diff --git a/joinmarket/taker.py b/joinmarket/taker.py index c925008e..f5da2892 100644 --- a/joinmarket/taker.py +++ b/joinmarket/taker.py @@ -147,58 +147,65 @@ def auth_counterparty(self, nick, btc_sig, auth_pub): return True def recv_txio(self, nick, utxo_list, auth_pub, cj_addr, change_addr): - if nick not in self.nonrespondants: - log.debug(('recv_txio => nick={} not in ' - 'nonrespondants {}').format(nick, self.nonrespondants)) - return - self.utxos[nick] = utxo_list - utxo_data = jm_single().bc_interface.query_utxo_set(self.utxos[nick]) - if None in utxo_data: - log.error(('ERROR outputs unconfirmed or already spent. ' - 'utxo_data={}').format(pprint.pformat(utxo_data))) - # when internal reviewing of makers is created, add it here to - # immediately quit; currently, the timeout thread suffices. - return - #Complete maker authorization: - #Extract the address fields from the utxos - #Construct the Bitcoin address for the auth_pub field - #Ensure that at least one address from utxos corresponds. - input_addresses = [d['address'] for d in utxo_data] - auth_address = btc.pubkey_to_address(auth_pub, get_p2pk_vbyte()) - if not auth_address in input_addresses: - log.error("ERROR maker's authorising pubkey is not included " - "in the transaction: " + str(auth_address)) - return + if nick: + if nick not in self.nonrespondants: + log.debug(('recv_txio => nick={} not in ' + 'nonrespondants {}').format(nick, self.nonrespondants)) + return + self.utxos[nick] = utxo_list + utxo_data = jm_single().bc_interface.query_utxo_set(self.utxos[nick]) + if None in utxo_data: + log.error(('ERROR outputs unconfirmed or already spent. ' + 'utxo_data={}').format(pprint.pformat(utxo_data))) + # when internal reviewing of makers is created, add it here to + # immediately quit; currently, the timeout thread suffices. + return + #Complete maker authorization: + #Extract the address fields from the utxos + #Construct the Bitcoin address for the auth_pub field + #Ensure that at least one address from utxos corresponds. + input_addresses = [d['address'] for d in utxo_data] + auth_address = btc.pubkey_to_address(auth_pub, get_p2pk_vbyte()) + if not auth_address in input_addresses: + log.error("ERROR maker's authorising pubkey is not included " + "in the transaction: " + str(auth_address)) + return - total_input = sum([d['value'] for d in utxo_data]) - real_cjfee = calc_cj_fee(self.active_orders[nick]['ordertype'], - self.active_orders[nick]['cjfee'], self.cj_amount) - change_amount = (total_input - self.cj_amount - - self.active_orders[nick]['txfee'] + real_cjfee) - - # certain malicious and/or incompetent liquidity providers send - # inputs totalling less than the coinjoin amount! this leads to - # a change output of zero satoshis, so the invalid transaction - # fails harmlessly; let's fail earlier, with a clear message. - if change_amount < jm_single().DUST_THRESHOLD: - fmt = ('ERROR counterparty requires sub-dust change. No ' - 'action required. nick={}' - 'totalin={:d} cjamount={:d} change={:d}').format - log.warn(fmt(nick, total_input, self.cj_amount, change_amount)) - return # timeout marks this maker as nonresponsive - - self.outputs.append({'address': change_addr, 'value': change_amount}) - fmt = ('fee breakdown for {} totalin={:d} ' - 'cjamount={:d} txfee={:d} realcjfee={:d}').format - log.debug(fmt(nick, total_input, self.cj_amount, - self.active_orders[nick]['txfee'], real_cjfee)) - self.outputs.append({'address': cj_addr, 'value': self.cj_amount}) - self.cjfee_total += real_cjfee - self.maker_txfee_contributions += self.active_orders[nick]['txfee'] - self.nonrespondants.remove(nick) - if len(self.nonrespondants) > 0: - log.debug('nonrespondants = ' + str(self.nonrespondants)) - return + total_input = sum([d['value'] for d in utxo_data]) + real_cjfee = calc_cj_fee(self.active_orders[nick]['ordertype'], + self.active_orders[nick]['cjfee'], self.cj_amount) + change_amount = (total_input - self.cj_amount - + self.active_orders[nick]['txfee'] + real_cjfee) + + # certain malicious and/or incompetent liquidity providers send + # inputs totalling less than the coinjoin amount! this leads to + # a change output of zero satoshis, so the invalid transaction + # fails harmlessly; let's fail earlier, with a clear message. + if change_amount < jm_single().DUST_THRESHOLD: + fmt = ('ERROR counterparty requires sub-dust change. No ' + 'action required. nick={}' + 'totalin={:d} cjamount={:d} change={:d}').format + log.warn(fmt(nick, total_input, self.cj_amount, change_amount)) + return # timeout marks this maker as nonresponsive + + self.outputs.append({'address': change_addr, 'value': change_amount}) + fmt = ('fee breakdown for {} totalin={:d} ' + 'cjamount={:d} txfee={:d} realcjfee={:d}').format + log.debug(fmt(nick, total_input, self.cj_amount, + self.active_orders[nick]['txfee'], real_cjfee)) + self.outputs.append({'address': cj_addr, 'value': self.cj_amount}) + self.cjfee_total += real_cjfee + self.maker_txfee_contributions += self.active_orders[nick]['txfee'] + self.nonrespondants.remove(nick) + if len(self.nonrespondants) > 0: + log.debug('nonrespondants = ' + str(self.nonrespondants)) + return + #Note we fall through here immediately if nick is None; + #this is the case for recovery where we are going to do a join with + #less participants than originally intended. If minmakers is set to 0, + #disallowing completion with subset, assert is still true. + assert len(self.active_orders.keys()) >= jm_single().config.getint( + "POLICY", "minimum_makers") log.info('got all parts, enough to build a tx') self.nonrespondants = list(self.active_orders.keys()) @@ -405,43 +412,59 @@ def self_sign_and_push(self): return self.push() def recover_from_nonrespondants(self): + + def restart(): + self.end_timeout_thread = True + if self.finishcallback is not None: + self.finishcallback(self) + # finishcallback will check if self.all_responded is True + # and will know it came from here + log.info('nonresponding makers = ' + str(self.nonrespondants)) # if there is no choose_orders_recover then end and call finishcallback # so the caller can handle it in their own way, notable for sweeping # where simply replacing the makers wont work if not self.choose_orders_recover: - self.end_timeout_thread = True - if self.finishcallback is not None: - self.finishcallback(self) + restart() return if self.latest_tx is None: - # nonresponding to !fill, recover by finding another maker + # nonresponding to !fill-!auth, proceed with transaction anyway as long + # as number of makers is at least POLICY.minimum_makers (and not zero, + # i.e. disallow this kind of continuation). log.debug('nonresponse to !fill') for nr in self.nonrespondants: del self.active_orders[nr] - new_orders, new_makers_fee = self.choose_orders_recover( + minmakers = jm_single().config.getint("POLICY", "minimum_makers") + if len(self.active_orders.keys()) >= minmakers and minmakers != 0: + log.info("Completing the transaction with: " + str( + len(self.active_orders.keys())) + " makers.") + self.recv_txio(None, None, None, None, None) + elif minmakers == 0: + #Revert to the old algorithm: re-source number of orders + #still needed, but ignoring non-respondants and currently active + new_orders, new_makers_fee = self.choose_orders_recover( self.cj_amount, len(self.nonrespondants), self.nonrespondants, self.active_orders.keys()) - for nick, order in new_orders.iteritems(): - self.active_orders[nick] = order - self.nonrespondants = list(new_orders.keys()) - log.debug(('new active_orders = {} \nnew nonrespondants = ' + for nick, order in new_orders.iteritems(): + self.active_orders[nick] = order + self.nonrespondants = list(new_orders.keys()) + log.debug(('new active_orders = {} \nnew nonrespondants = ' '{}').format( pprint.pformat(self.active_orders), pprint.pformat(self.nonrespondants))) - #Re-source commitment; previous attempt will have been blacklisted - self.get_commitment(self.input_utxos, self.cj_amount) - self.msgchan.fill_orders(new_orders, self.cj_amount, - self.kp.hex_pk(), self.commitment) + #Re-source commitment; previous attempt will have been blacklisted + self.get_commitment(self.input_utxos, self.cj_amount) + self.msgchan.fill_orders(new_orders, self.cj_amount, + self.kp.hex_pk(), self.commitment) + else: + log.info("Too few makers responded to complete, trying again.") + restart() else: log.debug('nonresponse to !tx') - # nonresponding to !tx, have to restart tx from the beginning - self.end_timeout_thread = True - if self.finishcallback is not None: - self.finishcallback(self) - # finishcallback will check if self.all_responded is True and will know it came from here + # have to restart tx from the beginning + restart() class TimeoutThread(threading.Thread): diff --git a/sendpayment.py b/sendpayment.py index c5ad07e9..b92601b0 100644 --- a/sendpayment.py +++ b/sendpayment.py @@ -218,6 +218,13 @@ def sendpayment_choose_orders(self, log.info(noun + ' coinjoin fee = ' + str(float('%.3g' % ( 100.0 * total_fee_pc))) + '%') check_high_fee(total_fee_pc) + if jm_single().config.getint("POLICY", "minimum_makers") != 0: + log.info("If some makers don't respond, we will still " + "create a coinjoin with at least " + str( + jm_single().config.getint( + "POLICY", "minimum_makers")) + ". " + "If you don't want this feature, set minimum_makers=" + "0 in joinmarket.cfg") if raw_input('send with these orders? (y/n):')[0] != 'y': log.info('ending') self.taker.msgchan.shutdown() @@ -377,6 +384,20 @@ def main(): log.info('starting sendpayment') + #If we are not direct sending, then minimum_maker setting should + #not be larger than the requested number of counterparties + if options.makercount !=0 and options.makercount < jm_single().config.getint( + "POLICY", "minimum_makers"): + log.error("You selected a number of counterparties (" + \ + str(options.makercount) + \ + ") less than the " + "minimum requirement (" + \ + str(jm_single().config.getint("POLICY","minimum_makers")) + \ + "); you can edit the value 'minimum_makers'" + " in the POLICY section in joinmarket.cfg to correct this. " + "Quitting.") + exit(0) + if not options.userpcwallet: wallet = Wallet(wallet_name, options.amtmixdepths, options.gaplimit) else: diff --git a/test/regtest_joinmarket.cfg b/test/regtest_joinmarket.cfg index 50ba79d7..0f20723e 100644 --- a/test/regtest_joinmarket.cfg +++ b/test/regtest_joinmarket.cfg @@ -18,6 +18,8 @@ usessl = false, false socks5 = false, false socks5_host = localhost, localhost socks5_port = 9150, 9150 +[LOGGING] +console_log_level = DEBUG [POLICY] # for dust sweeping, try merge_algorithm = gradual # for more rapid dust sweeping, try merge_algorithm = greedy @@ -31,6 +33,7 @@ merge_algorithm = default # as our default. Note that for clients not using a local blockchain # instance, we retrieve an estimate from the API at cointape.com, currently. tx_fees = 3 +minimum_makers = 2 taker_utxo_retries = 3 taker_utxo_age = 1 taker_utxo_amtpercent = 20