1
0

ln_utxo_reserve

When we send max, decrease sent amount in order to keep some
reserve utxo, in order to be able to sweep lightning channels.
This commit is contained in:
ThomasV
2025-03-17 13:11:25 +01:00
parent 307f5d301e
commit b339b1e7e3
5 changed files with 86 additions and 17 deletions

View File

@@ -531,6 +531,10 @@ class TxEditor(WindowModalDialog):
# warn if spending unconf
if any((txin.block_height is not None and txin.block_height<=0) for txin in self.tx.inputs()):
messages.append(_('This transaction will spend unconfirmed coins.'))
# warn if a reserve utxo was added
if reserve_sats := sum(txo.value for txo in self.tx.outputs() if txo.is_utxo_reserve):
reserve_str = self.main_window.config.format_amount_and_units(reserve_sats)
messages.append(_('Could not spend max: a security reserve of {} was kept for your Lightning channels.').format(reserve_str))
# warn if we merge from mempool
if self.is_batching():
messages.append(_('This payment will be merged with another existing transaction.'))

View File

@@ -1418,7 +1418,10 @@ class LNWallet(LNWorker):
tx = self.wallet.make_unsigned_transaction(
coins=coins,
outputs=outputs,
fee_policy=fee_policy)
fee_policy=fee_policy,
# we do not know yet if peer accepts anchors, just assume they do
is_anchor_channel_opening=self.config.ENABLE_ANCHOR_CHANNELS,
)
tx.set_rbf(False)
# rm randomness from locktime, as we use the locktime as entropy for deriving the funding_privkey
# (and it would be confusing to get a collision as a consequence of the randomness)

View File

