Add custom error types (#290)

* add error types

* better subclassing

* add response models

* fix comments

* add response_description to mint endpoints
This commit is contained in:
callebtc
2023-07-25 23:26:50 +02:00
committed by GitHub
parent b196c34427
commit 3d676dd35f
9 changed files with 294 additions and 151 deletions

View File

@@ -1,21 +1,83 @@
from pydantic import BaseModel from typing import Optional
class CashuError(BaseModel): class CashuError(Exception):
code: int code: int
error: str detail: str
def __init__(self, detail, code=0):
super().__init__(detail)
self.code = code
self.detail = detail
class MintException(CashuError): class NotAllowedError(CashuError):
code = 100 detail = "Not allowed."
error = "Mint" 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): class TransactionError(CashuError):
code = 200 detail = "Transaction error."
error = "Lightning" 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): class TokenAlreadySpentError(TransactionError):
code = 201 detail = "Token already spent."
error = "invoice not paid." 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)

View File

@@ -1,14 +1,19 @@
import logging import logging
import sys 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 fastapi_profiler import PyInstrumentProfilerMiddleware
from loguru import logger from loguru import logger
from starlette.middleware import Middleware from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from starlette.middleware.cors import CORSMiddleware 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 ..core.settings import settings
from .router import router from .router import router
from .startup import start_mint_init from .startup import start_mint_init
@@ -100,9 +105,35 @@ def create_app(config_object="core.settings") -> FastAPI:
app = create_app() 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") @app.on_event("startup")
async def startup_mint(): async def startup_mint():
await start_mint_init() await start_mint_init()
app.include_router(router=router)

View File

