Merge pull request #3496 from SomberNight/coinchooser1
CoinChooser: privacy prefers confirmed and is default
This commit is contained in:
commit
936ee47d3a
|
@ -2674,6 +2674,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError):
|
||||||
return '\n'.join([key, "", " ".join(lines)])
|
return '\n'.join([key, "", " ".join(lines)])
|
||||||
|
|
||||||
choosers = sorted(coinchooser.COIN_CHOOSERS.keys())
|
choosers = sorted(coinchooser.COIN_CHOOSERS.keys())
|
||||||
|
if len(choosers) > 1:
|
||||||
chooser_name = coinchooser.get_name(self.config)
|
chooser_name = coinchooser.get_name(self.config)
|
||||||
msg = _('Choose coin (UTXO) selection method. The following are available:\n\n')
|
msg = _('Choose coin (UTXO) selection method. The following are available:\n\n')
|
||||||
msg += '\n\n'.join(fmt_docs(*item) for item in coinchooser.COIN_CHOOSERS.items())
|
msg += '\n\n'.join(fmt_docs(*item) for item in coinchooser.COIN_CHOOSERS.items())
|
||||||
|
|
|
@ -68,7 +68,7 @@ class PRNG:
|
||||||
x[i], x[j] = x[j], x[i]
|
x[i], x[j] = x[j], x[i]
|
||||||
|
|
||||||
|
|
||||||
Bucket = namedtuple('Bucket', ['desc', 'size', 'value', 'coins'])
|
Bucket = namedtuple('Bucket', ['desc', 'size', 'value', 'coins', 'min_height'])
|
||||||
|
|
||||||
def strip_unneeded(bkts, sufficient_funds):
|
def strip_unneeded(bkts, sufficient_funds):
|
||||||
'''Remove buckets that are unnecessary in achieving the spend amount'''
|
'''Remove buckets that are unnecessary in achieving the spend amount'''
|
||||||
|
@ -95,7 +95,8 @@ class CoinChooserBase(PrintError):
|
||||||
for coin in coins)
|
for coin in coins)
|
||||||
size = Transaction.virtual_size_from_weight(weight)
|
size = Transaction.virtual_size_from_weight(weight)
|
||||||
value = sum(coin['value'] for coin in coins)
|
value = sum(coin['value'] for coin in coins)
|
||||||
return Bucket(desc, size, value, coins)
|
min_height = min(coin['height'] for coin in coins)
|
||||||
|
return Bucket(desc, size, value, coins, min_height)
|
||||||
|
|
||||||
return list(map(make_Bucket, buckets.keys(), buckets.values()))
|
return list(map(make_Bucket, buckets.keys(), buckets.values()))
|
||||||
|
|
||||||
|
@ -198,9 +199,9 @@ class CoinChooserBase(PrintError):
|
||||||
tx.add_inputs([coin for b in buckets for coin in b.coins])
|
tx.add_inputs([coin for b in buckets for coin in b.coins])
|
||||||
tx_size = base_size + sum(bucket.size for bucket in buckets)
|
tx_size = base_size + sum(bucket.size for bucket in buckets)
|
||||||
|
|
||||||
# This takes a count of change outputs and returns a tx fee;
|
# This takes a count of change outputs and returns a tx fee
|
||||||
# each pay-to-bitcoin-address output serializes as 34 bytes
|
output_size = Transaction.estimated_output_size(change_addrs[0])
|
||||||
fee = lambda count: fee_estimator(tx_size + count * 34)
|
fee = lambda count: fee_estimator(tx_size + count * output_size)
|
||||||
change = self.change_outputs(tx, change_addrs, fee, dust_threshold)
|
change = self.change_outputs(tx, change_addrs, fee, dust_threshold)
|
||||||
tx.add_outputs(change)
|
tx.add_outputs(change)
|
||||||
|
|
||||||
|
@ -212,35 +213,14 @@ class CoinChooserBase(PrintError):
|
||||||
def choose_buckets(self, buckets, sufficient_funds, penalty_func):
|
def choose_buckets(self, buckets, sufficient_funds, penalty_func):
|
||||||
raise NotImplemented('To be subclassed')
|
raise NotImplemented('To be subclassed')
|
||||||
|
|
||||||
class CoinChooserOldestFirst(CoinChooserBase):
|
|
||||||
'''Maximize transaction priority. Select the oldest unspent
|
|
||||||
transaction outputs in your wallet, that are sufficient to cover
|
|
||||||
the spent amount. Then, remove any unneeded inputs, starting with
|
|
||||||
the smallest in value.
|
|
||||||
'''
|
|
||||||
|
|
||||||
def keys(self, coins):
|
|
||||||
return [coin['prevout_hash'] + ':' + str(coin['prevout_n'])
|
|
||||||
for coin in coins]
|
|
||||||
|
|
||||||
def choose_buckets(self, buckets, sufficient_funds, penalty_func):
|
|
||||||
'''Spend the oldest buckets first.'''
|
|
||||||
# Unconfirmed coins are young, not old
|
|
||||||
adj_height = lambda height: 99999999 if height <= 0 else height
|
|
||||||
buckets.sort(key = lambda b: max(adj_height(coin['height'])
|
|
||||||
for coin in b.coins))
|
|
||||||
selected = []
|
|
||||||
for bucket in buckets:
|
|
||||||
selected.append(bucket)
|
|
||||||
if sufficient_funds(selected):
|
|
||||||
return strip_unneeded(selected, sufficient_funds)
|
|
||||||
else:
|
|
||||||
raise NotEnoughFunds()
|
|
||||||
|
|
||||||
class CoinChooserRandom(CoinChooserBase):
|
class CoinChooserRandom(CoinChooserBase):
|
||||||
|
|
||||||
def bucket_candidates(self, buckets, sufficient_funds):
|
def bucket_candidates_any(self, buckets, sufficient_funds):
|
||||||
'''Returns a list of bucket sets.'''
|
'''Returns a list of bucket sets.'''
|
||||||
|
if not buckets:
|
||||||
|
raise NotEnoughFunds()
|
||||||
|
|
||||||
candidates = set()
|
candidates = set()
|
||||||
|
|
||||||
# Add all singletons
|
# Add all singletons
|
||||||
|
@ -267,8 +247,42 @@ class CoinChooserRandom(CoinChooserBase):
|
||||||
candidates = [[buckets[n] for n in c] for c in candidates]
|
candidates = [[buckets[n] for n in c] for c in candidates]
|
||||||
return [strip_unneeded(c, sufficient_funds) for c in candidates]
|
return [strip_unneeded(c, sufficient_funds) for c in candidates]
|
||||||
|
|
||||||
|
def bucket_candidates_prefer_confirmed(self, buckets, sufficient_funds):
|
||||||
|
"""Returns a list of bucket sets preferring confirmed coins.
|
||||||
|
|
||||||
|
Any bucket can be:
|
||||||
|
1. "confirmed" if it only contains confirmed coins; else
|
||||||
|
2. "unconfirmed" if it does not contain coins with unconfirmed parents
|
||||||
|
3. "unconfirmed parent" otherwise
|
||||||
|
|
||||||
|
This method tries to only use buckets of type 1, and if the coins there
|
||||||
|
are not enough, tries to use the next type but while also selecting
|
||||||
|
all buckets of all previous types.
|
||||||
|
"""
|
||||||
|
conf_buckets = [bkt for bkt in buckets if bkt.min_height > 0]
|
||||||
|
unconf_buckets = [bkt for bkt in buckets if bkt.min_height == 0]
|
||||||
|
unconf_par_buckets = [bkt for bkt in buckets if bkt.min_height == -1]
|
||||||
|
|
||||||
|
bucket_sets = [conf_buckets, unconf_buckets, unconf_par_buckets]
|
||||||
|
already_selected_buckets = []
|
||||||
|
|
||||||
|
for bkts_choose_from in bucket_sets:
|
||||||
|
try:
|
||||||
|
def sfunds(bkts):
|
||||||
|
return sufficient_funds(already_selected_buckets + bkts)
|
||||||
|
|
||||||
|
candidates = self.bucket_candidates_any(bkts_choose_from, sfunds)
|
||||||
|
break
|
||||||
|
except NotEnoughFunds:
|
||||||
|
already_selected_buckets += bkts_choose_from
|
||||||
|
else:
|
||||||
|
raise NotEnoughFunds()
|
||||||
|
|
||||||
|
candidates = [(already_selected_buckets + c) for c in candidates]
|
||||||
|
return [strip_unneeded(c, sufficient_funds) for c in candidates]
|
||||||
|
|
||||||
def choose_buckets(self, buckets, sufficient_funds, penalty_func):
|
def choose_buckets(self, buckets, sufficient_funds, penalty_func):
|
||||||
candidates = self.bucket_candidates(buckets, sufficient_funds)
|
candidates = self.bucket_candidates_prefer_confirmed(buckets, sufficient_funds)
|
||||||
penalties = [penalty_func(cand) for cand in candidates]
|
penalties = [penalty_func(cand) for cand in candidates]
|
||||||
winner = candidates[penalties.index(min(penalties))]
|
winner = candidates[penalties.index(min(penalties))]
|
||||||
self.print_error("Bucket sets:", len(buckets))
|
self.print_error("Bucket sets:", len(buckets))
|
||||||
|
@ -276,14 +290,15 @@ class CoinChooserRandom(CoinChooserBase):
|
||||||
return winner
|
return winner
|
||||||
|
|
||||||
class CoinChooserPrivacy(CoinChooserRandom):
|
class CoinChooserPrivacy(CoinChooserRandom):
|
||||||
'''Attempts to better preserve user privacy. First, if any coin is
|
"""Attempts to better preserve user privacy.
|
||||||
spent from a user address, all coins are. Compared to spending
|
First, if any coin is spent from a user address, all coins are.
|
||||||
from other addresses to make up an amount, this reduces
|
Compared to spending from other addresses to make up an amount, this reduces
|
||||||
information leakage about sender holdings. It also helps to
|
information leakage about sender holdings. It also helps to
|
||||||
reduce blockchain UTXO bloat, and reduce future privacy loss that
|
reduce blockchain UTXO bloat, and reduce future privacy loss that
|
||||||
would come from reusing that address' remaining UTXOs. Second, it
|
would come from reusing that address' remaining UTXOs.
|
||||||
penalizes change that is quite different to the sent amount.
|
Second, it penalizes change that is quite different to the sent amount.
|
||||||
Third, it penalizes change that is too big.'''
|
Third, it penalizes change that is too big.
|
||||||
|
"""
|
||||||
|
|
||||||
def keys(self, coins):
|
def keys(self, coins):
|
||||||
return [coin['address'] for coin in coins]
|
return [coin['address'] for coin in coins]
|
||||||
|
@ -296,6 +311,7 @@ class CoinChooserPrivacy(CoinChooserRandom):
|
||||||
def penalty(buckets):
|
def penalty(buckets):
|
||||||
badness = len(buckets) - 1
|
badness = len(buckets) - 1
|
||||||
total_input = sum(bucket.value for bucket in buckets)
|
total_input = sum(bucket.value for bucket in buckets)
|
||||||
|
# FIXME "change" here also includes fees
|
||||||
change = float(total_input - spent_amount)
|
change = float(total_input - spent_amount)
|
||||||
# Penalize change not roughly in output range
|
# Penalize change not roughly in output range
|
||||||
if change < min_change:
|
if change < min_change:
|
||||||
|
@ -309,13 +325,14 @@ class CoinChooserPrivacy(CoinChooserRandom):
|
||||||
return penalty
|
return penalty
|
||||||
|
|
||||||
|
|
||||||
COIN_CHOOSERS = {'Priority': CoinChooserOldestFirst,
|
COIN_CHOOSERS = {
|
||||||
'Privacy': CoinChooserPrivacy}
|
'Privacy': CoinChooserPrivacy,
|
||||||
|
}
|
||||||
|
|
||||||
def get_name(config):
|
def get_name(config):
|
||||||
kind = config.get('coin_chooser')
|
kind = config.get('coin_chooser')
|
||||||
if not kind in COIN_CHOOSERS:
|
if not kind in COIN_CHOOSERS:
|
||||||
kind = 'Priority'
|
kind = 'Privacy'
|
||||||
return kind
|
return kind
|
||||||
|
|
||||||
def get_coin_chooser(config):
|
def get_coin_chooser(config):
|
||||||
|
|
|
@ -135,6 +135,13 @@ class TestTransaction(unittest.TestCase):
|
||||||
self.assertEqual(tx.estimated_weight(), 772)
|
self.assertEqual(tx.estimated_weight(), 772)
|
||||||
self.assertEqual(tx.estimated_size(), 193)
|
self.assertEqual(tx.estimated_size(), 193)
|
||||||
|
|
||||||
|
def test_estimated_output_size(self):
|
||||||
|
estimated_output_size = transaction.Transaction.estimated_output_size
|
||||||
|
self.assertEqual(estimated_output_size('14gcRovpkCoGkCNBivQBvw7eso7eiNAbxG'), 34)
|
||||||
|
self.assertEqual(estimated_output_size('35ZqQJcBQMZ1rsv8aSuJ2wkC7ohUCQMJbT'), 32)
|
||||||
|
self.assertEqual(estimated_output_size('bc1q3g5tmkmlvxryhh843v4dz026avatc0zzr6h3af'), 31)
|
||||||
|
self.assertEqual(estimated_output_size('bc1qnvks7gfdu72de8qv6q6rhkkzu70fqz4wpjzuxjf6aydsx7wxfwcqnlxuv3'), 43)
|
||||||
|
|
||||||
# TODO other tests for segwit tx
|
# TODO other tests for segwit tx
|
||||||
def test_tx_signed_segwit(self):
|
def test_tx_signed_segwit(self):
|
||||||
tx = transaction.Transaction(signed_segwit_blob)
|
tx = transaction.Transaction(signed_segwit_blob)
|
||||||
|
|
|
@ -877,6 +877,13 @@ class Transaction:
|
||||||
|
|
||||||
return 4 * input_size + witness_size
|
return 4 * input_size + witness_size
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def estimated_output_size(cls, address):
|
||||||
|
"""Return an estimate of serialized output size in bytes."""
|
||||||
|
script = bitcoin.address_to_script(address)
|
||||||
|
# 8 byte value + 1 byte script len + script
|
||||||
|
return 9 + len(script) // 2
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def virtual_size_from_weight(cls, weight):
|
def virtual_size_from_weight(cls, weight):
|
||||||
return weight // 4 + (weight % 4 > 0)
|
return weight // 4 + (weight % 4 > 0)
|
||||||
|
|
Loading…
Reference in New Issue