diff --git a/cashu/core/base.py b/cashu/core/base.py index 7c78f0b..22a9c37 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -4,8 +4,9 @@ import math from dataclasses import dataclass from enum import Enum from sqlite3 import Row -from typing import Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Union +import cbor2 from loguru import logger from pydantic import BaseModel, root_validator @@ -747,43 +748,6 @@ class MintKeyset: # ------- TOKEN ------- -class TokenV1(BaseModel): - """ - A (legacy) Cashu token that includes proofs. This can only be received if the receiver knows the mint associated with the - keyset ids of the proofs. - """ - - # NOTE: not used in Pydantic validation - __root__: List[Proof] - - -class TokenV2Mint(BaseModel): - """ - Object that describes how to reach the mints associated with the proofs in a TokenV2 object. - """ - - url: str # mint URL - ids: List[str] # List of keyset id's that are from this mint - - -class TokenV2(BaseModel): - """ - A Cashu token that includes proofs and their respective mints. Can include proofs from multiple different mints and keysets. - """ - - proofs: List[Proof] - mints: Optional[List[TokenV2Mint]] = None - - def to_dict(self): - if self.mints: - return dict( - proofs=[p.to_dict() for p in self.proofs], - mints=[m.dict() for m in self.mints], - ) - else: - return dict(proofs=[p.to_dict() for p in self.proofs]) - - class TokenV3Token(BaseModel): mint: Optional[str] = None proofs: List[Proof] @@ -804,14 +768,6 @@ class TokenV3(BaseModel): memo: Optional[str] = None unit: Optional[str] = None - def to_dict(self, include_dleq=False): - return_dict = dict(token=[t.to_dict(include_dleq) for t in self.token]) - if self.memo: - return_dict.update(dict(memo=self.memo)) # type: ignore - if self.unit: - return_dict.update(dict(unit=self.unit)) # type: ignore - return return_dict - def get_proofs(self): return [proof for token in self.token for proof in token.proofs] @@ -824,6 +780,14 @@ class TokenV3(BaseModel): def get_mints(self): return list(set([t.mint for t in self.token if t.mint])) + def serialize_to_dict(self, include_dleq=False): + return_dict = dict(token=[t.to_dict(include_dleq) for t in self.token]) + if self.memo: + return_dict.update(dict(memo=self.memo)) # type: ignore + if self.unit: + return_dict.update(dict(unit=self.unit)) # type: ignore + return return_dict + @classmethod def deserialize(cls, tokenv3_serialized: str) -> "TokenV3": """ @@ -848,6 +812,230 @@ class TokenV3(BaseModel): tokenv3_serialized = prefix # encode the token as a base64 string tokenv3_serialized += base64.urlsafe_b64encode( - json.dumps(self.to_dict(include_dleq)).encode() + json.dumps(self.serialize_to_dict(include_dleq)).encode() ).decode() return tokenv3_serialized + + +class TokenV4DLEQ(BaseModel): + """ + Discrete Log Equality (DLEQ) Proof + """ + + e: bytes + s: bytes + r: bytes + + +class TokenV4Proof(BaseModel): + """ + Value token + """ + + a: int + s: str # secret + c: bytes # signature + d: Optional[TokenV4DLEQ] = None # DLEQ proof + w: Optional[str] = None # witness + + @classmethod + def from_proof(cls, proof: Proof, include_dleq=False): + return cls( + a=proof.amount, + s=proof.secret, + c=bytes.fromhex(proof.C), + d=( + TokenV4DLEQ( + e=bytes.fromhex(proof.dleq.e), + s=bytes.fromhex(proof.dleq.s), + r=bytes.fromhex(proof.dleq.r), + ) + if proof.dleq + else None + ), + w=proof.witness, + ) + + +class TokenV4Token(BaseModel): + # keyset ID + i: bytes + # proofs + p: List[TokenV4Proof] + + +class TokenV4(BaseModel): + # mint URL + m: str + # unit + u: str + # tokens + t: List[TokenV4Token] + # memo + d: Optional[str] = None + + @property + def mint(self) -> str: + return self.m + + @property + def memo(self) -> Optional[str]: + return self.d + + @property + def unit(self) -> str: + return self.u + + @property + def amounts(self) -> List[int]: + return [p.a for token in self.t for p in token.p] + + @property + def amount(self) -> int: + return sum(self.amounts) + + @property + def proofs(self) -> List[Proof]: + return [ + Proof( + id=token.i.hex(), + amount=p.a, + secret=p.s, + C=p.c.hex(), + dleq=( + DLEQWallet( + e=p.d.e.hex(), + s=p.d.s.hex(), + r=p.d.r.hex(), + ) + if p.d + else None + ), + witness=p.w, + ) + for token in self.t + for p in token.p + ] + + @classmethod + def from_tokenv3(cls, tokenv3: TokenV3): + if not len(tokenv3.get_mints()) == 1: + raise Exception("TokenV3 must contain proofs from only one mint.") + + proofs = tokenv3.get_proofs() + proofs_by_id: Dict[str, List[Proof]] = {} + for proof in proofs: + proofs_by_id.setdefault(proof.id, []).append(proof) + + cls.t = [] + for keyset_id, proofs in proofs_by_id.items(): + cls.t.append( + TokenV4Token( + i=bytes.fromhex(keyset_id), + p=[ + TokenV4Proof( + a=p.amount, + s=p.secret, + c=bytes.fromhex(p.C), + d=( + TokenV4DLEQ( + e=bytes.fromhex(p.dleq.e), + s=bytes.fromhex(p.dleq.s), + r=bytes.fromhex(p.dleq.r), + ) + if p.dleq + else None + ), + w=p.witness, + ) + for p in proofs + ], + ) + ) + + # set memo + cls.d = tokenv3.memo + # set mint + cls.m = tokenv3.get_mints()[0] + # set unit + cls.u = tokenv3.unit or "sat" + return cls(t=cls.t, d=cls.d, m=cls.m, u=cls.u) + + def serialize_to_dict(self, include_dleq=False): + return_dict: Dict[str, Any] = dict(t=[t.dict() for t in self.t]) + # strip dleq if needed + if not include_dleq: + for token in return_dict["t"]: + for proof in token["p"]: + if "d" in proof: + del proof["d"] + # strip witness if not present + for token in return_dict["t"]: + for proof in token["p"]: + if not proof.get("w"): + del proof["w"] + # optional memo + if self.d: + return_dict.update(dict(d=self.d)) + # mint + return_dict.update(dict(m=self.m)) + # unit + return_dict.update(dict(u=self.u)) + return return_dict + + def serialize(self, include_dleq=False) -> str: + """ + Takes a TokenV4 and serializes it as "cashuB. + """ + prefix = "cashuB" + tokenv4_serialized = prefix + # encode the token as a base64 string + tokenv4_serialized += base64.urlsafe_b64encode( + cbor2.dumps(self.serialize_to_dict(include_dleq)) + ).decode() + return tokenv4_serialized + + @classmethod + def deserialize(cls, tokenv4_serialized: str) -> "TokenV4": + """ + Ingesta a serialized "cashuB" token and returns a TokenV4. + """ + prefix = "cashuB" + assert tokenv4_serialized.startswith(prefix), Exception( + f"Token prefix not valid. Expected {prefix}." + ) + token_base64 = tokenv4_serialized[len(prefix) :] + # if base64 string is not a multiple of 4, pad it with "=" + token_base64 += "=" * (4 - len(token_base64) % 4) + + token = cbor2.loads(base64.urlsafe_b64decode(token_base64)) + return cls.parse_obj(token) + + def to_tokenv3(self) -> TokenV3: + tokenv3 = TokenV3() + for token in self.t: + tokenv3.token.append( + TokenV3Token( + mint=self.m, + proofs=[ + Proof( + id=token.i.hex(), + amount=p.a, + secret=p.s, + C=p.c.hex(), + dleq=( + DLEQWallet( + e=p.d.e.hex(), + s=p.d.s.hex(), + r=p.d.r.hex(), + ) + if p.d + else None + ), + witness=p.w, + ) + for p in token.p + ], + ) + ) + return tokenv3 diff --git a/cashu/wallet/api/api_helpers.py b/cashu/wallet/api/api_helpers.py index a40e1df..8b50fef 100644 --- a/cashu/wallet/api/api_helpers.py +++ b/cashu/wallet/api/api_helpers.py @@ -1,13 +1,9 @@ -from ...core.base import TokenV3 +from ...core.base import TokenV4 from ...wallet.crud import get_keysets -async def verify_mints(wallet, tokenObj: TokenV3): +async def verify_mints(wallet, tokenObj: TokenV4): # verify mints - mints = set([t.mint for t in tokenObj.token]) - if None in mints: - raise Exception("Token has missing mint information.") - for mint in mints: - assert mint - mint_keysets = await get_keysets(mint_url=mint, db=wallet.db) - assert len(mint_keysets), "We don't know this mint." + mint = tokenObj.mint + mint_keysets = await get_keysets(mint_url=mint, db=wallet.db) + assert len(mint_keysets), "We don't know this mint." diff --git a/cashu/wallet/api/router.py b/cashu/wallet/api/router.py index ab8b96e..7ad1579 100644 --- a/cashu/wallet/api/router.py +++ b/cashu/wallet/api/router.py @@ -8,7 +8,7 @@ from typing import Optional from fastapi import APIRouter, Query -from ...core.base import TokenV3 +from ...core.base import TokenV3, TokenV4 from ...core.helpers import sum_proofs from ...core.settings import settings from ...lightning.base import ( @@ -261,7 +261,7 @@ async def receive_command( wallet = await mint_wallet() initial_balance = wallet.available_balance if token: - tokenObj: TokenV3 = deserialize_token_from_string(token) + tokenObj: TokenV4 = deserialize_token_from_string(token) await verify_mints(wallet, tokenObj) await receive(wallet, tokenObj) elif nostr: @@ -352,7 +352,7 @@ async def pending( grouped_proofs = list(value) token = await wallet.serialize_proofs(grouped_proofs) tokenObj = deserialize_token_from_string(token) - mint = [t.mint for t in tokenObj.token if t.mint][0] + mint = tokenObj.mint reserved_date = datetime.utcfromtimestamp( int(grouped_proofs[0].time_reserved) # type: ignore ).strftime("%Y-%m-%d %H:%M:%S") diff --git a/cashu/wallet/cli/cli.py b/cashu/wallet/cli/cli.py index f3da086..46dbbc7 100644 --- a/cashu/wallet/cli/cli.py +++ b/cashu/wallet/cli/cli.py @@ -15,7 +15,7 @@ import click from click import Context from loguru import logger -from ...core.base import Invoice, Method, MintQuoteState, TokenV3, Unit +from ...core.base import Invoice, Method, MintQuoteState, TokenV3, TokenV4, Unit from ...core.helpers import sum_proofs from ...core.json_rpc.base import JSONRPCNotficationParams from ...core.logging import configure_logger @@ -479,11 +479,17 @@ async def balance(ctx: Context, verbose): @cli.command("send", help="Send tokens.") @click.argument("amount", type=float) -@click.argument("nostr", type=str, required=False) +@click.option( + "--memo", + "-m", + default=None, + help="Memo for the token.", + type=str, +) @click.option( "--nostr", "-n", - "nopt", + default=None, help="Send to nostr pubkey.", type=str, ) @@ -498,9 +504,10 @@ async def balance(ctx: Context, verbose): ) @click.option( "--legacy", + "-l", default=False, is_flag=True, - help="Print legacy token without mint information.", + help="Print legacy TokenV3 format.", type=bool, ) @click.option( @@ -535,8 +542,8 @@ async def balance(ctx: Context, verbose): async def send_command( ctx, amount: int, + memo: str, nostr: str, - nopt: str, lock: str, dleq: bool, legacy: bool, @@ -547,7 +554,7 @@ async def send_command( ): wallet: Wallet = ctx.obj["WALLET"] amount = int(amount * 100) if wallet.unit in [Unit.usd, Unit.eur] else int(amount) - if not nostr and not nopt: + if not nostr: await send( wallet, amount=amount, @@ -556,11 +563,10 @@ async def send_command( offline=offline, include_dleq=dleq, include_fees=include_fees, + memo=memo, ) else: - await send_nostr( - wallet, amount=amount, pubkey=nostr or nopt, verbose=verbose, yes=yes - ) + await send_nostr(wallet, amount=amount, pubkey=nostr, verbose=verbose, yes=yes) await print_balance(ctx) @@ -587,19 +593,18 @@ async def receive_cli( wallet: Wallet = ctx.obj["WALLET"] if token: - tokenObj = deserialize_token_from_string(token) - # verify that we trust all mints in these tokens - # ask the user if they want to trust the new mints - for mint_url in set([t.mint for t in tokenObj.token if t.mint]): - mint_wallet = Wallet( - mint_url, - os.path.join(settings.cashu_dir, wallet.name), - unit=tokenObj.unit or wallet.unit.name, - ) - await verify_mint(mint_wallet, mint_url) - receive_wallet = await receive(wallet, tokenObj) + token_obj = deserialize_token_from_string(token) + # verify that we trust the mint in this tokens + # ask the user if they want to trust the new mint + mint_url = token_obj.mint + mint_wallet = Wallet( + mint_url, + os.path.join(settings.cashu_dir, wallet.name), + unit=token_obj.unit, + ) + await verify_mint(mint_wallet, mint_url) + receive_wallet = await receive(wallet, token_obj) ctx.obj["WALLET"] = receive_wallet - elif nostr: await receive_nostr(wallet) # exit on keypress @@ -612,15 +617,17 @@ async def receive_cli( for key, value in groupby(reserved_proofs, key=itemgetter("send_id")): # type: ignore proofs = list(value) token = await wallet.serialize_proofs(proofs) - tokenObj = TokenV3.deserialize(token) - # verify that we trust all mints in these tokens - # ask the user if they want to trust the new mints - for mint_url in set([t.mint for t in tokenObj.token if t.mint]): - mint_wallet = Wallet( - mint_url, os.path.join(settings.cashu_dir, wallet.name) - ) - await verify_mint(mint_wallet, mint_url) - receive_wallet = await receive(wallet, tokenObj) + token_obj = TokenV4.deserialize(token) + # verify that we trust the mint of this token + # ask the user if they want to trust the mint + mint_url = token_obj.mint + mint_wallet = Wallet( + mint_url, + os.path.join(settings.cashu_dir, wallet.name), + unit=token_obj.unit, + ) + await verify_mint(mint_wallet, mint_url) + receive_wallet = await receive(wallet, token_obj) ctx.obj["WALLET"] = receive_wallet else: print("Error: enter token or use either flag --nostr or --all.") @@ -665,8 +672,8 @@ async def burn(ctx: Context, token: str, all: bool, force: bool, delete: str): proofs = [proof for proof in reserved_proofs if proof["send_id"] == delete] else: # check only the specified ones - tokenObj = TokenV3.deserialize(token) - proofs = tokenObj.get_proofs() + token_obj = TokenV3.deserialize(token) + proofs = token_obj.get_proofs() if delete: await wallet.invalidate(proofs) @@ -721,8 +728,8 @@ async def pending(ctx: Context, legacy, number: int, offset: int): grouped_proofs = list(value) # TODO: we can't return DLEQ because we don't store it token = await wallet.serialize_proofs(grouped_proofs, include_dleq=False) - tokenObj = deserialize_token_from_string(token) - mint = [t.mint for t in tokenObj.token][0] + token_obj = deserialize_token_from_string(token) + mint = token_obj.mint # token_hidden_secret = await wallet.serialize_proofs(grouped_proofs) assert grouped_proofs[0].time_reserved reserved_date = datetime.fromtimestamp( @@ -740,7 +747,7 @@ async def pending(ctx: Context, legacy, number: int, offset: int): grouped_proofs, legacy=True, ) - print(f"{token_legacy}\n") + print(f"Legacy token: {token_legacy}\n") print("--------------------------\n") print("To remove all spent tokens use: cashu burn -a") @@ -1077,5 +1084,5 @@ async def selfpay(ctx: Context, all: bool = False): print(f"Selfpay token for mint {wallet.url}:") print("") print(token) - tokenObj = TokenV3.deserialize(token) - await receive(wallet, tokenObj) + token_obj = TokenV4.deserialize(token) + await receive(wallet, token_obj) diff --git a/cashu/wallet/helpers.py b/cashu/wallet/helpers.py index b20e3c6..a618234 100644 --- a/cashu/wallet/helpers.py +++ b/cashu/wallet/helpers.py @@ -1,10 +1,9 @@ -import base64 -import json import os +from typing import Optional from loguru import logger -from ..core.base import TokenV1, TokenV2, TokenV3, TokenV3Token +from ..core.base import TokenV3, TokenV4 from ..core.db import Database from ..core.helpers import sum_proofs from ..core.migrations import migrate_databases @@ -55,7 +54,7 @@ async def redeem_TokenV3_multimint(wallet: Wallet, token: TokenV3) -> Wallet: os.path.join(settings.cashu_dir, wallet.name), unit=token.unit or wallet.unit.name, ) - keyset_ids = mint_wallet._get_proofs_keysets(t.proofs) + keyset_ids = mint_wallet._get_proofs_keyset_ids(t.proofs) logger.trace(f"Keysets in tokens: {' '.join(set(keyset_ids))}") await mint_wallet.load_mint() proofs_to_keep, _ = await mint_wallet.redeem(t.proofs) @@ -65,59 +64,24 @@ async def redeem_TokenV3_multimint(wallet: Wallet, token: TokenV3) -> Wallet: return mint_wallet -def serialize_TokenV2_to_TokenV3(tokenv2: TokenV2): - """Helper function to receive legacy TokenV2 tokens. - Takes a list of proofs and constructs a *serialized* TokenV3 to be received through - the ordinary path. - - Returns: - TokenV3: TokenV3 +async def redeem_TokenV4(wallet: Wallet, token: TokenV4) -> Wallet: """ - tokenv3 = TokenV3(token=[TokenV3Token(proofs=tokenv2.proofs)]) - if tokenv2.mints: - tokenv3.token[0].mint = tokenv2.mints[0].url - token_serialized = tokenv3.serialize() - return token_serialized - - -def serialize_TokenV1_to_TokenV3(tokenv1: TokenV1): - """Helper function to receive legacy TokenV1 tokens. - Takes a list of proofs and constructs a *serialized* TokenV3 to be received through - the ordinary path. - - Returns: - TokenV3: TokenV3 + Redeem a token with a single mint. """ - tokenv3 = TokenV3(token=[TokenV3Token(proofs=tokenv1.__root__)]) - token_serialized = tokenv3.serialize() - return token_serialized + await wallet.load_mint() + proofs_to_keep, _ = await wallet.redeem(token.proofs) + print(f"Received {wallet.unit.str(sum_proofs(proofs_to_keep))}") + return wallet -def deserialize_token_from_string(token: str) -> TokenV3: +def deserialize_token_from_string(token: str) -> TokenV4: # deserialize token - # ----- backwards compatibility ----- - - # V2Tokens (0.7-0.11.0) (eyJwcm9...) - if token.startswith("eyJwcm9"): - try: - tokenv2 = TokenV2.parse_obj(json.loads(base64.urlsafe_b64decode(token))) - token = serialize_TokenV2_to_TokenV3(tokenv2) - except Exception: - pass - - # V1Tokens (<0.7) (W3siaWQ...) - if token.startswith("W3siaWQ"): - try: - tokenv1 = TokenV1.parse_obj(json.loads(base64.urlsafe_b64decode(token))) - token = serialize_TokenV1_to_TokenV3(tokenv1) - except Exception: - pass - - if token.startswith("cashu"): - tokenObj = TokenV3.deserialize(token) - assert len(tokenObj.token), Exception("no proofs in token") - assert len(tokenObj.token[0].proofs), Exception("no proofs in token") + if token.startswith("cashuA"): + tokenV3Obj = TokenV3.deserialize(token) + return TokenV4.from_tokenv3(tokenV3Obj) + if token.startswith("cashuB"): + tokenObj = TokenV4.deserialize(token) return tokenObj raise Exception("Invalid token") @@ -125,38 +89,13 @@ def deserialize_token_from_string(token: str) -> TokenV3: async def receive( wallet: Wallet, - tokenObj: TokenV3, + tokenObj: TokenV4, ) -> Wallet: - logger.debug(f"receive: {tokenObj}") - proofs = [p for t in tokenObj.token for p in t.proofs] - - includes_mint_info: bool = any([t.mint for t in tokenObj.token]) - - if includes_mint_info: - # redeem tokens with new wallet instances - mint_wallet = await redeem_TokenV3_multimint( - wallet, - tokenObj, - ) - else: - # this is very legacy code, virtually any token should have mint information - # no mint information present, we extract the proofs find the mint and unit from the db - keyset_in_token = proofs[0].id - assert keyset_in_token - # we get the keyset from the db - mint_keysets = await get_keysets(id=keyset_in_token, db=wallet.db) - assert mint_keysets, Exception(f"we don't know this keyset: {keyset_in_token}") - mint_keyset = [k for k in mint_keysets if k.id == keyset_in_token][0] - assert mint_keyset.mint_url, Exception("we don't know this mint's URL") - # now we have the URL - mint_wallet = await Wallet.with_db( - mint_keyset.mint_url, - os.path.join(settings.cashu_dir, wallet.name), - unit=mint_keyset.unit.name or wallet.unit.name, - ) - await mint_wallet.load_mint(keyset_in_token) - _, _ = await mint_wallet.redeem(proofs) - print(f"Received {mint_wallet.unit.str(sum_proofs(proofs))}") + # redeem tokens with new wallet instances + mint_wallet = await redeem_TokenV4( + wallet, + tokenObj, + ) # reload main wallet so the balance updates await wallet.load_proofs(reload=True) @@ -172,6 +111,7 @@ async def send( offline: bool = False, include_dleq: bool = False, include_fees: bool = False, + memo: Optional[str] = None, ): """ Prints token to send to stdout. @@ -210,21 +150,9 @@ async def send( ) token = await wallet.serialize_proofs( - send_proofs, - include_mints=True, - include_dleq=include_dleq, + send_proofs, include_dleq=include_dleq, legacy=legacy, memo=memo ) print(token) await wallet.set_reserved(send_proofs, reserved=True) - if legacy: - print("") - print("Old token format:") - print("") - token = await wallet.serialize_proofs( - send_proofs, - legacy=True, - include_dleq=include_dleq, - ) - print(token) return wallet.available_balance, token diff --git a/cashu/wallet/nostr.py b/cashu/wallet/nostr.py index 357e33e..0a9ee48 100644 --- a/cashu/wallet/nostr.py +++ b/cashu/wallet/nostr.py @@ -6,7 +6,7 @@ import click from httpx import ConnectError from loguru import logger -from ..core.base import TokenV3 +from ..core.base import TokenV4 from ..core.settings import settings from ..nostr.client.client import NostrClient from ..nostr.event import Event @@ -127,10 +127,10 @@ async def receive_nostr( for w in words: try: # call the receive method - tokenObj: TokenV3 = deserialize_token_from_string(w) + tokenObj: TokenV4 = deserialize_token_from_string(w) print( - f"Receiving {tokenObj.get_amount()} sat on mint" - f" {tokenObj.get_mints()[0]} from nostr user {event.public_key} at" + f"Receiving {tokenObj.amount} sat on mint" + f" {tokenObj.mint} from nostr user {event.public_key} at" f" {date_str}" ) asyncio.run( diff --git a/cashu/wallet/proofs.py b/cashu/wallet/proofs.py index d3bd4cf..5a8911c 100644 --- a/cashu/wallet/proofs.py +++ b/cashu/wallet/proofs.py @@ -1,5 +1,3 @@ -import base64 -import json from itertools import groupby from typing import Dict, List, Optional @@ -7,10 +5,11 @@ from loguru import logger from ..core.base import ( Proof, - TokenV2, - TokenV2Mint, TokenV3, TokenV3Token, + TokenV4, + TokenV4Proof, + TokenV4Token, Unit, WalletKeyset, ) @@ -64,7 +63,7 @@ class WalletProofs(SupportsDb, SupportsKeysets): ret[unit].append(proof) return ret - def _get_proofs_keysets(self, proofs: List[Proof]) -> List[str]: + def _get_proofs_keyset_ids(self, proofs: List[Proof]) -> List[str]: """Extracts all keyset ids from a list of proofs. Args: @@ -92,8 +91,31 @@ class WalletProofs(SupportsDb, SupportsKeysets): ) return mint_urls - async def _make_token( - self, proofs: List[Proof], include_mints=True, include_unit=True + async def serialize_proofs( + self, + proofs: List[Proof], + include_dleq=False, + legacy=False, + memo: Optional[str] = None, + ) -> str: + """Produces sharable token with proofs and mint information. + + Args: + proofs (List[Proof]): List of proofs to be included in the token + legacy (bool, optional): Whether to produce a legacy V3 token. Defaults to False. + Returns: + str: Serialized Cashu token + """ + + if legacy: + tokenv3 = await self._make_tokenv3(proofs, memo) + return tokenv3.serialize(include_dleq) + else: + tokenv4 = await self._make_token(proofs, include_dleq, memo) + return tokenv4.serialize(include_dleq) + + async def _make_tokenv3( + self, proofs: List[Proof], memo: Optional[str] = None ) -> TokenV3: """ Takes list of proofs and produces a TokenV3 by looking up @@ -101,108 +123,81 @@ class WalletProofs(SupportsDb, SupportsKeysets): Args: proofs (List[Proof]): List of proofs to be included in the token - include_mints (bool, optional): Whether to include the mint URLs in the token. Defaults to True. - + memo (Optional[str], optional): Memo to be included in the token. Defaults to None. Returns: TokenV3: TokenV3 object """ token = TokenV3() - if include_unit: - token.unit = self.unit.name - if include_mints: - # we create a map from mint url to keyset id and then group - # all proofs with their mint url to build a tokenv3 + # we create a map from mint url to keyset id and then group + # all proofs with their mint url to build a tokenv3 - # extract all keysets from proofs - keysets = self._get_proofs_keysets(proofs) - # get all mint URLs for all unique keysets from db - mint_urls = await self._get_keyset_urls(keysets) + # extract all keysets from proofs + keysets = self._get_proofs_keyset_ids(proofs) + # get all mint URLs for all unique keysets from db + mint_urls = await self._get_keyset_urls(keysets) - # append all url-grouped proofs to token - for url, ids in mint_urls.items(): - mint_proofs = [p for p in proofs if p.id in ids] - token.token.append(TokenV3Token(mint=url, proofs=mint_proofs)) - else: - token_proofs = TokenV3Token(proofs=proofs) - token.token.append(token_proofs) + # append all url-grouped proofs to token + for url, ids in mint_urls.items(): + mint_proofs = [p for p in proofs if p.id in ids] + token.token.append(TokenV3Token(mint=url, proofs=mint_proofs)) + + if memo: + token.memo = memo return token - async def serialize_proofs( - self, proofs: List[Proof], include_mints=True, include_dleq=False, legacy=False - ) -> str: - """Produces sharable token with proofs and mint information. + async def _make_tokenv4( + self, proofs: List[Proof], include_dleq=False, memo: Optional[str] = None + ) -> TokenV4: + """ + Takes a list of proofs and returns a TokenV4 Args: - proofs (List[Proof]): List of proofs to be included in the token - include_mints (bool, optional): Whether to include the mint URLs in the token. Defaults to True. - legacy (bool, optional): Whether to produce a legacy V2 token. Defaults to False. + proofs (List[Proof]): List of proofs to be serialized Returns: - str: Serialized Cashu token + TokenV4: TokenV4 object """ - if legacy: - # V2 tokens - token_v2 = await self._make_token_v2(proofs, include_mints) - return await self._serialize_token_base64_tokenv2(token_v2) + # get all keysets from proofs + keyset_ids = set(self._get_proofs_keyset_ids(proofs)) + keysets = [self.keysets[i] for i in keyset_ids] + # we make sure that all proofs are from keysets of the same mint + if len(set([k.mint_url for k in keysets])) > 1: + raise ValueError("TokenV4 can only contain proofs from a single mint URL") + mint_url = keysets[0].mint_url + if not mint_url: + raise ValueError("No mint URL found for keyset") - # # deprecated code for V1 tokens - # proofs_serialized = [p.to_dict() for p in proofs] - # return base64.urlsafe_b64encode( - # json.dumps(proofs_serialized).encode() - # ).decode() + # we make sure that all keysets have the same unit + if len(set([k.unit for k in keysets])) > 1: + raise ValueError( + "TokenV4 can only contain proofs from keysets with the same unit" + ) + unit_str = keysets[0].unit.name - # V3 tokens - token = await self._make_token(proofs, include_mints) - return token.serialize(include_dleq) + tokens: List[TokenV4Token] = [] + for keyset_id in keyset_ids: + proofs_keyset = [p for p in proofs if p.id == keyset_id] + tokenv4_proofs = [] + for proof in proofs_keyset: + tokenv4_proofs.append(TokenV4Proof.from_proof(proof, include_dleq)) + tokenv4_token = TokenV4Token(i=bytes.fromhex(keyset_id), p=tokenv4_proofs) + tokens.append(tokenv4_token) - async def _make_token_v2(self, proofs: List[Proof], include_mints=True) -> TokenV2: + return TokenV4(m=mint_url, u=unit_str, t=tokens, d=memo) + + async def _make_token( + self, proofs: List[Proof], include_dleq=False, memo: Optional[str] = None + ) -> TokenV4: """ - Takes list of proofs and produces a TokenV2 by looking up - the keyset id and mint URLs from the database. - """ - # build token - token = TokenV2(proofs=proofs) - - # add mint information to the token, if requested - if include_mints: - # dummy object to hold information about the mint - mints: Dict[str, TokenV2Mint] = {} - # dummy object to hold all keyset id's we need to fetch from the db later - keysets: List[str] = [proof.id for proof in proofs if proof.id] - # iterate through unique keyset ids - for id in set(keysets): - # load the keyset from the db - keysets_db = await get_keysets(id=id, db=self.db) - keyset_db = keysets_db[0] if keysets_db else None - if keyset_db and keyset_db.mint_url and keyset_db.id: - # we group all mints according to URL - if keyset_db.mint_url not in mints: - mints[keyset_db.mint_url] = TokenV2Mint( - url=keyset_db.mint_url, - ids=[keyset_db.id], - ) - else: - # if a mint URL has multiple keysets, append to the already existing list - mints[keyset_db.mint_url].ids.append(keyset_db.id) - if len(mints) > 0: - # add mints grouped by url to the token - token.mints = list(mints.values()) - return token - - async def _serialize_token_base64_tokenv2(self, token: TokenV2) -> str: - """ - Takes a TokenV2 and serializes it in urlsafe_base64. + Takes a list of proofs and returns a TokenV4 Args: - token (TokenV2): TokenV2 object to be serialized + proofs (List[Proof]): List of proofs to be serialized Returns: - str: Serialized token + TokenV4: TokenV4 object """ - # encode the token as a base64 string - token_base64 = base64.urlsafe_b64encode( - json.dumps(token.to_dict()).encode() - ).decode() - return token_base64 + + return await self._make_tokenv4(proofs, include_dleq, memo) diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index ac236e4..f5d2e69 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -588,6 +588,11 @@ class Wallet( ) amounts = keep_outputs + send_outputs + + if not amounts: + logger.warning("Swap has no outputs") + return [], [] + # generate secrets for new outputs if secret_lock is None: secrets, rs, derivation_paths = await self.generate_n_secrets(len(amounts)) diff --git a/poetry.lock b/poetry.lock index ec9edbe..da396f1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -122,6 +122,57 @@ click = "*" ecdsa = "*" secp256k1 = "*" +[[package]] +name = "cbor2" +version = "5.6.2" +description = "CBOR (de)serializer with extensive tag support" +optional = false +python-versions = ">=3.8" +files = [ + {file = "cbor2-5.6.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:516b8390936bb172ff18d7b609a452eaa51991513628949b0a9bf25cbe5a7129"}, + {file = "cbor2-5.6.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1b8b504b590367a51fe8c0d9b8cb458a614d782d37b24483097e2b1e93ed0fff"}, + {file = "cbor2-5.6.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f687e6731b1198811223576800258a712ddbfdcfa86c0aee2cc8269193e6b96"}, + {file = "cbor2-5.6.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e94043d99fe779f62a15a5e156768588a2a7047bb3a127fa312ac1135ff5ecb"}, + {file = "cbor2-5.6.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8af7162fcf7aa2649f02563bdb18b2fa6478b751eee4df0257bffe19ea8f107a"}, + {file = "cbor2-5.6.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ea7ecd81c5c6e02c2635973f52a0dd1e19c0bf5ef51f813d8cd5e3e7ed072726"}, + {file = "cbor2-5.6.2-cp310-cp310-win_amd64.whl", hash = "sha256:3c7f223f1fedc74d33f363d184cb2bab9e4bdf24998f73b5e3bef366d6c41628"}, + {file = "cbor2-5.6.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7ea9e150029c3976c46ee9870b6dcdb0a5baae21008fe3290564886b11aa2b64"}, + {file = "cbor2-5.6.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:922e06710e5cf6f56b82b0b90d2f356aa229b99e570994534206985f675fd307"}, + {file = "cbor2-5.6.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b01a718e083e6de8b43296c3ccdb3aa8af6641f6bbb3ea1700427c6af73db28a"}, + {file = "cbor2-5.6.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac85eb731c524d148f608b9bdb2069fa79e374a10ed5d10a2405eba9a6561e60"}, + {file = "cbor2-5.6.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:03e5b68867b9d89ff2abd14ef7c6d42fbd991adc3e734a19a294935f22a4d05a"}, + {file = "cbor2-5.6.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7221b83000ee01d674572eec1d1caa366eac109d1d32c14d7af9a4aaaf496563"}, + {file = "cbor2-5.6.2-cp311-cp311-win_amd64.whl", hash = "sha256:9aca73b63bdc6561e1a0d38618e78b9c204c942260d51e663c92c4ba6c961684"}, + {file = "cbor2-5.6.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:377cfe9d5560c682486faef6d856226abf8b2801d95fa29d4e5d75b1615eb091"}, + {file = "cbor2-5.6.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fdc564ef2e9228bcd96ec8c6cdaa431a48ab03b3fb8326ead4b3f986330e5b9e"}, + {file = "cbor2-5.6.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d1c0021d9a1f673066de7c8941f71a59abb11909cc355892dda01e79a2b3045"}, + {file = "cbor2-5.6.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1fde9e704e96751e0729cc58b912d0e77c34387fb6bcceea0817069e8683df45"}, + {file = "cbor2-5.6.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:30e9ba8f4896726ca61869efacda50b6859aff92162ae5a0e192859664f36c81"}, + {file = "cbor2-5.6.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:211a1e18e65ac71e04434ff5b58bde5c53f85b9c5bc92a3c0e2265089d3034f3"}, + {file = "cbor2-5.6.2-cp312-cp312-win_amd64.whl", hash = "sha256:94981277b4bf448a2754c1f34a9d0055a9d1c5a8d102c933ffe95c80f1085bae"}, + {file = "cbor2-5.6.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f70db0ebcf005c25408e8d5cc4b9558c899f13a3e2f8281fa3d3be4894e0e821"}, + {file = "cbor2-5.6.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:22c24fe9ef1696a84b8fd80ff66eb0e5234505d8b9a9711fc6db57bce10771f3"}, + {file = "cbor2-5.6.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a4a3420f80d6b942874d66eaad07658066370df994ddee4125b48b2cbc61ece"}, + {file = "cbor2-5.6.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5b28d8ff0e726224a7429281700c28afe0e665f83f9ae79648cbae3f1a391cbf"}, + {file = "cbor2-5.6.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:c10ede9462458998f1b9c488e25fe3763aa2491119b7af472b72bf538d789e24"}, + {file = "cbor2-5.6.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:ea686dfb5e54d690e704ce04993bc8ca0052a7cd2d4b13dd333a41cca8a05a05"}, + {file = "cbor2-5.6.2-cp38-cp38-win_amd64.whl", hash = "sha256:22996159b491d545ecfd489392d3c71e5d0afb9a202dfc0edc8b2cf413a58326"}, + {file = "cbor2-5.6.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9faa0712d414a88cc1244c78cd4b28fced44f1827dbd8c1649e3c40588aa670f"}, + {file = "cbor2-5.6.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6031a284d93fc953fc2a2918f261c4f5100905bd064ca3b46961643e7312a828"}, + {file = "cbor2-5.6.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30c8a9a9df79f26e72d8d5fa51ef08eb250d9869a711bcf9539f1865916c983"}, + {file = "cbor2-5.6.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44bf7457fca23209e14dab8181dff82466a83b72e55b444dbbfe90fa67659492"}, + {file = "cbor2-5.6.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:cc29c068687aa2e7778f63b653f1346065b858427a2555df4dc2191f4a0de8ce"}, + {file = "cbor2-5.6.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:42eaf0f768bd27afcb38135d5bfc361d3a157f1f5c7dddcd8d391f7fa43d9de8"}, + {file = "cbor2-5.6.2-cp39-cp39-win_amd64.whl", hash = "sha256:8839b73befa010358477736680657b9d08c1ed935fd973decb1909712a41afdc"}, + {file = "cbor2-5.6.2-py3-none-any.whl", hash = "sha256:c0b53a65673550fde483724ff683753f49462d392d45d7b6576364b39e76e54c"}, + {file = "cbor2-5.6.2.tar.gz", hash = "sha256:b7513c2dea8868991fad7ef8899890ebcf8b199b9b4461c3c11d7ad3aef4820d"}, +] + +[package.extras] +benchmarks = ["pytest-benchmark (==4.0.0)"] +doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme (>=1.3.0)", "typing-extensions"] +test = ["coverage (>=7)", "hypothesis", "pytest"] + [[package]] name = "certifi" version = "2024.2.2" diff --git a/pyproject.toml b/pyproject.toml index c2931c2..1470a6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ bolt11 = "^2.0.5" pre-commit = "^3.5.0" websockets = "^12.0" slowapi = "^0.1.9" +cbor2 = "^5.6.2" [tool.poetry.extras] pgsql = ["psycopg2-binary"] diff --git a/tests/test_core.py b/tests/test_core.py index d0aa237..ec2a399 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,6 +1,6 @@ import pytest -from cashu.core.base import TokenV3 +from cashu.core.base import TokenV3, TokenV4, Unit from cashu.core.helpers import calculate_number_of_blank_outputs from cashu.core.split import amount_split @@ -21,6 +21,20 @@ def test_tokenv3_deserialize_get_attributes(): assert len(token.get_proofs()) == 2 +def test_tokenv3_deserialize_serialize(): + token_str = ( + "cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFt7ImlkIjogIkplaFpMVTZuQ3BSZCIsICJh" + "bW91bnQiOiAyLCAic2VjcmV0IjogIjBFN2lDazRkVmxSZjVQRjFnNFpWMnci" + "LCAiQyI6ICIwM2FiNTgwYWQ5NTc3OGVkNTI5NmY4YmVlNjU1ZGJkN2Q2NDJmNWQzMmRlOG" + "UyNDg0NzdlMGI0ZDZhYTg2M2ZjZDUifSwgeyJpZCI6ICJKZWhaTFU2bkNwUmQiLCAiYW" + "1vdW50IjogOCwgInNlY3JldCI6ICJzNklwZXh3SGNxcXVLZDZYbW9qTDJnIiwgIkMiOiAiM" + "DIyZDAwNGY5ZWMxNmE1OGFkOTAxNGMyNTliNmQ2MTRlZDM2ODgyOWYwMmMzODc3M2M0" + "NzIyMWY0OTYxY2UzZjIzIn1dLCAibWludCI6ICJodHRwOi8vbG9jYWxob3N0OjMzMzgifV19" + ) + token = TokenV3.deserialize(token_str) + assert token.serialize() == token_str + + def test_tokenv3_deserialize_serialize_with_dleq(): token_str = ( "cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFt7ImlkIjogIjFjQ05JQVoyWC93M" @@ -45,20 +59,6 @@ def test_tokenv3_deserialize_serialize_with_dleq(): assert token.serialize(include_dleq=True) == token_str -def test_tokenv3_deserialize_serialize(): - token_str = ( - "cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFt7ImlkIjogIkplaFpMVTZuQ3BSZCIsICJh" - "bW91bnQiOiAyLCAic2VjcmV0IjogIjBFN2lDazRkVmxSZjVQRjFnNFpWMnci" - "LCAiQyI6ICIwM2FiNTgwYWQ5NTc3OGVkNTI5NmY4YmVlNjU1ZGJkN2Q2NDJmNWQzMmRlOG" - "UyNDg0NzdlMGI0ZDZhYTg2M2ZjZDUifSwgeyJpZCI6ICJKZWhaTFU2bkNwUmQiLCAiYW" - "1vdW50IjogOCwgInNlY3JldCI6ICJzNklwZXh3SGNxcXVLZDZYbW9qTDJnIiwgIkMiOiAiM" - "DIyZDAwNGY5ZWMxNmE1OGFkOTAxNGMyNTliNmQ2MTRlZDM2ODgyOWYwMmMzODc3M2M0" - "NzIyMWY0OTYxY2UzZjIzIn1dLCAibWludCI6ICJodHRwOi8vbG9jYWxob3N0OjMzMzgifV19" - ) - token = TokenV3.deserialize(token_str) - assert token.serialize() == token_str - - def test_tokenv3_deserialize_serialize_no_dleq(): token_str = ( "cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFt7ImlkIjogIjFjQ05JQVoyWC93MSIsICJhb" @@ -107,7 +107,7 @@ def test_tokenv3_deserialize_with_memo(): assert token.memo == "Test memo" -def test_serialize_example_token_nut00(): +def test_tokenv3_serialize_example_token_nut00(): token_dict = { "token": [ { @@ -144,6 +144,107 @@ def test_serialize_example_token_nut00(): ) +def test_tokenv4_deserialize_get_attributes(): + token_str = "cashuBo2F0gaJhaUgArSaMTR9YJmFwgqNhYQJhc3hAMDZlM2UzZjY4NDRiOGZkOGQ3NDMwODY1MjY3MjQ5YWU3NjdhMzg5MDBjODdkNGE0ZDMxOGY4MTJmNzkzN2ZiMmFjWCEDXDG_wzG35Lu4vcAtiycLSQlNqH65afih9N2SrFJn3GCjYWEIYXN4QDBmNTE5YjgwOWZlNmQ5MzZkMjVhYmU1YjhjYTZhMDRlNDc3OTJjOTI0YTkwZWRmYjU1MmM1ZjkzODJkNzFjMDJhY1ghA4CNH8dD8NNt715E37Ar65X6p6uBUoDbe8JipQp81TIgYW11aHR0cDovL2xvY2FsaG9zdDozMzM4YXVjc2F0" + token = TokenV4.deserialize(token_str) + assert token.mint == "http://localhost:3338" + assert token.amounts == [2, 8] + assert token.amount == 10 + assert token.unit == Unit.sat.name + assert token.memo is None + assert len(token.proofs) == 2 + + +def test_tokenv4_deserialize_serialize(): + token_str = "cashuBo2F0gaJhaUgArSaMTR9YJmFwgqNhYQJhc3hAMDZlM2UzZjY4NDRiOGZkOGQ3NDMwODY1MjY3MjQ5YWU3NjdhMzg5MDBjODdkNGE0ZDMxOGY4MTJmNzkzN2ZiMmFjWCEDXDG_wzG35Lu4vcAtiycLSQlNqH65afih9N2SrFJn3GCjYWEIYXN4QDBmNTE5YjgwOWZlNmQ5MzZkMjVhYmU1YjhjYTZhMDRlNDc3OTJjOTI0YTkwZWRmYjU1MmM1ZjkzODJkNzFjMDJhY1ghA4CNH8dD8NNt715E37Ar65X6p6uBUoDbe8JipQp81TIgYW11aHR0cDovL2xvY2FsaG9zdDozMzM4YXVjc2F0" + token = TokenV4.deserialize(token_str) + assert token.serialize() == token_str + + +def test_tokenv4_deserialize_with_dleq(): + token_str = "cashuBo2F0gaJhaUgArSaMTR9YJmFwgqRhYQhhc3hAY2I4ZWViZWE3OGRjMTZmMWU4MmY5YTZlOWI4YTU3YTM5ZDM2M2M5MzZkMzBmZTI5YmVlZDI2M2MwOGFkOTY2M2FjWCECRmlA6zYOcRSgigEUDv0BBtC2Ag8x8ZOaZUKo8J2_VWdhZKNhZVggscHmr2oHB_x9Bzhgeg2p9Vbq5Ai23olDz2JbmCRx6dlhc1ggrPmtYrRAgEHnYLIQ83cgyFjAjWNqMeNhUadHMxEm0edhclggQ5c_5bES_NhtzunlDls70fhMDWDgo9DY0kk1GuJGM2ikYWECYXN4QDQxN2E2MjZmNWMyNmVhNjliODM0YTZkZTcxYmZiMGY3ZTQ0NDhlZGFkY2FlNGRmNWVhMzM3NDdmOTVhYjRhYjRhY1ghAwyZ1QstFpNe0sppbduQxiePmGVUUk0mWDj5JAFs74-LYWSjYWVYIPyAzLub_bwc60qFkNfETjig-ESZSR8xdpANy1rHwvHKYXNYIOCInwuipARTL8IFT6NoSJqeeSMjlcbPzL-YSmXjDLIuYXJYIOLk-C0Fhba02B0Ut1BjMQqzxVGaO1NJM9Wi_aDQ37jqYW11aHR0cDovL2xvY2FsaG9zdDozMzM4YXVjc2F0" + token = TokenV4.deserialize(token_str) + assert token.proofs[0].dleq is not None + assert token.proofs[0].dleq.e + assert token.proofs[0].dleq.s + assert token.proofs[0].dleq.r + + assert token.serialize(include_dleq=True) == token_str + + +def test_tokenv4_serialize_example_single_keyset_nut00(): + token_dict = { + "t": [ + { + "i": bytes.fromhex("00ad268c4d1f5826"), + "p": [ + { + "a": 1, + "s": "9a6dbb847bd232ba76db0df197216b29d3b8cc14553cd27827fc1cc942fedb4e", + "c": bytes.fromhex( + "038618543ffb6b8695df4ad4babcde92a34a96bdcd97dcee0d7ccf98d472126792" + ), + }, + ], + }, + ], + "d": "Thank you", + "m": "http://localhost:3338", + "u": "sat", + } + tokenObj = TokenV4.parse_obj(token_dict) + assert ( + tokenObj.serialize() + == "cashuBpGF0gaJhaUgArSaMTR9YJmFwgaNhYQFhc3hAOWE2ZGJiODQ3YmQyMzJiYTc2ZGIwZGYxOTcyMTZiMjlkM2I4Y2MxNDU1M2NkMjc4MjdmYzFjYzk0MmZlZGI0ZWFjWCEDhhhUP_trhpXfStS6vN6So0qWvc2X3O4NfM-Y1HISZ5JhZGlUaGFuayB5b3VhbXVodHRwOi8vbG9jYWxob3N0OjMzMzhhdWNzYXQ=" + ) + + +def test_tokenv4_serialize_example_token_nut00(): + token_dict = { + "t": [ + { + "i": bytes.fromhex("00ffd48b8f5ecf80"), + "p": [ + { + "a": 1, + "s": "acc12435e7b8484c3cf1850149218af90f716a52bf4a5ed347e48ecc13f77388", + "c": bytes.fromhex( + "0244538319de485d55bed3b29a642bee5879375ab9e7a620e11e48ba482421f3cf" + ), + }, + ], + }, + { + "i": bytes.fromhex("00ad268c4d1f5826"), + "p": [ + { + "a": 2, + "s": "1323d3d4707a58ad2e23ada4e9f1f49f5a5b4ac7b708eb0d61f738f48307e8ee", + "c": bytes.fromhex( + "023456aa110d84b4ac747aebd82c3b005aca50bf457ebd5737a4414fac3ae7d94d" + ), + }, + { + "a": 1, + "s": "56bcbcbb7cc6406b3fa5d57d2174f4eff8b4402b176926d3a57d3c3dcbb59d57", + "c": bytes.fromhex( + "0273129c5719e599379a974a626363c333c56cafc0e6d01abe46d5808280789c63" + ), + }, + ], + }, + ], + "m": "http://localhost:3338", + "u": "sat", + } + tokenObj = TokenV4.parse_obj(token_dict) + + assert ( + tokenObj.serialize() + == "cashuBo2F0gqJhaUgA_9SLj17PgGFwgaNhYQFhc3hAYWNjMTI0MzVlN2I4NDg0YzNjZjE4NTAxNDkyMThhZjkwZjcxNmE1MmJmNGE1ZWQzNDdlNDhlY2MxM2Y3NzM4OGFjWCECRFODGd5IXVW-07KaZCvuWHk3WrnnpiDhHki6SCQh88-iYWlIAK0mjE0fWCZhcIKjYWECYXN4QDEzMjNkM2Q0NzA3YTU4YWQyZTIzYWRhNGU5ZjFmNDlmNWE1YjRhYzdiNzA4ZWIwZDYxZjczOGY0ODMwN2U4ZWVhY1ghAjRWqhENhLSsdHrr2Cw7AFrKUL9Ffr1XN6RBT6w659lNo2FhAWFzeEA1NmJjYmNiYjdjYzY0MDZiM2ZhNWQ1N2QyMTc0ZjRlZmY4YjQ0MDJiMTc2OTI2ZDNhNTdkM2MzZGNiYjU5ZDU3YWNYIQJzEpxXGeWZN5qXSmJjY8MzxWyvwObQGr5G1YCCgHicY2FtdWh0dHA6Ly9sb2NhbGhvc3Q6MzMzOGF1Y3NhdA==" + ) + + def test_calculate_number_of_blank_outputs(): # Example from NUT-08 specification. fee_reserve_sat = 1000 diff --git a/tests/test_wallet_cli.py b/tests/test_wallet_cli.py index 26102da..ccf98d5 100644 --- a/tests/test_wallet_cli.py +++ b/tests/test_wallet_cli.py @@ -1,12 +1,10 @@ import asyncio -import base64 -import json from typing import Tuple import pytest from click.testing import CliRunner -from cashu.core.base import TokenV3 +from cashu.core.base import TokenV4 from cashu.core.settings import settings from cashu.wallet.cli.cli import cli from cashu.wallet.wallet import Wallet @@ -378,8 +376,8 @@ def test_send(mint, cli_prefix): assert result.exception is None print("test_send", result.output) token_str = result.output.split("\n")[0] - assert "cashuA" in token_str, "output does not have a token" - token = TokenV3.deserialize(token_str) + assert "cashuB" in token_str, "output does not have a token" + token = TokenV4.deserialize(token_str).to_tokenv3() assert token.token[0].proofs[0].dleq is None, "dleq included" @@ -392,8 +390,8 @@ def test_send_with_dleq(mint, cli_prefix): assert result.exception is None print("test_send_with_dleq", result.output) token_str = result.output.split("\n")[0] - assert "cashuA" in token_str, "output does not have a token" - token = TokenV3.deserialize(token_str) + assert "cashuB" in token_str, "output does not have a token" + token = TokenV4.deserialize(token_str).to_tokenv3() assert token.token[0].proofs[0].dleq is not None, "no dleq included" @@ -406,8 +404,8 @@ def test_send_legacy(mint, cli_prefix): assert result.exception is None print("test_send_legacy", result.output) # this is the legacy token in the output - token_str = result.output.split("\n")[4] - assert token_str.startswith("eyJwcm9v"), "output is not as expected" + token_str = result.output.split("\n")[0] + assert token_str.startswith("cashuAey"), "output is not as expected" def test_send_offline(mint, cli_prefix): @@ -419,7 +417,7 @@ def test_send_offline(mint, cli_prefix): assert result.exception is None print("SEND") print("test_send_without_split", result.output) - assert "cashuA" in result.output, "output does not have a token" + assert "cashuB" in result.output, "output does not have a token" def test_send_too_much(mint, cli_prefix): @@ -447,113 +445,6 @@ def test_receive_tokenv3(mint, cli_prefix): print(result.output) -def test_receive_tokenv3_no_mint(mint, cli_prefix): - # this test works only if the previous test succeeds because we simulate the case - # 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_dict = { - "token": [ - { - "proofs": [ - { - "id": "009a1f293253e41e", - "amount": 2, - "secret": "ea3420987e1ecd71de58e4ff00e8a94d1f1f9333dad98e923e3083d21bf314e2", - "C": "0204eb99cf27105b4de4029478376d6f71e9e3d5af1cc28a652c028d1bcd6537cc", - }, - { - "id": "009a1f293253e41e", - "amount": 8, - "secret": "3447975db92f43b269290e05b91805df7aa733f622e55d885a2cab78e02d4a72", - "C": "0286c78750d414bc067178cbac0f3551093cea47d213ebf356899c972448ee6255", - }, - ] - } - ] - } - token = "cashuA" + base64.b64encode(json.dumps(token_dict).encode()).decode() - print("RECEIVE") - print(token) - result = runner.invoke( - cli, - [ - *cli_prefix, - "receive", - token, - ], - ) - assert result.exception is None - print(result.output) - - -def test_receive_tokenv2(mint, cli_prefix): - runner = CliRunner() - token_dict = { - "proofs": [ - { - "id": "009a1f293253e41e", - "amount": 2, - "secret": ( - "a1efb610726b342aec209375397fee86a0b88732779ce218e99132f9a975db2a" - ), - "C": ( - "03057e5fe352bac785468ffa51a1ecf0f75af24d2d27ab1fd00164672a417d9523" - ), - }, - { - "id": "009a1f293253e41e", - "amount": 8, - "secret": ( - "b065a17938bc79d6224dc381873b8b7f3a46267e8b00d9ce59530354d9d81ae4" - ), - "C": ( - "021e83773f5eb66f837a5721a067caaa8d7018ef0745b4302f4e2c6cac8806dc69" - ), - }, - ], - "mints": [{"url": "http://localhost:3337", "ids": ["009a1f293253e41e"]}], - } - token = base64.b64encode(json.dumps(token_dict).encode()).decode() - result = runner.invoke( - cli, - [*cli_prefix, "receive", token], - ) - assert result.exception is None - print("RECEIVE") - print(result.output) - - -def test_receive_tokenv1(mint, cli_prefix): - runner = CliRunner() - token_dict = [ - { - "id": "009a1f293253e41e", - "amount": 2, - "secret": ( - "bc0360c041117969ef7b8add48d0981c669619aa5743cccce13d4a771c9e164d" - ), - "C": "026fd492f933e9240f36fb2559a7327f47b3441b895a5f8f0b1d6825fee73438f0", - }, - { - "id": "009a1f293253e41e", - "amount": 8, - "secret": ( - "cf83bd8df35bb104d3818511c1653e9ebeb2b645a36fd071b2229aa2c3044acd" - ), - "C": "0279606f3dfd7784757c6320b17e1bf2211f284318814c12bfaa40680e017abd34", - }, - ] - token = base64.b64encode(json.dumps(token_dict).encode()).decode() - result = runner.invoke( - cli, - [*cli_prefix, "receive", token], - ) - assert result.exception is None - print("RECEIVE") - print(result.output) - - def test_nostr_send(mint, cli_prefix): runner = CliRunner() result = runner.invoke(