Skip to content

Commit

Permalink
sm: replace exhaustive coin selection with one based on UTxO pool layout
Browse files Browse the repository at this point in the history
We couldn't rely on the UTxO pool layout previously since there where
many of them possible. Now we settled on one we can just use it for the
coin selection in order to *drastically* reduce overpayments.
  • Loading branch information
darosior committed Sep 30, 2021
1 parent c5ad47a commit 2cb282a
Showing 1 changed file with 72 additions and 38 deletions.
110 changes: 72 additions & 38 deletions Model/statemachine.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- could break with certain DELEGATION_PERIODs.
"""

import bisect
import itertools
import logging
import numpy as np
Expand Down Expand Up @@ -116,6 +117,9 @@ def __init__(
def __repr__(self):
return f"Coin(id={self.id}, amount={self.amount}, fan_block={self.fan_block}, state={self.processing_state})"

def __lt__(a, b):
return a.amount < b.amount

def is_confirmed(self):
"""Whether this coin was fanned out and confirmed"""
if self.processing_state == ProcessingState.CONFIRMED:
Expand Down Expand Up @@ -997,45 +1001,75 @@ def cancel_coin_selec_0(self, vault, needed_fee, feerate):
return coins

def cancel_coin_selec_1(self, vault, needed_fee, feerate):
"""Select the combination that results in the smallest overpayment"""
coins = []

best_combination = None
min_fee_added = None
max_paying_combination = None
max_fee_added = 0
allocated_coins = vault.allocated_coins()
for candidate in itertools.chain.from_iterable(
itertools.combinations(allocated_coins, r)
for r in range(1, len(allocated_coins) + 1)
):
added_fees = sum(
[c.amount - P2WPKH_INPUT_SIZE * feerate for c in candidate]
)
# In any case record the combination paying the most fees, as a
# best effort if we can't afford the whole fee needed.
if added_fees > max_fee_added:
max_paying_combination = candidate
max_fee_added = added_fees
# Record the combination overpaying the least
if added_fees < needed_fee:
continue
if min_fee_added is not None and added_fees >= min_fee_added:
continue
best_combination = candidate
min_fee_added = added_fees

combination = best_combination
if combination is None:
# FIXME: we usually have tons of unallocated coins, can we take some
# from there out of emergency?
combination = max_paying_combination
assert combination is not None
for coin in combination:
self.remove_coin(coin)
coins.append(coin)
"""Select the combination of fee-bumping coins that results in the
smallest overpayment possible.
The UTxO pool is laid out with large coins covering up to the reserve
and smaller coins used for a finer grained coin selection to avoid
overpayments.
First try to find the number of Vb (large) coins needed to cover for
the most part of the fees, then fill the gap with Vm (small) coins.
"""
txin_cost = P2WPKH_INPUT_SIZE * feerate
allocated_coins = sorted(vault.allocated_coins())
# All vb coins are always larger than vm coins (or at least we assume so)
vm_coins, vb_coins = (
allocated_coins[: -self.vb_coins_count],
allocated_coins[-self.vb_coins_count :],
)
# We often end up with more Vb coins which we would consider to be Vm coins
# above. Try to fix this based on their value.
while True:
# Sanity hard stop
if len(vm_coins) <= 6:
break
coin = vm_coins.pop(-1)
if coin.amount >= vb_coins[-1].amount * 0.80:
bisect.insort(vb_coins, coin)
else:
vm_coins.append(coin)
break

return coins
def coin_sum(coins):
return sum(c.amount for c in coins)

# First check if the needed amount is very low, in which case we don't
# even need a Vb coin.
if vb_coins[0].amount > needed_fee + txin_cost:
for i in range(1, len(vm_coins)):
if coin_sum(vm_coins[:i]) >= needed_fee + txin_cost * i:
return vm_coins[:i]
return [vb_coins[0]]

# Then gather enough vb coins
picked_vb_coins = []
paid = 0
# TODO: figure out why we have less overpayments by going in increasing order
for i in range(1, len(vb_coins)):
coin = vb_coins[-i]
if needed_fee - paid < coin.amount - txin_cost:
break
picked_vb_coins.append(coin)
paid += coin.amount - txin_cost

# And finally fill the gap with small Vm coins. Note we go through the
# list in reverse order as the Vm coins amount is increasing.
# TODO: figure out why we have less overpayments by going in increasing order
rem_fee = needed_fee - paid
for i in range(len(vm_coins)):
if coin_sum(vm_coins[:i]) >= rem_fee + txin_cost * i:
return picked_vb_coins + vm_coins[:i]

# All Vm coins couldn't fill the gap? Fall back to use only Vb coins
if len(coins) < len(vb_coins):
for i in range(1, len(vb_coins)):
if coin_sum(vb_coins[:i]) > needed_fee + txin_cost * i:
return vb_coins[:i]

logging.error(
f"Not enough reserve to pay for cancel fee ({needed_fee} sats) at feerate {feerate}",
)
return vb_coins + vm_coins

def finalize_cancel(self, tx, height):
"""Once the cancel is confirmed, any remaining fbcoins allocated to vault_id
Expand Down

0 comments on commit 2cb282a

Please sign in to comment.