mirror of
https://github.com/aljazceru/nutshell.git
synced 2025-12-20 10:34:20 +01:00
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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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))
|
||||
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:
|
||||
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
|
||||
except Exception as exc:
|
||||
return CashuError(code=0, error=str(exc))
|
||||
|
||||
|
||||
@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))
|
||||
|
||||
|
||||
@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))
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
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))
|
||||
return PostRestoreResponse(outputs=outputs, promises=promises)
|
||||
|
||||
@@ -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)}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user