Skip to content
This repository was archived by the owner on May 13, 2022. It is now read-only.

Commit 3b3bca9

Browse files
committed
configurable thresholds for utxo merge policies
1 parent 8c2b6d8 commit 3b3bca9

File tree

8 files changed

+96
-44
lines changed

8 files changed

+96
-44
lines changed

bitcoin/secp256k1_transaction.py

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -433,20 +433,3 @@ def mktx(*args):
433433
txobj["outs"].append(outobj)
434434

435435
return serialize(txobj)
436-
437-
438-
def select(unspent, value):
439-
value = int(value)
440-
high = [u for u in unspent if u["value"] >= value]
441-
high.sort(key=lambda u: u["value"])
442-
low = [u for u in unspent if u["value"] < value]
443-
low.sort(key=lambda u: -u["value"])
444-
if len(high):
445-
return [high[0]]
446-
i, tv = 0, 0
447-
while tv < value and i < len(low):
448-
tv += low[i]["value"]
449-
i += 1
450-
if tv < value:
451-
raise Exception("Not enough funds")
452-
return low[:i]

joinmarket/configure.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,11 +121,11 @@ def jm_single():
121121
confirm_timeout_hours = 6
122122
123123
[POLICY]
124-
# for dust sweeping, try merge_algorithm = gradual
125-
# for more rapid dust sweeping, try merge_algorithm = greedy
126-
# for most rapid dust sweeping, try merge_algorithm = greediest
127-
# but don't forget to bump your miner fees!
128-
merge_algorithm = default
124+
# this knob is a list of ints, being each the amount of utxos in a mixdepth
125+
# sufficient for kicking in the next-mergiest utxo selection algorithm. the
126+
# default errs on the side of privacy; lower values leak more correlations.
127+
# it won't merge until the 42nd utxo, merges gradually until the 59th, etc
128+
merge_algorithm = [42, 59, 72]
129129
# For takers: the minimum number of makers you allow in a transaction
130130
# to complete, accounting for the fact that some makers might not be
131131
# responsive. Should be an integer >=2 for privacy, or set to 0 if you

joinmarket/support.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,21 @@ def rand_weighted_choice(n, p_arr):
9898
def chunks(d, n):
9999
return [d[x:x + n] for x in xrange(0, len(d), n)]
100100

101+
def select_default(unspent, value):
102+
value = int(value)
103+
high = [u for u in unspent if u["value"] >= value]
104+
high.sort(key=lambda u: u["value"])
105+
low = [u for u in unspent if u["value"] < value]
106+
low.sort(key=lambda u: -u["value"])
107+
if len(high):
108+
return [high[0]]
109+
i, tv = 0, 0
110+
while tv < value and i < len(low):
111+
tv += low[i]["value"]
112+
i += 1
113+
if tv < value:
114+
raise Exception("Not enough funds")
115+
return low[:i]
101116

