import copy import random from .configure import validate_address, jm_single from .support import rand_exp_array, rand_norm_array, rand_weighted_choice """Utility functions for dealing with Taker schedules. - get_schedule(filename): attempt to read the schedule from the provided file - get_tumble_schedule(options, destaddrs): generate a schedule for tumbling from a given wallet, using options dict and specified destinations - tweak_tumble_schedule(options, schedule, last_completed): make alterations to the remaining entries in a mixdepth to maximize the chance of success on re-trying """ NO_ROUNDING = 16 #max btc significant figures not including LN class ScheduleGenerationError(Exception): pass class ScheduleGenerationErrorNoFunds(ScheduleGenerationError): pass def get_schedule(filename): with open(filename, "rb") as f: schedule = [] schedule_lines = f.readlines() for sl in schedule_lines: sl = sl.decode('utf-8') if sl.startswith("#"): continue try: (mixdepth, amount, makercount, destaddr, waittime, rounding, completed) = sl.split(',') except ValueError as e: return (False, "Failed to parse schedule line: " + sl) try: mixdepth = int(mixdepth) #TODO this isn't the right way, but floats must be allowed #for any persisted tumbler-style schedule if "." in amount: amount = float(amount) else: amount = int(amount) makercount = int(makercount) destaddr = destaddr.strip() waittime = float(waittime) rounding = int(rounding) completed = completed.strip() if not len(completed) == 64: completed = int(completed) except ValueError as e: return (False, "Failed to parse schedule line: " + sl) if destaddr not in ["INTERNAL", "addrask"]: success, errmsg = validate_address(destaddr) if not success: return (False, "Invalid address: " + destaddr + "," + errmsg) schedule.append([mixdepth, amount, makercount, destaddr, waittime, rounding, completed]) return (True, schedule) def get_amount_fractions(count): """Get 'count' fractions following uniform distn Note that this function is not entirely generic; it ensures that the final entry is larger than a certain fraction, for a reason specific to the way the tumbler algo works: the last entry corresponds to a sweep which takes the remaining coins; if this ends up being too small, it cannot be tweaked using the mincjamount setting, so we make sure it's appreciable to begin with. """ while True: knives = [random.random() for i in range(count-1)] knives = sorted(knives)[::-1] y = [] l = 1 k = 1 for k in knives: y.append( l - k ) l = k y.append(k) #Here we insist that the last entry in the list is more #than 5% of the total, to account for tweaks upwards #on previous joins. if y[-1] > 0.05: break return y def get_tumble_schedule(options, destaddrs, mixdepth_balance_dict, max_mixdepth_in_wallet=4): """ Default final setting for "amount_fraction" is zero, for each mixdepth. This is because we now use a general "schedule" syntax for both tumbler and any other taker algo; it interprets floats as fractions and integers as satoshis, and zero as sweep (as before). Args: * options - as specified in scripts/tumbler.py and taken from cli_options.py * destaddrs - a list of valid address strings for the destination of funds * mixdepth_balance_dict - a dict mapping mixdepths to balances, note zero balances are not included. * max_mixdepth_in_wallet - this is actually the highest mixdepth in this JM wallet ( so 4 actually means 5 mixdepths in wallet; we do not consider the possibility of a wallet not having a 0 mixdepth). The default value 4 is almost always used. """ def lower_bounded_int(thelist, lowerbound): return [int(l) if int(l) >= lowerbound else lowerbound for l in thelist] txcounts = rand_norm_array(options['txcountparams'][0], options['txcountparams'][1], options['mixdepthcount']) txcounts = lower_bounded_int(txcounts, options['mintxcount']) tx_list = [] ### stage 1 coinjoins, which sweep the entire mixdepth without creating change sweep_mixdepths = [] for mixdepth, balance in mixdepth_balance_dict.items(): if balance > 0: sweep_mixdepths.append(mixdepth) waits = rand_exp_array(options['timelambda']*options[ 'stage1_timelambda_increase'], len(sweep_mixdepths)) makercounts = rand_norm_array(options['makercountrange'][0], options['makercountrange'][1], len(sweep_mixdepths)) makercounts = lower_bounded_int(makercounts, options['minmakercount']) sweep_mixdepths = sorted(sweep_mixdepths)[::-1] nonempty_mixdepths = {} for mixdepth, wait, makercount in zip(sweep_mixdepths, waits, makercounts): # to know which mixdepths contain coins at the end of phase 1, we # can't simply check each filled mixdepth and add 1, because the sequencing # of these joins may sweep up coins that have already been swept. # so we keep track with a binary flag for every mixdepth during this sequence. nonempty_mixdepths[mixdepth] = 0 nonempty_mixdepths[(mixdepth + 1) % (max_mixdepth_in_wallet + 1)] = 1 tx = {'amount_fraction': 0, 'wait': round(wait, 2), 'srcmixdepth': mixdepth, 'makercount': makercount, 'destination': 'INTERNAL', 'rounding': NO_ROUNDING } tx_list.append(tx) try: lowest_nonempty_mixdepth = min([x for x, y in nonempty_mixdepths.items() if y == 1]) except ValueError: raise ScheduleGenerationErrorNoFunds ### stage 2 coinjoins, which create a number of random-amount coinjoins from each mixdepth for m, txcount in enumerate(txcounts): if options['mixdepthcount'] - options['addrcount'] <= m and m < \ options['mixdepthcount'] - 1: #these mixdepths send to a destination address, so their # amount_fraction cant be 1.0, some coins must be left over if txcount == 1: txcount = 2 amount_fractions = get_amount_fractions(txcount) # transaction times are uncorrelated # time between events in a poisson process followed exp waits = rand_exp_array(options['timelambda'], txcount) # number of makers to use follows a normal distribution makercounts = rand_norm_array(options['makercountrange'][0], options['makercountrange'][1], txcount) makercounts = lower_bounded_int(makercounts, options['minmakercount']) do_rounds = [random.random() < options['rounding_chance'] for _ in range(txcount)] for amount_fraction, wait, makercount, do_round in zip(amount_fractions, waits, makercounts, do_rounds): rounding = NO_ROUNDING if do_round: weight_sum = 1.0*sum(options['rounding_sigfig_weights']) weight_prob = [a/weight_sum for a in options['rounding_sigfig_weights']] rounding = rand_weighted_choice(len(weight_prob), weight_prob) + 1 tx = {'amount_fraction': amount_fraction, 'wait': round(wait, 2), 'srcmixdepth': (lowest_nonempty_mixdepth + m) % ( max_mixdepth_in_wallet + 1), 'makercount': makercount, 'destination': 'INTERNAL', 'rounding': rounding } tx_list.append(tx) #reset the final amt_frac to zero, as it's the last one for this mixdepth: tx_list[-1]['amount_fraction'] = 0 tx_list[-1]['rounding'] = NO_ROUNDING addrask = options['addrcount'] - len(destaddrs) external_dest_addrs = ['addrask'] * addrask + destaddrs[::-1] for mix_offset in range(options['addrcount']): srcmix = (lowest_nonempty_mixdepth + options['mixdepthcount'] - mix_offset - 1) % (max_mixdepth_in_wallet + 1) for tx in reversed(tx_list): if tx['srcmixdepth'] == srcmix: tx['destination'] = external_dest_addrs[mix_offset] break if mix_offset == 0: # setting last mixdepth to send all to dest tx_list_remove = [] ending_mixdepth = tx_list[-1]['srcmixdepth'] for tx in tx_list[::-1]: if tx['srcmixdepth'] != ending_mixdepth: break if tx['destination'] == 'INTERNAL': tx_list_remove.append(tx) else: tx['amount_fraction'] = 0 [tx_list.remove(t) for t in tx_list_remove] schedule = [] for t in tx_list: schedule.append([t['srcmixdepth'], t['amount_fraction'], t['makercount'], t['destination'], t['wait'], t['rounding'], 0]) return schedule def tweak_tumble_schedule(options, schedule, last_completed, destaddrs=[]): """If a tx in a schedule failed for some reason, and we want to make a best effort to complete the schedule, we can tweak the failed entry to improve the odds of success on re-try. Both the size/amount and the number of counterparties may have been a cause for failure, so we change both of those where possible. Returns a new, altered schedule file (should continue at same index) """ new_schedule = copy.deepcopy(schedule) altered = new_schedule[last_completed + 1] if not altered[3] in destaddrs: altered[3] = "INTERNAL" #For sweeps, we'll try with a lower number of counterparties if we can. #Note that this is usually counterproductive for non-sweeps, which fall #back and so benefit in reliability from *higher* counterparty numbers. if altered[1] == 0: new_n_cp = altered[2] - 1 if new_n_cp < jm_single().config.getint("POLICY", "minimum_makers"): new_n_cp = jm_single().config.getint("POLICY", "minimum_makers") altered[2] = new_n_cp if not altered[1] == 0: #For non-sweeps, there's a fractional amount (tumbler). #Increasing or decreasing the amount could improve the odds of success, #since it depends on liquidity and minsizes, so we tweak in both #directions randomly. #Strategy: #1. calculate the total percentage remaining in the mixdepth. #2. calculate the number remaining incl. sweep. #3. Re-use 'getamountfracs' algo for this reduced number, then scale it #to the number remaining. #4. As before, reset the final to '0' for sweep. #find the number of entries remaining, not including the final sweep, #for this mixdepth: #First get all sched entries for this mixdepth this_mixdepth_entries = [s for s in new_schedule if s[0] == altered[0]] already_done = this_mixdepth_entries[:this_mixdepth_entries.index(altered)] tobedone = this_mixdepth_entries[this_mixdepth_entries.index(altered):] #find total frac left to be spent alreadyspent = sum([x[1] for x in already_done]) tobespent = 1.0 - alreadyspent #power law for what's left: new_fracs = get_amount_fractions(len(tobedone)) #rescale; the sum must be 'tobespent': new_fracs = [x*tobespent for x in new_fracs] #starting from the known 'last_completed+1' index, apply these new #fractions, with 0 at the end for sweep for i, j in enumerate(range( last_completed + 1, last_completed + 1 + len(tobedone))): new_schedule[j][1] = new_fracs[i] #reset the sweep new_schedule[last_completed + 1 + len(tobedone) - 1][1] = 0 return new_schedule def human_readable_schedule_entry(se, amt=None, destn=None): hrs = [] hrs.append("From mixdepth " + str(se[0])) amt_info = str(amt) if amt else str(se[1]) hrs.append("sends amount: " + amt_info + " satoshis") dest_info = destn if destn else str(se[3]) hrs.append(("rounded to " + str(se[5]) + " significant figures" if se[5] != NO_ROUNDING else "without rounding")) hrs.append("to destination address: " + dest_info) hrs.append("after coinjoin with " + str(se[2]) + " counterparties.") return ", ".join(hrs) def schedule_to_text(schedule): return "\n".join([",".join([str(y) for y in x]) for x in schedule]).encode('utf-8')