diff --git a/cashu/core/base.py b/cashu/core/base.py index 5305ae7e..c5b52f67 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -185,6 +185,14 @@ class KeysetsResponse(BaseModel): keysets: list[KeysetsResponseKeyset] +class KeysResponse_deprecated(BaseModel): + __root__: Dict[str, str] + + +class KeysetsResponse_deprecated(BaseModel): + keysets: list[str] + + # ------- API: MINT ------- @@ -299,6 +307,7 @@ class WalletKeyset: """ id: str + id_deprecated: str # deprecated since 0.14.0 public_keys: Dict[int, PublicKey] mint_url: Union[str, None] = None valid_from: Union[str, None] = None @@ -325,7 +334,9 @@ def __init__( self.public_keys = public_keys # overwrite id by deriving it from the public keys self.id = derive_keyset_id(self.public_keys) + # BEGIN BACKWARDS COMPATIBILITY < 0.14.0 self.id_deprecated = derive_keyset_id_deprecated(self.public_keys) + # END BACKWARDS COMPATIBILITY < 0.14.0 def serialize(self): return json.dumps( @@ -361,6 +372,7 @@ class MintKeyset: """ id: str + id_deprecated: str # deprecated since 0.14.0 derivation_path: str private_keys: Dict[int, PrivateKey] public_keys: Union[Dict[int, PublicKey], None] = None @@ -424,19 +436,10 @@ def generate_keys(self, seed): self.private_keys = derive_keys(seed, self.derivation_path) self.public_keys = derive_pubkeys(self.private_keys) # type: ignore - if ( - self.version - and len(self.version.split(".")) > 1 - and int(self.version.split(".")[0]) == 0 - and int(self.version.split(".")[1]) <= 13 - ): - self.id = derive_keyset_id_deprecated(self.public_keys) # type: ignore - logger.warning( - "WARNING: Using deriving keyset id with old base64 format" - f" {self.id} (backwards compatibility < 0.14)" - ) - else: - self.id = derive_keyset_id(self.public_keys) # type: ignore + self.id = derive_keyset_id(self.public_keys) # type: ignore + # BEGIN BACKWARDS COMPATIBILITY < 0.14.0 + self.id_deprecated = derive_keyset_id_deprecated(self.public_keys) # type: ignore + # END BACKWARDS COMPATIBILITY < 0.14.0 class MintKeysets: diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index cc53102c..5cf1a5ff 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -104,6 +104,9 @@ async def load_keyset(self, derivation_path, autosave=True) -> MintKeyset: # store the new keyset in the current keysets self.keysets.keysets[keyset.id] = keyset + # BEGIN BACKWARDS COMPATIBILITY < 0.14.0 + self.keysets.keysets[keyset.id_deprecated] = keyset + # END BACKWARDS COMPATIBILITY < 0.14.0 logger.debug(f"Loaded keyset {keyset.id}.") return keyset @@ -132,6 +135,12 @@ async def init_keysets(self, autosave=True) -> None: logger.debug(f"Generating keys for keyset {v.id}") v.generate_keys(self.master_key) + # BEGIN BACKWARDS COMPATIBILITY < 0.14.0 + # we store all keysets also by their deprecated id + logger.debug(f"Loading deprecated keyset {v.id_deprecated} (new: {v.id})") + self.keysets.keysets[v.id_deprecated] = v + # END BACKWARDS COMPATIBILITY < 0.14.0 + logger.debug( f"Initialized {len(self.keysets.keysets)} keysets from the database." ) diff --git a/cashu/mint/router.py b/cashu/mint/router.py index 171fdaa1..b19464fe 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -1,6 +1,6 @@ from typing import List, Optional, Union -from fastapi import APIRouter +from fastapi import APIRouter, Request from loguru import logger from ..core.base import ( @@ -13,8 +13,10 @@ GetMeltResponse, GetMintResponse, KeysetsResponse, + KeysetsResponse_deprecated, KeysetsResponseKeyset, KeysResponse, + KeysResponse_deprecated, KeysResponseKeyset, PostMeltRequest, PostMintRequest, @@ -58,7 +60,7 @@ async def info() -> GetInfoResponse: @router.get( - "/keys", + "/v1/keys", name="Mint public keys", summary="Get the public keys of the newest mint keyset", response_description=( @@ -80,7 +82,7 @@ async def keys(): @router.get( - "/keys/{idBase64Urlsafe}", + "/v1/keys/{keyset_id}", name="Keyset public keys", summary="Public keys of a specific keyset", response_description=( @@ -89,18 +91,23 @@ async def keys(): ), response_model=KeysResponse, ) -async def keyset_keys(idBase64Urlsafe: str): +async def keyset_keys(keyset_id: str, request: Request): """ 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}") - id = idBase64Urlsafe.replace("-", "+").replace("_", "/") - keyset = ledger.get_keyset(keyset_id=id) - keyset = ledger.keysets.keysets.get(id) + logger.trace(f"> GET /keys/{keyset_id}") + # BEGIN BACKWARDS COMPATIBILITY < 0.14.0 + # if keyset_id is not hex, we assume it is base64 and sanitize it + try: + int(keyset_id, 16) + except ValueError: + keyset_id = keyset_id.replace("-", "+").replace("_", "/") + # END BACKWARDS COMPATIBILITY < 0.14.0 + keyset = ledger.get_keyset(keyset_id=keyset_id) + keyset = ledger.keysets.keysets.get(keyset_id) if not keyset: raise CashuError(code=0, detail="Keyset not found.") + keyset_for_response = KeysResponseKeyset( id=keyset.id, symbol=keyset.symbol, @@ -110,7 +117,7 @@ async def keyset_keys(idBase64Urlsafe: str): @router.get( - "/keysets", + "/v1/keysets", name="Active keysets", summary="Get all active keyset id of the mind", response_model=KeysetsResponse, @@ -120,13 +127,75 @@ async def keysets() -> KeysetsResponse: """This endpoint returns a list of keysets that the mint currently supports and will accept tokens from.""" logger.trace("> GET /keysets") keysets = [] - for keyset in ledger.keysets.keysets.values(): - keysets.append( - KeysetsResponseKeyset(id=keyset.id, symbol=keyset.symbol, active=True) - ) + for id, keyset in ledger.keysets.keysets.items(): + keysets.append(KeysetsResponseKeyset(id=id, symbol=keyset.symbol, active=True)) return KeysetsResponse(keysets=keysets) +# DEPRECATED + + +@router.get( + "/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_deprecated, + deprecated=True, +) +async def keys_deprecated(): + """This endpoint returns a dictionary of all supported token values of the mint and their associated public key.""" + logger.trace("> GET /keys") + keyset = ledger.get_keyset() + keys = KeysResponse_deprecated.parse_obj(keyset) + 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_deprecated, + deprecated=True, +) +async def keyset_deprecated(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}") + id = idBase64Urlsafe.replace("-", "+").replace("_", "/") + keyset = ledger.get_keyset(keyset_id=id) + keys = KeysResponse_deprecated.parse_obj(keyset) + return keys.__root__ + + +@router.get( + "/keysets", + name="Active keysets", + summary="Get all active keyset id of the mind", + response_model=KeysetsResponse_deprecated, + response_description="A list of all active keyset ids of the mint.", + deprecated=True, +) +async def keysets_deprecated() -> KeysetsResponse_deprecated: + """This endpoint returns a list of keysets that the mint currently supports and will accept tokens from.""" + logger.trace("> GET /keysets") + keysets = KeysetsResponse_deprecated(keysets=ledger.keysets.get_ids()) + return keysets + + +# END DEPRECATED + + @router.get( "/mint", name="Request mint", diff --git a/cashu/wallet/helpers.py b/cashu/wallet/helpers.py index 418a6b75..94cbc666 100644 --- a/cashu/wallet/helpers.py +++ b/cashu/wallet/helpers.py @@ -142,7 +142,7 @@ async def receive( assert keyset_in_token # we get the keyset from the db mint_keysets = await get_keyset(id=keyset_in_token, db=wallet.db) - assert mint_keysets, Exception("we don't know this keyset") + assert mint_keysets, Exception(f"we don't know this keyset: {keyset_in_token}") assert mint_keysets.mint_url, Exception("we don't know this mint's URL") # now we have the URL mint_wallet = await Wallet.with_db( diff --git a/cashu/wallet/protocols.py b/cashu/wallet/protocols.py index effb430b..fa04a3fe 100644 --- a/cashu/wallet/protocols.py +++ b/cashu/wallet/protocols.py @@ -1,5 +1,7 @@ from typing import Protocol +import requests + from ..core.crypto.secp import PrivateKey from ..core.db import Database @@ -14,3 +16,7 @@ class SupportsDb(Protocol): class SupportsKeysets(Protocol): keyset_id: str + + +class SupportsRequests(Protocol): + s: requests.Session diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 265ed222..64b2c39d 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -5,7 +5,7 @@ import time import uuid from itertools import groupby -from typing import Dict, List, Optional, Tuple, Union +from typing import Callable, Dict, List, Optional, Tuple, Union import requests from bip32 import BIP32 @@ -62,6 +62,7 @@ update_proof_reserved, ) from . import migrations +from .deprecated import LedgerAPIDeprecated from .htlc import WalletHTLC from .p2pk import WalletP2PK from .secrets import WalletSecrets @@ -99,7 +100,7 @@ async def wrapper(self, *args, **kwargs): return wrapper -class LedgerAPI(object): +class LedgerAPI(LedgerAPIDeprecated, object): keys: WalletKeyset # holds current keys of mint keyset_id: str # holds id of current keyset public_keys: Dict[int, PublicKey] # holds public keys of @@ -119,11 +120,17 @@ async def _init_s(self): return @staticmethod - def raise_on_error(resp: Response) -> None: + def raise_on_error( + resp: Response, + call_404: Optional[Callable] = None, + call_args: List = [], + call_kwargs: Dict = {}, + ) -> None: """Raises an exception if the response from the mint contains an error. Args: resp_dict (Response): Response dict (previously JSON) from mint + call_instead (Callable): Function to call instead of raising an exception Raises: Exception: if the response contains an error @@ -134,6 +141,11 @@ def raise_on_error(resp: Response) -> None: error_message = f"Mint Error: {resp_dict['detail']}" if "code" in resp_dict: error_message += f" (Code: {resp_dict['code']})" + # BEGIN BACKWARDS COMPATIBILITY < 0.14.0 + # if the error is a 404, we assume that the mint is not upgraded yet + if call_404 and resp.status_code == 404: + return call_404(*call_args, **call_kwargs) + # END BACKWARDS COMPATIBILITY < 0.14.0 raise Exception(error_message) # raise for status if no error resp.raise_for_status() @@ -247,9 +259,13 @@ async def _get_keys(self, url: str) -> WalletKeyset: Exception: If no keys are received from the mint """ resp = self.s.get( - url + "/keys", + url + "/v1/keys", + ) + self.raise_on_error( + resp, + call_404=self._get_keys_deprecated, # backwards compatibility < 0.14.0 + call_args=[url], ) - self.raise_on_error(resp) keys_dict: dict = resp.json() assert len(keys_dict), Exception("did not receive any keys") keys = KeysResponse.parse_obj(keys_dict) @@ -277,9 +293,13 @@ async def _get_keys_of_keyset(self, url: str, keyset_id: str) -> WalletKeyset: """ keyset_id_urlsafe = keyset_id.replace("+", "-").replace("/", "_") resp = self.s.get( - url + f"/keys/{keyset_id_urlsafe}", + url + f"/v1/keys/{keyset_id_urlsafe}", + ) + self.raise_on_error( + resp, + call_404=self._get_keys_of_keyset_deprecated, # backwards compatibility < 0.14.0 + call_args=[url, keyset_id], ) - self.raise_on_error(resp) keys_dict = resp.json() assert len(keys_dict), Exception("did not receive any keys") keys = KeysResponse.parse_obj(keys_dict) @@ -304,9 +324,13 @@ async def _get_keyset_ids(self, url: str) -> List[str]: Exception: If no keysets are received from the mint """ resp = self.s.get( - url + "/keysets", + url + "/v1/keysets", + ) + self.raise_on_error( + resp, + call_404=self._get_keyset_ids_deprecated, # backwards compatibility < 0.14.0 + call_args=[url], ) - 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") diff --git a/tests/test_cli.py b/tests/test_cli.py index 9bb1f86b..8ff123a7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,6 @@ import asyncio +import base64 +import json import pytest from click.testing import CliRunner @@ -205,12 +207,27 @@ def test_receive_tokenv3_no_mint(mint, cli_prefix): # where the mint URL is not in the token therefore, we need to know the mint keyset # already and have the mint URL in the db runner = CliRunner() - token = ( - "cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFt7ImlkIjogIjFjQ05JQVoyWC93MSIsICJhbW91bnQiOiAyLCAic2VjcmV0IjogIi1oM0ZXMFFoX1FYLW9ac1V2c0RuNlEiLC" - "AiQyI6ICIwMzY5Mzc4MzdlYjg5ZWI4NjMyNWYwOWUyOTIxMWQxYTI4OTRlMzQ2YmM1YzQwZTZhMThlNTk5ZmVjNjEwOGRmMGIifSwgeyJpZCI6ICIxY0NOSUFaMlgvdzEiLCAiYW" - "1vdW50IjogOCwgInNlY3JldCI6ICI3d0VhNUgzZGhSRGRNZl94c1k3c3JnIiwgIkMiOiAiMDJiZmZkM2NlZDkxNjUyMzcxMDg2NjQxMzJiMjgxYjBhZjY1ZTNlZWVkNTY3MmFkZj" - "M0Y2VhNzE5ODhhZWM1NWI1In1dfV19" - ) + token_dict = { + "token": [ + { + "proofs": [ + { + "id": "d5c08d2006765ffc", + "amount": 2, + "secret": "-h3FW0Qh_QX-oZsUvsDn6Q", + "C": "036937837eb89eb86325f09e29211d1a2894e346bc5c40e6a18e599fec6108df0b", + }, + { + "id": "d5c08d2006765ffc", + "amount": 8, + "secret": "7wEa5H3dhRDdMf_xsY7srg", + "C": "02bffd3ced9165237108664132b281b0af65e3eeed5672adf34cea71988aec55b5", + }, + ] + } + ] + } + token = "cashuA" + base64.b64encode(json.dumps(token_dict).encode()).decode() result = runner.invoke( cli, [ @@ -226,12 +243,28 @@ def test_receive_tokenv3_no_mint(mint, cli_prefix): def test_receive_tokenv2(mint, cli_prefix): runner = CliRunner() - token = ( - "eyJwcm9vZnMiOiBbeyJpZCI6ICIxY0NOSUFaMlgvdzEiLCAiYW1vdW50IjogMiwgInNlY3JldCI6ICJhUmREbzlFdW9yZUVfOW90enRNVVpnIiwgIkMiOiAiMDNhMzY5ZmUy" - "N2IxYmVmOTg4MzA3NDQyN2RjMzc1NmU0NThlMmMwYjQ1NWMwYmVmZGM4ZjVmNTA3YmM5MGQxNmU3In0sIHsiaWQiOiAiMWNDTklBWjJYL3cxIiwgImFtb3VudCI6IDgsICJzZWNy" - "ZXQiOiAiTEZQbFp6Ui1MWHFfYXFDMGhUeDQyZyIsICJDIjogIjAzNGNiYzQxYWY0ODIxMGFmNjVmYjVjOWIzOTNkMjhmMmQ5ZDZhOWE5MzI2YmI3MzQ2YzVkZmRmMTU5MDk1MzI2" - "YyJ9XSwgIm1pbnRzIjogW3sidXJsIjogImh0dHA6Ly9sb2NhbGhvc3Q6MzMzNyIsICJpZHMiOiBbIjFjQ05JQVoyWC93MSJdfV19" - ) + token_dict = { + "proofs": [ + { + "id": "d5c08d2006765ffc", + "amount": 2, + "secret": "aRdDo9EuoreE_9otztMUZg", + "C": ( + "03a369fe27b1bef9883074427dc3756e458e2c0b455c0befdc8f5f507bc90d16e7" + ), + }, + { + "id": "d5c08d2006765ffc", + "amount": 8, + "secret": "LFPlZzR-LXq_aqC0hTx42g", + "C": ( + "034cbc41af48210af65fb5c9b393d28f2d9d6a9a9326bb7346c5dfdf159095326c" + ), + }, + ], + "mints": [{"url": "http://localhost:3337", "ids": ["d5c08d2006765ffc"]}], + } + token = base64.b64encode(json.dumps(token_dict).encode()).decode() result = runner.invoke( cli, [*cli_prefix, "receive", token], @@ -243,11 +276,21 @@ def test_receive_tokenv2(mint, cli_prefix): def test_receive_tokenv1(mint, cli_prefix): runner = CliRunner() - token = ( - "W3siaWQiOiAiMWNDTklBWjJYL3cxIiwgImFtb3VudCI6IDIsICJzZWNyZXQiOiAiRnVsc2dzMktQV1FMcUlLX200SzgwQSIsICJDIjogIjAzNTc4OThlYzlhMjIxN2VhYWIx" - "ZDc3YmM1Mzc2OTUwMjJlMjU2YTljMmMwNjc0ZDJlM2FiM2JiNGI0ZDMzMWZiMSJ9LCB7ImlkIjogIjFjQ05JQVoyWC93MSIsICJhbW91bnQiOiA4LCAic2VjcmV0IjogInJlRDBD" - "azVNS2xBTUQ0dWk2OEtfbEEiLCAiQyI6ICIwMjNkODNkNDE0MDU0NWQ1NTg4NjUyMzU5YjJhMjFhODljODY1ZGIzMzAyZTkzMTZkYTM5NjA0YTA2ZDYwYWQzOGYifV0=" - ) + token_dict = [ + { + "id": "d5c08d2006765ffc", + "amount": 2, + "secret": "Fulsgs2KPWQLqIK_m4K80A", + "C": "0357898ec9a2217eaab1d77bc537695022e256a9c2c0674d2e3ab3bb4b4d331fb1", + }, + { + "id": "d5c08d2006765ffc", + "amount": 8, + "secret": "reD0Ck5MKlAMD4ui68K_lA", + "C": "023d83d4140545d5588652359b2a21a89c865db3302e9316da39604a06d60ad38f", + }, + ] + token = base64.b64encode(json.dumps(token_dict).encode()).decode() result = runner.invoke( cli, [*cli_prefix, "receive", token],