diff --git a/README.md b/README.md index 67e6c57..490828a 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ cashu info Returns: ```bash -Version: 0.7.1 +Version: 0.8 Debug: False Cashu dir: /home/user/.cashu Wallet: wallet diff --git a/cashu/core/base.py b/cashu/core/base.py index 78dfc86..d03ab98 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -6,6 +6,8 @@ from pydantic import BaseModel from cashu.core.crypto import derive_keys, derive_keyset_id, derive_pubkeys from cashu.core.secp import PrivateKey, PublicKey +# ------- PROOFS ------- + class P2SHScript(BaseModel): script: str @@ -16,7 +18,7 @@ class P2SHScript(BaseModel): class Proof(BaseModel): id: Union[ None, str - ] = "" # NOTE: None for backwards compatibility of old clients < 0.3 + ] = "" # NOTE: None for backwards compatibility for old clients that do not include the keyset id < 0.3 amount: int = 0 secret: str = "" C: str = "" @@ -39,10 +41,7 @@ class Proof(BaseModel): self.__setattr__(key, val) -class Proofs(BaseModel): - """TODO: Use this model""" - - proofs: List[Proof] +# ------- LIGHTNING INVOICE ------- class Invoice(BaseModel): @@ -56,9 +55,21 @@ class Invoice(BaseModel): time_paid: Union[None, str, int, float] = "" -class BlindedMessage(BaseModel): - amount: int - B_: str +# ------- API ------- + + +# ------- API: KEYS ------- + + +class KeysResponse(BaseModel): + __root__: Dict[str, str] + + +class KeysetsResponse(BaseModel): + keysets: list[str] + + +# ------- API: MINT ------- class BlindedSignature(BaseModel): @@ -67,8 +78,13 @@ class BlindedSignature(BaseModel): C_: str -class MintRequest(BaseModel): - blinded_messages: List[BlindedMessage] = [] +class PostMintResponseLegacy(BaseModel): + # NOTE: Backwards compability for < 0.8 where we used a simple list and not a key-value dictionary + __root__: List[BlindedSignature] = [] + + +class PostMintResponse(BaseModel): + promises: List[BlindedSignature] = [] class GetMintResponse(BaseModel): @@ -76,18 +92,38 @@ class GetMintResponse(BaseModel): hash: str +# ------- API: MELT ------- + + +class MeltRequest(BaseModel): + proofs: List[Proof] + invoice: str + + class GetMeltResponse(BaseModel): paid: Union[bool, None] preimage: Union[str, None] +# ------- API: SPLIT ------- + + +class BlindedMessage(BaseModel): + amount: int + B_: str + + +class BlindedMessages(BaseModel): + blinded_messages: List[BlindedMessage] = [] + + class SplitRequest(BaseModel): proofs: List[Proof] amount: int output_data: Union[ - MintRequest, None - ] = None # backwards compatibility with clients < v0.2.2 - outputs: Union[MintRequest, None] = None + BlindedMessages, None + ] = None # backwards compatibility with clients that called this output_data and not outputs < v0.2.2 + outputs: Union[BlindedMessages, None] = None def __init__(self, **data): super().__init__(**data) @@ -105,6 +141,9 @@ class PostSplitResponse(BaseModel): snd: List[BlindedSignature] +# ------- API: CHECK ------- + + class CheckRequest(BaseModel): proofs: List[Proof] @@ -117,29 +156,35 @@ class CheckFeesResponse(BaseModel): fee: Union[int, None] -class MeltRequest(BaseModel): - proofs: List[Proof] - invoice: str +# ------- KEYSETS ------- class KeyBase(BaseModel): + """ + Public key from a keyset id for a given amount. + """ + id: str amount: int pubkey: str class WalletKeyset: - id: str - public_keys: Dict[int, PublicKey] + """ + Contains the keyset from the wallets's perspective. + """ + + id: Union[str, None] + public_keys: Union[Dict[int, PublicKey], None] mint_url: Union[str, None] = None valid_from: Union[str, None] = None valid_to: Union[str, None] = None first_seen: Union[str, None] = None - active: bool = True + active: Union[bool, None] = True def __init__( self, - pubkeys: Dict[int, PublicKey] = None, + public_keys=None, mint_url=None, id=None, valid_from=None, @@ -153,20 +198,24 @@ class WalletKeyset: self.first_seen = first_seen self.active = active self.mint_url = mint_url - if pubkeys: - self.public_keys = pubkeys + if public_keys: + self.public_keys = public_keys self.id = derive_keyset_id(self.public_keys) class MintKeyset: - id: str + """ + Contains the keyset from the mint's perspective. + """ + + id: Union[str, None] derivation_path: str private_keys: Dict[int, PrivateKey] - public_keys: Dict[int, PublicKey] = {} + public_keys: Union[Dict[int, PublicKey], None] = None valid_from: Union[str, None] = None valid_to: Union[str, None] = None first_seen: Union[str, None] = None - active: bool = True + active: Union[bool, None] = True version: Union[str, None] = None def __init__( @@ -194,37 +243,50 @@ class MintKeyset: def generate_keys(self, seed): """Generates keys of a keyset from a seed.""" self.private_keys = derive_keys(seed, self.derivation_path) - self.public_keys = derive_pubkeys(self.private_keys) - self.id = derive_keyset_id(self.public_keys) + self.public_keys = derive_pubkeys(self.private_keys) # type: ignore + self.id = derive_keyset_id(self.public_keys) # type: ignore def get_keybase(self): + assert self.id is not None return { k: KeyBase(id=self.id, amount=k, pubkey=v.serialize().hex()) - for k, v in self.public_keys.items() + for k, v in self.public_keys.items() # type: ignore } class MintKeysets: + """ + Collection of keyset IDs and the corresponding keyset of the mint. + """ + keysets: Dict[str, MintKeyset] def __init__(self, keysets: List[MintKeyset]): - self.keysets: Dict[str, MintKeyset] = {k.id: k for k in keysets} + self.keysets = {k.id: k for k in keysets} # type: ignore def get_ids(self): return [k for k, _ in self.keysets.items()] -class TokenMintJson(BaseModel): +# ------- TOKEN ------- + + +class TokenV1(BaseModel): + # NOTE: not used in Pydantic validation + __root__: List[Proof] + + +class TokenMintV2(BaseModel): url: str ks: List[str] -class TokenJson(BaseModel): - tokens: List[Proof] - mints: Optional[Dict[str, TokenMintJson]] = None +class TokenV2(BaseModel): + proofs: List[Proof] + mints: Optional[Dict[str, TokenMintV2]] = None def to_dict(self): return dict( - tokens=[p.to_dict() for p in self.tokens], + proofs=[p.to_dict() for p in self.proofs], mints={k: v.dict() for k, v in self.mints.items()}, # type: ignore ) diff --git a/cashu/core/errors.py b/cashu/core/errors.py index f770896..19e757d 100644 --- a/cashu/core/errors.py +++ b/cashu/core/errors.py @@ -2,25 +2,20 @@ from pydantic import BaseModel class CashuError(BaseModel): - code = "000" - error = "CashuError" + code: int + error: str -# class CashuError(Exception, BaseModel): -# code = "000" -# error = "CashuError" +class MintException(CashuError): + code = 100 + error = "Mint" -# class MintException(CashuError): -# code = 100 -# error = "Mint" +class LightningException(MintException): + code = 200 + error = "Lightning" -# class LightningException(MintException): -# code = 200 -# error = "Lightning" - - -# class InvoiceNotPaidException(LightningException): -# code = 201 -# error = "invoice not paid." +class InvoiceNotPaidException(LightningException): + code = 201 + error = "invoice not paid." diff --git a/cashu/core/settings.py b/cashu/core/settings.py index e3dd787..a25ae87 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -57,4 +57,4 @@ NOSTR_PRIVATE_KEY = env.str("NOSTR_PRIVATE_KEY", default=None) NOSTR_RELAYS = env.list("NOSTR_RELAYS", default=["wss://nostr-pub.wellorder.net"]) MAX_ORDER = 64 -VERSION = "0.7.1" +VERSION = "0.8" diff --git a/cashu/mint/router.py b/cashu/mint/router.py index c7fe9f9..ecf6881 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -4,14 +4,17 @@ from fastapi import APIRouter from secp256k1 import PublicKey from cashu.core.base import ( + BlindedMessages, BlindedSignature, CheckFeesRequest, CheckFeesResponse, CheckRequest, GetMeltResponse, GetMintResponse, + KeysetsResponse, + KeysResponse, MeltRequest, - MintRequest, + PostMintResponse, PostSplitResponse, SplitRequest, ) @@ -22,28 +25,31 @@ router: APIRouter = APIRouter() @router.get("/keys") -async def keys() -> dict[int, str]: +async def keys() -> KeysResponse: """Get the public keys of the mint of the newest keyset""" keyset = ledger.get_keyset() - return keyset + keys = KeysResponse.parse_obj(keyset) + return keys @router.get("/keys/{idBase64Urlsafe}") -async def keyset_keys(idBase64Urlsafe: str) -> dict[int, str]: +async def keyset_keys(idBase64Urlsafe: str) -> KeysResponse: """ - Get the public keys of the mint of a specificy keyset id. - The id is encoded in base64_urlsafe and needs to be converted back to + Get the public keys of the mint of a specific keyset id. + The id is encoded in idBase64Urlsafe and needs to be converted back to normal base64 before it can be processed. """ id = idBase64Urlsafe.replace("-", "+").replace("_", "/") keyset = ledger.get_keyset(keyset_id=id) - return keyset + keys = KeysResponse.parse_obj(keyset) + return keys @router.get("/keysets") -async def keysets() -> dict[str, list[str]]: - """Get all active keysets of the mint""" - return {"keysets": ledger.keysets.get_ids()} +async def keysets() -> KeysetsResponse: + """Get all active keyset ids of the mint""" + keysets = KeysetsResponse(keysets=ledger.keysets.get_ids()) + return keysets @router.get("/mint") @@ -62,9 +68,9 @@ async def request_mint(amount: int = 0) -> GetMintResponse: @router.post("/mint") async def mint( - mint_request: MintRequest, + mint_request: BlindedMessages, payment_hash: Union[str, None] = None, -) -> Union[List[BlindedSignature], CashuError]: +) -> Union[PostMintResponse, CashuError]: """ Requests the minting of tokens belonging to a paid payment request. @@ -74,9 +80,10 @@ async def mint( promises = await ledger.mint( mint_request.blinded_messages, payment_hash=payment_hash ) - return promises + blinded_signatures = PostMintResponse(promises=promises) + return blinded_signatures except Exception as exc: - return CashuError(error=str(exc)) + return CashuError(code=0, error=str(exc)) @router.post("/melt") @@ -103,7 +110,7 @@ async def check_fees(payload: CheckFeesRequest) -> CheckFeesResponse: This is can be useful for checking whether an invoice is internal (Cashu-to-Cashu). """ fees_msat = await ledger.check_fees(payload.pr) - return CheckFeesResponse(fee=fees_msat / 1000) + return CheckFeesResponse(fee=fees_msat // 1000) @router.post("/split") @@ -124,9 +131,9 @@ async def split( try: split_return = await ledger.split(proofs, amount, outputs) except Exception as exc: - return CashuError(error=str(exc)) + return CashuError(code=0, error=str(exc)) if not split_return: - return CashuError(error="there was an error with the split") + return CashuError(code=0, error="there was an error with the split") frst_promises, scnd_promises = split_return resp = PostSplitResponse(fst=frst_promises, snd=scnd_promises) return resp diff --git a/cashu/wallet/cli.py b/cashu/wallet/cli.py index 3b7138e..18dbbf5 100644 --- a/cashu/wallet/cli.py +++ b/cashu/wallet/cli.py @@ -48,7 +48,7 @@ from cashu.wallet.crud import ( ) from cashu.wallet.wallet import Wallet as Wallet -from .cli_helpers import ( +from .clihelpers import ( get_mint_wallet, print_mint_balances, proofs_to_token, @@ -309,6 +309,7 @@ async def send(ctx, amount: int, lock: str, legacy: bool): @click.option("--lock", "-l", default=None, help="Lock tokens (P2SH).", type=str) @click.option( "--legacy", + "-l", default=False, is_flag=True, help="Print legacy token without mint information.", @@ -387,7 +388,11 @@ async def receive(ctx, token: str, lock: str): # deserialize token dtoken = json.loads(base64.urlsafe_b64decode(token)) - assert "tokens" in dtoken, Exception("no proofs in token") + # backwards compatibility wallet to wallet < 0.8: V2 tokens renamed "tokens" field to "proofs" + if "tokens" in dtoken: + dtoken["proofs"] = dtoken.pop("tokens") + + assert "proofs" in dtoken, Exception("no proofs in token") includes_mint_info: bool = "mints" in dtoken and dtoken.get("mints") is not None # if there is a `mints` field in the token @@ -402,7 +407,7 @@ async def receive(ctx, token: str, lock: str): await wallet.load_proofs() else: # no mint information present, we extract the proofs and use wallet's default mint - proofs = [Proof(**p) for p in dtoken["tokens"]] + proofs = [Proof(**p) for p in dtoken["proofs"]] _, _ = await wallet.redeem(proofs, script, signature) wallet.status() @@ -494,15 +499,23 @@ async def burn(ctx, token: str, all: bool, force: bool): else: # check only the specified ones proofs = [Proof(**p) for p in json.loads(base64.urlsafe_b64decode(token))] - wallet.status() + await wallet.invalidate(proofs) wallet.status() @cli.command("pending", help="Show pending tokens.") +@click.option( + "--legacy", + "-l", + default=False, + is_flag=True, + help="Print legacy token without mint information.", + type=bool, +) @click.pass_context @coro -async def pending(ctx): +async def pending(ctx, legacy): wallet: Wallet = ctx.obj["WALLET"] reserved_proofs = await get_reserved_proofs(wallet.db) if len(reserved_proofs): @@ -513,7 +526,7 @@ async def pending(ctx): ): grouped_proofs = list(value) token = await wallet.serialize_proofs(grouped_proofs) - token_hidden_secret = await wallet.serialize_proofs(grouped_proofs) + # token_hidden_secret = await wallet.serialize_proofs(grouped_proofs) reserved_date = datetime.utcfromtimestamp( int(grouped_proofs[0].time_reserved) ).strftime("%Y-%m-%d %H:%M:%S") @@ -521,9 +534,15 @@ async def pending(ctx): f"#{i} Amount: {sum_proofs(grouped_proofs)} sat Time: {reserved_date} ID: {key}\n" ) print(f"{token}\n") + + if legacy: + token_legacy = await wallet.serialize_proofs( + grouped_proofs, + legacy=True, + ) + print(f"{token_legacy}\n") print(f"--------------------------\n") print("To remove all spent tokens use: cashu burn -a") - wallet.status() @cli.command("lock", help="Generate receiving lock.") diff --git a/cashu/wallet/cli_helpers.py b/cashu/wallet/clihelpers.py similarity index 92% rename from cashu/wallet/cli_helpers.py rename to cashu/wallet/clihelpers.py index 132b6fe..3d8c402 100644 --- a/cashu/wallet/cli_helpers.py +++ b/cashu/wallet/clihelpers.py @@ -3,7 +3,7 @@ import urllib.parse import click -from cashu.core.base import Proof, TokenJson, TokenMintJson, WalletKeyset +from cashu.core.base import Proof, TokenMintV2, TokenV2, WalletKeyset from cashu.core.settings import CASHU_DIR, MINT_URL from cashu.wallet.crud import get_keyset from cashu.wallet.wallet import Wallet as Wallet @@ -19,7 +19,7 @@ async def verify_mints(ctx, dtoken): mint_url, os.path.join(CASHU_DIR, ctx.obj["WALLET_NAME"]) ) # make sure that this mint supports this keyset - mint_keysets = await keyset_wallet._get_keysets(mint_url) + mint_keysets = await keyset_wallet._get_keyset_ids(mint_url) assert keyset in mint_keysets["keysets"], "mint does not have this keyset." # we validate the keyset id by fetching the keys from the mint @@ -63,7 +63,7 @@ async def redeem_multimint(ctx, dtoken, script, signature): # redeem proofs of this keyset redeem_proofs = [ - Proof(**p) for p in dtoken["tokens"] if Proof(**p).id == keyset + Proof(**p) for p in dtoken["proofs"] if Proof(**p).id == keyset ] _, _ = await keyset_wallet.redeem( redeem_proofs, scnd_script=script, scnd_siganture=signature @@ -119,6 +119,7 @@ async def get_mint_wallet(ctx): mint_keysets: WalletKeyset = await get_keyset(mint_url=mint_url, db=mint_wallet.db) # type: ignore # load the keys + assert mint_keysets.id await mint_wallet.load_mint(keyset_id=mint_keysets.id) return mint_wallet @@ -151,7 +152,7 @@ async def proofs_to_token(wallet, proofs, url: str): Ingests proofs and """ # and add url and keyset id to token - token: TokenJson = await wallet._make_token(proofs, include_mints=False) + token: TokenV2 = await wallet._make_token(proofs, include_mints=False) token.mints = {} # get keysets of proofs @@ -161,12 +162,12 @@ async def proofs_to_token(wallet, proofs, url: str): # check whether we know the mint urls for these proofs for k in keysets: ks = await get_keyset(id=k, db=wallet.db) - url = ks.mint_url if ks is not None else None + url = ks.mint_url if ks and ks.mint_url else "" url = url or ( input(f"Enter mint URL (press enter for default {MINT_URL}): ") or MINT_URL ) - token.mints[url] = TokenMintJson(url=url, ks=keysets) # type: ignore + token.mints[url] = TokenMintV2(url=url, ks=keysets) # type: ignore token_serialized = await wallet._serialize_token_base64(token) return token_serialized diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index cc31331..461481f 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -14,17 +14,21 @@ import cashu.core.b_dhke as b_dhke import cashu.core.bolt11 as bolt11 from cashu.core.base import ( BlindedMessage, + BlindedMessages, BlindedSignature, CheckFeesRequest, CheckRequest, + GetMintResponse, Invoice, + KeysetsResponse, MeltRequest, - MintRequest, P2SHScript, + PostMintResponse, + PostMintResponseLegacy, Proof, SplitRequest, - TokenJson, - TokenMintJson, + TokenMintV2, + TokenV2, WalletKeyset, ) from cashu.core.bolt11 import Invoice as InvoiceBolt11 @@ -130,6 +134,8 @@ class LedgerAPI: keyset = await self._get_keys(self.url) # store current keyset + assert keyset.public_keys + assert keyset.id assert len(keyset.public_keys) > 0, "did not receive keys from mint." # check if current keyset is in db @@ -140,7 +146,7 @@ class LedgerAPI: # get all active keysets of this mint mint_keysets = [] try: - keysets_resp = await self._get_keysets(self.url) + keysets_resp = await self._get_keyset_ids(self.url) mint_keysets = keysets_resp["keysets"] # store active keysets except: @@ -160,7 +166,7 @@ class LedgerAPI: assert len(amounts) == len( secrets ), f"len(amounts)={len(amounts)} not equal to len(secrets)={len(secrets)}" - payloads: MintRequest = MintRequest() + payloads: BlindedMessages = BlindedMessages() rs = [] for secret, amount in zip(secrets, amounts): B_, r = b_dhke.step1_alice(secret) @@ -198,7 +204,7 @@ class LedgerAPI: int(amt): PublicKey(bytes.fromhex(val), raw=True) for amt, val in keys.items() } - keyset = WalletKeyset(pubkeys=keyset_keys, mint_url=url) + keyset = WalletKeyset(public_keys=keyset_keys, mint_url=url) return keyset async def _get_keyset(self, url: str, keyset_id: str): @@ -217,18 +223,19 @@ class LedgerAPI: int(amt): PublicKey(bytes.fromhex(val), raw=True) for amt, val in keys.items() } - keyset = WalletKeyset(pubkeys=keyset_keys, mint_url=url) + keyset = WalletKeyset(public_keys=keyset_keys, mint_url=url) return keyset - async def _get_keysets(self, url: str): + async def _get_keyset_ids(self, url: str): self.s = self._set_requests() resp = self.s.get( url + "/keysets", ) resp.raise_for_status() - keysets = resp.json() - assert len(keysets), Exception("did not receive any keysets") - return keysets + keysets_dict = resp.json() + keysets = KeysetsResponse.parse_obj(keysets_dict) + assert len(keysets.keysets), Exception("did not receive any keysets") + return keysets.dict() def request_mint(self, amount): """Requests a mint from the server and returns Lightning invoice.""" @@ -237,7 +244,8 @@ class LedgerAPI: resp.raise_for_status() return_dict = resp.json() self.raise_on_error(return_dict) - return Invoice(amount=amount, pr=return_dict["pr"], hash=return_dict["hash"]) + mint_response = GetMintResponse.parse_obj(return_dict) + return Invoice(amount=amount, pr=mint_response.pr, hash=mint_response.hash) async def mint(self, amounts, payment_hash=None): """Mints new coins and returns a proof of promise.""" @@ -251,10 +259,17 @@ class LedgerAPI: params={"payment_hash": payment_hash}, ) resp.raise_for_status() - promises_list = resp.json() - self.raise_on_error(promises_list) + reponse_dict = resp.json() + self.raise_on_error(reponse_dict) + try: + # backwards compatibility: parse promises < 0.8 with no "promises" field + promises = PostMintResponseLegacy.parse_obj(reponse_dict).__root__ + logger.warning( + "Parsing token with no promises field. Please upgrade mint to 0.8" + ) + except: + promises = PostMintResponse.parse_obj(reponse_dict).promises - promises = [BlindedSignature(**p) for p in promises_list] return self._construct_proofs(promises, secrets, rs) async def split(self, proofs, amount, scnd_secret: Optional[str] = None): @@ -304,7 +319,7 @@ class LedgerAPI: self.s = self._set_requests() resp = self.s.post( self.url + "/split", - json=split_payload.dict(include=_splitrequest_include_fields(proofs)), + json=split_payload.dict(include=_splitrequest_include_fields(proofs)), # type: ignore ) resp.raise_for_status() promises_dict = resp.json() @@ -337,7 +352,7 @@ class LedgerAPI: self.s = self._set_requests() resp = self.s.post( self.url + "/check", - json=payload.dict(include=_check_spendable_include_fields(proofs)), + json=payload.dict(include=_check_spendable_include_fields(proofs)), # type: ignore ) resp.raise_for_status() return_dict = resp.json() @@ -375,7 +390,7 @@ class LedgerAPI: self.s = self._set_requests() resp = self.s.post( self.url + "/melt", - json=payload.dict(include=_meltequest_include_fields(proofs)), + json=payload.dict(include=_meltequest_include_fields(proofs)), # type: ignore ) resp.raise_for_status() return_dict = resp.json() @@ -411,7 +426,9 @@ class Wallet(LedgerAPI): for id in set([p.id for p in proofs]): if id is None: continue - keyset: WalletKeyset = await get_keyset(id=id, db=self.db) + keyset_crud = await get_keyset(id=id, db=self.db) + assert keyset_crud is not None, "keyset not found" + keyset: WalletKeyset = keyset_crud if keyset.mint_url not in ret: ret[keyset.mint_url] = [p for p in proofs if p.id == id] else: @@ -489,27 +506,27 @@ class Wallet(LedgerAPI): async def _make_token(self, proofs: List[Proof], include_mints=True): """ - Takes list of proofs and produces a TokenJson by looking up + Takes list of proofs and produces a TokenV2 by looking up the keyset id and mint URLs from the database. """ # build token - token = TokenJson(tokens=proofs) + token = TokenV2(proofs=proofs) # add mint information to the token, if requested if include_mints: - # hold information about the mint - mints: Dict[str, TokenMintJson] = dict() + # dummy object to hold information about the mint + mints: Dict[str, TokenMintV2] = dict() # iterate through all proofs and add their keyset to `mints` for proof in proofs: if proof.id: # load the keyset from the db keyset = await get_keyset(id=proof.id, db=self.db) - if keyset and keyset.mint_url: + if keyset and keyset.mint_url and keyset.id: # TODO: replace this with a mint pubkey placeholder_mint_id = keyset.mint_url if placeholder_mint_id not in mints: # mint information - id = TokenMintJson( + id = TokenMintV2( url=keyset.mint_url, ks=[keyset.id], ) @@ -518,13 +535,15 @@ class Wallet(LedgerAPI): # if a mint has multiple keysets, append to the existing list if keyset.id not in mints[placeholder_mint_id].ks: mints[placeholder_mint_id].ks.append(keyset.id) + if len(mints) > 0: + # add dummy object to token token.mints = mints return token - async def _serialize_token_base64(self, token: TokenJson): + async def _serialize_token_base64(self, token: TokenV2): """ - Takes a TokenJson and serializes it in urlsafe_base64. + Takes a TokenV2 and serializes it in urlsafe_base64. """ # encode the token as a base64 string token_base64 = base64.urlsafe_b64encode( @@ -588,7 +607,8 @@ class Wallet(LedgerAPI): Splits proofs such that a Lightning invoice can be paid. """ amount, _ = await self.get_pay_amount_with_fees(invoice) - _, send_proofs = await self.split_to_send(self.proofs, amount) + # TODO: fix mypy asyncio return multiple values + _, send_proofs = await self.split_to_send(self.proofs, amount) # type: ignore return send_proofs async def split_to_send( diff --git a/docs/specs/00.md b/docs/specs/00.md index ec4a43e..6774f9c 100644 --- a/docs/specs/00.md +++ b/docs/specs/00.md @@ -17,10 +17,10 @@ Mint: `Bob` - `T` blinded message - `Z` proof (unblinded signature) -## Blind Diffie-Hellmann key exchange (BDHKE) +# Blind Diffie-Hellmann key exchange (BDHKE) -- Mint `Bob` publishes `K = kG` -- `Alice` picks secret `x` and computes `Y = hash_to_curve(x)` +- Mint `Bob` publishes `K = kG` +- `Alice` picks secret `x` and computes `Y = hash_to_curve(x)` - `Alice` sends to `Bob`: `T = Y + rG` with `r` being a random nonce - `Bob` sends back to `Alice` blinded key: `Q = kT` (these two steps are the DH key exchange) - `Alice` can calculate the unblinded key as `Q - rK = kY + krG - krG = kY = Z` @@ -31,52 +31,62 @@ Mint: `Bob` ### `BlindedMessage` -A encrypted ("blinded") secret and an amount sent from `Alice` to `Bob`. +An encrypted ("blinded") secret and an amount sent from `Alice` to `Bob` before [minting new tokens][04] ```json { - "amount": int, - "B_": str + "amount": int, + "B_": str } ``` + `amount` is the value of the requested token and `B_` is the encrypted secret message generated by `Alice`. + ### `BlindedSignature` -A signature on the `BlindedMessage` sent from `Bob` to `Alice`. +A signature on the `BlindedMessage` sent from `Bob` to `Alice` after [minting new tokens][04]. ```json { - "amount": int, - "C_": str, - "id": str | None + "amount": int, + "C_": str, + "id": str | None } ``` +`amount` is the value of the blinded token, `C_` is the blinded signature on the secret message `B_` sent in the previous step. `id` is the [keyset id][02] of the mint public keys that signed the token. + ### `Proof` -A `Proof` is also called a `Token` and has the following form: +A `Proof` is also called a `Token` in its serialized form. `Alice` sends the serialized to `Carol` to initiate a payment. Upon receiving the token, `Carol` deserializes it and requests a split from `Bob` to exchange it for new `BlindedSignature`'s. `Carol` sends the `Proof` to `Bob` together with new `BlindedMessage`'s that she wants to have signed. ```json { - "amount": int, - "secret": str, - "C": str, - "id": None | str, - "script": P2SHScript | None, + "amount": int, + "secret": str, + "C": str, + "id": None | str, + "script": P2SHScript | None, } ``` +`amount` is the value of the `Proof`, `secret` is the secret message, `C` is the unblinded signature on `secret`, `id` is the [keyset id][02] of the mint public keys that signed the token. `script` is a `P2SHScript` that specifies the spending condition for this `Proof` [TODO: P2SH documentation]. + ### `Proofs` -A list of `Proof`'s. In general, this will be used for most operations instead of a single `Proof`. `Proofs` can be serialized (see Methods/Serialization [TODO: Link Serialization]) +An array (list) of `Proof`'s. In general, this will be used for most operations instead of a single `Proof`. `Proofs` must be serialized before sending between wallets (see [Serialization](#serialization-of-proofs). ## 0.2 - Methods ### Serialization of `Proofs` -To send and receive `Proofs`, wallets serialize them in a `base64_urlsafe` format. +To send and receive `Proofs`, wallets serialize them in a `base64_urlsafe` format. There are two versions of the serialization format. -Example: +#### 0.2.1 - V1 tokens + +This token format is a list of `Proof`s. Each `Proof` contains the keyset id in the field `id` that can be used by a wallet to identify the mint of this token. A wallet that encounters an unknown `id`, it CAN ask the user to enter the mint url of this yet unknown mint. The wallet SHOULD explicity ask the user whether they trust the mint. + +##### Example JSON: ```json [ @@ -95,8 +105,66 @@ Example: ] ``` -becomes +When serialized, this becomes: ``` W3siaWQiOiAiRFNBbDludnZ5ZnZhIiwgImFtb3VudCI6IDgsICJzZWNyZXQiOiAiRGJSS0l5YTBldGR3STVzRkFOMEFYUSIsICJDIjogIjAyZGY3ZjJmYzI5NjMxYjcxYTFkYjExYzE2M2IwYjFjYjQwNDQ0YWEyYjNkMjUzZDQzYjY4ZDc3YTcyZWQyZDYyNSJ9LCB7ImlkIjogIkRTQWw5bnZ2eWZ2YSIsICJhbW91bnQiOiAxNiwgInNlY3JldCI6ICJkX1BQYzVLcHVBQjJNNjBXWUFXNS1RIiwgIkMiOiAiMDI3MGUwYTM3ZjdhMGIyMWVhYjQzYWY3NTFkZDNjMDNmNjFmMDRjNjI2YzA0NDhmNjAzZjFkMWY1YWU1YTdkN2U2In1d ``` + +#### 0.2.2 - V2 tokens + +This token format includes information about the mint as well. The field `proofs` is like a V1 token. Additionally, the field `mints` can include a list of multiple mints from which the `proofs` are from. The `url` field is the URL of the mint. `ks` is a list of the keyset IDs belonging to this mint. All keyset IDs of the `proofs` must be present here to allow a wallet to map each proof to a mint. + +##### Example JSON: + +```json +{ + "proofs": [ + { + "id": "DSAl9nvvyfva", + "amount": 2, + "secret": "bdYCbHGONundLeYvv1P5dQ", + "C": "02e6117fb1b1633a8c1657ed34ab25ecf8d4974091179c4773ec59f85f4e3991cf" + }, + { + "id": "DSAl9nvvyfva", + "amount": 8, + "secret": "KxyUPt5Mur_-RV8pCECJ6A", + "C": "03b9dcdb7f195e07218b95b7c2dadc8289159fc44047439830f765b8c50bfb6bda" + } + ], + "mints": { + "MINT_NAME": { + "url": "http://server.host:3339", + "ks": ["DSAl9nvvyfva"] + } + } +} +``` + +When serialized, this becomes: + +``` +eyJwcm9vZnMiOlt7ImlkIjoiRFNBbDludnZ5ZnZhIiwiYW1vdW50IjoyLCJzZWNyZXQiOiJiZFlDYkhHT051bmRMZVl2djFQNWRRIiwiQyI6IjAyZTYxMTdmYjFiMTYzM2E4YzE2NTdlZDM0YWIyNWVjZjhkNDk3NDA5MTE3OWM0NzczZWM1OWY4NWY0ZTM5OTFjZiJ9LHsiaWQiOiJEU0FsOW52dnlmdmEiLCJhbW91bnQiOjgsInNlY3JldCI6Ikt4eVVQdDVNdXJfLVJWOHBDRUNKNkEiLCJDIjoiMDNiOWRjZGI3ZjE5NWUwNzIxOGI5NWI3YzJkYWRjODI4OTE1OWZjNDQwNDc0Mzk4MzBmNzY1YjhjNTBiZmI2YmRhIn1dLCJtaW50cyI6eyJNSU5UX05BTUUiOnsidXJsIjoiaHR0cDovL3NlcnZlci5ob3N0OjMzMzkiLCJrcyI6WyJEU0FsOW52dnlmdmEiXX19fQ== +``` + +[00]: 00.md +[01]: 02.md +[03]: 03.md +[04]: 04.md +[05]: 05.md +[06]: 06.md +[07]: 07.md +[08]: 08.md +[09]: 09.md +[10]: 10.md +[11]: 11.md +[12]: 12.md +[13]: 13.md +[14]: 14.md +[15]: 15.md +[16]: 16.md +[17]: 17.md +[18]: 18.md +[19]: 19.md +[20]: 20.md \ No newline at end of file diff --git a/docs/specs/03.md b/docs/specs/03.md index f39b4c3..0106358 100644 --- a/docs/specs/03.md +++ b/docs/specs/03.md @@ -22,8 +22,8 @@ Response of `Bob`: ```json { - "pr": "lnbc100n1p3kdrv5sp5lpdxzghe5j67q...", - "hash": "67d1d9ea6ada225c115418671b64a..." + "pr": "lnbc100n1p3kdrv5sp5lpdxzghe5j67q...", + "hash": "67d1d9ea6ada225c115418671b64a..." } ``` diff --git a/docs/specs/04.md b/docs/specs/04.md index 6f088f3..e1c3761 100644 --- a/docs/specs/04.md +++ b/docs/specs/04.md @@ -12,15 +12,15 @@ Request of `Alice`: POST https://mint.host:3338/mint&payment_hash=67d1d9ea6ada225c115418671b64a ``` -With the data being of the form `MintRequest`: +With the data being of the form `BlindedMessages`: ```json { - "blinded_messages": - [ - BlindedMessage, - ... - ] + "blinded_messages": + [ + BlindedMessage, + ... + ] } ``` @@ -30,18 +30,18 @@ With curl: ```bash curl -X POST https://mint.host:3338/mint&payment_hash=67d1d9ea6ada225c115418671b64a -d \ { - "blinded_messages": - [ - { - "amount": 2, - "B_": "02634a2c2b34bec9e8a4aba4361f6bf202d7fa2365379b0840afe249a7a9d71239" - }, - { - "amount": 8, - "B_": "03b54ab451b15005f2c64d38fc512fca695914c8fd5094ee044e5724ad41fda247" - - } - ] + "blinded_messages": + [ + { + "amount": 2, + "B_": "02634a2c2b34bec9e8a4aba4361f6bf202d7fa2365379b0840afe249a7a9d71239" + }, + { + "amount": 8, + "B_": "03b54ab451b15005f2c64d38fc512fca695914c8fd5094ee044e5724ad41fda247" + + } + ] } ``` @@ -52,18 +52,18 @@ If the invoice was successfully paid, `Bob` responds with a `PostMintResponse` w ```json { "promises": - [ - { - "id": "DSAl9nvvyfva", - "amount": 2, - "C_": "03e61daa438fc7bcc53f6920ec6c8c357c24094fb04c1fc60e2606df4910b21ffb" - }, - { - "id": "DSAl9nvvyfva", - "amount": 8, - "C_": "03fd4ce5a16b65576145949e6f99f445f8249fee17c606b688b504a849cdc452de" - }, - ] + [ + { + "id": "DSAl9nvvyfva", + "amount": 2, + "C_": "03e61daa438fc7bcc53f6920ec6c8c357c24094fb04c1fc60e2606df4910b21ffb" + }, + { + "id": "DSAl9nvvyfva", + "amount": 8, + "C_": "03fd4ce5a16b65576145949e6f99f445f8249fee17c606b688b504a849cdc452de" + }, + ] } ``` @@ -79,16 +79,16 @@ A list multiple `Proof`'s is called `Proofs` and has the form: ```json { "proofs" : - [ - { - "id": "DSAl9nvvyfva", - "amount": 2, - "secret": "S+tDfc1Lfsrb06zaRdVTed6Izg", - "C": "0242b0fb43804d8ba9a64ceef249ad7a60f42c15fe6d4907238b05e857527832a3" - }, - { - ... - } - ] + [ + { + "id": "DSAl9nvvyfva", + "amount": 2, + "secret": "S+tDfc1Lfsrb06zaRdVTed6Izg", + "C": "0242b0fb43804d8ba9a64ceef249ad7a60f42c15fe6d4907238b05e857527832a3" + }, + { + ... + } + ] } ``` \ No newline at end of file diff --git a/docs/specs/05.md b/docs/specs/05.md index 1cfb716..70c1415 100644 --- a/docs/specs/05.md +++ b/docs/specs/05.md @@ -16,12 +16,12 @@ With the data being of the form `MeltRequest`: ```json { - "proofs": - [ - Proof, - ... - ], - "invoice": str + "proofs": + [ + Proof, + ... + ], + "invoice": str } ``` @@ -32,17 +32,17 @@ With curl: curl -X POST https://mint.host:3338/mint&payment_hash=67d1d9ea6ada225c115418671b64a -d \ { "proofs" : - [ - { - "id": "DSAl9nvvyfva", - "amount": 2, - "secret": "S+tDfc1Lfsrb06zaRdVTed6Izg", - "C": "0242b0fb43804d8ba9a64ceef249ad7a60f42c15fe6d4907238b05e857527832a3" - }, - { - ... - } - ], + [ + { + "id": "DSAl9nvvyfva", + "amount": 2, + "secret": "S+tDfc1Lfsrb06zaRdVTed6Izg", + "C": "0242b0fb43804d8ba9a64ceef249ad7a60f42c15fe6d4907238b05e857527832a3" + }, + { + ... + } + ], "invoice": "lnbc100n1p3kdrv5sp5lpdxzghe5j67q..." } ``` diff --git a/docs/specs/06.md b/docs/specs/06.md index 6106e98..2ad3fe8 100644 --- a/docs/specs/06.md +++ b/docs/specs/06.md @@ -26,9 +26,9 @@ With the data being of the form `SplitRequest`: ```json { - "proofs": Proofs, - "outputs": MintRequest, - "amount": int + "proofs": Proofs, + "outputs": BlindedMessages, + "amount": int } ``` @@ -37,31 +37,31 @@ With curl: ```bash curl -X POST https://mint.host:3338/split -d \ { - "proofs": - [ - { - "id": "DSAl9nvvyfva", - "amount": 2, - "secret": "S+tDfc1Lfsrb06zaRdVTed6Izg", - "C": "0242b0fb43804d8ba9a64ceef249ad7a60f42c15fe6d4907238b05e857527832a3" - }, - { - ... - } - ], - "outputs":{ - "blinded_messages": - [ - { - "amount": 2, - "B_": "02634a2c2b34bec9e8a4aba4361f6bf202d7fa2365379b0840afe249a7a9d71239" - }, - { - ... - } - ] - }, - "amount": 40 + "proofs": + [ + { + "id": "DSAl9nvvyfva", + "amount": 2, + "secret": "S+tDfc1Lfsrb06zaRdVTed6Izg", + "C": "0242b0fb43804d8ba9a64ceef249ad7a60f42c15fe6d4907238b05e857527832a3" + }, + { + ... + } + ], + "outputs":{ + "blinded_messages": + [ + { + "amount": 2, + "B_": "02634a2c2b34bec9e8a4aba4361f6bf202d7fa2365379b0840afe249a7a9d71239" + }, + { + ... + } + ] + }, + "amount": 40 } ``` diff --git a/docs/specs/cashu_client_spec.md b/docs/specs/cashu_client_spec.md index 73bb92d..c41effc 100644 --- a/docs/specs/cashu_client_spec.md +++ b/docs/specs/cashu_client_spec.md @@ -52,8 +52,8 @@ Here we see how `Alice` generates `N` blinded messages `T_i`. The following step - `Alice` remembers `r` for the construction of the proof in Step 5. ### Step 4: Request tokens -- `Alice` constructs JSON `MintRequest = {"blinded_messages" : ["amount" : , "B_" : ] }` [NOTE: rename "blinded_messages", rename "B_", rename "MintRequest"] -- `Alice` requests tokens via `POST /mint?payment_hash=` with body `MintRequest` [NOTE: rename MintRequest] +- `Alice` constructs JSON `BlindedMessages = {"blinded_messages" : ["amount" : , "B_" : ] }` [NOTE: rename "blinded_messages", rename "B_", rename "BlindedMessages"] +- `Alice` requests tokens via `POST /mint?payment_hash=` with body `BlindedMessages` [NOTE: rename BlindedMessages] - `Alice` receives from `Bob` a list of blinded signatures `List[BlindedSignature]`, one for each token, e.g. `[{"amount" : , "C_" : }, ...]` [NOTE: rename C_] - If an error occured, `Alice` receives JSON `{"error" : }}`[*TODO: Specify case of error*] @@ -121,5 +121,5 @@ Here we describe how `Alice` can request from `Bob` to make a Lightning payment # Todo: - Call subsections 1. and 1.2 etc so they can be referenced -- Define objets like `MintRequest` and `SplitRequests` once when they appear and reuse them. +- Define objets like `BlindedMessages` and `SplitRequests` once when they appear and reuse them. - Clarify whether a `TOKEN` is a single Proof or a list of Proofs diff --git a/pyproject.toml b/pyproject.toml index 861f4a9..b33cc7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "cashu" -version = "0.7.1" +version = "0.8" description = "Ecash wallet and mint." authors = ["calle "] license = "MIT" diff --git a/setup.py b/setup.py index 7ab643e..b0c77a2 100644 --- a/setup.py +++ b/setup.py @@ -13,11 +13,11 @@ entry_points = {"console_scripts": ["cashu = cashu.wallet.cli:cli"]} setuptools.setup( name="cashu", - version="0.7.1", - description="Ecash wallet and mint with Bitcoin Lightning support", + version="0.8", + description="Ecash wallet and mint for Bitcoin Lightning", long_description=long_description, long_description_content_type="text/markdown", - url="https://github.com/callebtc/cashu", + url="https://github.com/cashubtc/cashu", author="Calle", author_email="calle@protonmail.com", license="MIT", diff --git a/tests/test_tor.py b/tests/test_tor.py index 4d792bb..8cbc92e 100644 --- a/tests/test_tor.py +++ b/tests/test_tor.py @@ -4,7 +4,7 @@ import requests from cashu.tor.tor import TorProxy -# @pytest.mark.skip +@pytest.mark.skip def test_tor_setup(): s = requests.Session() diff --git a/tests/test_wallet.py b/tests/test_wallet.py index 1917792..4156cd2 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -68,8 +68,8 @@ async def test_get_keyset(wallet1: Wallet): @pytest.mark.asyncio -async def test_get_keysets(wallet1: Wallet): - keyset = await wallet1._get_keysets(wallet1.url) +async def test_get_keyset_ids(wallet1: Wallet): + keyset = await wallet1._get_keyset_ids(wallet1.url) assert type(keyset) == dict assert type(keyset["keysets"]) == list assert len(keyset["keysets"]) > 0