102117
def select_gradual(unspent, value):
103118
"""
@@ -174,6 +189,21 @@ def select_greediest(unspent, value):
174189
end += 1
175190
return low[0:end]
176191

192+
# ordered from most dusty (arguably, most private) to most mergiest (cheaper!)
193+
selectors = [select_default, select_gradual, select_greedy, select_greediest]
194+
195+
def utxo_selector(configured_levels):
196+
def select(unspent, value):
197+
length = len(unspent) # NB - counted only within each mixdepth
198+
try:
199+
for i in xrange(len(configured_levels)):
200+
if length < configured_levels[i]:
201+
return selectors[i](unspent, value)
202+
return selectors[len(configured_levels)](unspent, value)
203+
except IndexError:
204+
log.debug("Excess merge_algorithm levels. Configure fewer!")
205+
return selectors[-1](unspent, value) # express operator greed
206+
return select
177207

178208
def calc_cj_fee(ordertype, cjfee, cj_amount):
179209
if ordertype == 'absoffer':

joinmarket/wallet.py

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import os
44
import pprint
55
import sys
6+
import ast
67
from decimal import Decimal
78

89
from ConfigParser import NoSectionError
@@ -13,8 +14,7 @@
1314
from joinmarket.blockchaininterface import BitcoinCoreInterface, RegtestBitcoinCoreInterface
1415
from joinmarket.configure import jm_single, get_network, get_p2pk_vbyte
1516

16-
from joinmarket.support import get_log, select_gradual, select_greedy, \
17-
select_greediest
17+
from joinmarket.support import get_log, utxo_selector
1818

1919
log = get_log()
2020

@@ -46,19 +46,27 @@ def __init__(self):
4646
#some consumer scripts don't use an unspent, this marks it
4747
#as specifically absent (rather than just empty).
4848
self.unspent = None
49-
self.utxo_selector = btc.select # default fallback: upstream
5049
try:
51-
config = jm_single().config
52-
if config.get("POLICY", "merge_algorithm") == "gradual":
53-
self.utxo_selector = select_gradual
54-
elif config.get("POLICY", "merge_algorithm") == "greedy":
55-
self.utxo_selector = select_greedy
56-
elif config.get("POLICY", "merge_algorithm") == "greediest":
57-
self.utxo_selector = select_greediest
58-
elif config.get("POLICY", "merge_algorithm") != "default":
59-
raise Exception("Unknown merge algorithm")
50+
policy = jm_single().config.get("POLICY", "merge_algorithm")
6051
except NoSectionError:
61-
pass
52+
policy = "default" # maintain backwards compatibility!
53+
if policy == "default":
54+
self.merge_policy = [42] # well, almost (python lacks infinites)
55+
elif policy == "gradual":
56+
self.merge_policy = [60] # never goes beyond gradual
57+
elif policy == "greedy":
58+
self.merge_policy = [70, 70] # skip gradual, go greedy
59+
elif policy == "greediest":
60+
self.merge_policy = [80, 80, 80] # straight to greediest
61+
else:
62+
try: # stop supporting word configs, someday...
63+
self.merge_policy = ast.literal_eval(policy)
64+
if ((type(self.merge_policy) is not list) or
65+
any(type(level) is not int for level in self.merge_policy)):
66+
raise Exception("Merge policy must be a list of ints")
67+
except ValueError:
68+
raise Exception("Unparseable merge policy: "+policy)
69+
self.utxo_selector = utxo_selector(self.merge_policy)
6270

6371
def get_key_from_addr(self, addr):
6472
return None

test/commontest.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,9 +147,8 @@ def make_wallets(n,
147147
'wallet': w}
148148
for j in range(5):
149149
for k in range(wallet_structures[i][j]):
150-
deviation = sdev_amt * random.random()
151-
amt = mean_amt - sdev_amt / 2.0 + deviation
152-
if amt < 0: amt = 0.001
150+
amt = random.gauss(mean_amt, sdev_amt)
151+
if amt < 0.001: amt = 0.001
153152
amt = float(Decimal(amt).quantize(Decimal(10)**-8))
154153
jm_single().bc_interface.grab_coins(
155154
wallets[i + start_index]['wallet'].get_external_addr(j),

test/regtest_joinmarket.cfg

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,8 @@ socks5_port = 9150, 9150
2121
[LOGGING]
2222
console_log_level = DEBUG
2323
[POLICY]
24-
# for dust sweeping, try merge_algorithm = gradual
25-
# for more rapid dust sweeping, try merge_algorithm = greedy
26-
# for most rapid dust sweeping, try merge_algorithm = greediest
27-
# but don't forget to bump your miner fees!
28-
merge_algorithm = default
24+
# same as the default setting
25+
merge_algorithm = [42, 59, 72]
2926
# the fee estimate is based on a projection of how many satoshis
3027
# per kB are needed to get in one of the next N blocks, N set here
3128
# as the value of 'tx_fees'. This estimate can be extremely high

test/test_tx_creation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434

3535
@pytest.mark.parametrize(
3636
"nw, wallet_structures, mean_amt, sdev_amt, amount, pubs, k", [
37-
(1, [[2, 1, 4, 0, 0]], 4, 1.4, 600000000, vpubs[1:4], 2),
37+
(1, [[2, 1, 4, 0, 0]], 6, 1, 600000000, vpubs[1:4], 2),
3838
(1, [[3, 3, 0, 0, 3]], 4, 1.4, 100000000, vpubs[:4], 3),
3939
])
4040
def test_create_p2sh_output_tx(setup_tx_creation, nw, wallet_structures,

test/test_wallets.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,41 @@ def test_utxo_selection(setup_wallets, nw, wallet_structures, mean_amt,
350350
"failed to select sufficient coins, total: " + \
351351
str(total_selected) + ", should be: " + str(amount)
352352

353+
# the following test mainly checks that the algo switching based on
354+
# number of utxos occurs correctly; it also provides some rough sanity
355+
# testing on the general behavior of the merge algorithms.
356+
# TODO: separate & deterministic tests for each merge algo!
357+
358+
def test_merge_algo_switching(setup_wallets):
359+
wallet = make_wallets(1, [[5, 50, 75, 0, 0]], 1, 0.72)[0]['wallet']
360+
sync_wallet(wallet), sync_wallet(wallet)
361+
all_utxos = wallet.get_utxos_by_mixdepth()
362+
# test merge-avoiding with small utxo sets - select_default
363+
utxos = sorted(map(lambda x:x['value'], all_utxos[0].values()))
364+
for i in range(4):
365+
amount = (utxos[i]+utxos[i+1])/2
366+
selected = wallet.select_utxos(0, amount)
367+
assert (1 == len(selected)), "Default selection misbehaved! " + \
368+
"Selected " + str(selected) + " to reach sum " + str(amount) + \
369+
" from utxos " + str(utxos) + " (should pick SINGLE utxo)"
370+
# test merging with larger utxo sets - select_gradual
371+
utxos = sorted(map(lambda x:x['value'], all_utxos[1].values()))
372+
for i in range(49):
373+
amount = utxos[i+1]+1
374+
selected = wallet.select_utxos(1, amount)
375+
assert (1 < len(selected)), "Default selection misbehaved! "+\
376+
"Selected " + str(selected) + " to reach sum " + str(amount) + \
377+
" from utxos " + str(utxos) + " (should pick MULTIPLE utxos)"
378+
# TODO: test merging with intermediate sets - select_greedy
379+
# test merging with even larger utxo sets - select_greediest
380+
utxos = sorted(map(lambda x:x['value'], all_utxos[2].values()))
381+
for i in range(74):
382+
amount = sum(utxos[0:i+2])
383+
selected = wallet.select_utxos(2, amount)
384+
assert (i+2 <= len(selected)), "Default selection misbehaved! "+\
385+
"Selected " + str(selected) + " to reach sum " + str(amount) + \
386+
" from utxos " + str(utxos) + " (expected " + str(i+2) + ")"
387+
353388

354389
class TestWalletCreation(unittest.TestCase):
355390

0 commit comments

Comments
 (0)