You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

278 lines
13 KiB

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')