From 1c455bdcb5a84fa1671c5bb65811021c91287823 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 24 May 2024 22:55:05 +0200 Subject: [PATCH] add file --- cashu/wallet/v1_api.py | 539 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 539 insertions(+) create mode 100644 cashu/wallet/v1_api.py diff --git a/cashu/wallet/v1_api.py b/cashu/wallet/v1_api.py new file mode 100644 index 00000000..59e9ba17 --- /dev/null +++ b/cashu/wallet/v1_api.py @@ -0,0 +1,539 @@ +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 ..core.base import ( + BlindedMessage, + BlindedSignature, + Proof, + ProofState, + SpentState, + 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, + PostMeltResponse, + PostMeltResponse_deprecated, + PostMintQuoteRequest, + PostMintQuoteResponse, + PostMintRequest, + PostMintResponse, + PostRestoreResponse, + PostSplitRequest, + PostSplitResponse, +) +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, object): + 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) + logger.debug( + f"Received {len(keys.keysets)} keysets from mint:" + f" {' '.join([k.id + f' ({k.unit})' for k in keys.keysets])}." + ) + 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) -> PostMintQuoteResponse: + """Requests a mint quote from the server and returns a payment request. + + Args: + amount (int): Amount of tokens to mint + + Returns: + PostMintQuoteResponse: Mint Quote Response + + Raises: + Exception: If the mint request fails + """ + logger.trace("Requesting mint: GET /v1/mint/bolt11") + payload = PostMintQuoteRequest(unit=unit.name, amount=amount) + 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 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" + payload = PostMeltQuoteRequest( + unit=unit.name, request=payment_request, amount=amount + ) + 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 = "deprecated_" + str(uuid.uuid4()) + return PostMeltQuoteResponse( + quote=quote_id, + amount=amount or invoice_obj.amount_msat // 1000, + fee_reserve=ret.fee or 0, + paid=False, + 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 melt( + self, + quote: str, + proofs: List[Proof], + outputs: Optional[List[BlindedMessage]], + ) -> PostMeltResponse: + """ + 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, + ) + # 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 + ) + return PostMeltResponse( + paid=ret.paid, payment_preimage=ret.preimage, change=ret.change + ) + # END backwards compatibility < 0.15.0 + self.raise_on_error_request(resp) + return_dict = resp.json() + return PostMeltResponse.parse_obj(return_dict) + + @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 = PostSplitRequest(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 = PostSplitResponse.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=SpentState.unspent)) + elif spendable and pending: + states.append(ProofState(Y=p.Y, state=SpentState.pending)) + else: + states.append(ProofState(Y=p.Y, state=SpentState.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