From 3929d78d80eb9769d2c1aa76c38909395c2de36c Mon Sep 17 00:00:00 2001 From: kvngmikey Date: Mon, 1 Jun 2026 10:52:27 +0100 Subject: [PATCH 1/3] refactor(mint): split ledger.py into ledger/ package --- cashu/mint/ledger.py | 1413 ------------------------- cashu/mint/ledger/__init__.py | 3 + cashu/mint/ledger/blind_signatures.py | 189 ++++ cashu/mint/ledger/ledger.py | 177 ++++ cashu/mint/ledger/melt.py | 617 +++++++++++ cashu/mint/ledger/mint.py | 397 +++++++ cashu/mint/ledger/swap.py | 88 ++ cashu/mint/protocols.py | 15 +- 8 files changed, 1483 insertions(+), 1416 deletions(-) delete mode 100644 cashu/mint/ledger.py create mode 100644 cashu/mint/ledger/__init__.py create mode 100644 cashu/mint/ledger/blind_signatures.py create mode 100644 cashu/mint/ledger/ledger.py create mode 100644 cashu/mint/ledger/melt.py create mode 100644 cashu/mint/ledger/mint.py create mode 100644 cashu/mint/ledger/swap.py 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..073e52888 --- /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.errors import ( + LightningPaymentFailedError, + NotAllowedError, + TransactionError, +) +from ...core.helpers import sum_proofs +from ...core.crypto.keys import random_hash +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 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) diff --git a/cashu/mint/ledger/mint.py b/cashu/mint/ledger/mint.py new file mode 100644 index 000000000..bd6c3f8a0 --- /dev/null +++ b/cashu/mint/ledger/mint.py @@ -0,0 +1,397 @@ +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.errors import CashuError +from ...lightning.base import PaymentStatus +from ...core.crypto.keys import random_hash +from ...core.errors import ( + BatchDuplicateQuotesError, + LightningError, + NotAllowedError, + QuoteAlreadyIssuedError, + QuoteNotPaidError, + QuoteSignatureInvalidError, + TransactionAmountExceedsLimitError, + TransactionError, +) +from ...core.models import ( + PostMintBatchRequest, + PostMintQuoteCheckRequest, + PostMintQuoteRequest, +) +from ...core.settings import settings +from ...lightning.base import InvoiceResponse +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..a93737288 --- /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, + ): + """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]: ... From 41ee4bea9083fc47f2b808db61ab25e1244220ea Mon Sep 17 00:00:00 2001 From: kvngmikey Date: Mon, 1 Jun 2026 11:04:11 +0100 Subject: [PATCH 2/3] chore:ruff checks fix --- cashu/mint/ledger/melt.py | 2 +- cashu/mint/ledger/mint.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/cashu/mint/ledger/melt.py b/cashu/mint/ledger/melt.py index 073e52888..49bedca70 100644 --- a/cashu/mint/ledger/melt.py +++ b/cashu/mint/ledger/melt.py @@ -17,13 +17,13 @@ Proof, Unit, ) +from ...core.crypto.keys import random_hash from ...core.errors import ( LightningPaymentFailedError, NotAllowedError, TransactionError, ) from ...core.helpers import sum_proofs -from ...core.crypto.keys import random_hash from ...core.models import ( PostMeltQuoteRequest, PostMeltQuoteResponse, diff --git a/cashu/mint/ledger/mint.py b/cashu/mint/ledger/mint.py index bd6c3f8a0..83e5a6747 100644 --- a/cashu/mint/ledger/mint.py +++ b/cashu/mint/ledger/mint.py @@ -12,11 +12,10 @@ MintQuote, MintQuoteState, ) -from ...core.errors import CashuError -from ...lightning.base import PaymentStatus from ...core.crypto.keys import random_hash from ...core.errors import ( BatchDuplicateQuotesError, + CashuError, LightningError, NotAllowedError, QuoteAlreadyIssuedError, @@ -31,7 +30,7 @@ PostMintQuoteRequest, ) from ...core.settings import settings -from ...lightning.base import InvoiceResponse +from ...lightning.base import InvoiceResponse, PaymentStatus from ..protocols import SupportsEvents, SupportsWatchdog from .blind_signatures import LedgerBlindSignatures From a62789be5b8257707ab1e26c81203675f5d74bec Mon Sep 17 00:00:00 2001 From: kvngmikey Date: Mon, 1 Jun 2026 20:17:02 +0100 Subject: [PATCH 3/3] chore: address in-scope copilot reviews. --- cashu/mint/crud.py | 11 +++++++++++ cashu/mint/ledger/melt.py | 2 +- cashu/mint/ledger/swap.py | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) 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/melt.py b/cashu/mint/ledger/melt.py index 49bedca70..1ef5e42cd 100644 --- a/cashu/mint/ledger/melt.py +++ b/cashu/mint/ledger/melt.py @@ -127,7 +127,7 @@ async def melt_quote( PostMeltQuoteResponse: Melt quote response. """ if settings.mint_bolt11_disable_melt: - raise NotAllowedError("Melting with bol11 is disabled.") + raise NotAllowedError("Melting with bolt11 is disabled.") unit, method = self._verify_and_get_unit_method( melt_quote.unit, Method.bolt11.name diff --git a/cashu/mint/ledger/swap.py b/cashu/mint/ledger/swap.py index a93737288..b0fda5bfb 100644 --- a/cashu/mint/ledger/swap.py +++ b/cashu/mint/ledger/swap.py @@ -13,7 +13,7 @@ async def swap( 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.