diff --git a/cashu/core/errors.py b/cashu/core/errors.py index 19e757d..fa2ca4a 100644 --- a/cashu/core/errors.py +++ b/cashu/core/errors.py @@ -1,21 +1,83 @@ -from pydantic import BaseModel +from typing import Optional -class CashuError(BaseModel): +class CashuError(Exception): code: int - error: str + detail: str + + def __init__(self, detail, code=0): + super().__init__(detail) + self.code = code + self.detail = detail -class MintException(CashuError): - code = 100 - error = "Mint" +class NotAllowedError(CashuError): + detail = "Not allowed." + code = 10000 + + def __init__(self, detail: Optional[str] = None, code: Optional[int] = None): + super().__init__(detail or self.detail, code=code or self.code) -class LightningException(MintException): - code = 200 - error = "Lightning" +class TransactionError(CashuError): + detail = "Transaction error." + code = 11000 + + def __init__(self, detail: Optional[str] = None, code: Optional[int] = None): + super().__init__(detail or self.detail, code=code or self.code) -class InvoiceNotPaidException(LightningException): - code = 201 - error = "invoice not paid." +class TokenAlreadySpentError(TransactionError): + detail = "Token already spent." + code = 11001 + + def __init__(self): + super().__init__(self.detail, code=self.code) + + +class SecretTooLongError(TransactionError): + detail = "Secret too long." + code = 11003 + + def __init__(self): + super().__init__(self.detail, code=self.code) + + +class NoSecretInProofsError(TransactionError): + detail = "No secret in proofs." + code = 11004 + + def __init__(self): + super().__init__(self.detail, code=self.code) + + +class KeysetError(CashuError): + detail = "Keyset error." + code = 12000 + + def __init__(self, detail: Optional[str] = None, code: Optional[int] = None): + super().__init__(detail or self.detail, code=code or self.code) + + +class KeysetNotFoundError(KeysetError): + detail = "Keyset not found." + code = 12001 + + def __init__(self): + super().__init__(self.detail, code=self.code) + + +class LightningError(CashuError): + detail = "Lightning error." + code = 20000 + + def __init__(self, detail: Optional[str] = None, code: Optional[int] = None): + super().__init__(detail or self.detail, code=code or self.code) + + +class InvoiceNotPaidError(CashuError): + detail = "Lightning invoice not paid yet." + code = 20001 + + def __init__(self): + super().__init__(self.detail, code=2001) diff --git a/cashu/mint/app.py b/cashu/mint/app.py index 306ebc1..4de9e89 100644 --- a/cashu/mint/app.py +++ b/cashu/mint/app.py @@ -1,14 +1,19 @@ import logging import sys +from traceback import print_exception -from fastapi import FastAPI +from fastapi import FastAPI, status +from fastapi.responses import JSONResponse # from fastapi_profiler import PyInstrumentProfilerMiddleware from loguru import logger from starlette.middleware import Middleware from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.cors import CORSMiddleware +from starlette.requests import Request +from starlette.responses import Response +from ..core.errors import CashuError from ..core.settings import settings from .router import router from .startup import start_mint_init @@ -100,9 +105,35 @@ def create_app(config_object="core.settings") -> FastAPI: app = create_app() -app.include_router(router=router) + +@app.middleware("http") +async def catch_exceptions(request: Request, call_next): + try: + return await call_next(request) + except Exception as e: + try: + err_message = str(e) + except: + err_message = e.args[0] if e.args else "Unknown error" + + if isinstance(e, CashuError): + logger.error(f"CashuError: {err_message}") + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"detail": err_message, "code": e.code}, + ) + logger.error(f"Exception: {err_message}") + if settings.debug: + print_exception(*sys.exc_info()) + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"detail": err_message, "code": 0}, + ) @app.on_event("startup") async def startup_mint(): await start_mint_init() + + +app.include_router(router=router) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 67fc5d5..d5f3a1f 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -21,6 +21,17 @@ from ..core.crypto import b_dhke from ..core.crypto.keys import derive_pubkey, random_hash from ..core.crypto.secp import PublicKey from ..core.db import Connection, Database +from ..core.errors import ( + InvoiceNotPaidError, + KeysetError, + KeysetNotFoundError, + LightningError, + NoSecretInProofsError, + NotAllowedError, + SecretTooLongError, + TokenAlreadySpentError, + TransactionError, +) from ..core.helpers import fee_reserve, sum_proofs from ..core.p2pk import verify_p2pk_signature from ..core.script import verify_bitcoin_script @@ -202,15 +213,15 @@ class Ledger: def _verify_secret_criteria(self, proof: Proof) -> Literal[True]: """Verifies that a secret is present and is not too long (DOS prevention).""" if proof.secret is None or proof.secret == "": - raise Exception("no secret in proof.") + raise NoSecretInProofsError() if len(proof.secret) > 512: - raise Exception("secret too long.") + raise SecretTooLongError() return True def _verify_proof_bdhke(self, proof: Proof): """Verifies that the proof of promise was issued by this ledger.""" if not self._check_spendable(proof): - raise Exception(f"tokens already spent. Secret: {proof.secret}") + raise TokenAlreadySpentError() # if no keyset id is given in proof, assume the current one if not proof.id: private_key_amount = self.keyset.private_keys[proof.amount] @@ -256,14 +267,14 @@ class Ledger: or proof.p2shscript.signature is None ): # no script present although secret indicates one - raise Exception("no script in proof.") + raise TransactionError("no script in proof.") # execute and verify P2SH txin_p2sh_address, valid = verify_bitcoin_script( proof.p2shscript.script, proof.p2shscript.signature ) if not valid: - raise Exception("script invalid.") + raise TransactionError("script invalid.") # check if secret commits to script address assert secret.data == str( txin_p2sh_address @@ -284,11 +295,11 @@ class Ledger: if not proof.p2pksigs: # no signature present although secret indicates one logger.error(f"no p2pk signatures in proof: {proof.p2pksigs}") - raise Exception("no p2pk signatures in proof.") + raise TransactionError("no p2pk signatures in proof.") # we make sure that there are no duplicate signatures if len(set(proof.p2pksigs)) != len(proof.p2pksigs): - raise Exception("p2pk signatures must be unique.") + raise TransactionError("p2pk signatures must be unique.") # we parse the secret as a P2PK commitment # assert len(proof.secret.split(":")) == 5, "p2pk secret format invalid." @@ -451,7 +462,7 @@ class Ledger: ) logger.trace(f"Verifying amount {amount} is valid: {valid}") if not valid: - raise Exception("invalid amount: " + str(amount)) + raise NotAllowedError("invalid amount: " + str(amount)) return amount def _verify_equation_balanced( @@ -486,7 +497,7 @@ class Ledger: error, balance = await self.lightning.status() logger.trace(f"_request_lightning_invoice: Lightning wallet balance: {balance}") if error: - raise Exception(f"Lightning wallet not responding: {error}") + raise LightningError(f"Lightning wallet not responding: {error}") ( ok, checking_id, @@ -523,9 +534,9 @@ class Ledger: ) logger.trace(f"crud: _check_lightning_invoice: invoice: {invoice}") if invoice is None: - raise Exception("invoice not found.") + raise LightningError("invoice not found.") if invoice.issued: - raise Exception("tokens already issued for this invoice.") + raise LightningError("tokens already issued for this invoice.") assert invoice.payment_hash, "invoice has no payment hash." # set this invoice as issued @@ -537,7 +548,7 @@ class Ledger: try: if amount > invoice.amount: - raise Exception( + raise LightningError( f"requested amount too high: {amount}. Invoice amount: {invoice.amount}" ) logger.trace( @@ -550,7 +561,7 @@ class Ledger: if status.paid: return status.paid else: - raise Exception("Lightning invoice not paid yet.") + raise InvoiceNotPaidError() except Exception as e: # unset issued logger.trace(f"crud: unsetting invoice {invoice.payment_hash} as issued") @@ -577,7 +588,7 @@ class Ledger: error, balance = await self.lightning.status() logger.trace(f"_pay_lightning_invoice: Lightning wallet balance: {balance}") if error: - raise Exception(f"Lightning wallet not responding: {error}") + raise LightningError(f"Lightning wallet not responding: {error}") ( ok, checking_id, @@ -631,7 +642,7 @@ class Ledger: f"crud: _set_proofs_pending proof {p.secret} set as pending" ) except: - raise Exception("proofs already pending.") + raise TransactionError("proofs already pending.") async def _unset_proofs_pending( self, proofs: List[Proof], conn: Optional[Connection] = None @@ -674,7 +685,7 @@ class Ledger: for p in proofs: for pp in proofs_pending: if p.secret == pp.secret: - raise Exception("proofs are pending.") + raise TransactionError("proofs are pending.") async def _verify_proofs_and_outputs( self, proofs: List[Proof], outputs: Optional[List[BlindedMessage]] = None @@ -695,16 +706,16 @@ class Ledger: # Verify secret criteria if not all([self._verify_secret_criteria(p) for p in proofs]): - raise Exception("secrets do not match criteria.") + raise TransactionError("secrets do not match criteria.") # verify that only unique proofs were used if not self._verify_no_duplicate_proofs(proofs): - raise Exception("duplicate proofs.") + raise TransactionError("duplicate proofs.") # Verify input spending conditions if not all([self._verify_input_spending_conditions(p) for p in proofs]): - raise Exception("validation of input spending conditions failed.") + raise TransactionError("validation of input spending conditions failed.") # Verify ecash signatures if not all([self._verify_proof_bdhke(p) for p in proofs]): - raise Exception("could not verify proofs.") + raise TransactionError("could not verify proofs.") if not outputs: return @@ -713,12 +724,12 @@ class Ledger: # verify that only unique outputs were used if not self._verify_no_duplicate_outputs(outputs): - raise Exception("duplicate promises.") + raise TransactionError("duplicate promises.") if not self._verify_input_output_amounts(proofs, outputs): - raise Exception("input amounts less than output.") + raise TransactionError("input amounts less than output.") # Verify output spending conditions if outputs and not self._verify_output_spending_conditions(proofs, outputs): - raise Exception("validation of output spending conditions failed.") + raise TransactionError("validation of output spending conditions failed.") async def _generate_change_promises( self, @@ -776,7 +787,7 @@ class Ledger: for i in range(len(outputs)): outputs[i].amount = return_amounts_sorted[i] if not self._verify_no_duplicate_outputs(outputs): - raise Exception("duplicate promises.") + raise TransactionError("duplicate promises.") return_promises = await self._generate_promises(outputs, keyset) return return_promises else: @@ -786,9 +797,9 @@ class Ledger: def get_keyset(self, keyset_id: Optional[str] = None): """Returns a dictionary of hex public keys of a specific keyset for each supported amount""" if keyset_id and keyset_id not in self.keysets.keysets: - raise Exception("keyset does not exist") + raise KeysetNotFoundError() keyset = self.keysets.keysets[keyset_id] if keyset_id else self.keyset - assert keyset.public_keys, Exception("no public keys for this keyset") + assert keyset.public_keys, KeysetError("no public keys for this keyset") return {a: p.serialize().hex() for a, p in keyset.public_keys.items()} async def request_mint(self, amount: int): @@ -805,14 +816,16 @@ class Ledger: """ logger.trace(f"called request_mint") if settings.mint_max_peg_in and amount > settings.mint_max_peg_in: - raise Exception(f"Maximum mint amount is {settings.mint_max_peg_in} sat.") + raise NotAllowedError( + f"Maximum mint amount is {settings.mint_max_peg_in} sat." + ) if settings.mint_peg_out_only: - raise Exception("Mint does not allow minting new tokens.") + raise NotAllowedError("Mint does not allow minting new tokens.") logger.trace(f"requesting invoice for {amount} satoshis") payment_request, payment_hash = await self._request_lightning_invoice(amount) logger.trace(f"got invoice {payment_request} with hash {payment_hash}") - assert payment_request and payment_hash, Exception( + assert payment_request and payment_hash, LightningError( "could not fetch invoice from Lightning backend" ) @@ -856,7 +869,7 @@ class Ledger: if settings.lightning: if not hash: - raise Exception("no hash provided.") + raise NotAllowedError("no hash provided.") self.locks[hash] = ( self.locks.get(hash) or asyncio.Lock() ) # create a new lock if it doesn't exist @@ -867,7 +880,7 @@ class Ledger: for amount in amounts: if amount not in [2**i for i in range(settings.max_order)]: - raise Exception( + raise NotAllowedError( f"Can only mint amounts with 2^n up to {2**settings.max_order}." ) @@ -904,13 +917,13 @@ class Ledger: invoice_obj = bolt11.decode(invoice) invoice_amount = math.ceil(invoice_obj.amount_msat / 1000) if settings.mint_max_peg_out and invoice_amount > settings.mint_max_peg_out: - raise Exception( + raise NotAllowedError( f"Maximum melt amount is {settings.mint_max_peg_out} sat." ) fees_msat = await self.check_fees(invoice) - assert total_provided >= invoice_amount + fees_msat / 1000, Exception( - "provided proofs not enough for Lightning payment." - ) + assert ( + total_provided >= invoice_amount + fees_msat / 1000 + ), TransactionError("provided proofs not enough for Lightning payment.") # promises to return for overpaid fees return_promises: List[BlindedSignature] = [] @@ -933,7 +946,7 @@ class Ledger: await self._invalidate_proofs(proofs) logger.trace("invalidated proofs") # prepare change to compensate wallet for overpaid fees - assert fee_msat is not None, Exception("fees not valid") + assert fee_msat is not None, TransactionError("fees not valid") if outputs: return_promises = await self._generate_change_promises( total_provided=total_provided, @@ -943,7 +956,7 @@ class Ledger: ) else: logger.trace("lightning payment unsuccessful") - raise Exception("Lightning payment unsuccessful.") + raise LightningError("Lightning payment unsuccessful.") except Exception as e: logger.trace(f"exception: {e}") diff --git a/cashu/mint/router.py b/cashu/mint/router.py index 181079a..afeb675 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -38,7 +38,7 @@ router: APIRouter = APIRouter() response_model=GetInfoResponse, response_model_exclude_none=True, ) -async def info(): +async def info() -> GetInfoResponse: logger.trace(f"> GET /info") return GetInfoResponse( name=settings.mint_info_name, @@ -61,38 +61,43 @@ async def info(): "/keys", name="Mint public keys", summary="Get the public keys of the newest mint keyset", + response_description="A dictionary of all supported token values of the mint and their associated public key of the current keyset.", + response_model=KeysResponse, ) -async def keys() -> KeysResponse: +async def keys(): """This endpoint returns a dictionary of all supported token values of the mint and their associated public key.""" logger.trace(f"> GET /keys") keyset = ledger.get_keyset() keys = KeysResponse.parse_obj(keyset) - return keys + return keys.__root__ @router.get( "/keys/{idBase64Urlsafe}", name="Keyset public keys", summary="Public keys of a specific keyset", + response_description="A dictionary of all supported token values of the mint and their associated public key for a specific keyset.", + response_model=KeysResponse, ) -async def keyset_keys(idBase64Urlsafe: str) -> Union[KeysResponse, CashuError]: +async def keyset_keys(idBase64Urlsafe: str): """ Get the public keys of the mint from a specific keyset id. The id is encoded in idBase64Urlsafe (by a wallet) and is converted back to normal base64 before it can be processed (by the mint). """ logger.trace(f"> GET /keys/{idBase64Urlsafe}") - try: - id = idBase64Urlsafe.replace("-", "+").replace("_", "/") - keyset = ledger.get_keyset(keyset_id=id) - keys = KeysResponse.parse_obj(keyset) - return keys - except Exception as exc: - return CashuError(code=0, error=str(exc)) + id = idBase64Urlsafe.replace("-", "+").replace("_", "/") + keyset = ledger.get_keyset(keyset_id=id) + keys = KeysResponse.parse_obj(keyset) + return keys.__root__ @router.get( - "/keysets", name="Active keysets", summary="Get all active keyset id of the mind" + "/keysets", + name="Active keysets", + summary="Get all active keyset id of the mind", + response_model=KeysetsResponse, + response_description="A list of all active keyset ids of the mint.", ) async def keysets() -> KeysetsResponse: """This endpoint returns a list of keysets that the mint currently supports and will accept tokens from.""" @@ -101,8 +106,14 @@ async def keysets() -> KeysetsResponse: return keysets -@router.get("/mint", name="Request mint", summary="Request minting of new tokens") -async def request_mint(amount: int = 0) -> Union[GetMintResponse, CashuError]: +@router.get( + "/mint", + name="Request mint", + summary="Request minting of new tokens", + response_model=GetMintResponse, + response_description="A Lightning invoice to be paid and a hash to request minting of new tokens after payment.", +) +async def request_mint(amount: int = 0) -> GetMintResponse: """ Request minting of new tokens. The mint responds with a Lightning invoice. This endpoint can be used for a Lightning invoice UX flow. @@ -111,72 +122,72 @@ async def request_mint(amount: int = 0) -> Union[GetMintResponse, CashuError]: """ logger.trace(f"> GET /mint: amount={amount}") if amount > 21_000_000 * 100_000_000 or amount <= 0: - return CashuError(code=0, error="Amount must be a valid amount of sat.") + raise CashuError(code=0, detail="Amount must be a valid amount of sat.") if settings.mint_peg_out_only: - return CashuError(code=0, error="Mint does not allow minting new tokens.") - try: - payment_request, hash = await ledger.request_mint(amount) - resp = GetMintResponse(pr=payment_request, hash=hash) - logger.trace(f"< GET /mint: {resp}") - return resp - except Exception as exc: - return CashuError(code=0, error=str(exc)) + raise CashuError(code=0, detail="Mint does not allow minting new tokens.") + + payment_request, hash = await ledger.request_mint(amount) + resp = GetMintResponse(pr=payment_request, hash=hash) + logger.trace(f"< GET /mint: {resp}") + return resp @router.post( "/mint", name="Mint tokens", summary="Mint tokens in exchange for a Bitcoin paymemt that the user has made", + response_model=PostMintResponse, + response_description="A list of blinded signatures that can be used to create proofs.", ) async def mint( payload: PostMintRequest, hash: Optional[str] = None, payment_hash: Optional[str] = None, -) -> Union[PostMintResponse, CashuError]: +) -> PostMintResponse: """ Requests the minting of tokens belonging to a paid payment request. Call this endpoint after `GET /mint`. """ logger.trace(f"> POST /mint: {payload}") - try: - # BEGIN: backwards compatibility < 0.12 where we used to lookup payments with payment_hash - # We use the payment_hash to lookup the hash from the database and pass that one along. - hash = payment_hash or hash - # END: backwards compatibility < 0.12 - promises = await ledger.mint(payload.outputs, hash=hash) - blinded_signatures = PostMintResponse(promises=promises) - logger.trace(f"< POST /mint: {blinded_signatures}") - return blinded_signatures - except Exception as exc: - return CashuError(code=0, error=str(exc)) + + # BEGIN: backwards compatibility < 0.12 where we used to lookup payments with payment_hash + # We use the payment_hash to lookup the hash from the database and pass that one along. + hash = payment_hash or hash + # END: backwards compatibility < 0.12 + + promises = await ledger.mint(payload.outputs, hash=hash) + blinded_signatures = PostMintResponse(promises=promises) + logger.trace(f"< POST /mint: {blinded_signatures}") + return blinded_signatures @router.post( "/melt", name="Melt tokens", summary="Melt tokens for a Bitcoin payment that the mint will make for the user in exchange", + response_model=GetMeltResponse, + response_description="The state of the payment, a preimage as proof of payment, and a list of promises for change.", ) -async def melt(payload: PostMeltRequest) -> Union[CashuError, GetMeltResponse]: +async def melt(payload: PostMeltRequest) -> GetMeltResponse: """ Requests tokens to be destroyed and sent out via Lightning. """ logger.trace(f"> POST /melt: {payload}") - try: - ok, preimage, change_promises = await ledger.melt( - payload.proofs, payload.pr, payload.outputs - ) - resp = GetMeltResponse(paid=ok, preimage=preimage, change=change_promises) - logger.trace(f"< POST /melt: {resp}") - return resp - except Exception as exc: - return CashuError(code=0, error=str(exc)) + ok, preimage, change_promises = await ledger.melt( + payload.proofs, payload.pr, payload.outputs + ) + resp = GetMeltResponse(paid=ok, preimage=preimage, change=change_promises) + logger.trace(f"< POST /melt: {resp}") + return resp @router.post( "/check", name="Check proof state", summary="Check whether a proof is spent already or is pending in a transaction", + response_model=CheckSpendableResponse, + response_description="Two lists of booleans indicating whether the provided proofs are spendable or pending in a transaction respectively.", ) async def check_spendable( payload: CheckSpendableRequest, @@ -193,6 +204,8 @@ async def check_spendable( "/checkfees", name="Check fees", summary="Check fee reserve for a Lightning payment", + response_model=CheckFeesResponse, + response_description="The fees necessary to pay a Lightning invoice.", ) async def check_fees(payload: CheckFeesRequest) -> CheckFeesResponse: """ @@ -206,27 +219,28 @@ async def check_fees(payload: CheckFeesRequest) -> CheckFeesResponse: return CheckFeesResponse(fee=fees_sat) -@router.post("/split", name="Split", summary="Split proofs at a specified amount") +@router.post( + "/split", + name="Split", + summary="Split proofs at a specified amount", + response_model=PostSplitResponse, + response_description="A list of blinded signatures that can be used to create proofs.", +) async def split( payload: PostSplitRequest, -) -> Union[CashuError, PostSplitResponse, PostSplitResponse_Deprecated]: +) -> Union[PostSplitResponse, PostSplitResponse_Deprecated]: """ - Requetst a set of tokens with amount "total" to be split into two - newly minted sets with amount "split" and "total-split". + Requests a set of Proofs to be split into two a new set of BlindedSignatures. This endpoint is used by Alice to split a set of proofs before making a payment to Carol. It is then used by Carol (by setting split=total) to redeem the tokens. """ logger.trace(f"> POST /split: {payload}") assert payload.outputs, Exception("no outputs provided.") - try: - promises = await ledger.split( - proofs=payload.proofs, outputs=payload.outputs, amount=payload.amount - ) - except Exception as exc: - return CashuError(code=0, error=str(exc)) - if not promises: - return CashuError(code=0, error="there was an error with the split") + + promises = await ledger.split( + proofs=payload.proofs, outputs=payload.outputs, amount=payload.amount + ) if payload.amount: # BEGIN backwards compatibility < 0.13 @@ -253,12 +267,13 @@ async def split( @router.post( - "/restore", name="Restore", summary="Restores a blinded signature from a secret" + "/restore", + name="Restore", + summary="Restores a blinded signature from a secret", + response_model=PostRestoreResponse, + response_description="Two lists with the first being the list of the provided outputs that have an associated blinded signature which is given in the second list.", ) -async def restore(payload: PostMintRequest) -> Union[CashuError, PostRestoreResponse]: +async def restore(payload: PostMintRequest) -> PostRestoreResponse: assert payload.outputs, Exception("no outputs provided.") - try: - outputs, promises = await ledger.restore(payload.outputs) - except Exception as exc: - return CashuError(code=0, error=str(exc)) + outputs, promises = await ledger.restore(payload.outputs) return PostRestoreResponse(outputs=outputs, promises=promises) diff --git a/cashu/wallet/api/app.py b/cashu/wallet/api/app.py index cfe1ae2..d5eba00 100644 --- a/cashu/wallet/api/app.py +++ b/cashu/wallet/api/app.py @@ -1,5 +1,6 @@ from fastapi import FastAPI, Request, status from fastapi.responses import JSONResponse +from loguru import logger from ...core.settings import settings from .router import router @@ -28,9 +29,9 @@ app = create_app() @app.middleware("http") async def catch_exceptions(request: Request, call_next): try: - response = await call_next(request) - return response + return await call_next(request) except Exception as e: + logger.error(f"Exception: {e}") return JSONResponse( status_code=status.HTTP_400_BAD_REQUEST, content={"detail": str(e)} ) diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 5b48d7c..7723ca3 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -15,6 +15,7 @@ import requests from bip32 import BIP32 from loguru import logger from mnemonic import Mnemonic +from requests import Response from ..core import bolt11 as bolt11 from ..core.base import ( @@ -225,17 +226,24 @@ class LedgerAPI(object): return outputs, rs_return @staticmethod - def raise_on_error(resp_dict) -> None: + def raise_on_error(resp: Response) -> None: """Raises an exception if the response from the mint contains an error. Args: - resp_dict (_type_): Response dict (previously JSON) from mint + resp_dict (Response): Response dict (previously JSON) from mint Raises: Exception: if the response contains an error """ - if "error" in resp_dict: - raise Exception("Mint Error: {}".format(resp_dict["error"])) + resp_dict = resp.json() + if "detail" in resp_dict: + logger.error(f"Error from mint: {resp_dict}") + error_message = f"Mint Error: {resp_dict['detail']}" + if "code" in resp_dict: + error_message += f" (Code: {resp_dict['code']})" + raise Exception(error_message) + # raise for status if no error + resp.raise_for_status() async def _load_mint_keys(self, keyset_id: str = "") -> WalletKeyset: """Loads keys from mint and stores them in the database. @@ -347,7 +355,7 @@ class LedgerAPI(object): resp = self.s.get( url + "/keys", ) - resp.raise_for_status() + self.raise_on_error(resp) keys: dict = resp.json() assert len(keys), Exception("did not receive any keys") keyset_keys = { @@ -376,9 +384,8 @@ class LedgerAPI(object): resp = self.s.get( url + f"/keys/{keyset_id_urlsafe}", ) - resp.raise_for_status() + self.raise_on_error(resp) keys = resp.json() - self.raise_on_error(keys) assert len(keys), Exception("did not receive any keys") keyset_keys = { int(amt): PublicKey(bytes.fromhex(val), raw=True) @@ -403,7 +410,7 @@ class LedgerAPI(object): resp = self.s.get( url + "/keysets", ) - resp.raise_for_status() + self.raise_on_error(resp) keysets_dict = resp.json() keysets = KeysetsResponse.parse_obj(keysets_dict) assert len(keysets.keysets), Exception("did not receive any keysets") @@ -425,7 +432,7 @@ class LedgerAPI(object): resp = self.s.get( url + "/info", ) - resp.raise_for_status() + self.raise_on_error(resp) data: dict = resp.json() mint_info: GetInfoResponse = GetInfoResponse.parse_obj(data) return mint_info @@ -445,9 +452,8 @@ class LedgerAPI(object): """ logger.trace("Requesting mint: GET /mint") resp = self.s.get(self.url + "/mint", params={"amount": amount}) - resp.raise_for_status() + self.raise_on_error(resp) return_dict = resp.json() - self.raise_on_error(return_dict) mint_response = GetMintResponse.parse_obj(return_dict) return Invoice(amount=amount, pr=mint_response.pr, hash=mint_response.hash) @@ -483,9 +489,8 @@ class LedgerAPI(object): "payment_hash": hash, # backwards compatibility pre 0.12.0 }, ) - resp.raise_for_status() + self.raise_on_error(resp) reponse_dict = resp.json() - self.raise_on_error(reponse_dict) logger.trace("Lightning invoice checked. POST /mint") promises = PostMintResponse.parse_obj(reponse_dict).promises @@ -518,10 +523,8 @@ class LedgerAPI(object): self.url + "/split", json=split_payload.dict(include=_splitrequest_include_fields(proofs)), # type: ignore ) - resp.raise_for_status() + self.raise_on_error(resp) promises_dict = resp.json() - self.raise_on_error(promises_dict) - mint_response = PostMintResponse.parse_obj(promises_dict) promises = [BlindedSignature(**p.dict()) for p in mint_response.promises] @@ -547,9 +550,9 @@ class LedgerAPI(object): self.url + "/check", json=payload.dict(include=_check_proof_state_include_fields(proofs)), # type: ignore ) - resp.raise_for_status() + self.raise_on_error(resp) + return_dict = resp.json() - self.raise_on_error(return_dict) states = CheckSpendableResponse.parse_obj(return_dict) return states @@ -561,9 +564,9 @@ class LedgerAPI(object): self.url + "/checkfees", json=payload.dict(), ) - resp.raise_for_status() + self.raise_on_error(resp) + return_dict = resp.json() - self.raise_on_error(return_dict) return return_dict @async_set_requests @@ -589,9 +592,9 @@ class LedgerAPI(object): self.url + "/melt", json=payload.dict(include=_meltrequest_include_fields(proofs)), # type: ignore ) - resp.raise_for_status() + self.raise_on_error(resp) return_dict = resp.json() - self.raise_on_error(return_dict) + return GetMeltResponse.parse_obj(return_dict) @async_set_requests @@ -603,9 +606,8 @@ class LedgerAPI(object): """ payload = PostMintRequest(outputs=outputs) resp = self.s.post(self.url + "/restore", json=payload.dict()) - resp.raise_for_status() + self.raise_on_error(resp) reponse_dict = resp.json() - self.raise_on_error(reponse_dict) returnObj = PostRestoreResponse.parse_obj(reponse_dict) return returnObj.outputs, returnObj.promises diff --git a/tests/test_mint_api.py b/tests/test_mint_api.py index 87ea1ef..3f5e9e7 100644 --- a/tests/test_mint_api.py +++ b/tests/test_mint_api.py @@ -49,13 +49,13 @@ async def test_api_keyset_keys(ledger): @pytest.mark.asyncio async def test_api_mint_validation(ledger): response = requests.get(f"{BASE_URL}/mint?amount=-21") - assert "error" in response.json() + assert "detail" in response.json() response = requests.get(f"{BASE_URL}/mint?amount=0") - assert "error" in response.json() + assert "detail" in response.json() response = requests.get(f"{BASE_URL}/mint?amount=2100000000000001") - assert "error" in response.json() + assert "detail" in response.json() response = requests.get(f"{BASE_URL}/mint?amount=1") - assert "error" not in response.json() + assert "detail" not in response.json() @pytest.mark.asyncio diff --git a/tests/test_wallet.py b/tests/test_wallet.py index 6aaeb37..8559c56 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -2,7 +2,7 @@ import asyncio import shutil import time from pathlib import Path -from typing import Dict, List +from typing import Dict, List, Union import pytest import pytest_asyncio @@ -10,6 +10,18 @@ from mnemonic import Mnemonic from cashu.core.base import Proof, Secret, SecretKind, Tags from cashu.core.crypto.secp import PrivateKey, PublicKey +from cashu.core.errors import ( + CashuError, + InvoiceNotPaidError, + KeysetError, + KeysetNotFoundError, + LightningError, + NoSecretInProofsError, + NotAllowedError, + SecretTooLongError, + TokenAlreadySpentError, + TransactionError, +) from cashu.core.helpers import async_unwrap, sum_proofs from cashu.core.migrations import migrate_databases from cashu.core.settings import settings @@ -20,13 +32,20 @@ from cashu.wallet.wallet import Wallet as Wallet2 from tests.conftest import SERVER_ENDPOINT, mint -async def assert_err(f, msg): +async def assert_err(f, msg: Union[str, CashuError]): """Compute f() and expect an error message 'msg'.""" try: await f except Exception as exc: - if str(exc.args[0]) != msg: - raise Exception(f"Expected error: {msg}, got: {exc.args[0]}") + error_message: str = str(exc.args[0]) + if isinstance(msg, CashuError): + if msg.detail not in error_message: + raise Exception( + f"CashuError. Expected error: {msg.detail}, got: {error_message}" + ) + return + if msg not in error_message: + raise Exception(f"Expected error: {msg}, got: {error_message}") return raise Exception(f"Expected error: {msg}, got no error") @@ -119,7 +138,7 @@ async def test_get_info(wallet1: Wallet): async def test_get_nonexistent_keyset(wallet1: Wallet): await assert_err( wallet1._get_keys_of_keyset(wallet1.url, "nonexistent"), - "Mint Error: keyset does not exist", + KeysetNotFoundError(), ) @@ -221,7 +240,7 @@ async def test_double_spend(wallet1: Wallet): await wallet1.split(wallet1.proofs, 20) await assert_err( wallet1.split(doublespend, 20), - f"Mint Error: tokens already spent. Secret: {doublespend[0]['secret']}", + f"Mint Error: Token already spent.", ) assert wallet1.balance == 64 assert wallet1.available_balance == 64 diff --git a/tests/test_wallet_p2pk.py b/tests/test_wallet_p2pk.py index a0a01b7..5c9ba80 100644 --- a/tests/test_wallet_p2pk.py +++ b/tests/test_wallet_p2pk.py @@ -23,7 +23,7 @@ async def assert_err(f, msg): try: await f except Exception as exc: - if str(exc.args[0]) != msg: + if msg not in str(exc.args[0]): raise Exception(f"Expected error: {msg}, got: {exc.args[0]}") return raise Exception(f"Expected error: {msg}, got no error")