@@ -21,6 +21,17 @@ from ..core.crypto import b_dhke
from ..core.crypto.keys import derive_pubkey, random_hash from ..core.crypto.keys import derive_pubkey, random_hash
from ..core.crypto.secp import PublicKey from ..core.crypto.secp import PublicKey
from ..core.db import Connection, Database 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.helpers import fee_reserve, sum_proofs
from ..core.p2pk import verify_p2pk_signature from ..core.p2pk import verify_p2pk_signature
from ..core.script import verify_bitcoin_script from ..core.script import verify_bitcoin_script
@@ -202,15 +213,15 @@ class Ledger:
def _verify_secret_criteria(self, proof: Proof) -> Literal[True]: def _verify_secret_criteria(self, proof: Proof) -> Literal[True]:
"""Verifies that a secret is present and is not too long (DOS prevention).""" """Verifies that a secret is present and is not too long (DOS prevention)."""
if proof.secret is None or proof.secret == "": if proof.secret is None or proof.secret == "":
raise Exception("no secret in proof.") raise NoSecretInProofsError()
if len(proof.secret) > 512: if len(proof.secret) > 512:
raise Exception("secret too long.") raise SecretTooLongError()
return True return True
def _verify_proof_bdhke(self, proof: Proof): def _verify_proof_bdhke(self, proof: Proof):
"""Verifies that the proof of promise was issued by this ledger.""" """Verifies that the proof of promise was issued by this ledger."""
if not self._check_spendable(proof): 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 no keyset id is given in proof, assume the current one
if not proof.id: if not proof.id:
private_key_amount = self.keyset.private_keys[proof.amount] private_key_amount = self.keyset.private_keys[proof.amount]
@@ -256,14 +267,14 @@ class Ledger:
or proof.p2shscript.signature is None or proof.p2shscript.signature is None
): ):
# no script present although secret indicates one # no script present although secret indicates one
raise Exception("no script in proof.") raise TransactionError("no script in proof.")
# execute and verify P2SH # execute and verify P2SH
txin_p2sh_address, valid = verify_bitcoin_script( txin_p2sh_address, valid = verify_bitcoin_script(
proof.p2shscript.script, proof.p2shscript.signature proof.p2shscript.script, proof.p2shscript.signature
) )
if not valid: if not valid:
raise Exception("script invalid.") raise TransactionError("script invalid.")
# check if secret commits to script address # check if secret commits to script address
assert secret.data == str( assert secret.data == str(
txin_p2sh_address txin_p2sh_address
@@ -284,11 +295,11 @@ class Ledger:
if not proof.p2pksigs: if not proof.p2pksigs:
# no signature present although secret indicates one # no signature present although secret indicates one
logger.error(f"no p2pk signatures in proof: {proof.p2pksigs}") 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 # we make sure that there are no duplicate signatures
if len(set(proof.p2pksigs)) != len(proof.p2pksigs): 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 # we parse the secret as a P2PK commitment
# assert len(proof.secret.split(":")) == 5, "p2pk secret format invalid." # 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}") logger.trace(f"Verifying amount {amount} is valid: {valid}")
if not valid: if not valid:
raise Exception("invalid amount: " + str(amount)) raise NotAllowedError("invalid amount: " + str(amount))
return amount return amount
def _verify_equation_balanced( def _verify_equation_balanced(
@@ -486,7 +497,7 @@ class Ledger:
error, balance = await self.lightning.status() error, balance = await self.lightning.status()
logger.trace(f"_request_lightning_invoice: Lightning wallet balance: {balance}") logger.trace(f"_request_lightning_invoice: Lightning wallet balance: {balance}")
if error: if error:
raise Exception(f"Lightning wallet not responding: {error}") raise LightningError(f"Lightning wallet not responding: {error}")
( (
ok, ok,
checking_id, checking_id,
@@ -523,9 +534,9 @@ class Ledger:
) )
logger.trace(f"crud: _check_lightning_invoice: invoice: {invoice}") logger.trace(f"crud: _check_lightning_invoice: invoice: {invoice}")
if invoice is None: if invoice is None:
raise Exception("invoice not found.") raise LightningError("invoice not found.")
if invoice.issued: 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." assert invoice.payment_hash, "invoice has no payment hash."
# set this invoice as issued # set this invoice as issued
@@ -537,7 +548,7 @@ class Ledger:
try: try:
if amount > invoice.amount: if amount > invoice.amount:
raise Exception( raise LightningError(
f"requested amount too high: {amount}. Invoice amount: {invoice.amount}" f"requested amount too high: {amount}. Invoice amount: {invoice.amount}"
) )
logger.trace( logger.trace(
@@ -550,7 +561,7 @@ class Ledger:
if status.paid: if status.paid:
return status.paid return status.paid
else: else:
raise Exception("Lightning invoice not paid yet.") raise InvoiceNotPaidError()
except Exception as e: except Exception as e:
# unset issued # unset issued
logger.trace(f"crud: unsetting invoice {invoice.payment_hash} as issued") logger.trace(f"crud: unsetting invoice {invoice.payment_hash} as issued")
@@ -577,7 +588,7 @@ class Ledger:
error, balance = await self.lightning.status() error, balance = await self.lightning.status()
logger.trace(f"_pay_lightning_invoice: Lightning wallet balance: {balance}") logger.trace(f"_pay_lightning_invoice: Lightning wallet balance: {balance}")
if error: if error:
raise Exception(f"Lightning wallet not responding: {error}") raise LightningError(f"Lightning wallet not responding: {error}")
( (
ok, ok,
checking_id, checking_id,
@@ -631,7 +642,7 @@ class Ledger:
f"crud: _set_proofs_pending proof {p.secret} set as pending" f"crud: _set_proofs_pending proof {p.secret} set as pending"
) )
except: except:
raise Exception("proofs already pending.") raise TransactionError("proofs already pending.")
async def _unset_proofs_pending( async def _unset_proofs_pending(
self, proofs: List[Proof], conn: Optional[Connection] = None self, proofs: List[Proof], conn: Optional[Connection] = None
@@ -674,7 +685,7 @@ class Ledger:
for p in proofs: for p in proofs:
for pp in proofs_pending: for pp in proofs_pending:
if p.secret == pp.secret: if p.secret == pp.secret:
raise Exception("proofs are pending.") raise TransactionError("proofs are pending.")
async def _verify_proofs_and_outputs( async def _verify_proofs_and_outputs(
self, proofs: List[Proof], outputs: Optional[List[BlindedMessage]] = None self, proofs: List[Proof], outputs: Optional[List[BlindedMessage]] = None
@@ -695,16 +706,16 @@ class Ledger:
# Verify secret criteria # Verify secret criteria
if not all([self._verify_secret_criteria(p) for p in proofs]): 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 # verify that only unique proofs were used
if not self._verify_no_duplicate_proofs(proofs): if not self._verify_no_duplicate_proofs(proofs):
raise Exception("duplicate proofs.") raise TransactionError("duplicate proofs.")
# Verify input spending conditions # Verify input spending conditions
if not all([self._verify_input_spending_conditions(p) for p in proofs]): 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 # Verify ecash signatures
if not all([self._verify_proof_bdhke(p) for p in proofs]): 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: if not outputs:
return return
@@ -713,12 +724,12 @@ class Ledger:
# verify that only unique outputs were used # verify that only unique outputs were used
if not self._verify_no_duplicate_outputs(outputs): 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): 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 # Verify output spending conditions
if outputs and not self._verify_output_spending_conditions(proofs, outputs): 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( async def _generate_change_promises(
self, self,
@@ -776,7 +787,7 @@ class Ledger:
for i in range(len(outputs)): for i in range(len(outputs)):
outputs[i].amount = return_amounts_sorted[i] outputs[i].amount = return_amounts_sorted[i]
if not self._verify_no_duplicate_outputs(outputs): 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_promises = await self._generate_promises(outputs, keyset)
return return_promises return return_promises
else: else:
@@ -786,9 +797,9 @@ class Ledger:
def get_keyset(self, keyset_id: Optional[str] = None): def get_keyset(self, keyset_id: Optional[str] = None):
"""Returns a dictionary of hex public keys of a specific keyset for each supported amount""" """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: 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 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()} return {a: p.serialize().hex() for a, p in keyset.public_keys.items()}
async def request_mint(self, amount: int): async def request_mint(self, amount: int):
@@ -805,14 +816,16 @@ class Ledger:
""" """
logger.trace(f"called request_mint") logger.trace(f"called request_mint")
if settings.mint_max_peg_in and amount > settings.mint_max_peg_in: 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: 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") logger.trace(f"requesting invoice for {amount} satoshis")
payment_request, payment_hash = await self._request_lightning_invoice(amount) payment_request, payment_hash = await self._request_lightning_invoice(amount)
logger.trace(f"got invoice {payment_request} with hash {payment_hash}") 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" "could not fetch invoice from Lightning backend"
) )
@@ -856,7 +869,7 @@ class Ledger:
if settings.lightning: if settings.lightning:
if not hash: if not hash:
raise Exception("no hash provided.") raise NotAllowedError("no hash provided.")
self.locks[hash] = ( self.locks[hash] = (
self.locks.get(hash) or asyncio.Lock() self.locks.get(hash) or asyncio.Lock()
) # create a new lock if it doesn't exist ) # create a new lock if it doesn't exist
@@ -867,7 +880,7 @@ class Ledger:
for amount in amounts: for amount in amounts:
if amount not in [2**i for i in range(settings.max_order)]: 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}." 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_obj = bolt11.decode(invoice)
invoice_amount = math.ceil(invoice_obj.amount_msat / 1000) invoice_amount = math.ceil(invoice_obj.amount_msat / 1000)
if settings.mint_max_peg_out and invoice_amount > settings.mint_max_peg_out: 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." f"Maximum melt amount is {settings.mint_max_peg_out} sat."
) )
fees_msat = await self.check_fees(invoice) fees_msat = await self.check_fees(invoice)
assert total_provided >= invoice_amount + fees_msat / 1000, Exception( assert (
"provided proofs not enough for Lightning payment." total_provided >= invoice_amount + fees_msat / 1000
) ), TransactionError("provided proofs not enough for Lightning payment.")
# promises to return for overpaid fees # promises to return for overpaid fees
return_promises: List[BlindedSignature] = [] return_promises: List[BlindedSignature] = []
@@ -933,7 +946,7 @@ class Ledger:
await self._invalidate_proofs(proofs) await self._invalidate_proofs(proofs)
logger.trace("invalidated proofs") logger.trace("invalidated proofs")
# prepare change to compensate wallet for overpaid fees # 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: if outputs:
return_promises = await self._generate_change_promises( return_promises = await self._generate_change_promises(
total_provided=total_provided, total_provided=total_provided,
@@ -943,7 +956,7 @@ class Ledger:
) )
else: else:
logger.trace("lightning payment unsuccessful") logger.trace("lightning payment unsuccessful")
raise Exception("Lightning payment unsuccessful.") raise LightningError("Lightning payment unsuccessful.")
except Exception as e: except Exception as e:
logger.trace(f"exception: {e}") logger.trace(f"exception: {e}")

View File

@@ -38,7 +38,7 @@ router: APIRouter = APIRouter()
response_model=GetInfoResponse, response_model=GetInfoResponse,
response_model_exclude_none=True, response_model_exclude_none=True,
) )
async def info(): async def info() -> GetInfoResponse:
logger.trace(f"> GET /info") logger.trace(f"> GET /info")
return GetInfoResponse( return GetInfoResponse(
name=settings.mint_info_name, name=settings.mint_info_name,
@@ -61,38 +61,43 @@ async def info():
"/keys", "/keys",
name="Mint public keys", name="Mint public keys",
summary="Get the public keys of the newest mint keyset", 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.""" """This endpoint returns a dictionary of all supported token values of the mint and their associated public key."""
logger.trace(f"> GET /keys") logger.trace(f"> GET /keys")
keyset = ledger.get_keyset() keyset = ledger.get_keyset()
keys = KeysResponse.parse_obj(keyset) keys = KeysResponse.parse_obj(keyset)
return keys return keys.__root__
@router.get( @router.get(
"/keys/{idBase64Urlsafe}", "/keys/{idBase64Urlsafe}",
name="Keyset public keys", name="Keyset public keys",
summary="Public keys of a specific keyset", 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. 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 The id is encoded in idBase64Urlsafe (by a wallet) and is converted back to
normal base64 before it can be processed (by the mint). normal base64 before it can be processed (by the mint).
""" """
logger.trace(f"> GET /keys/{idBase64Urlsafe}") logger.trace(f"> GET /keys/{idBase64Urlsafe}")
try: id = idBase64Urlsafe.replace("-", "+").replace("_", "/")
id = idBase64Urlsafe.replace("-", "+").replace("_", "/") keyset = ledger.get_keyset(keyset_id=id)
keyset = ledger.get_keyset(keyset_id=id) keys = KeysResponse.parse_obj(keyset)
keys = KeysResponse.parse_obj(keyset) return keys.__root__
return keys
except Exception as exc:
return CashuError(code=0, error=str(exc))
@router.get( @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: async def keysets() -> KeysetsResponse:
"""This endpoint returns a list of keysets that the mint currently supports and will accept tokens from.""" """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 return keysets
@router.get("/mint", name="Request mint", summary="Request minting of new tokens") @router.get(
async def request_mint(amount: int = 0) -> Union[GetMintResponse, CashuError]: "/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. Request minting of new tokens. The mint responds with a Lightning invoice.
This endpoint can be used for a Lightning invoice UX flow. 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}") logger.trace(f"> GET /mint: amount={amount}")
if amount > 21_000_000 * 100_000_000 or amount <= 0: 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: if settings.mint_peg_out_only:
return CashuError(code=0, error="Mint does not allow minting new tokens.") raise CashuError(code=0, detail="Mint does not allow minting new tokens.")
try:
payment_request, hash = await ledger.request_mint(amount) payment_request, hash = await ledger.request_mint(amount)
resp = GetMintResponse(pr=payment_request, hash=hash) resp = GetMintResponse(pr=payment_request, hash=hash)
logger.trace(f"< GET /mint: {resp}") logger.trace(f"< GET /mint: {resp}")
return resp return resp
except Exception as exc:
return CashuError(code=0, error=str(exc))
@router.post( @router.post(
"/mint", "/mint",
name="Mint tokens", name="Mint tokens",
summary="Mint tokens in exchange for a Bitcoin paymemt that the user has made", 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( async def mint(
payload: PostMintRequest, payload: PostMintRequest,
hash: Optional[str] = None, hash: Optional[str] = None,
payment_hash: Optional[str] = None, payment_hash: Optional[str] = None,
) -> Union[PostMintResponse, CashuError]: ) -> PostMintResponse:
""" """
Requests the minting of tokens belonging to a paid payment request. Requests the minting of tokens belonging to a paid payment request.
Call this endpoint after `GET /mint`. Call this endpoint after `GET /mint`.
""" """
logger.trace(f"> POST /mint: {payload}") logger.trace(f"> POST /mint: {payload}")
try:
# BEGIN: backwards compatibility < 0.12 where we used to lookup payments with payment_hash # 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. # We use the payment_hash to lookup the hash from the database and pass that one along.
hash = payment_hash or hash hash = payment_hash or hash
# END: backwards compatibility < 0.12 # END: backwards compatibility < 0.12
promises = await ledger.mint(payload.outputs, hash=hash)
blinded_signatures = PostMintResponse(promises=promises) promises = await ledger.mint(payload.outputs, hash=hash)
logger.trace(f"< POST /mint: {blinded_signatures}") blinded_signatures = PostMintResponse(promises=promises)
return blinded_signatures logger.trace(f"< POST /mint: {blinded_signatures}")
except Exception as exc: return blinded_signatures
return CashuError(code=0, error=str(exc))
@router.post( @router.post(
"/melt", "/melt",
name="Melt tokens", name="Melt tokens",
summary="Melt tokens for a Bitcoin payment that the mint will make for the user in exchange", 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. Requests tokens to be destroyed and sent out via Lightning.
""" """
logger.trace(f"> POST /melt: {payload}") logger.trace(f"> POST /melt: {payload}")
try: ok, preimage, change_promises = await ledger.melt(
ok, preimage, change_promises = await ledger.melt( payload.proofs, payload.pr, payload.outputs
payload.proofs, payload.pr, payload.outputs )
) resp = GetMeltResponse(paid=ok, preimage=preimage, change=change_promises)
resp = GetMeltResponse(paid=ok, preimage=preimage, change=change_promises) logger.trace(f"< POST /melt: {resp}")
logger.trace(f"< POST /melt: {resp}") return resp
return resp
except Exception as exc:
return CashuError(code=0, error=str(exc))
@router.post( @router.post(
"/check", "/check",
name="Check proof state", name="Check proof state",
summary="Check whether a proof is spent already or is pending in a transaction", 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( async def check_spendable(
payload: CheckSpendableRequest, payload: CheckSpendableRequest,
@@ -193,6 +204,8 @@ async def check_spendable(
"/checkfees", "/checkfees",
name="Check fees", name="Check fees",
summary="Check fee reserve for a Lightning payment", 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: async def check_fees(payload: CheckFeesRequest) -> CheckFeesResponse:
""" """
@@ -206,27 +219,28 @@ async def check_fees(payload: CheckFeesRequest) -> CheckFeesResponse:
return CheckFeesResponse(fee=fees_sat) 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( async def split(
payload: PostSplitRequest, payload: PostSplitRequest,
) -> Union[CashuError, PostSplitResponse, PostSplitResponse_Deprecated]: ) -> Union[PostSplitResponse, PostSplitResponse_Deprecated]:
""" """
Requetst a set of tokens with amount "total" to be split into two Requests a set of Proofs to be split into two a new set of BlindedSignatures.
newly minted sets with amount "split" and "total-split".
This endpoint is used by Alice to split a set of proofs before making a payment to Carol. 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. It is then used by Carol (by setting split=total) to redeem the tokens.
""" """
logger.trace(f"> POST /split: {payload}") logger.trace(f"> POST /split: {payload}")
assert payload.outputs, Exception("no outputs provided.") assert payload.outputs, Exception("no outputs provided.")
try:
promises = await ledger.split( promises = await ledger.split(
proofs=payload.proofs, outputs=payload.outputs, amount=payload.amount 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")
if payload.amount: if payload.amount:
# BEGIN backwards compatibility < 0.13 # BEGIN backwards compatibility < 0.13
@@ -253,12 +267,13 @@ async def split(
@router.post( @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.") assert payload.outputs, Exception("no outputs provided.")
try: outputs, promises = await ledger.restore(payload.outputs)
outputs, promises = await ledger.restore(payload.outputs)
except Exception as exc:
return CashuError(code=0, error=str(exc))
return PostRestoreResponse(outputs=outputs, promises=promises) return PostRestoreResponse(outputs=outputs, promises=promises)

View File

@@ -1,5 +1,6 @@
from fastapi import FastAPI, Request, status from fastapi import FastAPI, Request, status
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from loguru import logger
from ...core.settings import settings from ...core.settings import settings
from .router import router from .router import router
@@ -28,9 +29,9 @@ app = create_app()
@app.middleware("http") @app.middleware("http")
async def catch_exceptions(request: Request, call_next): async def catch_exceptions(request: Request, call_next):
try: try:
response = await call_next(request) return await call_next(request)
return response
except Exception as e: except Exception as e:
logger.error(f"Exception: {e}")
return JSONResponse( return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST, content={"detail": str(e)} status_code=status.HTTP_400_BAD_REQUEST, content={"detail": str(e)}
) )

View File

@@ -15,6 +15,7 @@ import requests
from bip32 import BIP32 from bip32 import BIP32
from loguru import logger from loguru import logger
from mnemonic import Mnemonic from mnemonic import Mnemonic
from requests import Response
from ..core import bolt11 as bolt11 from ..core import bolt11 as bolt11
from ..core.base import ( from ..core.base import (
@@ -225,17 +226,24 @@ class LedgerAPI(object):
return outputs, rs_return return outputs, rs_return
@staticmethod @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. """Raises an exception if the response from the mint contains an error.
Args: Args:
resp_dict (_type_): Response dict (previously JSON) from mint resp_dict (Response): Response dict (previously JSON) from mint
Raises: Raises:
Exception: if the response contains an error Exception: if the response contains an error
""" """
if "error" in resp_dict: resp_dict = resp.json()
raise Exception("Mint Error: {}".format(resp_dict["error"])) 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: async def _load_mint_keys(self, keyset_id: str = "") -> WalletKeyset:
"""Loads keys from mint and stores them in the database. """Loads keys from mint and stores them in the database.
@@ -347,7 +355,7 @@ class LedgerAPI(object):
resp = self.s.get( resp = self.s.get(
url + "/keys", url + "/keys",
) )
resp.raise_for_status() self.raise_on_error(resp)
keys: dict = resp.json() keys: dict = resp.json()
assert len(keys), Exception("did not receive any keys") assert len(keys), Exception("did not receive any keys")
keyset_keys = { keyset_keys = {
@@ -376,9 +384,8 @@ class LedgerAPI(object):
resp = self.s.get( resp = self.s.get(
url + f"/keys/{keyset_id_urlsafe}", url + f"/keys/{keyset_id_urlsafe}",
) )
resp.raise_for_status() self.raise_on_error(resp)
keys = resp.json() keys = resp.json()
self.raise_on_error(keys)
assert len(keys), Exception("did not receive any keys") assert len(keys), Exception("did not receive any keys")
keyset_keys = { keyset_keys = {
int(amt): PublicKey(bytes.fromhex(val), raw=True) int(amt): PublicKey(bytes.fromhex(val), raw=True)
@@ -403,7 +410,7 @@ class LedgerAPI(object):
resp = self.s.get( resp = self.s.get(
url + "/keysets", url + "/keysets",
) )
resp.raise_for_status() self.raise_on_error(resp)
keysets_dict = resp.json() keysets_dict = resp.json()
keysets = KeysetsResponse.parse_obj(keysets_dict) keysets = KeysetsResponse.parse_obj(keysets_dict)
assert len(keysets.keysets), Exception("did not receive any keysets") assert len(keysets.keysets), Exception("did not receive any keysets")
@@ -425,7 +432,7 @@ class LedgerAPI(object):
resp = self.s.get( resp = self.s.get(
url + "/info", url + "/info",
) )
resp.raise_for_status() self.raise_on_error(resp)
data: dict = resp.json() data: dict = resp.json()
mint_info: GetInfoResponse = GetInfoResponse.parse_obj(data) mint_info: GetInfoResponse = GetInfoResponse.parse_obj(data)
return mint_info return mint_info
@@ -445,9 +452,8 @@ class LedgerAPI(object):
""" """
logger.trace("Requesting mint: GET /mint") logger.trace("Requesting mint: GET /mint")
resp = self.s.get(self.url + "/mint", params={"amount": amount}) resp = self.s.get(self.url + "/mint", params={"amount": amount})
resp.raise_for_status() self.raise_on_error(resp)
return_dict = resp.json() return_dict = resp.json()
self.raise_on_error(return_dict)
mint_response = GetMintResponse.parse_obj(return_dict) mint_response = GetMintResponse.parse_obj(return_dict)
return Invoice(amount=amount, pr=mint_response.pr, hash=mint_response.hash) 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 "payment_hash": hash, # backwards compatibility pre 0.12.0
}, },
) )
resp.raise_for_status() self.raise_on_error(resp)
reponse_dict = resp.json() reponse_dict = resp.json()
self.raise_on_error(reponse_dict)
logger.trace("Lightning invoice checked. POST /mint") logger.trace("Lightning invoice checked. POST /mint")
promises = PostMintResponse.parse_obj(reponse_dict).promises promises = PostMintResponse.parse_obj(reponse_dict).promises
@@ -518,10 +523,8 @@ class LedgerAPI(object):
self.url + "/split", self.url + "/split",
json=split_payload.dict(include=_splitrequest_include_fields(proofs)), # type: ignore json=split_payload.dict(include=_splitrequest_include_fields(proofs)), # type: ignore
) )
resp.raise_for_status() self.raise_on_error(resp)
promises_dict = resp.json() promises_dict = resp.json()
self.raise_on_error(promises_dict)
mint_response = PostMintResponse.parse_obj(promises_dict) mint_response = PostMintResponse.parse_obj(promises_dict)
promises = [BlindedSignature(**p.dict()) for p in mint_response.promises] promises = [BlindedSignature(**p.dict()) for p in mint_response.promises]
@@ -547,9 +550,9 @@ class LedgerAPI(object):
self.url + "/check", self.url + "/check",
json=payload.dict(include=_check_proof_state_include_fields(proofs)), # type: ignore 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() return_dict = resp.json()
self.raise_on_error(return_dict)
states = CheckSpendableResponse.parse_obj(return_dict) states = CheckSpendableResponse.parse_obj(return_dict)
return states return states
@@ -561,9 +564,9 @@ class LedgerAPI(object):
self.url + "/checkfees", self.url + "/checkfees",
json=payload.dict(), json=payload.dict(),
) )
resp.raise_for_status() self.raise_on_error(resp)
return_dict = resp.json() return_dict = resp.json()
self.raise_on_error(return_dict)
return return_dict return return_dict
@async_set_requests @async_set_requests
@@ -589,9 +592,9 @@ class LedgerAPI(object):
self.url + "/melt", self.url + "/melt",
json=payload.dict(include=_meltrequest_include_fields(proofs)), # type: ignore json=payload.dict(include=_meltrequest_include_fields(proofs)), # type: ignore
) )
resp.raise_for_status() self.raise_on_error(resp)
return_dict = resp.json() return_dict = resp.json()
self.raise_on_error(return_dict)
return GetMeltResponse.parse_obj(return_dict) return GetMeltResponse.parse_obj(return_dict)
@async_set_requests @async_set_requests
@@ -603,9 +606,8 @@ class LedgerAPI(object):
""" """
payload = PostMintRequest(outputs=outputs) payload = PostMintRequest(outputs=outputs)
resp = self.s.post(self.url + "/restore", json=payload.dict()) resp = self.s.post(self.url + "/restore", json=payload.dict())
resp.raise_for_status() self.raise_on_error(resp)
reponse_dict = resp.json() reponse_dict = resp.json()
self.raise_on_error(reponse_dict)
returnObj = PostRestoreResponse.parse_obj(reponse_dict) returnObj = PostRestoreResponse.parse_obj(reponse_dict)
return returnObj.outputs, returnObj.promises return returnObj.outputs, returnObj.promises

View File

@@ -49,13 +49,13 @@ async def test_api_keyset_keys(ledger):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_api_mint_validation(ledger): async def test_api_mint_validation(ledger):
response = requests.get(f"{BASE_URL}/mint?amount=-21") 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") 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") 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") response = requests.get(f"{BASE_URL}/mint?amount=1")
assert "error" not in response.json() assert "detail" not in response.json()
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@@ -2,7 +2,7 @@ import asyncio
import shutil import shutil
import time import time
from pathlib import Path from pathlib import Path
from typing import Dict, List from typing import Dict, List, Union
import pytest import pytest
import pytest_asyncio import pytest_asyncio
@@ -10,6 +10,18 @@ from mnemonic import Mnemonic
from cashu.core.base import Proof, Secret, SecretKind, Tags from cashu.core.base import Proof, Secret, SecretKind, Tags
from cashu.core.crypto.secp import PrivateKey, PublicKey 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.helpers import async_unwrap, sum_proofs
from cashu.core.migrations import migrate_databases from cashu.core.migrations import migrate_databases
from cashu.core.settings import settings 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 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'.""" """Compute f() and expect an error message 'msg'."""
try: try:
await f await f
except Exception as exc: except Exception as exc:
if str(exc.args[0]) != msg: error_message: str = str(exc.args[0])
raise Exception(f"Expected error: {msg}, got: {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 return
raise Exception(f"Expected error: {msg}, got no error") 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): async def test_get_nonexistent_keyset(wallet1: Wallet):
await assert_err( await assert_err(
wallet1._get_keys_of_keyset(wallet1.url, "nonexistent"), 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 wallet1.split(wallet1.proofs, 20)
await assert_err( await assert_err(
wallet1.split(doublespend, 20), 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.balance == 64
assert wallet1.available_balance == 64 assert wallet1.available_balance == 64

View File

@@ -23,7 +23,7 @@ async def assert_err(f, msg):
try: try:
await f await f
except Exception as exc: 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]}") raise Exception(f"Expected error: {msg}, got: {exc.args[0]}")
return return
raise Exception(f"Expected error: {msg}, got no error") raise Exception(f"Expected error: {msg}, got no error")