diff --git a/README.md b/README.md index d0b708cc..8bdd7733 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ This command runs the mint on your local computer. Skip this step if you want to ## Docker ``` -docker run -d -p 3338:3338 --name nutshell -e MINT_BACKEND_BOLT11_SAT=FakeWallet -e MINT_LISTEN_HOST=0.0.0.0 -e MINT_LISTEN_PORT=3338 -e MINT_PRIVATE_KEY=TEST_PRIVATE_KEY cashubtc/nutshell:0.15.3 poetry run mint +docker run -d -p 3338:3338 --name nutshell -e MINT_BACKEND_BOLT11_SAT=FakeWallet -e MINT_LISTEN_HOST=0.0.0.0 -e MINT_LISTEN_PORT=3338 -e MINT_PRIVATE_KEY=TEST_PRIVATE_KEY cashubtc/nutshell:0.16.0 poetry run mint ``` ## From this repository diff --git a/cashu/core/base.py b/cashu/core/base.py index 96c7313d..70dba554 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -1,6 +1,7 @@ import base64 import json import math +from abc import ABC, abstractmethod from dataclasses import dataclass from enum import Enum from sqlite3 import Row @@ -752,6 +753,48 @@ def generate_keys(self): # ------- TOKEN ------- +class Token(ABC): + @property + @abstractmethod + def proofs(self) -> List[Proof]: + ... + + @property + @abstractmethod + def amount(self) -> int: + ... + + @property + @abstractmethod + def mint(self) -> str: + ... + + @property + @abstractmethod + def keysets(self) -> List[str]: + ... + + @property + @abstractmethod + def memo(self) -> Optional[str]: + ... + + @memo.setter + @abstractmethod + def memo(self, memo: Optional[str]): + ... + + @property + @abstractmethod + def unit(self) -> str: + ... + + @unit.setter + @abstractmethod + def unit(self, unit: str): + ... + + class TokenV3Token(BaseModel): mint: Optional[str] = None proofs: List[Proof] @@ -763,25 +806,33 @@ def to_dict(self, include_dleq=False): return return_dict -class TokenV3(BaseModel): +class TokenV3(BaseModel, Token): """ A Cashu token that includes proofs and their respective mints. Can include proofs from multiple different mints and keysets. """ token: List[TokenV3Token] = [] memo: Optional[str] = None - unit: Optional[str] = None + unit: str = "sat" - def get_proofs(self): + @property + def proofs(self): return [proof for token in self.token for proof in token.proofs] - def get_amount(self): - return sum([p.amount for p in self.get_proofs()]) + @property + def amount(self): + return sum([p.amount for p in self.proofs]) - def get_keysets(self): - return list(set([p.id for p in self.get_proofs()])) + @property + def keysets(self): + return list(set([p.id for p in self.proofs])) - def get_mints(self): + @property + def mint(self): + return self.mints[0] + + @property + def mints(self): return list(set([t.mint for t in self.token if t.mint])) def serialize_to_dict(self, include_dleq=False): @@ -868,7 +919,7 @@ class TokenV4Token(BaseModel): p: List[TokenV4Proof] -class TokenV4(BaseModel): +class TokenV4(BaseModel, Token): # mint URL m: str # unit @@ -882,14 +933,25 @@ class TokenV4(BaseModel): def mint(self) -> str: return self.m + def set_mint(self, mint: str): + self.m = mint + @property def memo(self) -> Optional[str]: return self.d + @memo.setter + def memo(self, memo: Optional[str]): + self.d = memo + @property def unit(self) -> str: return self.u + @unit.setter + def unit(self, unit: str): + self.u = unit + @property def amounts(self) -> List[int]: return [p.a for token in self.t for p in token.p] @@ -921,12 +983,16 @@ def proofs(self) -> List[Proof]: for p in token.p ] + @property + def keysets(self) -> List[str]: + return list(set([p.i.hex() for p in self.t])) + @classmethod def from_tokenv3(cls, tokenv3: TokenV3): - if not len(tokenv3.get_mints()) == 1: + if not len(tokenv3.mints) == 1: raise Exception("TokenV3 must contain proofs from only one mint.") - proofs = tokenv3.get_proofs() + proofs = tokenv3.proofs proofs_by_id: Dict[str, List[Proof]] = {} for proof in proofs: proofs_by_id.setdefault(proof.id, []).append(proof) @@ -960,7 +1026,7 @@ def from_tokenv3(cls, tokenv3: TokenV3): # set memo cls.d = tokenv3.memo # set mint - cls.m = tokenv3.get_mints()[0] + cls.m = tokenv3.mint # set unit cls.u = tokenv3.unit or "sat" return cls(t=cls.t, d=cls.d, m=cls.m, u=cls.u) diff --git a/cashu/core/models.py b/cashu/core/models.py index f4cea2b1..a9743144 100644 --- a/cashu/core/models.py +++ b/cashu/core/models.py @@ -100,10 +100,8 @@ class PostMintQuoteRequest(BaseModel): class PostMintQuoteResponse(BaseModel): quote: str # quote id request: str # input payment request - paid: Optional[ - bool - ] # whether the request has been paid # DEPRECATED as per NUT PR #141 - state: str # state of the quote + paid: Optional[bool] # DEPRECATED as per NUT-04 PR #141 + state: Optional[str] # state of the quote expiry: Optional[int] # expiry of the quote @classmethod @@ -180,8 +178,10 @@ class PostMeltQuoteResponse(BaseModel): quote: str # quote id amount: int # input amount fee_reserve: int # input fee reserve - paid: bool # whether the request has been paid # DEPRECATED as per NUT PR #136 - state: str # state of the quote + paid: Optional[ + bool + ] # whether the request has been paid # DEPRECATED as per NUT PR #136 + state: Optional[str] # state of the quote expiry: Optional[int] # expiry of the quote payment_preimage: Optional[str] = None # payment preimage change: Union[List[BlindedSignature], None] = None diff --git a/cashu/wallet/api/api_helpers.py b/cashu/wallet/api/api_helpers.py index 8b50fefa..0ae6fa30 100644 --- a/cashu/wallet/api/api_helpers.py +++ b/cashu/wallet/api/api_helpers.py @@ -1,8 +1,8 @@ -from ...core.base import TokenV4 +from ...core.base import Token from ...wallet.crud import get_keysets -async def verify_mints(wallet, tokenObj: TokenV4): +async def verify_mints(wallet, tokenObj: Token): # verify mints mint = tokenObj.mint mint_keysets = await get_keysets(mint_url=mint, db=wallet.db) diff --git a/cashu/wallet/api/router.py b/cashu/wallet/api/router.py index 7ad15798..0065a50d 100644 --- a/cashu/wallet/api/router.py +++ b/cashu/wallet/api/router.py @@ -8,7 +8,7 @@ from fastapi import APIRouter, Query -from ...core.base import TokenV3, TokenV4 +from ...core.base import Token, TokenV3 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: TokenV4 = deserialize_token_from_string(token) + tokenObj: Token = deserialize_token_from_string(token) await verify_mints(wallet, tokenObj) await receive(wallet, tokenObj) elif nostr: @@ -317,7 +317,7 @@ async def burn( else: # check only the specified ones tokenObj = TokenV3.deserialize(token) - proofs = tokenObj.get_proofs() + proofs = tokenObj.proofs if delete: await wallet.invalidate(proofs) diff --git a/cashu/wallet/cli/cli.py b/cashu/wallet/cli/cli.py index 46dbbc79..618bbe0f 100644 --- a/cashu/wallet/cli/cli.py +++ b/cashu/wallet/cli/cli.py @@ -15,7 +15,7 @@ from click import Context from loguru import logger -from ...core.base import Invoice, Method, MintQuoteState, TokenV3, TokenV4, Unit +from ...core.base import Invoice, Method, MintQuoteState, TokenV4, Unit from ...core.helpers import sum_proofs from ...core.json_rpc.base import JSONRPCNotficationParams from ...core.logging import configure_logger @@ -672,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 - token_obj = TokenV3.deserialize(token) - proofs = token_obj.get_proofs() + tokenObj = deserialize_token_from_string(token) + proofs = tokenObj.proofs if delete: await wallet.invalidate(proofs) @@ -709,6 +709,14 @@ async def burn(ctx: Context, token: str, all: bool, force: bool, delete: str): @coro async def pending(ctx: Context, legacy, number: int, offset: int): wallet: Wallet = ctx.obj["WALLET"] + wallet = await Wallet.with_db( + url=wallet.url, + db=wallet.db.db_location, + name=wallet.name, + skip_db_read=False, + unit=wallet.unit.name, + load_all_keysets=True, + ) reserved_proofs = await get_reserved_proofs(wallet.db) if len(reserved_proofs): print("--------------------------\n") @@ -737,7 +745,7 @@ async def pending(ctx: Context, legacy, number: int, offset: int): ).strftime("%Y-%m-%d %H:%M:%S") print( f"#{i} Amount:" - f" {wallet.unit.str(sum_proofs(grouped_proofs))} Time:" + f" {Unit[token_obj.unit].str(sum_proofs(grouped_proofs))} Time:" f" {reserved_date} ID: {key} Mint: {mint}\n" ) print(f"{token}\n") diff --git a/cashu/wallet/helpers.py b/cashu/wallet/helpers.py index 9d020f9d..d75e1419 100644 --- a/cashu/wallet/helpers.py +++ b/cashu/wallet/helpers.py @@ -3,7 +3,7 @@ from loguru import logger -from ..core.base import TokenV3, TokenV4 +from ..core.base import Token, TokenV3, TokenV4 from ..core.db import Database from ..core.helpers import sum_proofs from ..core.migrations import migrate_databases @@ -34,7 +34,7 @@ async def list_mints(wallet: Wallet): return mints -async def redeem_TokenV3_multimint(wallet: Wallet, token: TokenV3) -> Wallet: +async def redeem_TokenV3(wallet: Wallet, token: TokenV3) -> Wallet: """ Helper function to iterate thruogh a token with multiple mints and redeem them from these mints one keyset at a time. @@ -46,9 +46,7 @@ async def redeem_TokenV3_multimint(wallet: Wallet, token: TokenV3) -> Wallet: token.unit = keysets[0].unit.name for t in token.token: - assert t.mint, Exception( - "redeem_TokenV3_multimint: multimint redeem without URL" - ) + assert t.mint, Exception("redeem_TokenV3: multimint redeem without URL") mint_wallet = await Wallet.with_db( t.mint, os.path.join(settings.cashu_dir, wallet.name), @@ -74,12 +72,23 @@ async def redeem_TokenV4(wallet: Wallet, token: TokenV4) -> Wallet: return wallet -def deserialize_token_from_string(token: str) -> TokenV4: - # deserialize token +async def redeem_universal(wallet: Wallet, token: Token) -> Wallet: + if isinstance(token, TokenV3): + return await redeem_TokenV3(wallet, token) + if isinstance(token, TokenV4): + return await redeem_TokenV4(wallet, token) + raise Exception("Invalid token type") + +def deserialize_token_from_string(token: str) -> Token: + # deserialize token if token.startswith("cashuA"): tokenV3Obj = TokenV3.deserialize(token) - return TokenV4.from_tokenv3(tokenV3Obj) + try: + return TokenV4.from_tokenv3(tokenV3Obj) + except ValueError as e: + logger.debug(f"Error converting TokenV3 to TokenV4: {e}") + return tokenV3Obj if token.startswith("cashuB"): tokenObj = TokenV4.deserialize(token) return tokenObj @@ -89,14 +98,9 @@ def deserialize_token_from_string(token: str) -> TokenV4: async def receive( wallet: Wallet, - tokenObj: TokenV4, + token: Token, ) -> Wallet: - # redeem tokens with new wallet instances - mint_wallet = await redeem_TokenV4( - wallet, - tokenObj, - ) - + mint_wallet = await redeem_universal(wallet, token) # reload main wallet so the balance updates await wallet.load_proofs(reload=True) return mint_wallet diff --git a/cashu/wallet/nostr.py b/cashu/wallet/nostr.py index 0a9ee485..217ed95a 100644 --- a/cashu/wallet/nostr.py +++ b/cashu/wallet/nostr.py @@ -6,7 +6,7 @@ from httpx import ConnectError from loguru import logger -from ..core.base import TokenV4 +from ..core.base import Token from ..core.settings import settings from ..nostr.client.client import NostrClient from ..nostr.event import Event @@ -127,18 +127,13 @@ def get_token_callback(event: Event, decrypted_content: str): for w in words: try: # call the receive method - tokenObj: TokenV4 = deserialize_token_from_string(w) + tokenObj: Token = deserialize_token_from_string(w) print( f"Receiving {tokenObj.amount} sat on mint" f" {tokenObj.mint} from nostr user {event.public_key} at" f" {date_str}" ) - asyncio.run( - receive( - wallet, - tokenObj, - ) - ) + asyncio.run(receive(wallet, tokenObj)) logger.trace( "Nostr: setting last check timestamp to" f" {event.created_at} ({date_str})" diff --git a/cashu/wallet/proofs.py b/cashu/wallet/proofs.py index 5a8911c2..61ff5006 100644 --- a/cashu/wallet/proofs.py +++ b/cashu/wallet/proofs.py @@ -106,6 +106,11 @@ async def serialize_proofs( Returns: str: Serialized Cashu token """ + # DEPRECATED: legacy token for base64 keysets + try: + _ = [bytes.fromhex(p.id) for p in proofs] + except ValueError: + legacy = True if legacy: tokenv3 = await self._make_tokenv3(proofs, memo) @@ -127,13 +132,15 @@ async def _make_tokenv3( Returns: TokenV3: TokenV3 object """ - token = TokenV3() - - # we create a map from mint url to keyset id and then group - # all proofs with their mint url to build a tokenv3 + token = TokenV3(memo=memo) # extract all keysets from proofs keysets = self._get_proofs_keyset_ids(proofs) + assert ( + set([k.unit for k in self.keysets.values()]) == 1 + ), "All keysets must have the same unit" + token.unit = self.keysets[keysets[0]].unit.name + # get all mint URLs for all unique keysets from db mint_urls = await self._get_keyset_urls(keysets) @@ -142,8 +149,6 @@ async def _make_tokenv3( 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 _make_tokenv4( diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 0c4bcd1d..ab2b40a3 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -120,6 +120,7 @@ async def with_db( name: str = "no_name", skip_db_read: bool = False, unit: str = "sat", + load_all_keysets: bool = False, ): """Initializes a wallet with a database and initializes the private key. @@ -130,6 +131,9 @@ async def with_db( skip_db_read (bool, optional): If true, values from db like private key and keysets are not loaded. Useful for running only migrations and returning. Defaults to False. + unit (str, optional): Unit of the wallet. Defaults to "sat". + load_all_keysets (bool, optional): If true, all keysets are loaded from the database. + Defaults to False. Returns: Wallet: Initialized wallet. @@ -137,16 +141,23 @@ async def with_db( logger.trace(f"Initializing wallet with database: {db}") self = cls(url=url, db=db, name=name, unit=unit) await self._migrate_database() - if not skip_db_read: - logger.trace("Mint init: loading private key and keysets from db.") - await self._init_private_key() - keysets_list = await get_keysets(mint_url=url, db=self.db) + + if skip_db_read: + return self + + logger.trace("Mint init: loading private key and keysets from db.") + await self._init_private_key() + keysets_list = await get_keysets( + mint_url=url if not load_all_keysets else None, db=self.db + ) + if not load_all_keysets: keysets_active_unit = [k for k in keysets_list if k.unit == self.unit] self.keysets = {k.id: k for k in keysets_active_unit} - logger.debug( - f"Loaded keysets: {' '.join([k.id + f' {k.unit}' for k in keysets_active_unit])}" - ) - + else: + self.keysets = {k.id: k for k in keysets_list} + logger.debug( + f"Loaded keysets: {' '.join([i + f' {k.unit}' for i, k in self.keysets.items()])}" + ) return self async def _migrate_database(self):