diff --git a/cashu/mint/crud.py b/cashu/mint/crud.py index 2421789ea..0914a5afe 100644 --- a/cashu/mint/crud.py +++ b/cashu/mint/crud.py @@ -253,6 +253,17 @@ async def get_mint_quote_by_request( conn: Optional[Connection] = None, ) -> Optional[MintQuote]: ... + @abstractmethod + async def try_update_mint_quote_last_checked( + self, + *, + quote_id: str, + last_checked: int, + rate_limit: int, + db: Database, + conn: Optional[Connection] = None, + ) -> bool: ... + @abstractmethod async def update_mint_quote( self, diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py deleted file mode 100644 index ffa651e24..000000000 --- a/cashu/mint/ledger.py +++ /dev/null @@ -1,1413 +0,0 @@ -import asyncio -import time -from typing import Dict, List, Mapping, Optional, Tuple - -import bolt11 -from loguru import logger - -from ..core.base import ( - DLEQ, - Amount, - BlindedMessage, - BlindedSignature, - MeltQuote, - MeltQuoteState, - Method, - MintKeyset, - MintQuote, - MintQuoteState, - Proof, - Unit, -) -from ..core.crypto import b_dhke -from ..core.crypto.aes import AESCipher -from ..core.crypto.keys import ( - derive_pubkey, - random_hash, -) -from ..core.crypto.secp import PrivateKey, PublicKey -from ..core.db import Connection, Database -from ..core.errors import ( - BatchDuplicateQuotesError, - CashuError, - LightningError, - LightningPaymentFailedError, - NotAllowedError, - QuoteAlreadyIssuedError, - QuoteNotPaidError, - QuoteSignatureInvalidError, - TransactionAmountExceedsLimitError, - TransactionError, -) -from ..core.helpers import sum_proofs -from ..core.models import ( - PostMeltQuoteRequest, - PostMeltQuoteResponse, - PostMintBatchRequest, - PostMintQuoteCheckRequest, - PostMintQuoteRequest, -) -from ..core.settings import settings -from ..core.split import amount_split -from ..lightning.base import ( - InvoiceResponse, - LightningBackend, - PaymentQuoteResponse, - PaymentResponse, - PaymentResult, - PaymentStatus, -) -from ..mint.crud import LedgerCrudSqlite -from .conditions import LedgerSpendingConditions -from .db.read import DbReadHelper -from .db.write import DbWriteHelper -from .events.events import LedgerEventManager -from .features import LedgerFeatures -from .keysets import LedgerKeysets -from .tasks import LedgerTasks -from .verification import LedgerVerification -from .watchdog import LedgerWatchdog - - -class Ledger( - LedgerVerification, - LedgerSpendingConditions, - LedgerTasks, - LedgerFeatures, - LedgerWatchdog, - LedgerKeysets, -): - backends: Mapping[Method, Mapping[Unit, LightningBackend]] = {} - keysets: Dict[str, MintKeyset] = {} - events = LedgerEventManager() - db: Database - db_read: DbReadHelper - db_write: DbWriteHelper - invoice_listener_tasks: List[asyncio.Task] = [] - watchdog_tasks: List[asyncio.Task] = [] - disable_melt: bool = False - pubkey: PublicKey - - def __init__( - self, - *, - db: Database, - seed: str, - derivation_path="", - amounts: Optional[List[int]] = None, - backends: Optional[Mapping[Method, Mapping[Unit, LightningBackend]]] = None, - seed_decryption_key: Optional[str] = None, - crud=LedgerCrudSqlite(), - ) -> None: - self.keysets: Dict[str, MintKeyset] = {} - self.backends: Mapping[Method, Mapping[Unit, LightningBackend]] = {} - self.events = LedgerEventManager() - self.db_read: DbReadHelper - self.locks: Dict[str, asyncio.Lock] = {} # holds multiprocessing locks - self.invoice_listener_tasks: List[asyncio.Task] = [] - self.watchdog_tasks: List[asyncio.Task] = [] - self.regular_tasks: List[asyncio.Task] = [] - - if not seed: - raise Exception("seed not set") - - # decrypt seed if seed_decryption_key is set - try: - self.seed = ( - AESCipher(seed_decryption_key).decrypt(seed) - if seed_decryption_key - else seed - ) - except Exception as e: - raise Exception( - f"Could not decrypt seed. Make sure that the seed is correct and the decryption key is set. {e}" - ) - self.derivation_path = derivation_path - - self.db = db - self.crud = crud - - if backends: - self.backends = backends - - if amounts: - self.amounts = amounts - else: - self.amounts = [2**n for n in range(settings.max_order)] - - self.pubkey = derive_pubkey(self.seed) - self.db_read = DbReadHelper(self.db, self.crud) - self.db_write = DbWriteHelper(self.db, self.crud, self.events, self.db_read) - - LedgerWatchdog.__init__(self) - - # ------- STARTUP ------- - - async def startup_ledger(self) -> None: - await self._startup_keysets() - await self._check_backends() - self.regular_tasks.append(asyncio.create_task(self._run_regular_tasks())) - self.invoice_listener_tasks = await self.dispatch_listeners() - if settings.mint_watchdog_enabled: - self.watchdog_tasks = await self.dispatch_watchdogs() - - async def _startup_keysets(self) -> None: - await self.init_keysets() - for derivation_path in settings.mint_derivation_path_list: - derivation_path = self.maybe_update_derivation_path(derivation_path) - await self.activate_keyset(derivation_path=derivation_path) - - async def _run_regular_tasks(self) -> None: - """ - Runs periodic ledger maintenance tasks forever. - This function intentionally loops forever and is designed to be scheduled as a Task. - """ - logger.info("Starting ledger regular tasks loop") - while True: - try: - await self._check_pending_proofs_and_melt_quotes() - await asyncio.sleep(settings.mint_regular_tasks_interval_seconds) - except Exception as e: - logger.error(f"Ledger regular task failed: {e}") - await asyncio.sleep(60) - - async def _check_backends(self) -> None: - for method in self.backends: - for unit in self.backends[method]: - logger.info( - f"Using {self.backends[method][unit].__class__.__name__} backend for" - f" method: '{method.name}' and unit: '{unit.name}'" - ) - status = await self.backends[method][unit].status() - if status.error_message: - logger.error( - "The backend for" - f" {self.backends[method][unit].__class__.__name__} isn't" - f" working properly: '{status.error_message}'" - ) - exit(1) - logger.info(f"Backend balance: {status.balance}") - - logger.info(f"Data dir: {settings.cashu_dir}") - - async def shutdown_ledger(self) -> None: - logger.debug("Disconnecting from database") - await self.db.engine.dispose() - logger.debug("Shutting down invoice listeners") - for task in self.invoice_listener_tasks: - task.cancel() - for task in self.watchdog_tasks: - task.cancel() - logger.debug("Shutting down regular tasks") - for task in self.regular_tasks: - task.cancel() - - async def _check_pending_proofs_and_melt_quotes(self): - """Startup routine that checks all pending melt quotes and either invalidates - their pending proofs for a successful melt or deletes them if the melt failed. - """ - # get all pending melt quotes - pending_melt_quotes = await self.crud.get_all_melt_quotes_from_pending_proofs( - db=self.db - ) - if not pending_melt_quotes: - return - logger.info(f"Checking {len(pending_melt_quotes)} pending melt quotes") - for quote in pending_melt_quotes: - quote = await self.get_melt_quote(quote_id=quote.quote) - logger.info(f"Melt quote {quote.quote} state: {quote.state}") - - # ------- ECASH ------- - - async def _generate_change_promises( - self, - fee_provided: int, - fee_paid: int, - outputs: Optional[List[BlindedMessage]], - melt_id: Optional[str] = None, - keyset: Optional[MintKeyset] = None, - ) -> List[BlindedSignature]: - """Generates a set of new promises (blinded signatures) from a set of blank outputs - (outputs with no or ignored amount) by looking at the difference between the Lightning - fee reserve provided by the wallet and the actual Lightning fee paid by the mint. - - If there is a positive difference, produces maximum `n_return_outputs` new outputs - with values close or equal to the fee difference. If the given number of `outputs` matches - the equation defined in NUT-08, we can be sure to return the overpaid fee perfectly. - Otherwise, a smaller amount will be returned. - - Args: - input_amount (int): Amount of the proofs provided by the client. - output_amount (int): Amount of the melt request to be paid. - output_fee_paid (int): Actually paid melt network fees. - outputs (Optional[List[BlindedMessage]]): Outputs to sign for returning the overpaid fees. - - Raises: - Exception: Output validation failed. - - Returns: - List[BlindedSignature]: Signatures on the outputs. - """ - # we make sure that the fee is positive - overpaid_fee = fee_provided - fee_paid - - if overpaid_fee <= 0 or outputs is None: - if overpaid_fee < 0: - logger.error( - f"Overpaid fee is negative ({overpaid_fee}). This should not happen." - ) - return [] - - logger.debug( - f"Lightning fee was: {fee_paid}. User provided: {fee_provided}. " - f"Returning difference: {overpaid_fee}." - ) - - return_amounts = amount_split(overpaid_fee) - - # We return at most as many outputs as were provided or as many as are - # required to pay back the overpaid fee. - n_return_outputs = min(len(outputs), len(return_amounts)) - - # we only need as many outputs as we have change to return - outputs = outputs[:n_return_outputs] - - # we sort the return_amounts in descending order so we only - # take the largest values in the next step - return_amounts_sorted = sorted(return_amounts, reverse=True) - # we need to imprint these amounts into the blanket outputs - for i in range(len(outputs)): - outputs[i].amount = return_amounts_sorted[i] # type: ignore - if not self._verify_no_duplicate_outputs(outputs): - raise TransactionError("duplicate promises.") - return_promises = await self._sign_blinded_messages(outputs) - # delete remaining unsigned blank outputs from db - if melt_id: - await self.crud.delete_blinded_messages_melt_id(melt_id=melt_id, db=self.db) - return return_promises - - # ------- TRANSACTIONS ------- - - async def mint_quote(self, quote_request: PostMintQuoteRequest) -> MintQuote: - """Creates a mint quote and stores it in the database. - - Args: - quote_request (PostMintQuoteRequest): Mint quote request. - - Raises: - Exception: Quote creation failed. - - Returns: - MintQuote: Mint quote object. - """ - logger.trace("called request_mint") - if not quote_request.amount > 0: - raise TransactionError("amount must be positive") - if ( - settings.mint_max_mint_bolt11_sat - and quote_request.amount > settings.mint_max_mint_bolt11_sat - ): - raise TransactionAmountExceedsLimitError( - f"Maximum mint amount is {settings.mint_max_mint_bolt11_sat} sat." - ) - if settings.mint_bolt11_disable_mint: - raise NotAllowedError("Minting with bolt11 is disabled.") - - unit, method = self._verify_and_get_unit_method( - quote_request.unit, Method.bolt11.name - ) - - if ( - quote_request.description - and not self.backends[method][unit].supports_description - ): - raise NotAllowedError("Backend does not support descriptions.") - - # Check maximum balance. - # TODO: Allow setting MINT_MAX_BALANCE per unit - if settings.mint_max_balance: - balance, fees_paid = await self.get_unit_balance_and_fees(unit, db=self.db) - if balance + quote_request.amount > settings.mint_max_balance: - raise NotAllowedError("Mint has reached maximum balance.") - - logger.trace(f"requesting invoice for {unit.str(quote_request.amount)}") - invoice_response: InvoiceResponse = await self.backends[method][ - unit - ].create_invoice( - amount=Amount(unit=unit, amount=quote_request.amount), - memo=quote_request.description, - ) - logger.trace( - f"got invoice {invoice_response.payment_request} with checking id" - f" {invoice_response.checking_id}" - ) - - if not (invoice_response.payment_request and invoice_response.checking_id): - raise LightningError("could not fetch bolt11 payment request from backend") - - # get invoice expiry time - invoice_obj = bolt11.decode(invoice_response.payment_request) - - # NOTE: we normalize the request to lowercase to avoid case sensitivity - # This works with Lightning but might not work with other methods - request = invoice_response.payment_request.lower() - - now = int(time.time()) - expiry = None - if settings.mint_quote_ttl is not None: - expiry = now + settings.mint_quote_ttl - elif invoice_obj.expiry is not None: - expiry = invoice_obj.date + invoice_obj.expiry - - quote = MintQuote( - quote=random_hash(), - method=method.name, - request=request, - checking_id=invoice_response.checking_id, - unit=quote_request.unit, - amount=quote_request.amount, - state=MintQuoteState.unpaid, - created_time=now, - expiry=expiry, - pubkey=quote_request.pubkey, - ) - await self.crud.store_mint_quote(quote=quote, db=self.db) - await self.events.submit(quote) - - return quote - - async def get_mint_quote(self, quote_id: str) -> MintQuote: - """Returns a mint quote. If the quote is not paid, checks with the backend if the associated request is paid. - - Args: - quote_id (str): ID of the mint quote. - - Raises: - Exception: Quote not found. - - Returns: - MintQuote: Mint quote object. - """ - quote = await self.crud.get_mint_quote(quote_id=quote_id, db=self.db) - if not quote: - raise Exception("quote not found") - - unit, method = self._verify_and_get_unit_method(quote.unit, quote.method) - - if quote.unpaid: - if not quote.checking_id: - raise CashuError("quote has no checking id") - - now = int(time.time()) - updated = await self.crud.try_update_mint_quote_last_checked( - quote_id=quote_id, - last_checked=now, - rate_limit=settings.mint_quote_backend_check_rate_limit, - db=self.db, - ) - if not updated: - logger.trace( - f"Lightning: checking invoice {quote.checking_id} skipped due to rate limit" - ) - return quote - quote.last_checked = now - - logger.trace(f"Lightning: checking invoice {quote.checking_id}") - status: PaymentStatus = await self.backends[method][ - unit - ].get_invoice_status(quote.checking_id) - if status.settled: - # change state to paid in one transaction, it could have been marked paid - # by the invoice listener in the mean time - async with self.db.get_connection( - lock_table="mint_quotes", - lock_select_statement="quote = :quote", - lock_parameters={"quote": quote_id}, - ) as conn: - quote = await self.crud.get_mint_quote( - quote_id=quote_id, db=self.db, conn=conn - ) - if not quote: - raise Exception("quote not found") - if quote.unpaid: - logger.trace(f"Setting quote {quote_id} as paid") - quote.state = MintQuoteState.paid - quote.paid_time = now - quote.last_checked = now - await self.crud.update_mint_quote( - quote=quote, db=self.db, conn=conn - ) - await self.events.submit(quote) - - return quote - - async def mint_quote_check( - self, payload: PostMintQuoteCheckRequest - ) -> List[MintQuote]: - """Batch check mint quotes. - - Args: - payload (PostMintQuoteCheckRequest): Request payload containing quote IDs. - - Returns: - List[MintQuote]: List of mint quotes matching the request. - """ - quotes: List[MintQuote] = [] - for quote_id in payload.quotes: - quote = await self.get_mint_quote(quote_id) - if not quote: - raise TransactionError(f"quote {quote_id} not found") - quotes.append(quote) - return quotes - - async def mint( - self, - *, - outputs: List[BlindedMessage], - quote_id: str, - signature: Optional[str] = None, - ) -> List[BlindedSignature]: - """Mints new coins if quote with `quote_id` was paid. Ingest blind messages `outputs` and returns blind signatures `promises`. - - Args: - outputs (List[BlindedMessage]): Outputs (blinded messages) to sign. - quote_id (str): Mint quote id. - witness (Optional[str], optional): NUT-19 witness signature. Defaults to None. - - Raises: - Exception: Validation of outputs failed. - Exception: Quote not paid. - Exception: Quote already issued. - Exception: Quote expired. - Exception: Amount to mint does not match quote amount. - - Returns: - List[BlindedSignature]: Signatures on the outputs. - """ - await self._verify_outputs(outputs) - sum_amount_outputs = sum([b.amount for b in outputs]) - # we already know from _verify_outputs that all outputs have the same unit because they have the same keyset - output_unit = self.keysets[outputs[0].id].unit - - quote = await self.get_mint_quote(quote_id) - if quote.pending: - raise TransactionError("Mint quote already pending.") - if quote.issued: - raise QuoteAlreadyIssuedError() - if quote.state != MintQuoteState.paid: - raise QuoteNotPaidError() - - previous_state = quote.state - await self.db_write._set_mint_quote_pending(quote_id=quote_id) - try: - if not quote.unit == output_unit.name: - raise TransactionError("quote unit does not match output unit") - if not quote.amount == sum_amount_outputs: - raise TransactionError("amount to mint does not match quote amount") - if quote.expiry and quote.expiry < int(time.time()): - raise TransactionError("quote expired") - if not self._verify_mint_quote_witness(quote, outputs, signature): - raise QuoteSignatureInvalidError() - await self._store_blinded_messages(outputs, mint_id=quote_id) - promises = await self._sign_blinded_messages(outputs) - except Exception as e: - await self.db_write._unset_mint_quote_pending( - quote_id=quote_id, state=previous_state - ) - raise e - await self.db_write._unset_mint_quote_pending( - quote_id=quote_id, state=MintQuoteState.issued - ) - - return promises - - async def mint_batch( - self, - payload: PostMintBatchRequest, - ) -> List[BlindedSignature]: - """Batch mint tokens. - - Args: - payload (PostMintBatchRequest): Request payload containing quote IDs, outputs, and signatures. - - Raises: - Exception: Validation of outputs failed. - Exception: Quote not paid. - Exception: Quote already issued. - Exception: Amount to mint does not match quote amount. - - Returns: - List[BlindedSignature]: Signatures on the outputs. - """ - if not payload.quotes: - raise TransactionError("batch must not be empty") - - if len(set(payload.quotes)) != len(payload.quotes): - raise BatchDuplicateQuotesError() - - if payload.signatures and len(payload.signatures) != len(payload.quotes): - raise TransactionError("signatures length must match quotes length") - - await self._verify_outputs(payload.outputs) - # we already know from _verify_outputs that all outputs have the same unit because they have the same keyset - output_unit = self.keysets[payload.outputs[0].id].unit - sum_amount_outputs = sum([b.amount for b in payload.outputs]) - - quotes: List[MintQuote] = [] - for quote_id in payload.quotes: - quote = await self.get_mint_quote(quote_id) - if not quote: - raise TransactionError(f"quote {quote_id} not found") - quotes.append(quote) - - # Check payment method consistency - methods = set([q.method for q in quotes]) - if len(methods) > 1: - raise TransactionError("all quotes must have the same method") - - # Check currency unit consistency - units = set([q.unit for q in quotes]) - if len(units) > 1: - raise TransactionError("all quotes must have the same unit") - if units.pop() != output_unit.name: - raise TransactionError("quote unit does not match output unit") - - for quote in quotes: - if quote.pending: - raise TransactionError("mint quote already pending") - if quote.issued: - raise QuoteAlreadyIssuedError() - if quote.state != MintQuoteState.paid: - raise QuoteNotPaidError() - - # Check amount balance - if payload.quote_amounts: - if len(payload.quote_amounts) != len(quotes): - raise TransactionError("quote_amounts length must match quotes length") - for i, quote in enumerate(quotes): - if ( - quote.method == "bolt11" - and payload.quote_amounts[i] != quote.amount - ): - raise TransactionError( - f"quote amount {payload.quote_amounts[i]} does not match quote {quote.quote} amount {quote.amount}" - ) - if payload.quote_amounts[i] > quote.amount: - raise TransactionError( - f"quote amount {payload.quote_amounts[i]} exceeds quote {quote.quote} amount {quote.amount}" - ) - - quote_amounts = payload.quote_amounts or [q.amount for q in quotes] - if "bolt11" in methods: - if sum(quote_amounts) != sum_amount_outputs: - raise TransactionError( - "amount to mint does not match quote amounts sum" - ) - else: - if sum_amount_outputs > sum(quote_amounts): - raise TransactionError("amount to mint exceeds quote amounts sum") - - # Signature validation (NUT-20) - for i, quote in enumerate(quotes): - sig = payload.signatures[i] if payload.signatures else None - - if not quote.pubkey and sig: - raise QuoteSignatureInvalidError() - - # The spec says msg_to_sign = quote_id[i] || B_0 || B_1 || ... || B_(n-1) - # This logic is inside self._verify_mint_quote_witness, let's reuse it. - if not self._verify_mint_quote_witness(quote, payload.outputs, sig): - raise QuoteSignatureInvalidError() - - # Set all quotes to pending - quotes = await self.db_write._set_mint_quotes_pending(quote_ids=payload.quotes) - - try: - for quote in quotes: - if quote.expiry and quote.expiry < int(time.time()): - raise TransactionError("quote expired") - - # Store all blinded messages - await self._store_blinded_messages( - payload.outputs, mint_id=payload.quotes[0] - ) - promises = await self._sign_blinded_messages(payload.outputs) - - # Set all quotes to issued - await self.db_write._unset_mint_quotes_pending( - quote_ids=payload.quotes, state=MintQuoteState.issued - ) - - except Exception as e: - # Revert pending status - await self.db_write._unset_mint_quotes_pending( - quote_ids=payload.quotes, state=MintQuoteState.paid - ) - raise e - - return promises - - def create_internal_melt_quote( - self, mint_quote: MintQuote, melt_quote: PostMeltQuoteRequest - ) -> PaymentQuoteResponse: - unit, method = self._verify_and_get_unit_method( - melt_quote.unit, Method.bolt11.name - ) - # NOTE: we normalize the request to lowercase to avoid case sensitivity - # This works with Lightning but might not work with other methods - request = melt_quote.request.lower() - - if not request == mint_quote.request: - raise TransactionError("bolt11 requests do not match") - if not mint_quote.unit == melt_quote.unit: - raise TransactionError("units do not match") - if not mint_quote.method == method.name: - raise TransactionError("methods do not match") - if mint_quote.paid: - raise TransactionError("mint quote already paid") - if mint_quote.issued: - raise TransactionError("mint quote already issued") - if not mint_quote.unpaid: - raise TransactionError("mint quote is not unpaid") - - if not mint_quote.checking_id: - raise TransactionError("mint quote has no checking id") - if melt_quote.is_mpp: - raise TransactionError("internal payments do not support mpp") - - internal_fee = Amount(unit, 0) # no internal fees - amount = Amount(unit, mint_quote.amount) - - payment_quote = PaymentQuoteResponse( - checking_id=mint_quote.checking_id, - amount=amount, - fee=internal_fee, - ) - logger.info( - f"Issuing internal melt quote: {request} ->" - f" {mint_quote.quote} ({amount.str()} + {internal_fee.str()} fees)" - ) - - return payment_quote - - def validate_payment_quote( - self, melt_quote: PostMeltQuoteRequest, payment_quote: PaymentQuoteResponse - ): - # payment quote validation - unit, method = self._verify_and_get_unit_method( - melt_quote.unit, Method.bolt11.name - ) - if not payment_quote.checking_id: - raise Exception("quote has no checking id") - # verify that payment quote amount is as expected - if ( - melt_quote.is_mpp - and melt_quote.mpp_amount != payment_quote.amount.to(Unit.msat).amount - ): - logger.error( - f"expected {payment_quote.amount.to(Unit.msat).amount} msat but got {melt_quote.mpp_amount}" - ) - raise TransactionError("quote amount not as requested") - # make sure the backend returned the amount with a correct unit - if not payment_quote.amount.unit == unit: - raise TransactionError("payment quote amount units do not match") - # fee from the backend must be in the same unit as the amount - if not payment_quote.fee.unit == unit: - raise TransactionError("payment quote fee units do not match") - - async def melt_quote( - self, melt_quote: PostMeltQuoteRequest - ) -> PostMeltQuoteResponse: - """Creates a melt quote and stores it in the database. - - Args: - melt_quote (PostMeltQuoteRequest): Melt quote request. - - Raises: - Exception: Quote invalid. - Exception: Quote already paid. - Exception: Quote already issued. - - Returns: - PostMeltQuoteResponse: Melt quote response. - """ - if settings.mint_bolt11_disable_melt: - raise NotAllowedError("Melting with bol11 is disabled.") - - unit, method = self._verify_and_get_unit_method( - melt_quote.unit, Method.bolt11.name - ) - - # NOTE: we normalize the request to lowercase to avoid case sensitivity - # This works with Lightning but might not work with other methods - request = melt_quote.request.lower() - - # check if there is a mint quote with the same payment request - # so that we would be able to handle the transaction internally - # and therefore respond with internal transaction fees (0 for now) - mint_quote = await self.crud.get_mint_quote(request=request, db=self.db) - if mint_quote and mint_quote.unit == melt_quote.unit: - # check if the melt quote is partial and error if it is. - # it's just not possible to handle this case - if melt_quote.is_mpp: - raise TransactionError("internal mpp not allowed.") - payment_quote = self.create_internal_melt_quote(mint_quote, melt_quote) - else: - # not internal - # verify that the backend supports mpp if the quote request has an amount - if melt_quote.is_mpp and not self.backends[method][unit].supports_mpp: - raise TransactionError("backend does not support mpp.") - # get payment quote by backend - payment_quote = await self.backends[method][unit].get_payment_quote( - melt_quote=melt_quote - ) - - self.validate_payment_quote(melt_quote, payment_quote) - - # verify that the amount of the proofs is not larger than the maximum allowed - if ( - settings.mint_max_melt_bolt11_sat - and payment_quote.amount.to(unit).amount > settings.mint_max_melt_bolt11_sat - ): - raise NotAllowedError( - f"Maximum melt amount is {settings.mint_max_melt_bolt11_sat} sat." - ) - - # We assume that the request is a bolt11 invoice, this works since we - # support only the bol11 method for now. - invoice_obj = bolt11.decode(melt_quote.request) - if not invoice_obj.amount_msat: - raise TransactionError("invoice has no amount.") - # we set the expiry of this quote to the expiry of the bolt11 invoice - now = int(time.time()) - expiry = None - if settings.melt_quote_ttl is not None: - expiry = now + settings.melt_quote_ttl - elif invoice_obj.expiry is not None: - expiry = invoice_obj.date + invoice_obj.expiry - - quote = MeltQuote( - quote=random_hash(), - method=method.name, - request=request, - checking_id=payment_quote.checking_id, - unit=unit.name, - amount=payment_quote.amount.to(unit).amount, - state=MeltQuoteState.unpaid, - fee_reserve=payment_quote.fee.to(unit).amount, - created_time=now, - expiry=expiry, - ) - await self.db_write._store_melt_quote(quote) - await self.events.submit(quote) - - return PostMeltQuoteResponse( - quote=quote.quote, - amount=quote.amount, - unit=quote.unit, - request=quote.request, - fee_reserve=quote.fee_reserve, - paid=quote.paid, # deprecated - state=quote.state.value, - expiry=quote.expiry, - ) - - async def get_melt_quote(self, quote_id: str, rollback_unknown=False) -> MeltQuote: - """Returns a melt quote. - - If the melt quote is pending, checks status of the payment with the backend. - - If settled, sets the quote as paid and invalidates pending proofs (commit). - - If failed, sets the quote as unpaid and unsets pending proofs (rollback). - - If rollback_unknown is set, do the same for unknown states as for failed states. - - Args: - quote_id (str): ID of the melt quote. - rollback_unknown (bool, optional): Rollback unknown payment states to unpaid. Defaults to False. - - Raises: - Exception: Quote not found. - - Returns: - MeltQuote: Melt quote object. - """ - melt_quote = await self.crud.get_melt_quote(quote_id=quote_id, db=self.db) - if not melt_quote: - raise Exception("quote not found") - - unit, method = self._verify_and_get_unit_method( - melt_quote.unit, melt_quote.method - ) - - # we only check the state with the backend if there is no associated internal - # mint quote for this melt quote - is_internal = await self.crud.get_mint_quote( - request=melt_quote.request, db=self.db - ) - - if melt_quote.pending and not is_internal: - logger.debug( - "Lightning: checking outgoing Lightning payment" - f" {melt_quote.checking_id}" - ) - status: PaymentStatus = await self.backends[method][ - unit - ].get_payment_status(melt_quote.checking_id) - logger.debug(f"State: {status.result}") - if status.settled: - logger.debug(f"Setting quote {quote_id} as paid") - melt_quote.state = MeltQuoteState.paid - if status.fee: - melt_quote.fee_paid = status.fee.to(unit).amount - if status.preimage: - melt_quote.payment_preimage = status.preimage - melt_quote.paid_time = int(time.time()) - pending_proofs = await self.crud.get_pending_proofs_for_quote( - quote_id=quote_id, db=self.db - ) - - # change to compensate wallet for overpaid fees - melt_outputs = await self.crud.get_blinded_messages_melt_id( - melt_id=quote_id, db=self.db - ) - if melt_outputs: - total_provided = sum_proofs(pending_proofs) - input_fees = self.get_fees_for_proofs(pending_proofs) - fee_reserve_provided = ( - total_provided - melt_quote.amount - input_fees - ) - return_promises = await self._generate_change_promises( - fee_provided=fee_reserve_provided, - fee_paid=melt_quote.fee_paid, - outputs=melt_outputs, - melt_id=quote_id, - keyset=self.keysets[melt_outputs[0].id], - ) - melt_quote.change = return_promises - - # Calculate fees - proofs_by_keyset: Dict[str, List[Proof]] = {} - for p in pending_proofs: - proofs_by_keyset.setdefault(p.id, []).append(p) - keyset_fees = {} - for keyset_id, keyset_proofs in proofs_by_keyset.items(): - keyset_fees[keyset_id] = self.get_fees_for_proofs(keyset_proofs) - - melt_quote = ( - await self.db_write.set_melt_quote_paid_and_invalidate_proofs( - quote=melt_quote, - proofs=pending_proofs, - keysets=self.keysets, - keyset_fees=keyset_fees, - ) - ) - - if status.failed or (rollback_unknown and status.unknown): - logger.debug(f"Setting quote {quote_id} as unpaid") - pending_proofs = await self.crud.get_pending_proofs_for_quote( - quote_id=quote_id, db=self.db - ) - melt_quote = await self.db_write.unset_melt_quote_pending_and_proofs( - quote=melt_quote, - proofs=pending_proofs, - keysets=self.keysets, - state=MeltQuoteState.unpaid, - ) - - return melt_quote - - 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. - Exception: Melt quote already issued. - - Returns: - MeltQuote: Settled melt quote. - """ - # first we check if there is a mint quote with the same payment request - # so that we can handle the transaction internally without the backend - mint_quote = await self.crud.get_mint_quote( - request=melt_quote.request, db=self.db - ) - if not mint_quote: - return melt_quote - - # settle externally if units are different - if mint_quote.unit != melt_quote.unit: - return melt_quote - - # we settle the transaction internally - if melt_quote.paid: - raise TransactionError("melt quote already paid") - - # verify amounts from bolt11 invoice - bolt11_request = melt_quote.request - invoice_obj = bolt11.decode(bolt11_request) - - if not invoice_obj.amount_msat: - raise TransactionError("invoice has no amount.") - if not mint_quote.amount == melt_quote.amount: - raise TransactionError("amounts do not match") - if not bolt11_request == mint_quote.request: - raise TransactionError("bolt11 requests do not match") - if not mint_quote.method == melt_quote.method: - raise TransactionError("methods do not match") - - if mint_quote.paid: - raise TransactionError("mint quote already paid") - if mint_quote.issued: - raise TransactionError("mint quote already issued") - - if mint_quote.state != MintQuoteState.unpaid: - raise TransactionError("mint quote is not unpaid") - - logger.info( - f"Settling bolt11 payment internally: {melt_quote.quote} ->" - f" {mint_quote.quote} ({melt_quote.amount} {melt_quote.unit})" - ) - - melt_quote.fee_paid = 0 # no internal fees - melt_quote.state = MeltQuoteState.paid - melt_quote.paid_time = int(time.time()) - - mint_quote.state = MintQuoteState.paid - mint_quote.paid_time = melt_quote.paid_time - - async with self.db.get_connection() as conn: - await self.crud.update_melt_quote(quote=melt_quote, db=self.db, conn=conn) - await self.crud.update_mint_quote(quote=mint_quote, db=self.db, conn=conn) - - await self.events.submit(melt_quote) - await self.events.submit(mint_quote) - - return melt_quote - - async def async_melt( - self, - *, - proofs: List[Proof], - quote: str, - outputs: Optional[List[BlindedMessage]] = None, - ) -> PostMeltQuoteResponse: - """Invalidates proofs and pays a Lightning invoice asynchronously. - - Args: - proofs (List[Proof]): Proofs provided for paying the Lightning invoice - quote (str): ID of the melt quote. - outputs (Optional[List[BlindedMessage]]): Blank outputs for returning overpaid fees to the wallet. - - Returns: - PostMeltQuoteResponse: Melt quote response with pending state. - """ - # get melt quote - melt_quote = await self.get_melt_quote(quote_id=quote) - if not melt_quote: - raise TransactionError("melt quote not found") - if not melt_quote.unpaid: - raise TransactionError(f"melt quote is not unpaid: {melt_quote.state}") - - # Launch actual melt task - asyncio.create_task(self.melt(proofs=proofs, quote=quote, outputs=outputs)) - - melt_quote.state = MeltQuoteState.pending - return PostMeltQuoteResponse.from_melt_quote(melt_quote) - - async def melt( - self, - *, - proofs: List[Proof], - quote: str, - outputs: Optional[List[BlindedMessage]] = None, - ) -> PostMeltQuoteResponse: - """Invalidates proofs and pays a Lightning invoice. - - Args: - proofs (List[Proof]): Proofs provided for paying the Lightning invoice - quote (str): ID of the melt quote. - outputs (Optional[List[BlindedMessage]]): Blank outputs for returning overpaid fees to the wallet. - - Raises: - e: Lightning payment unsuccessful - - Returns: - PostMeltQuoteResponse: Melt quote response. - """ - # make sure we're allowed to melt - if self.disable_melt and settings.mint_disable_melt_on_error: - raise NotAllowedError("Melt is disabled. Please contact the operator.") - - # get melt quote and check if it was already paid - melt_quote = await self.get_melt_quote(quote_id=quote) - if not melt_quote.unpaid: - raise TransactionError(f"melt quote is not unpaid: {melt_quote.state}") - - unit, method = self._verify_and_get_unit_method( - melt_quote.unit, melt_quote.method - ) - - # make sure that the proofs are in the same unit as the quote - self._verify_proofs_unit(proofs, expected_unit=unit) - - # make sure that the outputs (for fee return) are in the same unit as the quote - if outputs: - # _verify_outputs checks if all outputs have the same unit - await self._verify_outputs( - outputs, skip_amount_check=True, expected_unit=unit - ) - - # verify SIG_ALL signatures - message_to_sign = ( - "".join([p.secret for p in proofs] + [o.B_ for o in outputs or []]) + quote - ) - self._verify_sigall_spending_conditions(proofs, outputs or [], message_to_sign) - - # verify that the amount of the input proofs is equal to the amount of the quote - total_provided = sum_proofs(proofs) - input_fees = self.get_fees_for_proofs(proofs) - total_needed = melt_quote.amount + melt_quote.fee_reserve + input_fees - # we need the fees specifically for lightning to return the overpaid fees - fee_reserve_provided = total_provided - melt_quote.amount - input_fees - if total_provided < total_needed: - raise TransactionError( - f"not enough inputs provided for melt. Provided: {total_provided}, needed: {total_needed}" - ) - if fee_reserve_provided < melt_quote.fee_reserve: - raise TransactionError( - f"not enough fee reserve provided for melt. Provided fee reserve: {fee_reserve_provided}, needed: {melt_quote.fee_reserve}" - ) - - # verify inputs and their spending conditions - # note, we do not verify outputs here, as they are only used for returning overpaid fees - # We must have called _verify_outputs here already! (see above) - await self.verify_inputs_and_outputs(proofs=proofs) - - # set quote and proofs to pending to avoid race conditions - melt_quote = await self.db_write.verify_and_set_melt_quote_pending( - quote=melt_quote, proofs=proofs, keysets=self.keysets - ) - - # store the change outputs - if outputs: - await self._store_blinded_messages(outputs, melt_id=melt_quote.quote) - - # if the melt corresponds to an internal mint, mark both as paid - 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}") - try: - payment = await self.backends[method][unit].pay_invoice( - melt_quote, melt_quote.fee_reserve * 1000 - ) - logger.debug( - f"Melt – Result: {payment.result.name}: preimage: {payment.preimage}," - f" fee: {payment.fee.str() if payment.fee is not None else 'None'}" - ) - if ( - payment.checking_id - and payment.checking_id != melt_quote.checking_id - ): - logger.warning( - f"pay_invoice returned different checking_id: {payment.checking_id} than melt quote: {melt_quote.checking_id}. Will use it for potentially checking payment status later." - ) - melt_quote.checking_id = payment.checking_id - await self.crud.update_melt_quote(quote=melt_quote, db=self.db) - except Exception as e: - logger.error(f"Exception during pay_invoice: {e}") - payment = PaymentResponse( - result=PaymentResult.UNKNOWN, - error_message=str(e), - ) - - match payment.result: - case PaymentResult.FAILED | PaymentResult.UNKNOWN: - # explicitly check payment status for failed or unknown payment states - checking_id = payment.checking_id or melt_quote.checking_id - logger.debug( - f"Payment state is {payment.result.name}.{' Error: ' + payment.error_message + '.' if payment.error_message else ''} Checking status for {checking_id}." - ) - try: - status = await self.backends[method][unit].get_payment_status( - checking_id - ) - except Exception as e: - # Something went wrong. We might have lost connection to the backend. Keep transaction pending and return. - logger.error( - f"Lightning backend error: could not check payment status. Proofs for melt quote {melt_quote.quote} are stuck as PENDING.\nError: {e}" - ) - self.disable_melt = True - return PostMeltQuoteResponse.from_melt_quote(melt_quote) - - match status.result: - case PaymentResult.FAILED | PaymentResult.UNKNOWN: - # Everything as expected. Payment AND a status check both agree on a failure. We roll back the transaction. - await self.db_write.unset_melt_quote_pending_and_proofs( - quote=melt_quote, - proofs=proofs, - keysets=self.keysets, - state=MeltQuoteState.unpaid, - ) - if status.error_message: - logger.error( - f"Status check error: {status.error_message}" - ) - raise LightningPaymentFailedError( - f"Lightning payment failed{': ' + payment.error_message if payment.error_message else ''}." - ) - case _: - # Something went wrong with our implementation or the backend. Status check returned different result than payment. Keep transaction pending and return. - logger.error( - f"Payment state was {payment.result} but additional payment state check returned {status.result.name}. Proofs for melt quote {melt_quote.quote} are stuck as PENDING." - ) - self.disable_melt = True - return PostMeltQuoteResponse.from_melt_quote(melt_quote) - - case PaymentResult.SETTLED: - # payment successful - if payment.fee: - melt_quote.fee_paid = payment.fee.to( - to_unit=unit, round="up" - ).amount - if payment.preimage: - melt_quote.payment_preimage = payment.preimage - # set quote as paid - melt_quote.state = MeltQuoteState.paid - melt_quote.paid_time = int(time.time()) - # NOTE: This is the only branch for a successful payment - - case PaymentResult.PENDING | _: - logger.debug( - f"Lightning payment is {payment.result.name}: {payment.checking_id}" - ) - return PostMeltQuoteResponse.from_melt_quote(melt_quote) - - # melt was successful (either internal or via backend), invalidate proofs - # prepare change to compensate wallet for overpaid fees - return_promises: List[BlindedSignature] = [] - if outputs: - return_promises = await self._generate_change_promises( - fee_provided=fee_reserve_provided, - fee_paid=melt_quote.fee_paid, - outputs=outputs, - melt_id=melt_quote.quote, - keyset=self.keysets[outputs[0].id], - ) - - melt_quote.change = return_promises - - # Calculate fees - proofs_by_keyset: Dict[str, List[Proof]] = {} - for p in proofs: - proofs_by_keyset.setdefault(p.id, []).append(p) - keyset_fees = {} - for keyset_id, keyset_proofs in proofs_by_keyset.items(): - keyset_fees[keyset_id] = self.get_fees_for_proofs(keyset_proofs) - - melt_quote = await self.db_write.set_melt_quote_paid_and_invalidate_proofs( - quote=melt_quote, - proofs=proofs, - keysets=self.keysets, - keyset_fees=keyset_fees, - ) - - return PostMeltQuoteResponse.from_melt_quote(melt_quote) - - async def swap( - self, - *, - proofs: List[Proof], - outputs: List[BlindedMessage], - keyset: Optional[MintKeyset] = None, - ): - """Consumes proofs and prepares new promises based on the amount swap. Used for swapping tokens - Before sending or for redeeming tokens for new ones that have been received by another wallet. - - Args: - proofs (List[Proof]): Proofs to be invalidated for the swap. - outputs (List[BlindedMessage]): New outputs that should be signed in return. - keyset (Optional[MintKeyset], optional): Keyset to use. Uses default keyset if not given. Defaults to None. - - Raises: - Exception: Validation of proofs or outputs failed - - Returns: - List[BlindedSignature]: New promises (signatures) for the outputs. - """ - logger.trace("swap called") - # verify spending inputs, outputs, and spending conditions - await self.verify_inputs_and_outputs(proofs=proofs, outputs=outputs) - await self.db_write._verify_spent_proofs_and_set_pending( - proofs, keysets=self.keysets - ) - try: - Ys = [p.Y for p in proofs] - lock_parameters = {f"y{i}": y for i, y in enumerate(Ys)} - ys_list = ", ".join(f":y{i}" for i in range(len(Ys))) - async with self.db.get_connection( - lock_table="proofs_pending", - lock_select_statement=f"y IN ({ys_list})", - lock_parameters=lock_parameters, - ) as conn: - await self._store_blinded_messages(outputs, keyset=keyset, conn=conn) - - # Calculate fees - proofs_by_keyset: Dict[str, List[Proof]] = {} - for p in proofs: - proofs_by_keyset.setdefault(p.id, []).append(p) - keyset_fees = {} - for keyset_id, keyset_proofs in proofs_by_keyset.items(): - keyset_fees[keyset_id] = self.get_fees_for_proofs(keyset_proofs) - - await self.db_write.invalidate_proofs( - proofs=proofs, - keysets=self.keysets, - keyset_fees=keyset_fees, - conn=conn, - ) - promises = await self._sign_blinded_messages(outputs, conn) - except Exception as e: - logger.trace(f"swap failed: {e}") - raise e - finally: - # delete proofs from pending list - await self.db_write._unset_proofs_pending(proofs, keysets=self.keysets) - - logger.trace("swap successful") - return promises - - async def restore( - self, outputs: List[BlindedMessage] - ) -> Tuple[List[BlindedMessage], List[BlindedSignature]]: - signatures: List[BlindedSignature] = [] - return_outputs: List[BlindedMessage] = [] - async with self.db.get_connection() as conn: - for output in outputs: - logger.trace(f"looking for promise: {output}") - promise = await self.crud.get_blind_signature( - b_=output.B_, db=self.db, conn=conn - ) - if promise is not None: - signatures.append(promise) - return_outputs.append(output) - logger.trace(f"promise found: {promise}") - return return_outputs, signatures - - # ------- BLIND SIGNATURES ------- - - async def _store_blinded_messages( - self, - outputs: List[BlindedMessage], - keyset: Optional[MintKeyset] = None, - mint_id: Optional[str] = None, - melt_id: Optional[str] = None, - swap_id: Optional[str] = None, - conn: Optional[Connection] = None, - ) -> None: - """Stores a blinded message in the database. - - Args: - outputs (List[BlindedMessage]): Blinded messages to store. - keyset (Optional[MintKeyset], optional): Keyset to use. Uses default keyset if not given. Defaults to None. - conn: (Optional[Connection], optional): Database connection to reuse. Will create a new one if not given. Defaults to None. - """ - async with self.db.get_connection(conn) as conn: - for i, output in enumerate(outputs): - keyset = keyset or self.keysets[output.id] - if output.id not in self.keysets: - raise TransactionError(f"keyset {output.id} not found") - if output.id != keyset.id: - raise TransactionError("keyset id does not match output id") - if not keyset.active: - raise TransactionError("keyset is not active") - logger.trace(f"Storing blinded message with keyset {keyset.id}.") - await self.crud.store_blinded_message( - id=keyset.id, - amount=output.amount, - b_=output.B_, - mint_id=mint_id, - melt_id=melt_id, - swap_id=swap_id, - order_index=i, - db=self.db, - conn=conn, - ) - logger.trace(f"Stored blinded message for {output.amount}") - - async def _sign_blinded_messages( - self, - outputs: List[BlindedMessage], - conn: Optional[Connection] = None, - ) -> list[BlindedSignature]: - """Generates a promises (Blind signatures) for given amount and returns a pair (amount, C'). - - Important: When a promises is once created it should be considered issued to the user since the user - will always be able to restore promises later through the backup restore endpoint. That means that additional - checks in the code that might decide not to return these promises should be avoided once this function is - called. Only call this function if the transaction is fully validated! - - Args: - B_s (List[BlindedMessage]): Blinded secret (point on curve) - keyset (Optional[MintKeyset], optional): Which keyset to use. Private keys will be taken from this keyset. - If not given will use the keyset of the first output. Defaults to None. - conn: (Optional[Connection], optional): Database connection to reuse. Will create a new one if not given. Defaults to None. - Returns: - list[BlindedSignature]: Generated BlindedSignatures. - """ - promises: List[ - Tuple[str, PublicKey, int, PublicKey, PrivateKey, PrivateKey] - ] = [] - for output in outputs: - B_ = PublicKey(bytes.fromhex(output.B_)) - if output.id not in self.keysets: - raise TransactionError(f"keyset {output.id} not found") - keyset = self.keysets[output.id] - if output.id != keyset.id: - raise TransactionError("keyset id does not match output id") - if not keyset.active: - raise TransactionError("keyset is not active") - keyset_id = output.id - logger.trace(f"Generating promise with keyset {keyset_id}.") - private_key_amount = keyset.private_keys[output.amount] - C_, e, s = b_dhke.step2_bob(B_, private_key_amount) - promises.append((keyset_id, B_, output.amount, C_, e, s)) - - keyset = keyset or self.keyset - - signatures = [] - async with self.db.get_connection(conn) as conn: - for promise in promises: - keyset_id, B_, amount, C_, e, s = promise - logger.trace(f"crud: _generate_promise storing promise for {amount}") - await self.crud.update_blinded_message_signature( - amount=amount, - b_=B_.format().hex(), - c_=C_.format().hex(), - e=e.to_hex(), - s=s.to_hex(), - db=self.db, - conn=conn, - ) - logger.trace(f"crud: _generate_promise stored promise for {amount}") - signature = BlindedSignature( - id=keyset_id, - amount=amount, - C_=C_.format().hex(), - dleq=DLEQ(e=e.to_hex(), s=s.to_hex()), - ) - signatures.append(signature) - - # bump keyset balance - await self.crud.bump_keyset_balance( - db=self.db, keyset=self.keysets[keyset_id], amount=amount, conn=conn - ) - - return signatures diff --git a/cashu/mint/ledger/__init__.py b/cashu/mint/ledger/__init__.py new file mode 100644 index 000000000..6759bae7b --- /dev/null +++ b/cashu/mint/ledger/__init__.py @@ -0,0 +1,3 @@ +from .ledger import Ledger + +__all__ = ["Ledger"] diff --git a/cashu/mint/ledger/blind_signatures.py b/cashu/mint/ledger/blind_signatures.py new file mode 100644 index 000000000..489e84151 --- /dev/null +++ b/cashu/mint/ledger/blind_signatures.py @@ -0,0 +1,189 @@ +from typing import List, Optional, Tuple + +from loguru import logger + +from ...core.base import DLEQ, BlindedMessage, BlindedSignature, MintKeyset +from ...core.crypto import b_dhke +from ...core.crypto.secp import PrivateKey, PublicKey +from ...core.db import Connection +from ...core.errors import TransactionError +from ...core.split import amount_split +from ..verification import LedgerVerification + + +class LedgerBlindSignatures(LedgerVerification): + async def _generate_change_promises( + self, + fee_provided: int, + fee_paid: int, + outputs: Optional[List[BlindedMessage]], + melt_id: Optional[str] = None, + keyset: Optional[MintKeyset] = None, + ) -> List[BlindedSignature]: + """Generates a set of new promises (blinded signatures) from a set of blank outputs + (outputs with no or ignored amount) by looking at the difference between the Lightning + fee reserve provided by the wallet and the actual Lightning fee paid by the mint. + + If there is a positive difference, produces maximum `n_return_outputs` new outputs + with values close or equal to the fee difference. If the given number of `outputs` matches + the equation defined in NUT-08, we can be sure to return the overpaid fee perfectly. + Otherwise, a smaller amount will be returned. + + Args: + input_amount (int): Amount of the proofs provided by the client. + output_amount (int): Amount of the melt request to be paid. + output_fee_paid (int): Actually paid melt network fees. + outputs (Optional[List[BlindedMessage]]): Outputs to sign for returning the overpaid fees. + + Raises: + Exception: Output validation failed. + + Returns: + List[BlindedSignature]: Signatures on the outputs. + """ + # we make sure that the fee is positive + overpaid_fee = fee_provided - fee_paid + + if overpaid_fee <= 0 or outputs is None: + if overpaid_fee < 0: + logger.error( + f"Overpaid fee is negative ({overpaid_fee}). This should not happen." + ) + return [] + + logger.debug( + f"Lightning fee was: {fee_paid}. User provided: {fee_provided}. " + f"Returning difference: {overpaid_fee}." + ) + + return_amounts = amount_split(overpaid_fee) + + # We return at most as many outputs as were provided or as many as are + # required to pay back the overpaid fee. + n_return_outputs = min(len(outputs), len(return_amounts)) + + # we only need as many outputs as we have change to return + outputs = outputs[:n_return_outputs] + + # we sort the return_amounts in descending order so we only + # take the largest values in the next step + return_amounts_sorted = sorted(return_amounts, reverse=True) + # we need to imprint these amounts into the blanket outputs + for i in range(len(outputs)): + outputs[i].amount = return_amounts_sorted[i] # type: ignore + if not self._verify_no_duplicate_outputs(outputs): + raise TransactionError("duplicate promises.") + return_promises = await self._sign_blinded_messages(outputs) + # delete remaining unsigned blank outputs from db + if melt_id: + await self.crud.delete_blinded_messages_melt_id(melt_id=melt_id, db=self.db) + return return_promises + + async def _store_blinded_messages( + self, + outputs: List[BlindedMessage], + keyset: Optional[MintKeyset] = None, + mint_id: Optional[str] = None, + melt_id: Optional[str] = None, + swap_id: Optional[str] = None, + conn: Optional[Connection] = None, + ) -> None: + """Stores a blinded message in the database. + + Args: + outputs (List[BlindedMessage]): Blinded messages to store. + keyset (Optional[MintKeyset], optional): Keyset to use. Uses default keyset if not given. Defaults to None. + conn: (Optional[Connection], optional): Database connection to reuse. Will create a new one if not given. Defaults to None. + """ + async with self.db.get_connection(conn) as conn: + for i, output in enumerate(outputs): + keyset = keyset or self.keysets[output.id] + if output.id not in self.keysets: + raise TransactionError(f"keyset {output.id} not found") + if output.id != keyset.id: + raise TransactionError("keyset id does not match output id") + if not keyset.active: + raise TransactionError("keyset is not active") + logger.trace(f"Storing blinded message with keyset {keyset.id}.") + await self.crud.store_blinded_message( + id=keyset.id, + amount=output.amount, + b_=output.B_, + mint_id=mint_id, + melt_id=melt_id, + swap_id=swap_id, + order_index=i, + db=self.db, + conn=conn, + ) + logger.trace(f"Stored blinded message for {output.amount}") + + async def _sign_blinded_messages( + self, + outputs: List[BlindedMessage], + conn: Optional[Connection] = None, + ) -> list[BlindedSignature]: + """Generates a promises (Blind signatures) for given amount and returns a pair (amount, C'). + + Important: When a promises is once created it should be considered issued to the user since the user + will always be able to restore promises later through the backup restore endpoint. That means that additional + checks in the code that might decide not to return these promises should be avoided once this function is + called. Only call this function if the transaction is fully validated! + + Args: + B_s (List[BlindedMessage]): Blinded secret (point on curve) + keyset (Optional[MintKeyset], optional): Which keyset to use. Private keys will be taken from this keyset. + If not given will use the keyset of the first output. Defaults to None. + conn: (Optional[Connection], optional): Database connection to reuse. Will create a new one if not given. Defaults to None. + Returns: + list[BlindedSignature]: Generated BlindedSignatures. + """ + promises: List[ + Tuple[str, PublicKey, int, PublicKey, PrivateKey, PrivateKey] + ] = [] + for output in outputs: + B_ = PublicKey(bytes.fromhex(output.B_)) + if output.id not in self.keysets: + raise TransactionError(f"keyset {output.id} not found") + keyset = self.keysets[output.id] + if output.id != keyset.id: + raise TransactionError("keyset id does not match output id") + if not keyset.active: + raise TransactionError("keyset is not active") + keyset_id = output.id + logger.trace(f"Generating promise with keyset {keyset_id}.") + private_key_amount = keyset.private_keys[output.amount] + C_, e, s = b_dhke.step2_bob(B_, private_key_amount) + promises.append((keyset_id, B_, output.amount, C_, e, s)) + + keyset = keyset or self.keyset + + signatures = [] + async with self.db.get_connection(conn) as conn: + for promise in promises: + keyset_id, B_, amount, C_, e, s = promise + logger.trace(f"crud: _generate_promise storing promise for {amount}") + await self.crud.update_blinded_message_signature( + amount=amount, + b_=B_.format().hex(), + c_=C_.format().hex(), + e=e.to_hex(), + s=s.to_hex(), + db=self.db, + conn=conn, + ) + logger.trace(f"crud: _generate_promise stored promise for {amount}") + signature = BlindedSignature( + id=keyset_id, + amount=amount, + C_=C_.format().hex(), + dleq=DLEQ(e=e.to_hex(), s=s.to_hex()), + ) + signatures.append(signature) + + # bump keyset balance + await self.crud.bump_keyset_balance( + db=self.db, keyset=self.keysets[keyset_id], amount=amount, conn=conn + ) + + return signatures diff --git a/cashu/mint/ledger/ledger.py b/cashu/mint/ledger/ledger.py new file mode 100644 index 000000000..b5436cd4a --- /dev/null +++ b/cashu/mint/ledger/ledger.py @@ -0,0 +1,177 @@ +import asyncio +from typing import Dict, List, Mapping, Optional + +from loguru import logger + +from ...core.base import ( + Method, + MintKeyset, + Unit, +) +from ...core.crypto.aes import AESCipher +from ...core.crypto.keys import derive_pubkey +from ...core.crypto.secp import PublicKey +from ...core.db import Database +from ...core.settings import settings +from ...lightning.base import LightningBackend +from ..crud import LedgerCrudSqlite +from ..db.read import DbReadHelper +from ..db.write import DbWriteHelper +from ..events.events import LedgerEventManager +from ..features import LedgerFeatures +from ..keysets import LedgerKeysets +from ..tasks import LedgerTasks +from ..watchdog import LedgerWatchdog +from .melt import LedgerMelt +from .mint import LedgerMint +from .swap import LedgerSwap + + +class Ledger( + LedgerMint, + LedgerMelt, + LedgerSwap, + LedgerTasks, + LedgerFeatures, + LedgerWatchdog, + LedgerKeysets, +): + backends: Mapping[Method, Mapping[Unit, LightningBackend]] = {} + keysets: Dict[str, MintKeyset] = {} + events = LedgerEventManager() + db: Database + db_read: DbReadHelper + db_write: DbWriteHelper + invoice_listener_tasks: List[asyncio.Task] = [] + watchdog_tasks: List[asyncio.Task] = [] + disable_melt: bool = False + pubkey: PublicKey + + def __init__( + self, + *, + db: Database, + seed: str, + derivation_path="", + amounts: Optional[List[int]] = None, + backends: Optional[Mapping[Method, Mapping[Unit, LightningBackend]]] = None, + seed_decryption_key: Optional[str] = None, + crud=LedgerCrudSqlite(), + ) -> None: + self.keysets: Dict[str, MintKeyset] = {} + self.backends: Mapping[Method, Mapping[Unit, LightningBackend]] = {} + self.events = LedgerEventManager() + self.db_read: DbReadHelper + self.locks: Dict[str, asyncio.Lock] = {} # holds multiprocessing locks + self.invoice_listener_tasks: List[asyncio.Task] = [] + self.watchdog_tasks: List[asyncio.Task] = [] + self.regular_tasks: List[asyncio.Task] = [] + + if not seed: + raise Exception("seed not set") + + # decrypt seed if seed_decryption_key is set + try: + self.seed = ( + AESCipher(seed_decryption_key).decrypt(seed) + if seed_decryption_key + else seed + ) + except Exception as e: + raise Exception( + f"Could not decrypt seed. Make sure that the seed is correct and the decryption key is set. {e}" + ) + self.derivation_path = derivation_path + + self.db = db + self.crud = crud + + if backends: + self.backends = backends + + if amounts: + self.amounts = amounts + else: + self.amounts = [2**n for n in range(settings.max_order)] + + self.pubkey = derive_pubkey(self.seed) + self.db_read = DbReadHelper(self.db, self.crud) + self.db_write = DbWriteHelper(self.db, self.crud, self.events, self.db_read) + + LedgerWatchdog.__init__(self) + + # ------- STARTUP ------- + + async def startup_ledger(self) -> None: + await self._startup_keysets() + await self._check_backends() + self.regular_tasks.append(asyncio.create_task(self._run_regular_tasks())) + self.invoice_listener_tasks = await self.dispatch_listeners() + if settings.mint_watchdog_enabled: + self.watchdog_tasks = await self.dispatch_watchdogs() + + async def _startup_keysets(self) -> None: + await self.init_keysets() + for derivation_path in settings.mint_derivation_path_list: + derivation_path = self.maybe_update_derivation_path(derivation_path) + await self.activate_keyset(derivation_path=derivation_path) + + async def _run_regular_tasks(self) -> None: + """ + Runs periodic ledger maintenance tasks forever. + This function intentionally loops forever and is designed to be scheduled as a Task. + """ + logger.info("Starting ledger regular tasks loop") + while True: + try: + await self._check_pending_proofs_and_melt_quotes() + await asyncio.sleep(settings.mint_regular_tasks_interval_seconds) + except Exception as e: + logger.error(f"Ledger regular task failed: {e}") + await asyncio.sleep(60) + + async def _check_backends(self) -> None: + for method in self.backends: + for unit in self.backends[method]: + logger.info( + f"Using {self.backends[method][unit].__class__.__name__} backend for" + f" method: '{method.name}' and unit: '{unit.name}'" + ) + status = await self.backends[method][unit].status() + if status.error_message: + logger.error( + "The backend for" + f" {self.backends[method][unit].__class__.__name__} isn't" + f" working properly: '{status.error_message}'" + ) + exit(1) + logger.info(f"Backend balance: {status.balance}") + + logger.info(f"Data dir: {settings.cashu_dir}") + + async def shutdown_ledger(self) -> None: + logger.debug("Disconnecting from database") + await self.db.engine.dispose() + logger.debug("Shutting down invoice listeners") + for task in self.invoice_listener_tasks: + task.cancel() + for task in self.watchdog_tasks: + task.cancel() + logger.debug("Shutting down regular tasks") + for task in self.regular_tasks: + task.cancel() + + async def _check_pending_proofs_and_melt_quotes(self): + """Startup routine that checks all pending melt quotes and either invalidates + their pending proofs for a successful melt or deletes them if the melt failed. + """ + # get all pending melt quotes + pending_melt_quotes = await self.crud.get_all_melt_quotes_from_pending_proofs( + db=self.db + ) + if not pending_melt_quotes: + return + logger.info(f"Checking {len(pending_melt_quotes)} pending melt quotes") + for quote in pending_melt_quotes: + quote = await self.get_melt_quote(quote_id=quote.quote) + logger.info(f"Melt quote {quote.quote} state: {quote.state}") diff --git a/cashu/mint/ledger/melt.py b/cashu/mint/ledger/melt.py new file mode 100644 index 000000000..1ef5e42cd --- /dev/null +++ b/cashu/mint/ledger/melt.py @@ -0,0 +1,617 @@ +import asyncio +import time +from typing import Dict, List, Optional + +import bolt11 +from loguru import logger + +from ...core.base import ( + Amount, + BlindedMessage, + BlindedSignature, + MeltQuote, + MeltQuoteState, + Method, + MintQuote, + MintQuoteState, + Proof, + Unit, +) +from ...core.crypto.keys import random_hash +from ...core.errors import ( + LightningPaymentFailedError, + NotAllowedError, + TransactionError, +) +from ...core.helpers import sum_proofs +from ...core.models import ( + PostMeltQuoteRequest, + PostMeltQuoteResponse, +) +from ...core.settings import settings +from ...lightning.base import ( + PaymentQuoteResponse, + PaymentResponse, + PaymentResult, + PaymentStatus, +) +from ..protocols import SupportsEvents +from .blind_signatures import LedgerBlindSignatures + + +class LedgerMelt(LedgerBlindSignatures, SupportsEvents): + disable_melt: bool + + def create_internal_melt_quote( + self, mint_quote: MintQuote, melt_quote: PostMeltQuoteRequest + ) -> PaymentQuoteResponse: + unit, method = self._verify_and_get_unit_method( + melt_quote.unit, Method.bolt11.name + ) + # NOTE: we normalize the request to lowercase to avoid case sensitivity + # This works with Lightning but might not work with other methods + request = melt_quote.request.lower() + + if not request == mint_quote.request: + raise TransactionError("bolt11 requests do not match") + if not mint_quote.unit == melt_quote.unit: + raise TransactionError("units do not match") + if not mint_quote.method == method.name: + raise TransactionError("methods do not match") + if mint_quote.paid: + raise TransactionError("mint quote already paid") + if mint_quote.issued: + raise TransactionError("mint quote already issued") + if not mint_quote.unpaid: + raise TransactionError("mint quote is not unpaid") + + if not mint_quote.checking_id: + raise TransactionError("mint quote has no checking id") + if melt_quote.is_mpp: + raise TransactionError("internal payments do not support mpp") + + internal_fee = Amount(unit, 0) # no internal fees + amount = Amount(unit, mint_quote.amount) + + payment_quote = PaymentQuoteResponse( + checking_id=mint_quote.checking_id, + amount=amount, + fee=internal_fee, + ) + logger.info( + f"Issuing internal melt quote: {request} ->" + f" {mint_quote.quote} ({amount.str()} + {internal_fee.str()} fees)" + ) + + return payment_quote + + def validate_payment_quote( + self, melt_quote: PostMeltQuoteRequest, payment_quote: PaymentQuoteResponse + ): + # payment quote validation + unit, method = self._verify_and_get_unit_method( + melt_quote.unit, Method.bolt11.name + ) + if not payment_quote.checking_id: + raise Exception("quote has no checking id") + # verify that payment quote amount is as expected + if ( + melt_quote.is_mpp + and melt_quote.mpp_amount != payment_quote.amount.to(Unit.msat).amount + ): + logger.error( + f"expected {payment_quote.amount.to(Unit.msat).amount} msat but got {melt_quote.mpp_amount}" + ) + raise TransactionError("quote amount not as requested") + # make sure the backend returned the amount with a correct unit + if not payment_quote.amount.unit == unit: + raise TransactionError("payment quote amount units do not match") + # fee from the backend must be in the same unit as the amount + if not payment_quote.fee.unit == unit: + raise TransactionError("payment quote fee units do not match") + + async def melt_quote( + self, melt_quote: PostMeltQuoteRequest + ) -> PostMeltQuoteResponse: + """Creates a melt quote and stores it in the database. + + Args: + melt_quote (PostMeltQuoteRequest): Melt quote request. + + Raises: + Exception: Quote invalid. + Exception: Quote already paid. + Exception: Quote already issued. + + Returns: + PostMeltQuoteResponse: Melt quote response. + """ + if settings.mint_bolt11_disable_melt: + raise NotAllowedError("Melting with bolt11 is disabled.") + + unit, method = self._verify_and_get_unit_method( + melt_quote.unit, Method.bolt11.name + ) + + # NOTE: we normalize the request to lowercase to avoid case sensitivity + # This works with Lightning but might not work with other methods + request = melt_quote.request.lower() + + # check if there is a mint quote with the same payment request + # so that we would be able to handle the transaction internally + # and therefore respond with internal transaction fees (0 for now) + mint_quote = await self.crud.get_mint_quote(request=request, db=self.db) + if mint_quote and mint_quote.unit == melt_quote.unit: + # check if the melt quote is partial and error if it is. + # it's just not possible to handle this case + if melt_quote.is_mpp: + raise TransactionError("internal mpp not allowed.") + payment_quote = self.create_internal_melt_quote(mint_quote, melt_quote) + else: + # not internal + # verify that the backend supports mpp if the quote request has an amount + if melt_quote.is_mpp and not self.backends[method][unit].supports_mpp: + raise TransactionError("backend does not support mpp.") + # get payment quote by backend + payment_quote = await self.backends[method][unit].get_payment_quote( + melt_quote=melt_quote + ) + + self.validate_payment_quote(melt_quote, payment_quote) + + # verify that the amount of the proofs is not larger than the maximum allowed + if ( + settings.mint_max_melt_bolt11_sat + and payment_quote.amount.to(unit).amount > settings.mint_max_melt_bolt11_sat + ): + raise NotAllowedError( + f"Maximum melt amount is {settings.mint_max_melt_bolt11_sat} sat." + ) + + # We assume that the request is a bolt11 invoice, this works since we + # support only the bol11 method for now. + invoice_obj = bolt11.decode(melt_quote.request) + if not invoice_obj.amount_msat: + raise TransactionError("invoice has no amount.") + # we set the expiry of this quote to the expiry of the bolt11 invoice + now = int(time.time()) + expiry = None + if settings.melt_quote_ttl is not None: + expiry = now + settings.melt_quote_ttl + elif invoice_obj.expiry is not None: + expiry = invoice_obj.date + invoice_obj.expiry + + quote = MeltQuote( + quote=random_hash(), + method=method.name, + request=request, + checking_id=payment_quote.checking_id, + unit=unit.name, + amount=payment_quote.amount.to(unit).amount, + state=MeltQuoteState.unpaid, + fee_reserve=payment_quote.fee.to(unit).amount, + created_time=now, + expiry=expiry, + ) + await self.db_write._store_melt_quote(quote) + await self.events.submit(quote) + + return PostMeltQuoteResponse( + quote=quote.quote, + amount=quote.amount, + unit=quote.unit, + request=quote.request, + fee_reserve=quote.fee_reserve, + paid=quote.paid, # deprecated + state=quote.state.value, + expiry=quote.expiry, + ) + + async def get_melt_quote(self, quote_id: str, rollback_unknown=False) -> MeltQuote: + """Returns a melt quote. + + If the melt quote is pending, checks status of the payment with the backend. + - If settled, sets the quote as paid and invalidates pending proofs (commit). + - If failed, sets the quote as unpaid and unsets pending proofs (rollback). + - If rollback_unknown is set, do the same for unknown states as for failed states. + + Args: + quote_id (str): ID of the melt quote. + rollback_unknown (bool, optional): Rollback unknown payment states to unpaid. Defaults to False. + + Raises: + Exception: Quote not found. + + Returns: + MeltQuote: Melt quote object. + """ + melt_quote = await self.crud.get_melt_quote(quote_id=quote_id, db=self.db) + if not melt_quote: + raise Exception("quote not found") + + unit, method = self._verify_and_get_unit_method( + melt_quote.unit, melt_quote.method + ) + + # we only check the state with the backend if there is no associated internal + # mint quote for this melt quote + is_internal = await self.crud.get_mint_quote( + request=melt_quote.request, db=self.db + ) + + if melt_quote.pending and not is_internal: + logger.debug( + "Lightning: checking outgoing Lightning payment" + f" {melt_quote.checking_id}" + ) + status: PaymentStatus = await self.backends[method][ + unit + ].get_payment_status(melt_quote.checking_id) + logger.debug(f"State: {status.result}") + if status.settled: + logger.debug(f"Setting quote {quote_id} as paid") + melt_quote.state = MeltQuoteState.paid + if status.fee: + melt_quote.fee_paid = status.fee.to(unit).amount + if status.preimage: + melt_quote.payment_preimage = status.preimage + melt_quote.paid_time = int(time.time()) + pending_proofs = await self.crud.get_pending_proofs_for_quote( + quote_id=quote_id, db=self.db + ) + + # change to compensate wallet for overpaid fees + melt_outputs = await self.crud.get_blinded_messages_melt_id( + melt_id=quote_id, db=self.db + ) + if melt_outputs: + total_provided = sum_proofs(pending_proofs) + input_fees = self.get_fees_for_proofs(pending_proofs) + fee_reserve_provided = ( + total_provided - melt_quote.amount - input_fees + ) + return_promises = await self._generate_change_promises( + fee_provided=fee_reserve_provided, + fee_paid=melt_quote.fee_paid, + outputs=melt_outputs, + melt_id=quote_id, + keyset=self.keysets[melt_outputs[0].id], + ) + melt_quote.change = return_promises + + # Calculate fees + proofs_by_keyset: Dict[str, List[Proof]] = {} + for p in pending_proofs: + proofs_by_keyset.setdefault(p.id, []).append(p) + keyset_fees = {} + for keyset_id, keyset_proofs in proofs_by_keyset.items(): + keyset_fees[keyset_id] = self.get_fees_for_proofs(keyset_proofs) + + melt_quote = ( + await self.db_write.set_melt_quote_paid_and_invalidate_proofs( + quote=melt_quote, + proofs=pending_proofs, + keysets=self.keysets, + keyset_fees=keyset_fees, + ) + ) + + if status.failed or (rollback_unknown and status.unknown): + logger.debug(f"Setting quote {quote_id} as unpaid") + pending_proofs = await self.crud.get_pending_proofs_for_quote( + quote_id=quote_id, db=self.db + ) + melt_quote = await self.db_write.unset_melt_quote_pending_and_proofs( + quote=melt_quote, + proofs=pending_proofs, + keysets=self.keysets, + state=MeltQuoteState.unpaid, + ) + + return melt_quote + + 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. + Exception: Melt quote already issued. + + Returns: + MeltQuote: Settled melt quote. + """ + # first we check if there is a mint quote with the same payment request + # so that we can handle the transaction internally without the backend + mint_quote = await self.crud.get_mint_quote( + request=melt_quote.request, db=self.db + ) + if not mint_quote: + return melt_quote + + # settle externally if units are different + if mint_quote.unit != melt_quote.unit: + return melt_quote + + # we settle the transaction internally + if melt_quote.paid: + raise TransactionError("melt quote already paid") + + # verify amounts from bolt11 invoice + bolt11_request = melt_quote.request + invoice_obj = bolt11.decode(bolt11_request) + + if not invoice_obj.amount_msat: + raise TransactionError("invoice has no amount.") + if not mint_quote.amount == melt_quote.amount: + raise TransactionError("amounts do not match") + if not bolt11_request == mint_quote.request: + raise TransactionError("bolt11 requests do not match") + if not mint_quote.method == melt_quote.method: + raise TransactionError("methods do not match") + + if mint_quote.paid: + raise TransactionError("mint quote already paid") + if mint_quote.issued: + raise TransactionError("mint quote already issued") + + if mint_quote.state != MintQuoteState.unpaid: + raise TransactionError("mint quote is not unpaid") + + logger.info( + f"Settling bolt11 payment internally: {melt_quote.quote} ->" + f" {mint_quote.quote} ({melt_quote.amount} {melt_quote.unit})" + ) + + melt_quote.fee_paid = 0 # no internal fees + melt_quote.state = MeltQuoteState.paid + melt_quote.paid_time = int(time.time()) + + mint_quote.state = MintQuoteState.paid + mint_quote.paid_time = melt_quote.paid_time + + async with self.db.get_connection() as conn: + await self.crud.update_melt_quote(quote=melt_quote, db=self.db, conn=conn) + await self.crud.update_mint_quote(quote=mint_quote, db=self.db, conn=conn) + + await self.events.submit(melt_quote) + await self.events.submit(mint_quote) + + return melt_quote + + async def async_melt( + self, + *, + proofs: List[Proof], + quote: str, + outputs: Optional[List[BlindedMessage]] = None, + ) -> PostMeltQuoteResponse: + """Invalidates proofs and pays a Lightning invoice asynchronously. + + Args: + proofs (List[Proof]): Proofs provided for paying the Lightning invoice + quote (str): ID of the melt quote. + outputs (Optional[List[BlindedMessage]]): Blank outputs for returning overpaid fees to the wallet. + + Returns: + PostMeltQuoteResponse: Melt quote response with pending state. + """ + # get melt quote + melt_quote = await self.get_melt_quote(quote_id=quote) + if not melt_quote: + raise TransactionError("melt quote not found") + if not melt_quote.unpaid: + raise TransactionError(f"melt quote is not unpaid: {melt_quote.state}") + + # Launch actual melt task + asyncio.create_task(self.melt(proofs=proofs, quote=quote, outputs=outputs)) + + melt_quote.state = MeltQuoteState.pending + return PostMeltQuoteResponse.from_melt_quote(melt_quote) + + async def melt( + self, + *, + proofs: List[Proof], + quote: str, + outputs: Optional[List[BlindedMessage]] = None, + ) -> PostMeltQuoteResponse: + """Invalidates proofs and pays a Lightning invoice. + + Args: + proofs (List[Proof]): Proofs provided for paying the Lightning invoice + quote (str): ID of the melt quote. + outputs (Optional[List[BlindedMessage]]): Blank outputs for returning overpaid fees to the wallet. + + Raises: + e: Lightning payment unsuccessful + + Returns: + PostMeltQuoteResponse: Melt quote response. + """ + # make sure we're allowed to melt + if self.disable_melt and settings.mint_disable_melt_on_error: + raise NotAllowedError("Melt is disabled. Please contact the operator.") + + # get melt quote and check if it was already paid + melt_quote = await self.get_melt_quote(quote_id=quote) + if not melt_quote.unpaid: + raise TransactionError(f"melt quote is not unpaid: {melt_quote.state}") + + unit, method = self._verify_and_get_unit_method( + melt_quote.unit, melt_quote.method + ) + + # make sure that the proofs are in the same unit as the quote + self._verify_proofs_unit(proofs, expected_unit=unit) + + # make sure that the outputs (for fee return) are in the same unit as the quote + if outputs: + # _verify_outputs checks if all outputs have the same unit + await self._verify_outputs( + outputs, skip_amount_check=True, expected_unit=unit + ) + + # verify SIG_ALL signatures + message_to_sign = ( + "".join([p.secret for p in proofs] + [o.B_ for o in outputs or []]) + quote + ) + self._verify_sigall_spending_conditions(proofs, outputs or [], message_to_sign) + + # verify that the amount of the input proofs is equal to the amount of the quote + total_provided = sum_proofs(proofs) + input_fees = self.get_fees_for_proofs(proofs) + total_needed = melt_quote.amount + melt_quote.fee_reserve + input_fees + # we need the fees specifically for lightning to return the overpaid fees + fee_reserve_provided = total_provided - melt_quote.amount - input_fees + if total_provided < total_needed: + raise TransactionError( + f"not enough inputs provided for melt. Provided: {total_provided}, needed: {total_needed}" + ) + if fee_reserve_provided < melt_quote.fee_reserve: + raise TransactionError( + f"not enough fee reserve provided for melt. Provided fee reserve: {fee_reserve_provided}, needed: {melt_quote.fee_reserve}" + ) + + # verify inputs and their spending conditions + # note, we do not verify outputs here, as they are only used for returning overpaid fees + # We must have called _verify_outputs here already! (see above) + await self.verify_inputs_and_outputs(proofs=proofs) + + # set quote and proofs to pending to avoid race conditions + melt_quote = await self.db_write.verify_and_set_melt_quote_pending( + quote=melt_quote, proofs=proofs, keysets=self.keysets + ) + + # store the change outputs + if outputs: + await self._store_blinded_messages(outputs, melt_id=melt_quote.quote) + + # if the melt corresponds to an internal mint, mark both as paid + 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}") + try: + payment = await self.backends[method][unit].pay_invoice( + melt_quote, melt_quote.fee_reserve * 1000 + ) + logger.debug( + f"Melt – Result: {payment.result.name}: preimage: {payment.preimage}," + f" fee: {payment.fee.str() if payment.fee is not None else 'None'}" + ) + if ( + payment.checking_id + and payment.checking_id != melt_quote.checking_id + ): + logger.warning( + f"pay_invoice returned different checking_id: {payment.checking_id} than melt quote: {melt_quote.checking_id}. Will use it for potentially checking payment status later." + ) + melt_quote.checking_id = payment.checking_id + await self.crud.update_melt_quote(quote=melt_quote, db=self.db) + except Exception as e: + logger.error(f"Exception during pay_invoice: {e}") + payment = PaymentResponse( + result=PaymentResult.UNKNOWN, + error_message=str(e), + ) + + match payment.result: + case PaymentResult.FAILED | PaymentResult.UNKNOWN: + # explicitly check payment status for failed or unknown payment states + checking_id = payment.checking_id or melt_quote.checking_id + logger.debug( + f"Payment state is {payment.result.name}.{' Error: ' + payment.error_message + '.' if payment.error_message else ''} Checking status for {checking_id}." + ) + try: + status = await self.backends[method][unit].get_payment_status( + checking_id + ) + except Exception as e: + # Something went wrong. We might have lost connection to the backend. Keep transaction pending and return. + logger.error( + f"Lightning backend error: could not check payment status. Proofs for melt quote {melt_quote.quote} are stuck as PENDING.\nError: {e}" + ) + self.disable_melt = True + return PostMeltQuoteResponse.from_melt_quote(melt_quote) + + match status.result: + case PaymentResult.FAILED | PaymentResult.UNKNOWN: + # Everything as expected. Payment AND a status check both agree on a failure. We roll back the transaction. + await self.db_write.unset_melt_quote_pending_and_proofs( + quote=melt_quote, + proofs=proofs, + keysets=self.keysets, + state=MeltQuoteState.unpaid, + ) + if status.error_message: + logger.error( + f"Status check error: {status.error_message}" + ) + raise LightningPaymentFailedError( + f"Lightning payment failed{': ' + payment.error_message if payment.error_message else ''}." + ) + case _: + # Something went wrong with our implementation or the backend. Status check returned different result than payment. Keep transaction pending and return. + logger.error( + f"Payment state was {payment.result} but additional payment state check returned {status.result.name}. Proofs for melt quote {melt_quote.quote} are stuck as PENDING." + ) + self.disable_melt = True + return PostMeltQuoteResponse.from_melt_quote(melt_quote) + + case PaymentResult.SETTLED: + # payment successful + if payment.fee: + melt_quote.fee_paid = payment.fee.to( + to_unit=unit, round="up" + ).amount + if payment.preimage: + melt_quote.payment_preimage = payment.preimage + # set quote as paid + melt_quote.state = MeltQuoteState.paid + melt_quote.paid_time = int(time.time()) + # NOTE: This is the only branch for a successful payment + + case PaymentResult.PENDING | _: + logger.debug( + f"Lightning payment is {payment.result.name}: {payment.checking_id}" + ) + return PostMeltQuoteResponse.from_melt_quote(melt_quote) + + # melt was successful (either internal or via backend), invalidate proofs + # prepare change to compensate wallet for overpaid fees + return_promises: List[BlindedSignature] = [] + if outputs: + return_promises = await self._generate_change_promises( + fee_provided=fee_reserve_provided, + fee_paid=melt_quote.fee_paid, + outputs=outputs, + melt_id=melt_quote.quote, + keyset=self.keysets[outputs[0].id], + ) + + melt_quote.change = return_promises + + # Calculate fees + proofs_by_keyset: Dict[str, List[Proof]] = {} + for p in proofs: + proofs_by_keyset.setdefault(p.id, []).append(p) + keyset_fees = {} + for keyset_id, keyset_proofs in proofs_by_keyset.items(): + keyset_fees[keyset_id] = self.get_fees_for_proofs(keyset_proofs) + + melt_quote = await self.db_write.set_melt_quote_paid_and_invalidate_proofs( + quote=melt_quote, + proofs=proofs, + keysets=self.keysets, + keyset_fees=keyset_fees, + ) + + return PostMeltQuoteResponse.from_melt_quote(melt_quote) diff --git a/cashu/mint/ledger/mint.py b/cashu/mint/ledger/mint.py new file mode 100644 index 000000000..83e5a6747 --- /dev/null +++ b/cashu/mint/ledger/mint.py @@ -0,0 +1,396 @@ +import time +from typing import List, Optional + +import bolt11 +from loguru import logger + +from ...core.base import ( + Amount, + BlindedMessage, + BlindedSignature, + Method, + MintQuote, + MintQuoteState, +) +from ...core.crypto.keys import random_hash +from ...core.errors import ( + BatchDuplicateQuotesError, + CashuError, + LightningError, + NotAllowedError, + QuoteAlreadyIssuedError, + QuoteNotPaidError, + QuoteSignatureInvalidError, + TransactionAmountExceedsLimitError, + TransactionError, +) +from ...core.models import ( + PostMintBatchRequest, + PostMintQuoteCheckRequest, + PostMintQuoteRequest, +) +from ...core.settings import settings +from ...lightning.base import InvoiceResponse, PaymentStatus +from ..protocols import SupportsEvents, SupportsWatchdog +from .blind_signatures import LedgerBlindSignatures + + +class LedgerMint(LedgerBlindSignatures, SupportsWatchdog, SupportsEvents): + async def mint_quote(self, quote_request: PostMintQuoteRequest) -> MintQuote: + """Creates a mint quote and stores it in the database. + + Args: + quote_request (PostMintQuoteRequest): Mint quote request. + + Raises: + Exception: Quote creation failed. + + Returns: + MintQuote: Mint quote object. + """ + logger.trace("called request_mint") + if not quote_request.amount > 0: + raise TransactionError("amount must be positive") + if ( + settings.mint_max_mint_bolt11_sat + and quote_request.amount > settings.mint_max_mint_bolt11_sat + ): + raise TransactionAmountExceedsLimitError( + f"Maximum mint amount is {settings.mint_max_mint_bolt11_sat} sat." + ) + if settings.mint_bolt11_disable_mint: + raise NotAllowedError("Minting with bolt11 is disabled.") + + unit, method = self._verify_and_get_unit_method( + quote_request.unit, Method.bolt11.name + ) + + if ( + quote_request.description + and not self.backends[method][unit].supports_description + ): + raise NotAllowedError("Backend does not support descriptions.") + + # Check maximum balance. + # TODO: Allow setting MINT_MAX_BALANCE per unit + if settings.mint_max_balance: + balance, fees_paid = await self.get_unit_balance_and_fees(unit, db=self.db) + if balance + quote_request.amount > settings.mint_max_balance: + raise NotAllowedError("Mint has reached maximum balance.") + + logger.trace(f"requesting invoice for {unit.str(quote_request.amount)}") + invoice_response: InvoiceResponse = await self.backends[method][ + unit + ].create_invoice( + amount=Amount(unit=unit, amount=quote_request.amount), + memo=quote_request.description, + ) + logger.trace( + f"got invoice {invoice_response.payment_request} with checking id" + f" {invoice_response.checking_id}" + ) + + if not (invoice_response.payment_request and invoice_response.checking_id): + raise LightningError("could not fetch bolt11 payment request from backend") + + # get invoice expiry time + invoice_obj = bolt11.decode(invoice_response.payment_request) + + # NOTE: we normalize the request to lowercase to avoid case sensitivity + # This works with Lightning but might not work with other methods + request = invoice_response.payment_request.lower() + + now = int(time.time()) + expiry = None + if settings.mint_quote_ttl is not None: + expiry = now + settings.mint_quote_ttl + elif invoice_obj.expiry is not None: + expiry = invoice_obj.date + invoice_obj.expiry + + quote = MintQuote( + quote=random_hash(), + method=method.name, + request=request, + checking_id=invoice_response.checking_id, + unit=quote_request.unit, + amount=quote_request.amount, + state=MintQuoteState.unpaid, + created_time=now, + expiry=expiry, + pubkey=quote_request.pubkey, + ) + await self.crud.store_mint_quote(quote=quote, db=self.db) + await self.events.submit(quote) + + return quote + + async def get_mint_quote(self, quote_id: str) -> MintQuote: + """Returns a mint quote. If the quote is not paid, checks with the backend if the associated request is paid. + + Args: + quote_id (str): ID of the mint quote. + + Raises: + Exception: Quote not found. + + Returns: + MintQuote: Mint quote object. + """ + quote = await self.crud.get_mint_quote(quote_id=quote_id, db=self.db) + if not quote: + raise Exception("quote not found") + + unit, method = self._verify_and_get_unit_method(quote.unit, quote.method) + + if quote.unpaid: + if not quote.checking_id: + raise CashuError("quote has no checking id") + + now = int(time.time()) + updated = await self.crud.try_update_mint_quote_last_checked( + quote_id=quote_id, + last_checked=now, + rate_limit=settings.mint_quote_backend_check_rate_limit, + db=self.db, + ) + if not updated: + logger.trace( + f"Lightning: checking invoice {quote.checking_id} skipped due to rate limit" + ) + return quote + quote.last_checked = now + + logger.trace(f"Lightning: checking invoice {quote.checking_id}") + status: PaymentStatus = await self.backends[method][ + unit + ].get_invoice_status(quote.checking_id) + if status.settled: + # change state to paid in one transaction, it could have been marked paid + # by the invoice listener in the mean time + async with self.db.get_connection( + lock_table="mint_quotes", + lock_select_statement="quote = :quote", + lock_parameters={"quote": quote_id}, + ) as conn: + quote = await self.crud.get_mint_quote( + quote_id=quote_id, db=self.db, conn=conn + ) + if not quote: + raise Exception("quote not found") + if quote.unpaid: + logger.trace(f"Setting quote {quote_id} as paid") + quote.state = MintQuoteState.paid + quote.paid_time = now + quote.last_checked = now + await self.crud.update_mint_quote( + quote=quote, db=self.db, conn=conn + ) + await self.events.submit(quote) + + return quote + + async def mint_quote_check( + self, payload: PostMintQuoteCheckRequest + ) -> List[MintQuote]: + """Batch check mint quotes. + + Args: + payload (PostMintQuoteCheckRequest): Request payload containing quote IDs. + + Returns: + List[MintQuote]: List of mint quotes matching the request. + """ + quotes: List[MintQuote] = [] + for quote_id in payload.quotes: + quote = await self.get_mint_quote(quote_id) + if not quote: + raise TransactionError(f"quote {quote_id} not found") + quotes.append(quote) + return quotes + + async def mint( + self, + *, + outputs: List[BlindedMessage], + quote_id: str, + signature: Optional[str] = None, + ) -> List[BlindedSignature]: + """Mints new coins if quote with `quote_id` was paid. Ingest blind messages `outputs` and returns blind signatures `promises`. + + Args: + outputs (List[BlindedMessage]): Outputs (blinded messages) to sign. + quote_id (str): Mint quote id. + witness (Optional[str], optional): NUT-19 witness signature. Defaults to None. + + Raises: + Exception: Validation of outputs failed. + Exception: Quote not paid. + Exception: Quote already issued. + Exception: Quote expired. + Exception: Amount to mint does not match quote amount. + + Returns: + List[BlindedSignature]: Signatures on the outputs. + """ + await self._verify_outputs(outputs) + sum_amount_outputs = sum([b.amount for b in outputs]) + # we already know from _verify_outputs that all outputs have the same unit because they have the same keyset + output_unit = self.keysets[outputs[0].id].unit + + quote = await self.get_mint_quote(quote_id) + if quote.pending: + raise TransactionError("Mint quote already pending.") + if quote.issued: + raise QuoteAlreadyIssuedError() + if quote.state != MintQuoteState.paid: + raise QuoteNotPaidError() + + previous_state = quote.state + await self.db_write._set_mint_quote_pending(quote_id=quote_id) + try: + if not quote.unit == output_unit.name: + raise TransactionError("quote unit does not match output unit") + if not quote.amount == sum_amount_outputs: + raise TransactionError("amount to mint does not match quote amount") + if quote.expiry and quote.expiry < int(time.time()): + raise TransactionError("quote expired") + if not self._verify_mint_quote_witness(quote, outputs, signature): + raise QuoteSignatureInvalidError() + await self._store_blinded_messages(outputs, mint_id=quote_id) + promises = await self._sign_blinded_messages(outputs) + except Exception as e: + await self.db_write._unset_mint_quote_pending( + quote_id=quote_id, state=previous_state + ) + raise e + await self.db_write._unset_mint_quote_pending( + quote_id=quote_id, state=MintQuoteState.issued + ) + + return promises + + async def mint_batch( + self, + payload: PostMintBatchRequest, + ) -> List[BlindedSignature]: + """Batch mint tokens. + + Args: + payload (PostMintBatchRequest): Request payload containing quote IDs, outputs, and signatures. + + Raises: + Exception: Validation of outputs failed. + Exception: Quote not paid. + Exception: Quote already issued. + Exception: Amount to mint does not match quote amount. + + Returns: + List[BlindedSignature]: Signatures on the outputs. + """ + if not payload.quotes: + raise TransactionError("batch must not be empty") + + if len(set(payload.quotes)) != len(payload.quotes): + raise BatchDuplicateQuotesError() + + if payload.signatures and len(payload.signatures) != len(payload.quotes): + raise TransactionError("signatures length must match quotes length") + + await self._verify_outputs(payload.outputs) + # we already know from _verify_outputs that all outputs have the same unit because they have the same keyset + output_unit = self.keysets[payload.outputs[0].id].unit + sum_amount_outputs = sum([b.amount for b in payload.outputs]) + + quotes: List[MintQuote] = [] + for quote_id in payload.quotes: + quote = await self.get_mint_quote(quote_id) + if not quote: + raise TransactionError(f"quote {quote_id} not found") + quotes.append(quote) + + # Check payment method consistency + methods = set([q.method for q in quotes]) + if len(methods) > 1: + raise TransactionError("all quotes must have the same method") + + # Check currency unit consistency + units = set([q.unit for q in quotes]) + if len(units) > 1: + raise TransactionError("all quotes must have the same unit") + if units.pop() != output_unit.name: + raise TransactionError("quote unit does not match output unit") + + for quote in quotes: + if quote.pending: + raise TransactionError("mint quote already pending") + if quote.issued: + raise QuoteAlreadyIssuedError() + if quote.state != MintQuoteState.paid: + raise QuoteNotPaidError() + + # Check amount balance + if payload.quote_amounts: + if len(payload.quote_amounts) != len(quotes): + raise TransactionError("quote_amounts length must match quotes length") + for i, quote in enumerate(quotes): + if ( + quote.method == "bolt11" + and payload.quote_amounts[i] != quote.amount + ): + raise TransactionError( + f"quote amount {payload.quote_amounts[i]} does not match quote {quote.quote} amount {quote.amount}" + ) + if payload.quote_amounts[i] > quote.amount: + raise TransactionError( + f"quote amount {payload.quote_amounts[i]} exceeds quote {quote.quote} amount {quote.amount}" + ) + + quote_amounts = payload.quote_amounts or [q.amount for q in quotes] + if "bolt11" in methods: + if sum(quote_amounts) != sum_amount_outputs: + raise TransactionError( + "amount to mint does not match quote amounts sum" + ) + else: + if sum_amount_outputs > sum(quote_amounts): + raise TransactionError("amount to mint exceeds quote amounts sum") + + # Signature validation (NUT-20) + for i, quote in enumerate(quotes): + sig = payload.signatures[i] if payload.signatures else None + + if not quote.pubkey and sig: + raise QuoteSignatureInvalidError() + + # The spec says msg_to_sign = quote_id[i] || B_0 || B_1 || ... || B_(n-1) + # This logic is inside self._verify_mint_quote_witness, let's reuse it. + if not self._verify_mint_quote_witness(quote, payload.outputs, sig): + raise QuoteSignatureInvalidError() + + # Set all quotes to pending + quotes = await self.db_write._set_mint_quotes_pending(quote_ids=payload.quotes) + + try: + for quote in quotes: + if quote.expiry and quote.expiry < int(time.time()): + raise TransactionError("quote expired") + + # Store all blinded messages + await self._store_blinded_messages( + payload.outputs, mint_id=payload.quotes[0] + ) + promises = await self._sign_blinded_messages(payload.outputs) + + # Set all quotes to issued + await self.db_write._unset_mint_quotes_pending( + quote_ids=payload.quotes, state=MintQuoteState.issued + ) + + except Exception as e: + # Revert pending status + await self.db_write._unset_mint_quotes_pending( + quote_ids=payload.quotes, state=MintQuoteState.paid + ) + raise e + + return promises diff --git a/cashu/mint/ledger/swap.py b/cashu/mint/ledger/swap.py new file mode 100644 index 000000000..b0fda5bfb --- /dev/null +++ b/cashu/mint/ledger/swap.py @@ -0,0 +1,88 @@ +from typing import Dict, List, Optional, Tuple + +from loguru import logger + +from ...core.base import BlindedMessage, BlindedSignature, MintKeyset, Proof +from .blind_signatures import LedgerBlindSignatures + + +class LedgerSwap(LedgerBlindSignatures): + async def swap( + self, + *, + proofs: List[Proof], + outputs: List[BlindedMessage], + keyset: Optional[MintKeyset] = None, + ) -> List[BlindedSignature]: + """Consumes proofs and prepares new promises based on the amount swap. Used for swapping tokens + Before sending or for redeeming tokens for new ones that have been received by another wallet. + + Args: + proofs (List[Proof]): Proofs to be invalidated for the swap. + outputs (List[BlindedMessage]): New outputs that should be signed in return. + keyset (Optional[MintKeyset], optional): Keyset to use. Uses default keyset if not given. Defaults to None. + + Raises: + Exception: Validation of proofs or outputs failed + + Returns: + List[BlindedSignature]: New promises (signatures) for the outputs. + """ + logger.trace("swap called") + # verify spending inputs, outputs, and spending conditions + await self.verify_inputs_and_outputs(proofs=proofs, outputs=outputs) + await self.db_write._verify_spent_proofs_and_set_pending( + proofs, keysets=self.keysets + ) + try: + Ys = [p.Y for p in proofs] + lock_parameters = {f"y{i}": y for i, y in enumerate(Ys)} + ys_list = ", ".join(f":y{i}" for i in range(len(Ys))) + async with self.db.get_connection( + lock_table="proofs_pending", + lock_select_statement=f"y IN ({ys_list})", + lock_parameters=lock_parameters, + ) as conn: + await self._store_blinded_messages(outputs, keyset=keyset, conn=conn) + + # Calculate fees + proofs_by_keyset: Dict[str, List[Proof]] = {} + for p in proofs: + proofs_by_keyset.setdefault(p.id, []).append(p) + keyset_fees = {} + for keyset_id, keyset_proofs in proofs_by_keyset.items(): + keyset_fees[keyset_id] = self.get_fees_for_proofs(keyset_proofs) + + await self.db_write.invalidate_proofs( + proofs=proofs, + keysets=self.keysets, + keyset_fees=keyset_fees, + conn=conn, + ) + promises = await self._sign_blinded_messages(outputs, conn) + except Exception as e: + logger.trace(f"swap failed: {e}") + raise e + finally: + # delete proofs from pending list + await self.db_write._unset_proofs_pending(proofs, keysets=self.keysets) + + logger.trace("swap successful") + return promises + + async def restore( + self, outputs: List[BlindedMessage] + ) -> Tuple[List[BlindedMessage], List[BlindedSignature]]: + signatures: List[BlindedSignature] = [] + return_outputs: List[BlindedMessage] = [] + async with self.db.get_connection() as conn: + for output in outputs: + logger.trace(f"looking for promise: {output}") + promise = await self.crud.get_blind_signature( + b_=output.B_, db=self.db, conn=conn + ) + if promise is not None: + signatures.append(promise) + return_outputs.append(output) + logger.trace(f"promise found: {promise}") + return return_outputs, signatures diff --git a/cashu/mint/protocols.py b/cashu/mint/protocols.py index 1fcd3799f..46cfab84c 100644 --- a/cashu/mint/protocols.py +++ b/cashu/mint/protocols.py @@ -1,8 +1,8 @@ -from typing import Dict, List, Mapping, Protocol +from typing import Dict, List, Mapping, Optional, Protocol, Tuple -from ..core.base import Method, MintKeyset, Unit +from ..core.base import Amount, Method, MintKeyset, Unit from ..core.crypto.secp import PublicKey -from ..core.db import Database +from ..core.db import Connection, Database from ..lightning.base import LightningBackend from ..mint.crud import LedgerCrud from .db.read import DbReadHelper @@ -37,3 +37,12 @@ class SupportsDb(Protocol): class SupportsEvents(Protocol): events: LedgerEventManager + + +class SupportsWatchdog(Protocol): + async def get_unit_balance_and_fees( + self, + unit: Unit, + db: Database, + conn: Optional[Connection] = None, + ) -> Tuple[Amount, Amount]: ...