From 0fab55419c869915d12a78f3e042afdadd5b1c48 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 25 May 2024 11:43:24 +0200 Subject: [PATCH] fees for melt inputs --- cashu/core/settings.py | 1 + cashu/mint/ledger.py | 43 +++++++++++++++++++++++++++++++++-------- cashu/wallet/cli/cli.py | 6 +++--- cashu/wallet/secrets.py | 10 ++-------- cashu/wallet/wallet.py | 38 +++++++++++++++++++++++++----------- 5 files changed, 68 insertions(+), 30 deletions(-) diff --git a/cashu/core/settings.py b/cashu/core/settings.py index 205c3099..2cb259b9 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -61,6 +61,7 @@ class MintSettings(CashuSettings): mint_max_secret_length: int = Field(default=512) mint_input_fee_ppk: int = Field(default=0) + mint_internal_quote_input_fee_reserve_percent: float = Field(default=1.0) class MintBackends(MintSettings): diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index e5a34e28..24548104 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -1,4 +1,5 @@ import asyncio +import math import time from typing import Dict, List, Mapping, Optional, Tuple @@ -568,14 +569,24 @@ async def melt_quote( if not mint_quote.checking_id: raise TransactionError("mint quote has no checking id") + internal_fee = Amount( + unit, + math.ceil( + mint_quote.amount + / 100 + * settings.mint_internal_quote_input_fee_reserve_percent + ), + ) + amount = Amount(unit, mint_quote.amount) + payment_quote = PaymentQuoteResponse( checking_id=mint_quote.checking_id, - amount=Amount(unit, mint_quote.amount), - fee=Amount(unit, amount=0), + amount=amount, + fee=internal_fee, ) logger.info( f"Issuing internal melt quote: {request} ->" - f" {mint_quote.quote} ({mint_quote.amount} {mint_quote.unit})" + f" {mint_quote.quote} ({amount.str()} + {internal_fee.str()} fees)" ) else: # not internal, get payment quote by backend @@ -671,11 +682,16 @@ async def get_melt_quote(self, quote_id: str) -> MeltQuote: return melt_quote - async def melt_mint_settle_internally(self, melt_quote: MeltQuote) -> MeltQuote: + async def melt_mint_settle_internally( + self, melt_quote: MeltQuote, proofs: List[Proof] + ) -> MeltQuote: """Settles a melt quote internally if there is a mint quote with the same payment request. + `proofs` are passed to determine the ecash input transaction fees for this melt quote. + Args: melt_quote (MeltQuote): Melt quote to settle. + proofs (List[Proof]): Proofs provided for paying the Lightning invoice. Raises: Exception: Melt quote already paid. @@ -691,10 +707,19 @@ async def melt_mint_settle_internally(self, melt_quote: MeltQuote) -> MeltQuote: ) if not mint_quote: return melt_quote + # we settle the transaction internally if melt_quote.paid: raise TransactionError("melt quote already paid") + # verify that the amount of the input proofs is equal to the amount of the quote + total_provided = sum_proofs(proofs) + total_needed = melt_quote.amount + melt_quote.fee_reserve + if not total_provided >= total_needed: + raise TransactionError( + f"not enough inputs provided for melt. Provided: {total_provided}, needed: {total_needed}" + ) + # verify amounts from bolt11 invoice bolt11_request = melt_quote.request invoice_obj = bolt11.decode(bolt11_request) @@ -719,8 +744,10 @@ async def melt_mint_settle_internally(self, melt_quote: MeltQuote) -> MeltQuote: f" {mint_quote.quote} ({melt_quote.amount} {melt_quote.unit})" ) - # we handle this transaction internally - melt_quote.fee_paid = 0 + # the internal transaction costs at least the ecash input fee + melt_quote.fee_paid = min( + self.get_fees_for_proofs(proofs), melt_quote.fee_reserve + ) melt_quote.paid = True melt_quote.paid_time = int(time.time()) await self.crud.update_melt_quote(quote=melt_quote, db=self.db) @@ -772,7 +799,7 @@ async def melt( # verify that the amount of the input proofs is equal to the amount of the quote total_provided = sum_proofs(proofs) - total_needed = melt_quote.amount + (melt_quote.fee_reserve or 0) + total_needed = melt_quote.amount + melt_quote.fee_reserve if not total_provided >= total_needed: raise TransactionError( f"not enough inputs provided for melt. Provided: {total_provided}, needed: {total_needed}" @@ -793,7 +820,7 @@ async def melt( await self._set_proofs_pending(proofs, quote_id=melt_quote.quote) try: # settle the transaction internally if there is a mint quote with the same payment request - melt_quote = await self.melt_mint_settle_internally(melt_quote) + melt_quote = await self.melt_mint_settle_internally(melt_quote, proofs) # quote not paid yet (not internal), pay it with the backend if not melt_quote.paid: logger.debug(f"Lightning: pay invoice {melt_quote.request}") diff --git a/cashu/wallet/cli/cli.py b/cashu/wallet/cli/cli.py index f2548d75..df2ba851 100644 --- a/cashu/wallet/cli/cli.py +++ b/cashu/wallet/cli/cli.py @@ -217,7 +217,7 @@ async def pay( if wallet.available_balance < total_amount: print(" Error: Balance too low.") return - _, send_proofs = await wallet.split_to_send(wallet.proofs, total_amount) + send_proofs, fees = await wallet.select_to_send(wallet.proofs, total_amount) try: melt_response = await wallet.melt( send_proofs, invoice, quote.fee_reserve, quote.quote @@ -348,7 +348,7 @@ async def swap(ctx: Context): total_amount = quote.amount + quote.fee_reserve if outgoing_wallet.available_balance < total_amount: raise Exception("balance too low") - _, send_proofs = await outgoing_wallet.split_to_send( + send_proofs, fees = await outgoing_wallet.select_to_send( outgoing_wallet.proofs, total_amount, set_reserved=True ) await outgoing_wallet.melt( @@ -977,7 +977,7 @@ async def selfpay(ctx: Context, all: bool = False): mint_balance_dict = await wallet.balance_per_minturl() mint_balance = int(mint_balance_dict[wallet.url]["available"]) # send balance once to mark as reserved - await wallet.split_to_send(wallet.proofs, mint_balance, None, set_reserved=True) + await wallet.select_to_send(wallet.proofs, mint_balance, set_reserved=True) # load all reserved proofs (including the one we just sent) reserved_proofs = await get_reserved_proofs(wallet.db) if not len(reserved_proofs): diff --git a/cashu/wallet/secrets.py b/cashu/wallet/secrets.py index d8398d3e..3e6e5722 100644 --- a/cashu/wallet/secrets.py +++ b/cashu/wallet/secrets.py @@ -94,19 +94,13 @@ async def _init_private_key(self, from_mnemonic: Optional[str] = None) -> None: except Exception as e: logger.error(e) - async def _generate_secret(self) -> str: + async def _generate_random_secret(self) -> str: """Returns base64 encoded deterministic random string. NOTE: This method should probably retire after `deterministic_secrets`. We are deriving secrets from a counter but don't store the respective blinding factor. We won't be able to restore any ecash generated with these secrets. """ - # secret_counter = await bump_secret_derivation(db=self.db, keyset_id=keyset_id) - # logger.trace(f"secret_counter: {secret_counter}") - # s, _, _ = await self.generate_determinstic_secret(secret_counter, keyset_id) - # # return s.decode("utf-8") - # return hashlib.sha256(s).hexdigest() - # return random 32 byte hex string return hashlib.sha256(os.urandom(32)).hexdigest() @@ -230,7 +224,7 @@ async def generate_locked_secrets( # append predefined secrets (to send) to random secrets (to keep) # generate secrets to keep secrets = [ - await self._generate_secret() for s in range(len(keep_outputs)) + await self._generate_random_secret() for s in range(len(keep_outputs)) ] + secret_locks # TODO: derive derivation paths from secrets derivation_paths = ["custom"] * len(secrets) diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 9277a05a..9e4d29cf 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -874,7 +874,7 @@ async def select_to_send( tolerance: int = 0, ) -> Tuple[List[Proof], int]: """ - Selects proofs such that a certain amount can be sent. + Selects proofs such that a desired amount can be sent. Args: proofs (List[Proof]): Proofs to split @@ -886,11 +886,10 @@ async def select_to_send( int: Fees for the transaction """ - # select proofs that are not reserved - proofs = [p for p in proofs if not p.reserved] - # select proofs that are in the keysets of the mint - proofs = [p for p in proofs if p.id in self.keysets] + # select proofs that are not reserved and are in the active keysets of the mint + proofs = self.active_proofs(proofs) + # coin selection for potentially offline sending send_proofs, fees = await self._select_proofs_to_send(proofs, amount, tolerance) if not send_proofs and offline: raise Exception( @@ -898,6 +897,7 @@ async def select_to_send( f" {', '.join([Amount(self.unit, p.amount).str() for p in proofs])}" ) + # offline coin selection unsuccessful, we need to swap proofs before we can send if not send_proofs and not offline: # we set the proofs as reserved later _, send_proofs = await self.split_to_send( @@ -916,7 +916,10 @@ async def split_to_send( set_reserved: bool = False, ) -> Tuple[List[Proof], List[Proof]]: """ - Splits proofs such that a desired amount can be sent. + 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 + proofs are returned to be kept. All newly created proofs will be stored in the database but if `set_reserved` is set + to True, the proofs to be sent (which sum up to `amount`) will be marked as reserved so they aren't used in other + transactions. Args: proofs (List[Proof]): Proofs to split @@ -929,12 +932,10 @@ async def split_to_send( Returns: Tuple[List[Proof], List[Proof]]: Tuple of proofs to keep and proofs to send """ - # select proofs that are not reserved - proofs = [p for p in proofs if not p.reserved] - - # select proofs that are in the active keysets of the mint - proofs = [p for p in proofs if p.id in self.keysets] + # select proofs that are not reserved and are in the active keysets of the mint + proofs = self.active_proofs(proofs) + # coin selection for swapping spendable_proofs, fees = await self._select_proofs_to_split(proofs, amount) logger.debug( f"Amount to send: {self.unit.str(amount)} (+ {self.unit.str(fees)} fees)" @@ -963,6 +964,21 @@ def proof_amounts(self): """Returns a sorted list of amounts of all proofs""" return [p.amount for p in sorted(self.proofs, key=lambda p: p.amount)] + def active_proofs(self, proofs: List[Proof]): + """Returns a list of proofs that + - have an id that is in the current `self.keysets` which have the unit in `self.unit` + - are not reserved + """ + + def is_active_proof(p: Proof) -> bool: + return ( + p.id in self.keysets + and self.keysets[p.id].unit == self.unit + and not p.reserved + ) + + return [p for p in proofs if is_active_proof(p)] + def balance_per_keyset(self) -> Dict[str, Dict[str, Union[int, str]]]: ret: Dict[str, Dict[str, Union[int, str]]] = { key: {