import json import uuid from posixpath import join from typing import List, Optional, Tuple, Union import bolt11 import httpx from httpx import Response from loguru import logger from pydantic import ValidationError from ..core.base import ( BlindedMessage, BlindedSignature, MeltQuoteState, Proof, ProofSpentState, ProofState, Unit, WalletKeyset, ) from ..core.crypto.secp import PublicKey from ..core.db import Database from ..core.models import ( CheckFeesResponse_deprecated, GetInfoResponse, KeysetsResponse, KeysetsResponseKeyset, KeysResponse, PostCheckStateRequest, PostCheckStateResponse, PostMeltQuoteRequest, PostMeltQuoteResponse, PostMeltRequest, PostMeltRequestOptionMpp, PostMeltRequestOptions, PostMeltResponse_deprecated, PostMintQuoteRequest, PostMintQuoteResponse, PostMintRequest, PostMintResponse, PostRestoreResponse, PostSwapRequest, PostSwapResponse, ) from ..core.settings import settings from ..tor.tor import TorProxy from .crud import ( get_lightning_invoice, ) from .wallet_deprecated import LedgerAPIDeprecated def async_set_httpx_client(func): """ Decorator that wraps around any async class method of LedgerAPI that makes API calls. Sets some HTTP headers and starts a Tor instance if none is already running and and sets local proxy to use it. """ async def wrapper(self, *args, **kwargs): # set proxy proxies_dict = {} proxy_url: Union[str, None] = None if settings.tor and TorProxy().check_platform(): self.tor = TorProxy(timeout=True) self.tor.run_daemon(verbose=True) proxy_url = "socks5://localhost:9050" elif settings.socks_proxy: proxy_url = f"socks5://{settings.socks_proxy}" elif settings.http_proxy: proxy_url = settings.http_proxy if proxy_url: proxies_dict.update({"all://": proxy_url}) headers_dict = {"Client-version": settings.version} self.httpx = httpx.AsyncClient( verify=not settings.debug, proxies=proxies_dict, # type: ignore headers=headers_dict, base_url=self.url, timeout=None if settings.debug else 60, ) return await func(self, *args, **kwargs) return wrapper def async_ensure_mint_loaded(func): """Decorator that ensures that the mint is loaded before calling the wrapped function. If the mint is not loaded, it will be loaded first. """ async def wrapper(self, *args, **kwargs): if not self.keysets: await self.load_mint() return await func(self, *args, **kwargs) return wrapper class LedgerAPI(LedgerAPIDeprecated): tor: TorProxy db: Database # we need the db for melt_deprecated httpx: httpx.AsyncClient def __init__(self, url: str, db: Database): self.url = url self.db = db @async_set_httpx_client async def _init_s(self): """Dummy function that can be called from outside to use LedgerAPI.s""" return @staticmethod def raise_on_error_request( resp: Response, ) -> None: """Raises an exception if the response from the mint contains an error. Args: resp_dict (Response): Response dict (previously JSON) from mint Raises: Exception: if the response contains an error """ try: resp_dict = resp.json() except json.JSONDecodeError: # if we can't decode the response, raise for status resp.raise_for_status() return if "detail" in resp_dict: logger.trace(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() """ ENDPOINTS """ @async_set_httpx_client async def _get_keys(self) -> List[WalletKeyset]: """API that gets the current keys of the mint Args: url (str): Mint URL Returns: WalletKeyset: Current mint keyset Raises: Exception: If no keys are received from the mint """ resp = await self.httpx.get( join(self.url, "/v1/keys"), ) # BEGIN backwards compatibility < 0.15.0 # assume the mint has not upgraded yet if we get a 404 if resp.status_code == 404: ret = await self._get_keys_deprecated(self.url) return [ret] # END backwards compatibility < 0.15.0 self.raise_on_error_request(resp) keys_dict: dict = resp.json() assert len(keys_dict), Exception("did not receive any keys") keys = KeysResponse.parse_obj(keys_dict) keysets_str = " ".join([f"{k.id} ({k.unit})" for k in keys.keysets]) logger.debug(f"Received {len(keys.keysets)} keysets from mint: {keysets_str}.") ret = [ WalletKeyset( id=keyset.id, unit=keyset.unit, public_keys={ int(amt): PublicKey(bytes.fromhex(val), raw=True) for amt, val in keyset.keys.items() }, mint_url=self.url, ) for keyset in keys.keysets ] return ret @async_set_httpx_client async def _get_keyset(self, keyset_id: str) -> WalletKeyset: """API that gets the keys of a specific keyset from the mint. Args: keyset_id (str): base64 keyset ID, needs to be urlsafe-encoded before sending to mint (done in this method) Returns: WalletKeyset: Keyset with ID keyset_id Raises: Exception: If no keys are received from the mint """ keyset_id_urlsafe = keyset_id.replace("+", "-").replace("/", "_") resp = await self.httpx.get( join(self.url, f"/v1/keys/{keyset_id_urlsafe}"), ) # BEGIN backwards compatibility < 0.15.0 # assume the mint has not upgraded yet if we get a 404 if resp.status_code == 404: ret = await self._get_keyset_deprecated(self.url, keyset_id) return ret # END backwards compatibility < 0.15.0 self.raise_on_error_request(resp) keys_dict = resp.json() assert len(keys_dict), Exception("did not receive any keys") keys = KeysResponse.parse_obj(keys_dict) this_keyset = keys.keysets[0] keyset_keys = { int(amt): PublicKey(bytes.fromhex(val), raw=True) for amt, val in this_keyset.keys.items() } keyset = WalletKeyset( id=keyset_id, unit=this_keyset.unit, public_keys=keyset_keys, mint_url=self.url, ) return keyset @async_set_httpx_client async def _get_keysets(self) -> List[KeysetsResponseKeyset]: """API that gets a list of all active keysets of the mint. Returns: KeysetsResponse (List[str]): List of all active keyset IDs of the mint Raises: Exception: If no keysets are received from the mint """ resp = await self.httpx.get( join(self.url, "/v1/keysets"), ) # BEGIN backwards compatibility < 0.15.0 # assume the mint has not upgraded yet if we get a 404 if resp.status_code == 404: ret = await self._get_keysets_deprecated(self.url) return ret # END backwards compatibility < 0.15.0 self.raise_on_error_request(resp) keysets_dict = resp.json() keysets = KeysetsResponse.parse_obj(keysets_dict).keysets if not keysets: raise Exception("did not receive any keysets") return keysets @async_set_httpx_client async def _get_info(self) -> GetInfoResponse: """API that gets the mint info. Returns: GetInfoResponse: Current mint info Raises: Exception: If the mint info request fails """ resp = await self.httpx.get( join(self.url, "/v1/info"), ) # BEGIN backwards compatibility < 0.15.0 # assume the mint has not upgraded yet if we get a 404 if resp.status_code == 404: ret = await self._get_info_deprecated() return ret # END backwards compatibility < 0.15.0 self.raise_on_error_request(resp) data: dict = resp.json() mint_info: GetInfoResponse = GetInfoResponse.parse_obj(data) return mint_info @async_set_httpx_client @async_ensure_mint_loaded async def mint_quote( self, amount: int, unit: Unit, memo: Optional[str] = None ) -> PostMintQuoteResponse: """Requests a mint quote from the server and returns a payment request. Args: amount (int): Amount of tokens to mint unit (Unit): Unit of the amount memo (Optional[str], optional): Memo to attach to Lightning invoice. Defaults to None. Returns: PostMintQuoteResponse: Mint Quote Response Raises: Exception: If the mint request fails """ logger.trace("Requesting mint: POST /v1/mint/bolt11") payload = PostMintQuoteRequest(unit=unit.name, amount=amount, description=memo) resp = await self.httpx.post( join(self.url, "/v1/mint/quote/bolt11"), json=payload.dict() ) # BEGIN backwards compatibility < 0.15.0 # assume the mint has not upgraded yet if we get a 404 if resp.status_code == 404: ret = await self.request_mint_deprecated(amount) return ret # END backwards compatibility < 0.15.0 self.raise_on_error_request(resp) return_dict = resp.json() return PostMintQuoteResponse.parse_obj(return_dict) @async_set_httpx_client @async_ensure_mint_loaded async def get_mint_quote(self, quote: str) -> PostMintQuoteResponse: """Returns an existing mint quote from the server. Args: quote (str): Quote ID Returns: PostMintQuoteResponse: Mint Quote Response """ resp = await self.httpx.get( join(self.url, f"/v1/mint/quote/bolt11/{quote}"), ) self.raise_on_error_request(resp) return_dict = resp.json() return PostMintQuoteResponse.parse_obj(return_dict) @async_set_httpx_client @async_ensure_mint_loaded async def mint( self, outputs: List[BlindedMessage], quote: str ) -> List[BlindedSignature]: """Mints new coins and returns a proof of promise. Args: outputs (List[BlindedMessage]): Outputs to mint new tokens with quote (str): Quote ID. Returns: list[Proof]: List of proofs. Raises: Exception: If the minting fails """ outputs_payload = PostMintRequest(outputs=outputs, quote=quote) logger.trace("Checking Lightning invoice. POST /v1/mint/bolt11") def _mintrequest_include_fields(outputs: List[BlindedMessage]): """strips away fields from the model that aren't necessary for the /mint""" outputs_include = {"id", "amount", "B_"} return { "quote": ..., "outputs": {i: outputs_include for i in range(len(outputs))}, } payload = outputs_payload.dict(include=_mintrequest_include_fields(outputs)) # type: ignore resp = await self.httpx.post( join(self.url, "/v1/mint/bolt11"), json=payload, # type: ignore ) # BEGIN backwards compatibility < 0.15.0 # assume the mint has not upgraded yet if we get a 404 if resp.status_code == 404: ret = await self.mint_deprecated(outputs, quote) return ret # END backwards compatibility < 0.15.0 self.raise_on_error_request(resp) response_dict = resp.json() logger.trace("Lightning invoice checked. POST /v1/mint/bolt11") promises = PostMintResponse.parse_obj(response_dict).signatures return promises @async_set_httpx_client @async_ensure_mint_loaded async def melt_quote( self, payment_request: str, unit: Unit, amount: Optional[int] = None ) -> PostMeltQuoteResponse: """Checks whether the Lightning payment is internal.""" invoice_obj = bolt11.decode(payment_request) assert invoice_obj.amount_msat, "invoice must have amount" # add mpp amount for partial melts melt_options = None if amount: melt_options = PostMeltRequestOptions( mpp=PostMeltRequestOptionMpp(amount=amount) ) payload = PostMeltQuoteRequest( unit=unit.name, request=payment_request, options=melt_options ) resp = await self.httpx.post( join(self.url, "/v1/melt/quote/bolt11"), json=payload.dict(), ) # BEGIN backwards compatibility < 0.15.0 # assume the mint has not upgraded yet if we get a 404 if resp.status_code == 404: ret: CheckFeesResponse_deprecated = await self.check_fees_deprecated( payment_request ) quote_id = f"deprecated_{uuid.uuid4()}" return PostMeltQuoteResponse( quote=quote_id, amount=amount or invoice_obj.amount_msat // 1000, fee_reserve=ret.fee or 0, paid=False, state=MeltQuoteState.unpaid.value, expiry=invoice_obj.expiry, ) # END backwards compatibility < 0.15.0 self.raise_on_error_request(resp) return_dict = resp.json() return PostMeltQuoteResponse.parse_obj(return_dict) @async_set_httpx_client @async_ensure_mint_loaded async def get_melt_quote(self, quote: str) -> PostMeltQuoteResponse: """Returns an existing melt quote from the server. Args: quote (str): Quote ID Returns: PostMeltQuoteResponse: Melt Quote Response """ resp = await self.httpx.get( join(self.url, f"/v1/melt/quote/bolt11/{quote}"), ) self.raise_on_error_request(resp) return_dict = resp.json() return PostMeltQuoteResponse.parse_obj(return_dict) @async_set_httpx_client @async_ensure_mint_loaded async def melt( self, quote: str, proofs: List[Proof], outputs: Optional[List[BlindedMessage]], ) -> PostMeltQuoteResponse: """ Accepts proofs and a lightning invoice to pay in exchange. """ payload = PostMeltRequest(quote=quote, inputs=proofs, outputs=outputs) def _meltrequest_include_fields( proofs: List[Proof], outputs: List[BlindedMessage] ): """strips away fields from the model that aren't necessary for the /melt""" proofs_include = {"id", "amount", "secret", "C", "witness"} outputs_include = {"id", "amount", "B_"} return { "quote": ..., "inputs": {i: proofs_include for i in range(len(proofs))}, "outputs": {i: outputs_include for i in range(len(outputs))}, } resp = await self.httpx.post( join(self.url, "/v1/melt/bolt11"), json=payload.dict(include=_meltrequest_include_fields(proofs, outputs)), # type: ignore timeout=None, ) try: self.raise_on_error_request(resp) return_dict = resp.json() return PostMeltQuoteResponse.parse_obj(return_dict) except Exception as e: # BEGIN backwards compatibility < 0.15.0 # assume the mint has not upgraded yet if we get a 404 if resp.status_code == 404: invoice = await get_lightning_invoice(id=quote, db=self.db) assert invoice, f"no invoice found for id {quote}" ret: PostMeltResponse_deprecated = await self.melt_deprecated( proofs=proofs, outputs=outputs, invoice=invoice.bolt11 ) elif isinstance(e, ValidationError): # BEGIN backwards compatibility < 0.16.0 # before 0.16.0, mints return PostMeltResponse_deprecated ret = PostMeltResponse_deprecated.parse_obj(return_dict) # END backwards compatibility < 0.16.0 else: raise e return PostMeltQuoteResponse( quote=quote, amount=0, fee_reserve=0, paid=ret.paid or False, state=( MeltQuoteState.paid.value if ret.paid else MeltQuoteState.unpaid.value ), payment_preimage=ret.preimage, change=ret.change, expiry=None, ) # END backwards compatibility < 0.15.0 @async_set_httpx_client @async_ensure_mint_loaded async def split( self, proofs: List[Proof], outputs: List[BlindedMessage], ) -> List[BlindedSignature]: """Consume proofs and create new promises based on amount split.""" logger.debug("Calling split. POST /v1/swap") split_payload = PostSwapRequest(inputs=proofs, outputs=outputs) # construct payload def _splitrequest_include_fields(proofs: List[Proof]): """strips away fields from the model that aren't necessary for /v1/swap""" proofs_include = { "id", "amount", "secret", "C", "witness", } return { "outputs": ..., "inputs": {i: proofs_include for i in range(len(proofs))}, } resp = await self.httpx.post( join(self.url, "/v1/swap"), json=split_payload.dict(include=_splitrequest_include_fields(proofs)), # type: ignore ) # BEGIN backwards compatibility < 0.15.0 # assume the mint has not upgraded yet if we get a 404 if resp.status_code == 404: ret = await self.split_deprecated(proofs, outputs) return ret # END backwards compatibility < 0.15.0 self.raise_on_error_request(resp) promises_dict = resp.json() mint_response = PostSwapResponse.parse_obj(promises_dict) promises = [BlindedSignature(**p.dict()) for p in mint_response.signatures] if len(promises) == 0: raise Exception("received no splits.") return promises @async_set_httpx_client @async_ensure_mint_loaded async def check_proof_state(self, proofs: List[Proof]) -> PostCheckStateResponse: """ Checks whether the secrets in proofs are already spent or not and returns a list of booleans. """ payload = PostCheckStateRequest(Ys=[p.Y for p in proofs]) resp = await self.httpx.post( join(self.url, "/v1/checkstate"), json=payload.dict(), ) # BEGIN backwards compatibility < 0.15.0 # assume the mint has not upgraded yet if we get a 404 if resp.status_code == 404: ret = await self.check_proof_state_deprecated(proofs) # convert CheckSpendableResponse_deprecated to CheckSpendableResponse states: List[ProofState] = [] for spendable, pending, p in zip(ret.spendable, ret.pending, proofs): if spendable and not pending: states.append(ProofState(Y=p.Y, state=ProofSpentState.unspent)) elif spendable and pending: states.append(ProofState(Y=p.Y, state=ProofSpentState.pending)) else: states.append(ProofState(Y=p.Y, state=ProofSpentState.spent)) ret = PostCheckStateResponse(states=states) return ret # END backwards compatibility < 0.15.0 self.raise_on_error_request(resp) return PostCheckStateResponse.parse_obj(resp.json()) @async_set_httpx_client @async_ensure_mint_loaded async def restore_promises( self, outputs: List[BlindedMessage] ) -> Tuple[List[BlindedMessage], List[BlindedSignature]]: """ Asks the mint to restore promises corresponding to outputs. """ payload = PostMintRequest(quote="restore", outputs=outputs) resp = await self.httpx.post(join(self.url, "/v1/restore"), json=payload.dict()) # BEGIN backwards compatibility < 0.15.0 # assume the mint has not upgraded yet if we get a 404 if resp.status_code == 404: ret = await self.restore_promises_deprecated(outputs) return ret # END backwards compatibility < 0.15.0 self.raise_on_error_request(resp) response_dict = resp.json() returnObj = PostRestoreResponse.parse_obj(response_dict) # BEGIN backwards compatibility < 0.15.1 # if the mint returns promises, duplicate into signatures if returnObj.promises: returnObj.signatures = returnObj.promises # END backwards compatibility < 0.15.1 return returnObj.outputs, returnObj.signatures