@@ -846,6 +846,13 @@ Warning: setting this to too low will result in lots of payment failures."""),
ACCEPT_ZEROCONF_CHANNELS = ConfigVar('accept_zeroconf_channels', default=False, type_=bool)
ZEROCONF_TRUSTED_NODE = ConfigVar('zeroconf_trusted_node', default='', type_=str)
ZEROCONF_MIN_OPENING_FEE = ConfigVar('zeroconf_min_opening_fee', default=5000, type_=int)
LN_UTXO_RESERVE = ConfigVar(
'ln_utxo_reserve',
default=10000,
type_=int,
short_desc=lambda: _("Amount that must be kept on-chain in order to sweep anchor output channels"),
long_desc=lambda: _("Do not set this below dust limit"),
)
# connect to remote WT
WATCHTOWER_CLIENT_URL = ConfigVar('watchtower_url', default=None, type_=str)

View File

@@ -1875,6 +1875,7 @@ class PartialTxOutput(TxOutput, PSBTSection):
self._script_descriptor = None # type: Optional[Descriptor]
self.is_mine = False # type: bool # whether the wallet considers the output to be ismine
self.is_change = False # type: bool # whether the wallet considers the output to be change
self.is_utxo_reserve = False # type: bool # whether this is a change output added to satisfy anchor channel requirements
@property
def pubkeys(self) -> Set[bytes]:

View File

@@ -1837,6 +1837,37 @@ class Abstract_Wallet(ABC, Logger, EventListener):
assert is_address(selected_addr), f"not valid bitcoin address: {selected_addr}"
return selected_addr
def should_keep_reserve_utxo(
self,
tx_inputs: List[PartialTxInput],
tx_outputs: List[PartialTxOutput],
is_anchor_channel_opening: bool,
) -> bool:
channels_need_reserve = self.lnworker and any(chan.has_anchors() and not chan.is_redeemed() for chan in self.lnworker.channels.values())
# note: is_anchor_channel_opening is used in unit tests, without lnworker
is_reserve_needed = is_anchor_channel_opening or channels_need_reserve
if not is_reserve_needed:
return False
coins_in_wallet = self.get_spendable_coins(nonlocal_only=False, confirmed_only=False)
prevout_coins_in_wallet = set(c.prevout for c in coins_in_wallet)
amount_in_wallet = sum(c.value_sats() for c in coins_in_wallet)
amount_consumed = sum(c.value_sats() for c in tx_inputs if c.prevout in prevout_coins_in_wallet)
amount_retained = sum(o.value for o in tx_outputs if self.is_mine(o.address))
to_be_spent_sat = amount_consumed - amount_retained
assert amount_in_wallet - to_be_spent_sat >= 0
if amount_in_wallet - to_be_spent_sat >= self.config.LN_UTXO_RESERVE:
# there will be enough remaining after we send
return False
# we will need to subtract the reserve
self.logger.info(f'we should keep a reserve: {to_be_spent_sat=}, {amount_in_wallet=}')
return True
def is_low_reserve(self) -> bool:
return self.should_keep_reserve_utxo([], [], False)
@profiler(min_threshold=0.1)
def make_unsigned_transaction(
self, *,
@@ -1853,6 +1884,7 @@ class Abstract_Wallet(ABC, Logger, EventListener):
merge_duplicate_outputs: bool = False,
locktime: Optional[int] = None,
tx_version: Optional[int] = None,
is_anchor_channel_opening: bool = False,
) -> PartialTransaction:
"""Can raise NotEnoughFunds or NoDynamicFeeEstimates."""
@@ -1864,6 +1896,7 @@ class Abstract_Wallet(ABC, Logger, EventListener):
raise Exception("Some inputs already contain signatures!")
if inputs is None:
inputs = []
# make sure inputs and coins do not overlap
if inputs:
input_set = set(txin.prevout for txin in inputs)
coins = [coin for coin in coins if (coin.prevout not in input_set)]
@@ -1944,6 +1977,10 @@ class Abstract_Wallet(ABC, Logger, EventListener):
amount = change[0].value
if amount <= self.lnworker.num_sats_can_receive():
tx.replace_output_address(change[0].address, DummyAddress.SWAP)
if self.should_keep_reserve_utxo(tx.inputs(), tx.outputs(), is_anchor_channel_opening):
raise NotEnoughFunds()
self.logger.debug(f'coinchooser returned tx with {len(tx.inputs())} inputs and {len(tx.outputs())} outputs')
else:
# "spend max" branch
# note: This *will* spend inputs with negative effective value (if there are any).
@@ -1952,23 +1989,40 @@ class Abstract_Wallet(ABC, Logger, EventListener):
# forever. see #5433
# note: Actually, it might be the case that not all UTXOs from the wallet are
# being spent if the user manually selected UTXOs.
sendable = sum(map(lambda c: c.value_sats(), coins))
for (_,i) in i_max:
outputs[i].value = 0
tx = PartialTransaction.from_io(list(coins), list(outputs))
fee = fee_estimator(tx.estimated_size())
amount = sendable - tx.output_value() - fee
if amount < 0:
raise NotEnoughFunds()
distr_amount = 0
for (weight, i) in i_max:
val = int((amount/i_max_sum) * weight)
outputs[i].value = val
distr_amount += val
def distribute_amount(amount):
if amount < 0:
raise NotEnoughFunds()
distr_amount = 0
for (weight, i) in i_max:
# fixme: this does not check that value >= dust_threshold
val = int((amount/i_max_sum) * weight)
outputs[i].value = val
distr_amount += val
(x,i) = i_max[-1]
outputs[i].value += (amount - distr_amount)
(x,i) = i_max[-1]
outputs[i].value += (amount - distr_amount)
tx = PartialTransaction.from_io(list(coins), list(outputs))
tx_inputs = inputs + coins # these do not overlap, see above
distribute_amount(0)
tx = PartialTransaction.from_io(list(tx_inputs), list(outputs))
fee = fee_estimator(tx.estimated_size())
input_amount = sum(c.value_sats() for c in tx_inputs)
allocated_amount = sum(o.value for o in outputs if not parse_max_spend(o.value))
to_distribute = input_amount - allocated_amount
distribute_amount(to_distribute - fee)
if self.should_keep_reserve_utxo(tx_inputs, outputs, is_anchor_channel_opening):
self.logger.info(f'Adding change output to meet utxo reserve requirements')
change_addr = self.get_change_addresses_for_new_transaction(change_addr)[0]
change = PartialTxOutput.from_address_and_value(change_addr, self.config.LN_UTXO_RESERVE)
change.is_utxo_reserve = True # for GUI
outputs.append(change)
to_distribute -= change.value
tx = PartialTransaction.from_io(list(tx_inputs), list(outputs))
fee = fee_estimator(tx.estimated_size())
distribute_amount(to_distribute - fee)
tx = PartialTransaction.from_io(list(tx_inputs), list(outputs))
assert len(tx.outputs()) > 0, "any bitcoin tx must have at least 1 output by consensus"
if locktime is None: