Fix: wallet include fees in swap outputs for inputs of successive melt (#630)

* wallet: add fees to outputs for melt that requires a split

* add test that requires a swap

* verify test fails, will revert

* revert true

* hopefully fix the tests

* fix default fee selection

* cleanup and renamings

* cleanup coinselect function, estimate fees

* fix test

* add comments

* weird error
This commit is contained in:
callebtc
2024-10-03 09:50:23 +02:00
committed by GitHub
parent f8f061f810
commit 700320a8c4
7 changed files with 112 additions and 56 deletions

View File

@@ -87,11 +87,8 @@ class LNbitsWallet(LightningBackend):
url=f"{self.endpoint}/api/v1/payments", json=data url=f"{self.endpoint}/api/v1/payments", json=data
) )
r.raise_for_status() r.raise_for_status()
except Exception: except Exception as e:
return InvoiceResponse( return InvoiceResponse(ok=False, error_message=str(e))
ok=False,
error_message=r.json()["detail"],
)
data = r.json() data = r.json()
checking_id, payment_request = data["checking_id"], data["payment_request"] checking_id, payment_request = data["checking_id"], data["payment_request"]

View File

@@ -203,10 +203,12 @@ async def pay(
quote = await wallet.melt_quote(invoice, amount) quote = await wallet.melt_quote(invoice, amount)
logger.debug(f"Quote: {quote}") logger.debug(f"Quote: {quote}")
total_amount = quote.amount + quote.fee_reserve total_amount = quote.amount + quote.fee_reserve
# estimate ecash fee for the coinselected proofs
ecash_fees = wallet.coinselect_fee(wallet.proofs, total_amount)
if not yes: if not yes:
potential = ( potential = (
f" ({wallet.unit.str(total_amount)} with potential fees)" f" ({wallet.unit.str(total_amount + ecash_fees)} with potential fees)"
if quote.fee_reserve if quote.fee_reserve or ecash_fees
else "" else ""
) )
message = f"Pay {wallet.unit.str(quote.amount)}{potential}?" message = f"Pay {wallet.unit.str(quote.amount)}{potential}?"
@@ -215,15 +217,16 @@ async def pay(
abort=True, abort=True,
default=True, default=True,
) )
# we need to include fees so we can use the proofs for melting the `total_amount`
send_proofs, _ = await wallet.select_to_send(
wallet.proofs, total_amount, include_fees=True, set_reserved=True
)
print("Paying Lightning invoice ...", end="", flush=True) print("Paying Lightning invoice ...", end="", flush=True)
assert total_amount > 0, "amount is not positive" assert total_amount > 0, "amount is not positive"
if wallet.available_balance < total_amount: if wallet.available_balance < total_amount:
print(" Error: Balance too low.") print(" Error: Balance too low.")
return return
send_proofs, fees = await wallet.select_to_send(
wallet.proofs, total_amount, include_fees=True, set_reserved=True
)
try: try:
melt_response = await wallet.melt( melt_response = await wallet.melt(
send_proofs, invoice, quote.fee_reserve, quote.quote send_proofs, invoice, quote.fee_reserve, quote.quote

View File

@@ -62,9 +62,7 @@ async def send_nostr(
pubkey = await nip5_to_pubkey(wallet, pubkey) pubkey = await nip5_to_pubkey(wallet, pubkey)
await wallet.load_mint() await wallet.load_mint()
await wallet.load_proofs() await wallet.load_proofs()
_, send_proofs = await wallet.swap_to_send( _, send_proofs = await wallet.swap_to_send(wallet.proofs, amount, set_reserved=True)
wallet.proofs, amount, set_reserved=True, include_fees=False
)
token = await wallet.serialize_proofs(send_proofs, include_dleq=include_dleq) token = await wallet.serialize_proofs(send_proofs, include_dleq=include_dleq)
if pubkey.startswith("npub"): if pubkey.startswith("npub"):

View File

@@ -36,7 +36,7 @@ class WalletTransactions(SupportsDb, SupportsKeysets):
def get_fees_for_proofs_ppk(self, proofs: List[Proof]) -> int: def get_fees_for_proofs_ppk(self, proofs: List[Proof]) -> int:
return sum([self.keysets[p.id].input_fee_ppk for p in proofs]) return sum([self.keysets[p.id].input_fee_ppk for p in proofs])
async def _select_proofs_to_send( def coinselect(
self, self,
proofs: List[Proof], proofs: List[Proof],
amount_to_send: Union[int, float], amount_to_send: Union[int, float],
@@ -59,7 +59,7 @@ class WalletTransactions(SupportsDb, SupportsKeysets):
return [] return []
logger.trace( logger.trace(
f"_select_proofs_to_send amount_to_send: {amount_to_send}  amounts we have: {amount_summary(proofs, self.unit)} (sum: {sum_proofs(proofs)})" f"coinselect amount_to_send: {amount_to_send}  amounts we have: {amount_summary(proofs, self.unit)} (sum: {sum_proofs(proofs)})"
) )
sorted_proofs = sorted(proofs, key=lambda p: p.amount) sorted_proofs = sorted(proofs, key=lambda p: p.amount)
@@ -91,7 +91,7 @@ class WalletTransactions(SupportsDb, SupportsKeysets):
logger.trace( logger.trace(
f"> selecting more proofs from {amount_summary(smaller_proofs[1:], self.unit)} sum: {sum_proofs(smaller_proofs[1:])} to reach {remainder}" f"> selecting more proofs from {amount_summary(smaller_proofs[1:], self.unit)} sum: {sum_proofs(smaller_proofs[1:])} to reach {remainder}"
) )
selected_proofs += await self._select_proofs_to_send( selected_proofs += self.coinselect(
smaller_proofs[1:], remainder, include_fees=include_fees smaller_proofs[1:], remainder, include_fees=include_fees
) )
sum_selected_proofs = sum_proofs(selected_proofs) sum_selected_proofs = sum_proofs(selected_proofs)
@@ -101,10 +101,14 @@ class WalletTransactions(SupportsDb, SupportsKeysets):
return [next_bigger] return [next_bigger]
logger.trace( logger.trace(
f"_select_proofs_to_send - selected proof amounts: {amount_summary(selected_proofs, self.unit)} (sum: {sum_proofs(selected_proofs)})" f"coinselect - selected proof amounts: {amount_summary(selected_proofs, self.unit)} (sum: {sum_proofs(selected_proofs)})"
) )
return selected_proofs return selected_proofs
def coinselect_fee(self, proofs: List[Proof], amount: int) -> int:
proofs_send = self.coinselect(proofs, amount, include_fees=True)
return self.get_fees_for_proofs(proofs_send)
async def set_reserved(self, proofs: List[Proof], reserved: bool) -> None: async def set_reserved(self, proofs: List[Proof], reserved: bool) -> None:
"""Mark a proof as reserved or reset it in the wallet db to avoid reuse when it is sent. """Mark a proof as reserved or reset it in the wallet db to avoid reuse when it is sent.

View File

@@ -585,16 +585,28 @@ class Wallet(
self.verify_proofs_dleq(proofs) self.verify_proofs_dleq(proofs)
return await self.split(proofs=proofs, amount=0) return await self.split(proofs=proofs, amount=0)
def swap_send_and_keep_output_amounts( def determine_output_amounts(
self, proofs: List[Proof], amount: int, fees: int = 0 self,
proofs: List[Proof],
amount: int,
include_fees: bool = False,
keyset_id_outputs: Optional[str] = None,
) -> Tuple[List[int], List[int]]: ) -> Tuple[List[int], List[int]]:
"""This function generates a suitable amount split for the outputs to keep and the outputs to send. It """This function generates a suitable amount split for the outputs to keep and the outputs to send. It
calculates the amount to keep based on the wallet state and the amount to send based on the amount calculates the amount to keep based on the wallet state and the amount to send based on the amount
provided. provided.
Amount to keep is based on the proofs we have in the wallet
Amount to send is optimally split based on the amount provided plus optionally the fees required to receive them.
Args: Args:
proofs (List[Proof]): Proofs to be split. proofs (List[Proof]): Proofs to be split.
amount (int): Amount to be sent. amount (int): Amount to be sent.
include_fees (bool, optional): If True, the fees are included in the amount to send (output of
this method, to be sent in the future). This is not the fee that is required to swap the
`proofs` (input to this method). Defaults to False.
keyset_id_outputs (str, optional): The keyset ID of the outputs to be produced, used to determine the
fee if `include_fees` is set.
Returns: Returns:
Tuple[List[int], List[int]]: Two lists of amounts, one for keeping and one for sending. Tuple[List[int], List[int]]: Two lists of amounts, one for keeping and one for sending.
@@ -602,25 +614,34 @@ class Wallet(
# create a suitable amount split based on the proofs provided # create a suitable amount split based on the proofs provided
total = sum_proofs(proofs) total = sum_proofs(proofs)
keep_amt, send_amt = total - amount, amount keep_amt, send_amt = total - amount, amount
if include_fees:
keyset_id = keyset_id_outputs or self.keyset_id
tmp_proofs = [Proof(id=keyset_id) for _ in amount_split(send_amt)]
fee = self.get_fees_for_proofs(tmp_proofs)
keep_amt -= fee
send_amt += fee
logger.trace(f"Keep amount: {keep_amt}, send amount: {send_amt}") logger.trace(f"Keep amount: {keep_amt}, send amount: {send_amt}")
logger.trace(f"Total input: {sum_proofs(proofs)}") logger.trace(f"Total input: {sum_proofs(proofs)}")
# generate splits for outputs # generate optimal split for outputs to send
send_outputs = amount_split(send_amt) send_amounts = amount_split(send_amt)
# we subtract the fee for the entire transaction from the amount to keep # we subtract the input fee for the entire transaction from the amount to keep
keep_amt -= self.get_fees_for_proofs(proofs) keep_amt -= self.get_fees_for_proofs(proofs)
logger.trace(f"Keep amount: {keep_amt}") logger.trace(f"Keep amount: {keep_amt}")
# we determine the amounts to keep based on the wallet state # we determine the amounts to keep based on the wallet state
keep_outputs = self.split_wallet_state(keep_amt) keep_amounts = self.split_wallet_state(keep_amt)
return keep_outputs, send_outputs return keep_amounts, send_amounts
async def split( async def split(
self, self,
proofs: List[Proof], proofs: List[Proof],
amount: int, amount: int,
secret_lock: Optional[Secret] = None, secret_lock: Optional[Secret] = None,
include_fees: bool = False,
) -> Tuple[List[Proof], List[Proof]]: ) -> Tuple[List[Proof], List[Proof]]:
"""Calls the swap API to split the proofs into two sets of proofs, one for keeping and one for sending. """Calls the swap API to split the proofs into two sets of proofs, one for keeping and one for sending.
@@ -632,6 +653,9 @@ class Wallet(
proofs (List[Proof]): Proofs to be split. proofs (List[Proof]): Proofs to be split.
amount (int): Amount to be sent. amount (int): Amount to be sent.
secret_lock (Optional[Secret], optional): Secret to lock the tokens to be sent. Defaults to None. secret_lock (Optional[Secret], optional): Secret to lock the tokens to be sent. Defaults to None.
include_fees (bool, optional): If True, the fees are included in the amount to send (output of
this method, to be sent in the future). This is not the fee that is required to swap the
`proofs` (input to this method) which must already be included. Defaults to False.
Returns: Returns:
Tuple[List[Proof], List[Proof]]: Two lists of proofs, one for keeping and one for sending. Tuple[List[Proof], List[Proof]]: Two lists of proofs, one for keeping and one for sending.
@@ -647,18 +671,16 @@ class Wallet(
input_fees = self.get_fees_for_proofs(proofs) input_fees = self.get_fees_for_proofs(proofs)
logger.debug(f"Input fees: {input_fees}") logger.debug(f"Input fees: {input_fees}")
# create a suitable amount lists to keep and send based on the proofs # create a suitable amounts to keep and send.
# provided and the state of the wallet keep_outputs, send_outputs = self.determine_output_amounts(
keep_outputs, send_outputs = self.swap_send_and_keep_output_amounts( proofs,
proofs, amount, input_fees amount,
include_fees=include_fees,
keyset_id_outputs=self.keyset_id,
) )
amounts = keep_outputs + send_outputs amounts = keep_outputs + send_outputs
if not amounts:
logger.warning("Swap has no outputs")
return [], []
# generate secrets for new outputs # generate secrets for new outputs
if secret_lock is None: if secret_lock is None:
secrets, rs, derivation_paths = await self.generate_n_secrets(len(amounts)) secrets, rs, derivation_paths = await self.generate_n_secrets(len(amounts))
@@ -674,7 +696,7 @@ class Wallet(
await self._check_used_secrets(secrets) await self._check_used_secrets(secrets)
# construct outputs # construct outputs
outputs, rs = self._construct_outputs(amounts, secrets, rs) outputs, rs = self._construct_outputs(amounts, secrets, rs, self.keyset_id)
# potentially add witnesses to outputs based on what requirement the proofs indicate # potentially add witnesses to outputs based on what requirement the proofs indicate
outputs = await self.add_witnesses_to_outputs(proofs, outputs) outputs = await self.add_witnesses_to_outputs(proofs, outputs)
@@ -1036,14 +1058,15 @@ class Wallet(
If `set_reserved` is set to True, the proofs are marked as reserved so they aren't used in other transactions. If `set_reserved` is set to True, the proofs are marked as reserved so they aren't used in other transactions.
If `include_fees` is set to False, the swap fees are not included in the amount to be selected. If `include_fees` is set to True, the selection includes the swap fees to receive the selected proofs.
Args: Args:
proofs (List[Proof]): Proofs to split proofs (List[Proof]): Proofs to split
amount (int): Amount to split to amount (int): Amount to split to
set_reserved (bool, optional): If set, the proofs are marked as reserved. Defaults to False. set_reserved (bool, optional): If set, the proofs are marked as reserved. Defaults to False.
offline (bool, optional): If set, the coin selection is done offline. Defaults to False. offline (bool, optional): If set, the coin selection is done offline. Defaults to False.
include_fees (bool, optional): If set, the fees are included in the amount to be selected. Defaults to False. include_fees (bool, optional): If set, the fees for spending the proofs later are included in the
amount to be selected. Defaults to False.
Returns: Returns:
List[Proof]: Proofs to send List[Proof]: Proofs to send
@@ -1055,9 +1078,7 @@ class Wallet(
raise Exception("balance too low.") raise Exception("balance too low.")
# coin selection for potentially offline sending # coin selection for potentially offline sending
send_proofs = await self._select_proofs_to_send( send_proofs = self.coinselect(proofs, amount, include_fees=include_fees)
proofs, amount, include_fees=include_fees
)
fees = self.get_fees_for_proofs(send_proofs) fees = self.get_fees_for_proofs(send_proofs)
logger.trace( logger.trace(
f"select_to_send: selected: {self.unit.str(sum_proofs(send_proofs))} (+ {self.unit.str(fees)} fees) wanted: {self.unit.str(amount)}" f"select_to_send: selected: {self.unit.str(sum_proofs(send_proofs))} (+ {self.unit.str(fees)} fees) wanted: {self.unit.str(amount)}"
@@ -1068,7 +1089,10 @@ class Wallet(
logger.debug("Offline coin selection unsuccessful. Splitting proofs.") logger.debug("Offline coin selection unsuccessful. Splitting proofs.")
# we set the proofs as reserved later # we set the proofs as reserved later
_, send_proofs = await self.swap_to_send( _, send_proofs = await self.swap_to_send(
proofs, amount, set_reserved=False proofs,
amount,
set_reserved=False,
include_fees=include_fees,
) )
else: else:
raise Exception( raise Exception(
@@ -1086,7 +1110,7 @@ class Wallet(
*, *,
secret_lock: Optional[Secret] = None, secret_lock: Optional[Secret] = None,
set_reserved: bool = False, set_reserved: bool = False,
include_fees: bool = True, include_fees: bool = False,
) -> Tuple[List[Proof], List[Proof]]: ) -> Tuple[List[Proof], List[Proof]]:
""" """
Swaps a set of proofs with the mint to get a set that sums up to a desired amount that can be sent. The remaining Swaps a set of proofs with the mint to get a set that sums up to a desired amount that can be sent. The remaining
@@ -1101,6 +1125,7 @@ class Wallet(
set_reserved (bool, optional): If set, the proofs are marked as reserved. Should be set to False if a payment attempt set_reserved (bool, optional): If set, the proofs are marked as reserved. Should be set to False if a payment attempt
is made with the split that could fail (like a Lightning payment). Should be set to True if the token to be sent is is made with the split that could fail (like a Lightning payment). Should be set to True if the token to be sent is
displayed to the user to be then sent to someone else. Defaults to False. displayed to the user to be then sent to someone else. Defaults to False.
include_fees (bool, optional): If set, the fees for spending the send_proofs later are included in the amount to be selected. Defaults to True.
Returns: Returns:
Tuple[List[Proof], List[Proof]]: Tuple of proofs to keep and proofs to send Tuple[List[Proof], List[Proof]]: Tuple of proofs to keep and proofs to send
@@ -1110,11 +1135,10 @@ class Wallet(
if sum_proofs(proofs) < amount: if sum_proofs(proofs) < amount:
raise Exception("balance too low.") raise Exception("balance too low.")
# coin selection for swapping # coin selection for swapping, needs to include fees
swap_proofs = await self._select_proofs_to_send( swap_proofs = self.coinselect(proofs, amount, include_fees=True)
proofs, amount, include_fees=True
) # Extra rule: add proofs from inactive keysets to swap_proofs to get rid of them
# add proofs from inactive keysets to swap_proofs to get rid of them
swap_proofs += [ swap_proofs += [
p p
for p in proofs for p in proofs
@@ -1125,7 +1149,9 @@ class Wallet(
logger.debug( logger.debug(
f"Amount to send: {self.unit.str(amount)} (+ {self.unit.str(fees)} fees)" f"Amount to send: {self.unit.str(amount)} (+ {self.unit.str(fees)} fees)"
) )
keep_proofs, send_proofs = await self.split(swap_proofs, amount, secret_lock) keep_proofs, send_proofs = await self.split(
swap_proofs, amount, secret_lock, include_fees=include_fees
)
if set_reserved: if set_reserved:
await self.set_reserved(send_proofs, reserved=True) await self.set_reserved(send_proofs, reserved=True)
return keep_proofs, send_proofs return keep_proofs, send_proofs

View File

@@ -96,19 +96,47 @@ async def test_get_fees_for_proofs(wallet1: Wallet, ledger: Ledger):
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.skipif(is_regtest, reason="only works with FakeWallet") @pytest.mark.skipif(is_regtest, reason="only works with FakeWallet")
async def test_wallet_fee(wallet1: Wallet, ledger: Ledger): async def test_wallet_selection_with_fee(wallet1: Wallet, ledger: Ledger):
# THIS TEST IS A FAKE, WE SET THE WALLET FEES MANUALLY IN set_ledger_keyset_fees
# It would be better to test if the wallet can get the fees from the mint itself
# but the ledger instance does not update the responses from the `mint` that is running in the background
# so we just pretend here and test really nothing...
# set fees to 100 ppk # set fees to 100 ppk
set_ledger_keyset_fees(100, ledger, wallet1) set_ledger_keyset_fees(100, ledger, wallet1)
# THIS TEST IS A FAKE, WE SET THE WALLET FEES MANUALLY IN set_ledger_keyset_fees
# check if all wallet keysets have the correct fees # check if all wallet keysets have the correct fees
for keyset in wallet1.keysets.values(): for keyset in wallet1.keysets.values():
assert keyset.input_fee_ppk == 100 assert keyset.input_fee_ppk == 100
invoice = await wallet1.request_mint(64)
await pay_if_regtest(invoice.bolt11)
await wallet1.mint(64, id=invoice.id)
send_proofs, _ = await wallet1.select_to_send(wallet1.proofs, 10)
assert sum_proofs(send_proofs) == 10
send_proofs_with_fees, _ = await wallet1.select_to_send(
wallet1.proofs, 10, include_fees=True
)
assert sum_proofs(send_proofs_with_fees) == 11
@pytest.mark.asyncio
@pytest.mark.skipif(is_regtest, reason="only works with FakeWallet")
async def test_wallet_swap_to_send_with_fee(wallet1: Wallet, ledger: Ledger):
# set fees to 100 ppk
set_ledger_keyset_fees(100, ledger, wallet1)
invoice = await wallet1.request_mint(64)
await pay_if_regtest(invoice.bolt11)
await wallet1.mint(64, id=invoice.id, split=[32, 32]) # make sure we need to swap
# quirk: this should call a `/v1/swap` with the mint but the mint will
# throw an error since the fees are only changed in the `ledger` instance, not in the uvicorn API server
# this *should* succeed if the fees were set in the API server
# at least, we can verify that the wallet is correctly computing the fees
# by asserting for this super specific error message from the (API server) mint
await assert_err(
wallet1.select_to_send(wallet1.proofs, 10),
"Mint Error: inputs (32) - fees (0) vs outputs (31) are not balanced.",
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_split_with_fees(wallet1: Wallet, ledger: Ledger): async def test_split_with_fees(wallet1: Wallet, ledger: Ledger):

View File

@@ -254,7 +254,7 @@ async def test_swap_to_send(wallet1: Wallet):
assert_amt(send_proofs, 32) assert_amt(send_proofs, 32)
assert_amt(keep_proofs, 0) assert_amt(keep_proofs, 0)
spendable_proofs = await wallet1._select_proofs_to_send(wallet1.proofs, 32) spendable_proofs = wallet1.coinselect(wallet1.proofs, 32)
assert sum_proofs(spendable_proofs) == 32 assert sum_proofs(spendable_proofs) == 32
assert sum_proofs(send_proofs) == 32 assert sum_proofs(send_proofs) == 32